openvibers/openvibe.design Commits
Patterns: seven layout rules learned shipping on 10-30, each with a live demo (#31)
Changes
@@@ -7,3 +7,30 @@.dbtn,.pill-action,.opill,.tab-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:13px;font-weight:650;cursor:pointer}.dbtn.primary,.pill-action.primary,.opill.primary{background:var(--accent);border-color:transparent;color:var(--on-accent)}.status-badge,.meta-chip{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:6px 10px;background:color-mix(in srgb, var(--success) 16%, transparent);color:var(--success);font-size:12px;font-weight:700}.status-badge.warn{background:color-mix(in srgb, var(--warning) 16%, transparent);color:var(--warning)}.status-badge.bad{background:color-mix(in srgb, var(--danger) 16%, transparent);color:var(--danger)}.meta-chip{background:var(--bg-primary);border:1px solid var(--border);color:var(--text-secondary);font-weight:500}.demo-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.stream-box{display:grid;gap:7px;max-height:170px;overflow:auto;scroll-behavior:smooth;padding:2px 4px 2px 0}.stream-item,.attempt,.empty-demo,.progress-line,.outcome-line,.ship-line,.tab-panel{padding:12px;border-radius:12px;background:var(--bg-primary);border:1px solid var(--border)}.stream-item.enter{animation:row-in 180ms cubic-bezier(.2,.7,.2,1) both}@keyframes row-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}.demo-fold{width:100%;border:1px solid var(--border);border-radius:12px;background:var(--bg-primary);padding:12px;color:inherit;text-align:left}.demo-fold summary{min-height:44px;cursor:pointer;display:flex;align-items:center;font-weight:700}.fold-content{padding:8px 0 0;color:var(--text-secondary)}.progress-line{display:flex;align-items:center;gap:10px;min-height:44px}.progress-fold{display:none;padding:12px;color:var(--text-muted)}.progress-line.open+.progress-fold{display:block}.outcome-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.outcome-copy{flex:1;min-width:140px}.outcome-copy strong,.outcome-copy small{display:block}.outcome-copy small{color:var(--text-muted);margin-top:3px}.attempt-list{display:none;padding:8px 0 0 12px}.ship-line.open+.attempt-list{display:grid;gap:6px}.empty-demo{display:flex;align-items:center;justify-content:space-between;gap:10px}.demo-tabs{display:flex;gap:4px;border-bottom:1px solid var(--border)}.tab-button[aria-selected=true]{color:var(--accent);border-color:var(--accent)}.tab-panel{margin-top:10px;min-height:56px}.target-box{display:inline-grid;place-items:center;width:44px;height:44px;border:1px dashed var(--accent);border-radius:10px}.mark-demo{display:flex;align-items:center;gap:14px;font-size:26px;font-weight:800;letter-spacing:-.05em}.ov-mark{display:inline-grid;place-items:center;width:48px;height:48px;border-radius:16px;background:linear-gradient(145deg,var(--accent),var(--accent-light));color:var(--text-primary);font-size:23px;transition:transform 360ms cubic-bezier(.34,1.56,.64,1)}.ov-mark.loading{animation:mark-pulse 900ms ease-in-out infinite alternate}@keyframes mark-pulse{to{transform:rotate(12deg) scale(.88)}}.bottom-demo{position:relative;min-height:155px;padding:12px 12px 70px;border:1px solid var(--border);border-radius:14px;background:var(--bg-primary)}.bottom-nav{position:absolute;bottom:0;left:0;right:0;min-height:56px;padding:6px 12px max(6px,env(safe-area-inset-bottom));display:flex;justify-content:space-around;background:var(--bg-secondary);border-top:1px solid var(--border);border-radius:0 0 14px 14px}.site-switcher-demo{display:flex;align-items:center;gap:12px}.switcher-pop{display:none;position:absolute;z-index:2;top:55px;left:16px;padding:14px;border-radius:14px;background:var(--bg-secondary);border:1px solid var(--border);box-shadow:0 18px 50px color-mix(in srgb, var(--text-primary) 28%, transparent)}.site-switcher-demo.open .switcher-pop{display:grid;gap:8px}.site-switcher-demo{position:relative}.sample-nav{height:52px;padding:0 14px;display:flex;align-items:center;gap:16px;border:1px solid var(--border);border-radius:14px}.sample-nav span:last-child{margin-left:auto;color:var(--text-muted)}.demo-tabs button:focus-visible,.dbtn:focus-visible,.tab-button:focus-visible,.demo-fold summary:focus-visible{outline:3px solid var(--accent);outline-offset:2px}@media(max-width:620px){.pattern-page,.patterns-index{padding:24px 14px 100px}.pcards,.pcodegrid{grid-template-columns:1fr}.pnext{flex-direction:column}.pdemo-body{padding:14px}.pbug,.prule,.ptokens,.pnative{padding:16px}.pcodegrid pre{font-size:10px}}@media(prefers-reduced-motion:reduce){.pcard,.ov-mark{transition:none}.pcard:hover{transform:none}.stream-item.enter{animation:none}.ov-mark.loading{animation:none}.demo-fold,.stream-box{scroll-behavior:auto}}++++/* Layout that holds (10-30): the demos for the seven rules. */++.decor-demo{position:relative;height:170px;overflow:hidden;border-radius:14px;border:1px solid var(--border);background:var(--bg-primary)}++.decor-bar{display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--border);font-weight:700}++.decor-mark{display:grid;place-items:center;width:26px;height:26px;border-radius:9px;background:var(--accent);color:var(--on-accent);font-size:11px;font-weight:800}++.decor-head{display:block;margin:14px 12px 4px;font-size:22px}.decor-demo p{margin:0 12px;color:var(--text-secondary);font-size:14px}++.decor-leaf{position:absolute;top:10px;width:9px;height:9px;border-radius:0 70% 0 70%;background:var(--warning);opacity:0;pointer-events:none}++.decor-leaf:nth-of-type(2n){background:var(--accent)}.decor-leaf:nth-of-type(3n){background:var(--success)}++.cols-demo{display:grid;gap:6px;max-width:320px}.cols-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 3em;gap:8px;align-items:center;padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary)}++.cols-demo.per-row .cols-row{grid-template-columns:auto 1fr auto}.cols-band{justify-self:start;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:750;color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}++.cols-name{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700}.cols-score{text-align:right;font-variant-numeric:tabular-nums}++.num-demo{border-collapse:collapse;min-width:240px}.num-demo th,.num-demo td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left}++.num-demo .num{text-align:right;font-variant-numeric:tabular-nums}.num-demo.broken td.num{text-align:left;font-variant-numeric:normal}++.tscroll-demo{max-width:220px;overflow-x:auto;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary)}++.tscroll-table{border-collapse:collapse}.tscroll-table th,.tscroll-table td{padding:8px 10px;border-bottom:1px solid var(--border);text-align:left;white-space:nowrap}++.tscroll-demo.broken{overflow-x:visible}.tscroll-demo.broken .tscroll-table{width:100%;table-layout:fixed}.tscroll-demo.broken th,.tscroll-demo.broken td{white-space:normal;overflow-wrap:anywhere}++.measure-card{display:grid;place-items:center;width:200px;height:120px;border-radius:18px;border:1px solid var(--border);background:var(--bg-primary);font-weight:800}++.drag-demo{position:relative;height:150px;border-radius:14px;border:1px dashed var(--border);background:var(--bg-primary);touch-action:none}++.drag-chip{position:absolute;min-height:44px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;font-weight:700;cursor:grab;transition:top .25s,left .25s,right .25s,bottom .25s}++.drag-chip.is-moving{cursor:grabbing;transition:none;box-shadow:var(--shadow-lg)}++.drag-chip[data-at=tl]{top:10px;left:10px}.drag-chip[data-at=tr]{top:10px;right:10px}.drag-chip[data-at=bl]{bottom:10px;left:10px}.drag-chip[data-at=br]{bottom:10px;right:10px}++.clear-demo{--clear:0px;position:relative;height:170px;border-radius:14px;border:1px solid var(--border);background:var(--bg-primary);overflow:hidden}++.clear-toasts{position:absolute;right:10px;bottom:calc(10px + var(--clear));display:grid;gap:6px;transition:bottom .26s cubic-bezier(.2,.7,.2,1)}++.clear-toast{padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-secondary);font-size:13px;font-weight:650;box-shadow:var(--shadow-lg)}++.clear-pill{position:absolute;right:10px;bottom:10px;display:inline-flex;align-items:center;height:42px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);font-weight:750;font-size:13px}++.clear-pill::before{content:'';width:8px;height:8px;margin-right:8px;border-radius:50%;background:var(--warning)}++@media(prefers-reduced-motion:reduce){.drag-chip,.clear-toasts{transition:none}}@@@ -51,6 +51,85 @@const nav=node('nav','sample-nav');nav.setAttribute('aria-label','Example page navbar');nav.append(node('strong','','OpenVibe'),node('span','','Patterns'),node('span','','One shared navbar'));widget.append(nav);say('This page itself uses the shared Frame navbar once.');} else if (id === 'bottom-tabs') {const frame=node('div','bottom-demo');frame.append(node('strong','','Scrollable content'),node('p','','This content reserves space under the fixed phone tabs.'),node('p','','The final row remains visible above the bar.'));const nav=node('nav','bottom-nav');nav.setAttribute('aria-label','Phone tabs');['Home','Runs','Settings'].forEach(x=>nav.append(button(x,()=>say(`${x} tab selected.`))));frame.append(nav);widget.append(frame);++ } else if (id === 'decor-stays-out') {++ const frame = node('div', 'decor-demo'), bar = node('div', 'decor-bar'), head = node('strong', 'decor-head', 'Conductor');++ bar.append(node('span', 'decor-mark', 'ov'), node('span', '', 'Codes'));++ frame.append(bar, head, node('p', '', 'The page’s own text stays clear.'));++ widget.append(frame);++ const fall = (land) => {++ frame.querySelectorAll('.decor-leaf').forEach((x) => x.remove());++ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;++ if (still) { say('Reduced motion is on: no fall at all.'); return; }++ const barBottom = bar.offsetTop + bar.offsetHeight;++ for (let i = 0; i < 6; i++) {++ const leaf = node('span', 'decor-leaf'); frame.append(leaf);++ const x = 26 + i * 22 + Math.round(Math.random() * 10);++ leaf.style.left = `${x}px`;++ const y = land ? barBottom - 14 - Math.round(Math.random() * 3) : 60 + Math.round(Math.random() * 70);++ leaf.animate([{ transform: 'translateY(0) rotate(0)', opacity: 0 }, { opacity: 1, offset: 0.15 }, { transform: `translateY(${y}px) rotate(${180 + i * 40}deg)`, opacity: 1, offset: 0.72 }, { transform: `translateY(${y}px) rotate(${180 + i * 40}deg)`, opacity: 0 }], { duration: 2400 + i * 120, easing: 'cubic-bezier(.3,.1,.5,1)', fill: 'forwards' });++ }++ say(land ? 'The leaves land on the bar’s edge and fade there.' : 'The old way: the leaves fall across the heading.');++ };++ control('Play the fall', () => fall(true), true);++ control('The old way', () => fall(false));++ } else if (id === 'shared-columns') {++ const list = node('div', 'cols-demo'), rows = [['1st', 'atlas-1', '1014'], ['2nd–12th', 'nimbus-12', '1009'], ['13th', 'a', '986']];++ rows.forEach(([band, name, score]) => { const r = node('div', 'cols-row'); r.append(node('span', 'cols-band', band), node('span', 'cols-name', name), node('span', 'cols-score', score)); list.append(r); });++ widget.append(list);++ const set = (shared) => { list.classList.toggle('per-row', !shared); say(shared ? 'Every row shares one set of columns: names line up.' : 'Each row sizes its own columns: names jump around.'); };++ control('Shared columns', () => set(true), true);++ control('Per-row columns', () => set(false));++ set(true);++ } else if (id === 'numbers-align') {++ const table = node('table', 'num-demo'), head = node('tr'), body = node('tbody');++ ['Model', 'Votes'].forEach((h, i) => { const th = node('th', i ? 'num' : '', h); head.append(th); });++ const thead = node('thead'); thead.append(head);++ [['atlas-1', '8'], ['nimbus-2', '128'], ['orbit', '1,024']].forEach(([m, v]) => { const tr = node('tr'); tr.append(node('td', '', m), node('td', 'num', v)); body.append(tr); });++ table.append(thead, body); widget.append(table);++ control('Aligned', () => { table.classList.remove('broken'); say('Right-aligned, tabular figures: the digits stack under the header.'); }, true);++ control('The bug', () => { table.classList.add('broken'); say('A broader td rule wins: the numbers sit left under a right-aligned header.'); });++ } else if (id === 'tables-scroll-inside') {++ const box = node('div', 'tscroll-demo'), table = node('table', 'tscroll-table');++ table.innerHTML = '<thead><tr><th>Provider</th><th>Plan</th><th>€ / month</th><th>Traffic</th></tr></thead><tbody><tr><td>Hetzner</td><td>CX22</td><td>4.51</td><td>20 TB</td></tr><tr><td>Netcup</td><td>VPS 1000 G11</td><td>6.74</td><td>unmetered</td></tr></tbody>';++ box.append(table); widget.append(box);++ control('Scroll inside', () => { box.classList.remove('broken'); say('Whole words; the table scrolls inside its box.'); }, true);++ control('The bug', () => { box.classList.add('broken'); say('Words break mid-letter to fit the screen.'); });++ } else if (id === 'measure-at-rest') {++ const card = node('div', 'measure-card', 'Dock'); widget.append(card);++ const play = (atRest) => {++ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;++ const anim = card.animate([{ transform: 'scale(.82)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: still ? 0 : 300, easing: 'cubic-bezier(.2,.9,.25,1.08)' });++ const read = () => { const b = card.getBoundingClientRect(); say(`${atRest ? 'At rest' : 'Mid-entrance'}: ${Math.round(b.width)} × ${Math.round(b.height)} px${atRest ? '' : ` (the card is ${card.offsetWidth} × ${card.offsetHeight})`}.`); };++ if (atRest) anim.finished.then(read); else setTimeout(read, still ? 0 : 60);++ };++ control('Measure at rest', () => play(true), true);++ control('Measure mid-entrance', () => play(false));++ } else if (id === 'drag-or-tap') {++ const box = node('div', 'drag-demo'), chip = node('button', 'drag-chip', 'Tap or drag me');++ chip.type = 'button'; box.append(chip); widget.append(box);++ let s = null, swallow = false, taps = 0;++ const corners = ['tl', 'tr', 'bl', 'br']; let at = 'br'; chip.dataset.at = at;++ const move = (e) => { if (!s || e.pointerId !== s.id) return; s.dx = e.clientX - s.x; s.dy = e.clientY - s.y; if (!s.live && Math.abs(s.dx) + Math.abs(s.dy) >= 6) { s.live = true; chip.classList.add('is-moving'); } if (s.live) chip.style.transform = `translate(${s.dx}px, ${s.dy}px)`; };++ const end = (e) => {++ if (!s || e.pointerId !== s.id) return;++ window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);++ const live = s.live, r = chip.getBoundingClientRect(), b = box.getBoundingClientRect(); s = null;++ chip.classList.remove('is-moving'); chip.style.transform = '';++ if (!live) return;++ swallow = true; setTimeout(() => { swallow = false; }, 0);++ at = `${r.top + r.height / 2 < b.top + b.height / 2 ? 't' : 'b'}${r.left + r.width / 2 < b.left + b.width / 2 ? 'l' : 'r'}`;++ chip.dataset.at = corners.includes(at) ? at : 'br';++ say('A drag: it snapped to the nearest corner, and the press was not a tap.');++ };++ chip.addEventListener('pointerdown', (e) => { if (e.button) return; s = { x: e.clientX, y: e.clientY, dx: 0, dy: 0, id: e.pointerId, live: false }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end); });++ chip.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); return; } taps++; say(`A tap (${taps}). It moved less than 6 px, so it stayed a tap.`); }, true);++ } else if (id === 'floating-clears-toasts') {++ const screen = node('div', 'clear-demo'), stack = node('div', 'clear-toasts'), pill = node('span', 'clear-pill', 'Needs you');++ screen.append(stack, pill); widget.append(screen);++ const toast = () => { const t = node('div', 'clear-toast', 'Codes finished a task'); stack.append(t); setTimeout(() => t.remove(), 3200); };++ const set = (on) => { screen.style.setProperty('--clear', on ? '52px' : '0px'); pill.hidden = false; say(on ? 'The pill says how much room it takes: toasts stack above it.' : 'Nobody says: the toast covers the pill.'); toast(); };++ control('Clear the pill', () => set(true), true);++ control('The bug', () => set(false));} else if (id === 'mark-system') {const row=node('div','mark-demo'),mark=node('span','ov-mark','ov');row.append(mark,node('span','','OpenVibe'));widget.append(row);control('Loading state',()=>{mark.classList.toggle('loading');say(mark.classList.contains('loading')?'Loading state active.':'Loading state stopped.');});control('Change site',()=>{mark.textContent=mark.textContent==='ov'?'lv':'ov';mark.style.borderRadius=mark.textContent==='lv'?'50%':'16px';say('The mark morphs for the active site.');});control('Open five studies',()=>{window.location.href='/patterns/mark/studies';});}@@@ -35,6 +35,13 @@ const patterns = [['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],['single-navbar','Navigation','One navbar per page', 'Render one shared navbar at the top of each page. Put local section navigation inside the page body instead of adding a second header.', '2026-10-30 · A page-specific header sat beneath the Frame navbar and duplicated the page title.', 'The shared Frame owns the page navbar.', t('spaceMd','tap')],['bottom-tabs','Navigation','Bottom tabs without overlap', 'On phones, reserve space for a bottom tab bar in the scrollable content. Keep the last item and focused controls clear of the fixed bar and safe area.', '2026-10-30 · The fixed phone tabs covered the final rows and hid the focused control.', 'Scrollable content has enough bottom inset for the tabs and safe area.', t('tap','spaceMd','durationBase')],++ ['decor-stays-out','Layout that holds','Decorations stay out of content', 'A decoration (a seasonal fall, confetti, a sparkle) moves only inside the bar or element it belongs to, and settles on that edge. It never crosses a heading, a field or text, and reduced motion turns it off.', '2026-10-30 · Once a day the mark’s leaves fell 120–280 px from the brand, across the page heading on phones (Shared 4.50.1).', 'A flourish lands on its own bar’s edge and fades there.', t('durationSlow','easingStandard','durationReduced')],++ ['shared-columns','Layout that holds','One set of columns for a list', 'When a phone folds a table into stacked rows, every row uses the same columns. A grid each row sizes for itself makes names and numbers jump from row to row.', '2026-10-30 · Bench’s ratings rows each sized their own grid, so model names started at a different place in every row (Bench #35).', 'Rows of one list line up with each other.', t('spaceMd')],++ ['numbers-align','Layout that holds','Numbers line up under their header', 'A column of numbers is right-aligned, with tabular figures, and its header aligns the same way. Check that a broader rule does not override the alignment with higher specificity.', '2026-10-30 · Vote counts sat left-aligned under a right-aligned header, because a table-wide td rule outranked the numeric class (Bench #35).', 'Digits stack, and the header sits over them.', t('spaceMd','text')],++ ['tables-scroll-inside','Layout that holds','Tables scroll inside themselves', 'On a narrow screen, a table keeps its words whole and scrolls sideways inside its own box. The page never scrolls sideways, and a cell never breaks a word mid-letter.', '2026-10-30 · An answer’s table on a phone broke “Provider” and “Hetzner” across lines, because prose text could wrap anywhere (Actor #194).', 'Whole words in cells; only the table scrolls.', t('spaceMd')],++ ['measure-at-rest','Layout that holds','Measure at rest', 'Read an element’s box only after its entrance animation has ended. getBoundingClientRect includes transforms, so an element measured mid-entrance looks smaller or further away than it is.', '2026-10-30 · A Dock card measured 362 × 506 instead of 400 × 560, because it was read during its scale-in (Shared 4.51.0).', 'Wait for the motion to settle before you measure.', t('durationBase','easingStandard')],++ ['drag-or-tap','Layout that holds','Drag or tap, never both', 'A press becomes a drag only after the pointer moves 6 px. Listen to the moves on the window, because a quick drag leaves the handle before then. Swallow the click that ends a drag, so a drag that began on a tab does not select it.', '2026-10-30 · A fast drag from a Dock tab never started (the moves left the head before 6 px), and a slow one would have changed tabs (Shared 4.51.0).', 'Small moves are taps; real moves are drags.', t('tap','durationFast')],++ ['floating-clears-toasts','Layout that holds','Floating surfaces clear the toasts', 'A floating control or card in a corner tells the page how much room it takes (a CSS variable). Toasts in that corner stack above it instead of covering it.', '2026-10-30 · The Dock’s pill and desktop toasts both lived in the bottom-right corner (Shared 4.51.0).', 'Two things in one corner never cover each other.', t('spaceMd','durationBase','easingStandard')],['mark-system','Brand','The mark studies', 'Treat the mark as a working component with a loading state and a per-site morph. Compare the five studies in the live study page before choosing the mark used by the kit.', '2026-10-30 · A static logo could not communicate loading or adapt to a site identity.', 'The mark carries its identity through loading and site changes.', t('durationSlow','easingStandard','easingBack')],].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));
@@ -7,3 +7,30 @@
@@ -7,3 +7,30 @@
.dbtn,.pill-action,.opill,.tab-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:13px;font-weight:650;cursor:pointer}.dbtn.primary,.pill-action.primary,.opill.primary{background:var(--accent);border-color:transparent;color:var(--on-accent)}.status-badge,.meta-chip{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:6px 10px;background:color-mix(in srgb, var(--success) 16%, transparent);color:var(--success);font-size:12px;font-weight:700}.status-badge.warn{background:color-mix(in srgb, var(--warning) 16%, transparent);color:var(--warning)}.status-badge.bad{background:color-mix(in srgb, var(--danger) 16%, transparent);color:var(--danger)}.meta-chip{background:var(--bg-primary);border:1px solid var(--border);color:var(--text-secondary);font-weight:500}.demo-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.stream-box{display:grid;gap:7px;max-height:170px;overflow:auto;scroll-behavior:smooth;padding:2px 4px 2px 0}.stream-item,.attempt,.empty-demo,.progress-line,.outcome-line,.ship-line,.tab-panel{padding:12px;border-radius:12px;background:var(--bg-primary);border:1px solid var(--border)}.stream-item.enter{animation:row-in 180ms cubic-bezier(.2,.7,.2,1) both}@keyframes row-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}.demo-fold{width:100%;border:1px solid var(--border);border-radius:12px;background:var(--bg-primary);padding:12px;color:inherit;text-align:left}.demo-fold summary{min-height:44px;cursor:pointer;display:flex;align-items:center;font-weight:700}.fold-content{padding:8px 0 0;color:var(--text-secondary)}.progress-line{display:flex;align-items:center;gap:10px;min-height:44px}.progress-fold{display:none;padding:12px;color:var(--text-muted)}.progress-line.open+.progress-fold{display:block}.outcome-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.outcome-copy{flex:1;min-width:140px}.outcome-copy strong,.outcome-copy small{display:block}.outcome-copy small{color:var(--text-muted);margin-top:3px}.attempt-list{display:none;padding:8px 0 0 12px}.ship-line.open+.attempt-list{display:grid;gap:6px}.empty-demo{display:flex;align-items:center;justify-content:space-between;gap:10px}.demo-tabs{display:flex;gap:4px;border-bottom:1px solid var(--border)}.tab-button[aria-selected=true]{color:var(--accent);border-color:var(--accent)}.tab-panel{margin-top:10px;min-height:56px}.target-box{display:inline-grid;place-items:center;width:44px;height:44px;border:1px dashed var(--accent);border-radius:10px}.mark-demo{display:flex;align-items:center;gap:14px;font-size:26px;font-weight:800;letter-spacing:-.05em}.ov-mark{display:inline-grid;place-items:center;width:48px;height:48px;border-radius:16px;background:linear-gradient(145deg,var(--accent),var(--accent-light));color:var(--text-primary);font-size:23px;transition:transform 360ms cubic-bezier(.34,1.56,.64,1)}.ov-mark.loading{animation:mark-pulse 900ms ease-in-out infinite alternate}@keyframes mark-pulse{to{transform:rotate(12deg) scale(.88)}}.bottom-demo{position:relative;min-height:155px;padding:12px 12px 70px;border:1px solid var(--border);border-radius:14px;background:var(--bg-primary)}.bottom-nav{position:absolute;bottom:0;left:0;right:0;min-height:56px;padding:6px 12px max(6px,env(safe-area-inset-bottom));display:flex;justify-content:space-around;background:var(--bg-secondary);border-top:1px solid var(--border);border-radius:0 0 14px 14px}.site-switcher-demo{display:flex;align-items:center;gap:12px}.switcher-pop{display:none;position:absolute;z-index:2;top:55px;left:16px;padding:14px;border-radius:14px;background:var(--bg-secondary);border:1px solid var(--border);box-shadow:0 18px 50px color-mix(in srgb, var(--text-primary) 28%, transparent)}.site-switcher-demo.open .switcher-pop{display:grid;gap:8px}.site-switcher-demo{position:relative}.sample-nav{height:52px;padding:0 14px;display:flex;align-items:center;gap:16px;border:1px solid var(--border);border-radius:14px}.sample-nav span:last-child{margin-left:auto;color:var(--text-muted)}.demo-tabs button:focus-visible,.dbtn:focus-visible,.tab-button:focus-visible,.demo-fold summary:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
.dbtn,.pill-action,.opill,.tab-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:13px;font-weight:650;cursor:pointer}.dbtn.primary,.pill-action.primary,.opill.primary{background:var(--accent);border-color:transparent;color:var(--on-accent)}.status-badge,.meta-chip{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:6px 10px;background:color-mix(in srgb, var(--success) 16%, transparent);color:var(--success);font-size:12px;font-weight:700}.status-badge.warn{background:color-mix(in srgb, var(--warning) 16%, transparent);color:var(--warning)}.status-badge.bad{background:color-mix(in srgb, var(--danger) 16%, transparent);color:var(--danger)}.meta-chip{background:var(--bg-primary);border:1px solid var(--border);color:var(--text-secondary);font-weight:500}.demo-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.stream-box{display:grid;gap:7px;max-height:170px;overflow:auto;scroll-behavior:smooth;padding:2px 4px 2px 0}.stream-item,.attempt,.empty-demo,.progress-line,.outcome-line,.ship-line,.tab-panel{padding:12px;border-radius:12px;background:var(--bg-primary);border:1px solid var(--border)}.stream-item.enter{animation:row-in 180ms cubic-bezier(.2,.7,.2,1) both}@keyframes row-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}.demo-fold{width:100%;border:1px solid var(--border);border-radius:12px;background:var(--bg-primary);padding:12px;color:inherit;text-align:left}.demo-fold summary{min-height:44px;cursor:pointer;display:flex;align-items:center;font-weight:700}.fold-content{padding:8px 0 0;color:var(--text-secondary)}.progress-line{display:flex;align-items:center;gap:10px;min-height:44px}.progress-fold{display:none;padding:12px;color:var(--text-muted)}.progress-line.open+.progress-fold{display:block}.outcome-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.outcome-copy{flex:1;min-width:140px}.outcome-copy strong,.outcome-copy small{display:block}.outcome-copy small{color:var(--text-muted);margin-top:3px}.attempt-list{display:none;padding:8px 0 0 12px}.ship-line.open+.attempt-list{display:grid;gap:6px}.empty-demo{display:flex;align-items:center;justify-content:space-between;gap:10px}.demo-tabs{display:flex;gap:4px;border-bottom:1px solid var(--border)}.tab-button[aria-selected=true]{color:var(--accent);border-color:var(--accent)}.tab-panel{margin-top:10px;min-height:56px}.target-box{display:inline-grid;place-items:center;width:44px;height:44px;border:1px dashed var(--accent);border-radius:10px}.mark-demo{display:flex;align-items:center;gap:14px;font-size:26px;font-weight:800;letter-spacing:-.05em}.ov-mark{display:inline-grid;place-items:center;width:48px;height:48px;border-radius:16px;background:linear-gradient(145deg,var(--accent),var(--accent-light));color:var(--text-primary);font-size:23px;transition:transform 360ms cubic-bezier(.34,1.56,.64,1)}.ov-mark.loading{animation:mark-pulse 900ms ease-in-out infinite alternate}@keyframes mark-pulse{to{transform:rotate(12deg) scale(.88)}}.bottom-demo{position:relative;min-height:155px;padding:12px 12px 70px;border:1px solid var(--border);border-radius:14px;background:var(--bg-primary)}.bottom-nav{position:absolute;bottom:0;left:0;right:0;min-height:56px;padding:6px 12px max(6px,env(safe-area-inset-bottom));display:flex;justify-content:space-around;background:var(--bg-secondary);border-top:1px solid var(--border);border-radius:0 0 14px 14px}.site-switcher-demo{display:flex;align-items:center;gap:12px}.switcher-pop{display:none;position:absolute;z-index:2;top:55px;left:16px;padding:14px;border-radius:14px;background:var(--bg-secondary);border:1px solid var(--border);box-shadow:0 18px 50px color-mix(in srgb, var(--text-primary) 28%, transparent)}.site-switcher-demo.open .switcher-pop{display:grid;gap:8px}.site-switcher-demo{position:relative}.sample-nav{height:52px;padding:0 14px;display:flex;align-items:center;gap:16px;border:1px solid var(--border);border-radius:14px}.sample-nav span:last-child{margin-left:auto;color:var(--text-muted)}.demo-tabs button:focus-visible,.dbtn:focus-visible,.tab-button:focus-visible,.demo-fold summary:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
@media(max-width:620px){.pattern-page,.patterns-index{padding:24px 14px 100px}.pcards,.pcodegrid{grid-template-columns:1fr}.pnext{flex-direction:column}.pdemo-body{padding:14px}.pbug,.prule,.ptokens,.pnative{padding:16px}.pcodegrid pre{font-size:10px}}
@media(max-width:620px){.pattern-page,.patterns-index{padding:24px 14px 100px}.pcards,.pcodegrid{grid-template-columns:1fr}.pnext{flex-direction:column}.pdemo-body{padding:14px}.pbug,.prule,.ptokens,.pnative{padding:16px}.pcodegrid pre{font-size:10px}}
@media(prefers-reduced-motion:reduce){.pcard,.ov-mark{transition:none}.pcard:hover{transform:none}.stream-item.enter{animation:none}.ov-mark.loading{animation:none}.demo-fold,.stream-box{scroll-behavior:auto}}
@media(prefers-reduced-motion:reduce){.pcard,.ov-mark{transition:none}.pcard:hover{transform:none}.stream-item.enter{animation:none}.ov-mark.loading{animation:none}.demo-fold,.stream-box{scroll-behavior:auto}}
+
+/* Layout that holds (10-30): the demos for the seven rules. */
+.decor-demo{position:relative;height:170px;overflow:hidden;border-radius:14px;border:1px solid var(--border);background:var(--bg-primary)}
+.decor-bar{display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--border);font-weight:700}
+.decor-mark{display:grid;place-items:center;width:26px;height:26px;border-radius:9px;background:var(--accent);color:var(--on-accent);font-size:11px;font-weight:800}
+.decor-head{display:block;margin:14px 12px 4px;font-size:22px}.decor-demo p{margin:0 12px;color:var(--text-secondary);font-size:14px}
+.decor-leaf{position:absolute;top:10px;width:9px;height:9px;border-radius:0 70% 0 70%;background:var(--warning);opacity:0;pointer-events:none}
+.decor-leaf:nth-of-type(2n){background:var(--accent)}.decor-leaf:nth-of-type(3n){background:var(--success)}
+.cols-demo{display:grid;gap:6px;max-width:320px}.cols-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 3em;gap:8px;align-items:center;padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary)}
+.cols-demo.per-row .cols-row{grid-template-columns:auto 1fr auto}.cols-band{justify-self:start;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:750;color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}
+.cols-name{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700}.cols-score{text-align:right;font-variant-numeric:tabular-nums}
+.num-demo{border-collapse:collapse;min-width:240px}.num-demo th,.num-demo td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left}
+.num-demo .num{text-align:right;font-variant-numeric:tabular-nums}.num-demo.broken td.num{text-align:left;font-variant-numeric:normal}
+.tscroll-demo{max-width:220px;overflow-x:auto;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary)}
+.tscroll-table{border-collapse:collapse}.tscroll-table th,.tscroll-table td{padding:8px 10px;border-bottom:1px solid var(--border);text-align:left;white-space:nowrap}
+.tscroll-demo.broken{overflow-x:visible}.tscroll-demo.broken .tscroll-table{width:100%;table-layout:fixed}.tscroll-demo.broken th,.tscroll-demo.broken td{white-space:normal;overflow-wrap:anywhere}
+.measure-card{display:grid;place-items:center;width:200px;height:120px;border-radius:18px;border:1px solid var(--border);background:var(--bg-primary);font-weight:800}
+.drag-demo{position:relative;height:150px;border-radius:14px;border:1px dashed var(--border);background:var(--bg-primary);touch-action:none}
+.drag-chip{position:absolute;min-height:44px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;font-weight:700;cursor:grab;transition:top .25s,left .25s,right .25s,bottom .25s}
+.drag-chip.is-moving{cursor:grabbing;transition:none;box-shadow:var(--shadow-lg)}
+.drag-chip[data-at=tl]{top:10px;left:10px}.drag-chip[data-at=tr]{top:10px;right:10px}.drag-chip[data-at=bl]{bottom:10px;left:10px}.drag-chip[data-at=br]{bottom:10px;right:10px}
+.clear-demo{--clear:0px;position:relative;height:170px;border-radius:14px;border:1px solid var(--border);background:var(--bg-primary);overflow:hidden}
+.clear-toasts{position:absolute;right:10px;bottom:calc(10px + var(--clear));display:grid;gap:6px;transition:bottom .26s cubic-bezier(.2,.7,.2,1)}
+.clear-toast{padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-secondary);font-size:13px;font-weight:650;box-shadow:var(--shadow-lg)}
+.clear-pill{position:absolute;right:10px;bottom:10px;display:inline-flex;align-items:center;height:42px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);font-weight:750;font-size:13px}
+.clear-pill::before{content:'';width:8px;height:8px;margin-right:8px;border-radius:50%;background:var(--warning)}
+@media(prefers-reduced-motion:reduce){.drag-chip,.clear-toasts{transition:none}}
@@ -51,6 +51,85 @@
@@ -51,6 +51,85 @@
const nav=node('nav','sample-nav');nav.setAttribute('aria-label','Example page navbar');nav.append(node('strong','','OpenVibe'),node('span','','Patterns'),node('span','','One shared navbar'));widget.append(nav);say('This page itself uses the shared Frame navbar once.');
const nav=node('nav','sample-nav');nav.setAttribute('aria-label','Example page navbar');nav.append(node('strong','','OpenVibe'),node('span','','Patterns'),node('span','','One shared navbar'));widget.append(nav);say('This page itself uses the shared Frame navbar once.');
} else if (id === 'bottom-tabs') {
} else if (id === 'bottom-tabs') {
const frame=node('div','bottom-demo');frame.append(node('strong','','Scrollable content'),node('p','','This content reserves space under the fixed phone tabs.'),node('p','','The final row remains visible above the bar.'));const nav=node('nav','bottom-nav');nav.setAttribute('aria-label','Phone tabs');['Home','Runs','Settings'].forEach(x=>nav.append(button(x,()=>say(`${x} tab selected.`))));frame.append(nav);widget.append(frame);
const frame=node('div','bottom-demo');frame.append(node('strong','','Scrollable content'),node('p','','This content reserves space under the fixed phone tabs.'),node('p','','The final row remains visible above the bar.'));const nav=node('nav','bottom-nav');nav.setAttribute('aria-label','Phone tabs');['Home','Runs','Settings'].forEach(x=>nav.append(button(x,()=>say(`${x} tab selected.`))));frame.append(nav);widget.append(frame);
+ } else if (id === 'decor-stays-out') {
+ const frame = node('div', 'decor-demo'), bar = node('div', 'decor-bar'), head = node('strong', 'decor-head', 'Conductor');
+ bar.append(node('span', 'decor-mark', 'ov'), node('span', '', 'Codes'));
+ frame.append(bar, head, node('p', '', 'The page’s own text stays clear.'));
+ widget.append(frame);
+ const fall = (land) => {
+ frame.querySelectorAll('.decor-leaf').forEach((x) => x.remove());
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (still) { say('Reduced motion is on: no fall at all.'); return; }
+ const barBottom = bar.offsetTop + bar.offsetHeight;
+ for (let i = 0; i < 6; i++) {
+ const leaf = node('span', 'decor-leaf'); frame.append(leaf);
+ const x = 26 + i * 22 + Math.round(Math.random() * 10);
+ leaf.style.left = `${x}px`;
+ const y = land ? barBottom - 14 - Math.round(Math.random() * 3) : 60 + Math.round(Math.random() * 70);
+ leaf.animate([{ transform: 'translateY(0) rotate(0)', opacity: 0 }, { opacity: 1, offset: 0.15 }, { transform: `translateY(${y}px) rotate(${180 + i * 40}deg)`, opacity: 1, offset: 0.72 }, { transform: `translateY(${y}px) rotate(${180 + i * 40}deg)`, opacity: 0 }], { duration: 2400 + i * 120, easing: 'cubic-bezier(.3,.1,.5,1)', fill: 'forwards' });
+ }
+ say(land ? 'The leaves land on the bar’s edge and fade there.' : 'The old way: the leaves fall across the heading.');
+ };
+ control('Play the fall', () => fall(true), true);
+ control('The old way', () => fall(false));
+ } else if (id === 'shared-columns') {
+ const list = node('div', 'cols-demo'), rows = [['1st', 'atlas-1', '1014'], ['2nd–12th', 'nimbus-12', '1009'], ['13th', 'a', '986']];
+ rows.forEach(([band, name, score]) => { const r = node('div', 'cols-row'); r.append(node('span', 'cols-band', band), node('span', 'cols-name', name), node('span', 'cols-score', score)); list.append(r); });
+ widget.append(list);
+ const set = (shared) => { list.classList.toggle('per-row', !shared); say(shared ? 'Every row shares one set of columns: names line up.' : 'Each row sizes its own columns: names jump around.'); };
+ control('Shared columns', () => set(true), true);
+ control('Per-row columns', () => set(false));
+ set(true);
+ } else if (id === 'numbers-align') {
+ const table = node('table', 'num-demo'), head = node('tr'), body = node('tbody');
+ ['Model', 'Votes'].forEach((h, i) => { const th = node('th', i ? 'num' : '', h); head.append(th); });
+ const thead = node('thead'); thead.append(head);
+ [['atlas-1', '8'], ['nimbus-2', '128'], ['orbit', '1,024']].forEach(([m, v]) => { const tr = node('tr'); tr.append(node('td', '', m), node('td', 'num', v)); body.append(tr); });
+ table.append(thead, body); widget.append(table);
+ control('Aligned', () => { table.classList.remove('broken'); say('Right-aligned, tabular figures: the digits stack under the header.'); }, true);
+ control('The bug', () => { table.classList.add('broken'); say('A broader td rule wins: the numbers sit left under a right-aligned header.'); });
+ } else if (id === 'tables-scroll-inside') {
+ const box = node('div', 'tscroll-demo'), table = node('table', 'tscroll-table');
+ table.innerHTML = '<thead><tr><th>Provider</th><th>Plan</th><th>€ / month</th><th>Traffic</th></tr></thead><tbody><tr><td>Hetzner</td><td>CX22</td><td>4.51</td><td>20 TB</td></tr><tr><td>Netcup</td><td>VPS 1000 G11</td><td>6.74</td><td>unmetered</td></tr></tbody>';
+ box.append(table); widget.append(box);
+ control('Scroll inside', () => { box.classList.remove('broken'); say('Whole words; the table scrolls inside its box.'); }, true);
+ control('The bug', () => { box.classList.add('broken'); say('Words break mid-letter to fit the screen.'); });
+ } else if (id === 'measure-at-rest') {
+ const card = node('div', 'measure-card', 'Dock'); widget.append(card);
+ const play = (atRest) => {
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const anim = card.animate([{ transform: 'scale(.82)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: still ? 0 : 300, easing: 'cubic-bezier(.2,.9,.25,1.08)' });
+ const read = () => { const b = card.getBoundingClientRect(); say(`${atRest ? 'At rest' : 'Mid-entrance'}: ${Math.round(b.width)} × ${Math.round(b.height)} px${atRest ? '' : ` (the card is ${card.offsetWidth} × ${card.offsetHeight})`}.`); };
+ if (atRest) anim.finished.then(read); else setTimeout(read, still ? 0 : 60);
+ };
+ control('Measure at rest', () => play(true), true);
+ control('Measure mid-entrance', () => play(false));
+ } else if (id === 'drag-or-tap') {
+ const box = node('div', 'drag-demo'), chip = node('button', 'drag-chip', 'Tap or drag me');
+ chip.type = 'button'; box.append(chip); widget.append(box);
+ let s = null, swallow = false, taps = 0;
+ const corners = ['tl', 'tr', 'bl', 'br']; let at = 'br'; chip.dataset.at = at;
+ const move = (e) => { if (!s || e.pointerId !== s.id) return; s.dx = e.clientX - s.x; s.dy = e.clientY - s.y; if (!s.live && Math.abs(s.dx) + Math.abs(s.dy) >= 6) { s.live = true; chip.classList.add('is-moving'); } if (s.live) chip.style.transform = `translate(${s.dx}px, ${s.dy}px)`; };
+ const end = (e) => {
+ if (!s || e.pointerId !== s.id) return;
+ window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
+ const live = s.live, r = chip.getBoundingClientRect(), b = box.getBoundingClientRect(); s = null;
+ chip.classList.remove('is-moving'); chip.style.transform = '';
+ if (!live) return;
+ swallow = true; setTimeout(() => { swallow = false; }, 0);
+ at = `${r.top + r.height / 2 < b.top + b.height / 2 ? 't' : 'b'}${r.left + r.width / 2 < b.left + b.width / 2 ? 'l' : 'r'}`;
+ chip.dataset.at = corners.includes(at) ? at : 'br';
+ say('A drag: it snapped to the nearest corner, and the press was not a tap.');
+ };
+ chip.addEventListener('pointerdown', (e) => { if (e.button) return; s = { x: e.clientX, y: e.clientY, dx: 0, dy: 0, id: e.pointerId, live: false }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end); });
+ chip.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); return; } taps++; say(`A tap (${taps}). It moved less than 6 px, so it stayed a tap.`); }, true);
+ } else if (id === 'floating-clears-toasts') {
+ const screen = node('div', 'clear-demo'), stack = node('div', 'clear-toasts'), pill = node('span', 'clear-pill', 'Needs you');
+ screen.append(stack, pill); widget.append(screen);
+ const toast = () => { const t = node('div', 'clear-toast', 'Codes finished a task'); stack.append(t); setTimeout(() => t.remove(), 3200); };
+ const set = (on) => { screen.style.setProperty('--clear', on ? '52px' : '0px'); pill.hidden = false; say(on ? 'The pill says how much room it takes: toasts stack above it.' : 'Nobody says: the toast covers the pill.'); toast(); };
+ control('Clear the pill', () => set(true), true);
+ control('The bug', () => set(false));
} else if (id === 'mark-system') {
} else if (id === 'mark-system') {
const row=node('div','mark-demo'),mark=node('span','ov-mark','ov');row.append(mark,node('span','','OpenVibe'));widget.append(row);control('Loading state',()=>{mark.classList.toggle('loading');say(mark.classList.contains('loading')?'Loading state active.':'Loading state stopped.');});control('Change site',()=>{mark.textContent=mark.textContent==='ov'?'lv':'ov';mark.style.borderRadius=mark.textContent==='lv'?'50%':'16px';say('The mark morphs for the active site.');});control('Open five studies',()=>{window.location.href='/patterns/mark/studies';});
const row=node('div','mark-demo'),mark=node('span','ov-mark','ov');row.append(mark,node('span','','OpenVibe'));widget.append(row);control('Loading state',()=>{mark.classList.toggle('loading');say(mark.classList.contains('loading')?'Loading state active.':'Loading state stopped.');});control('Change site',()=>{mark.textContent=mark.textContent==='ov'?'lv':'ov';mark.style.borderRadius=mark.textContent==='lv'?'50%':'16px';say('The mark morphs for the active site.');});control('Open five studies',()=>{window.location.href='/patterns/mark/studies';});
}
}
@@ -35,6 +35,13 @@ const patterns = [
@@ -35,6 +35,13 @@ const patterns = [
['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],
['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],
['single-navbar','Navigation','One navbar per page', 'Render one shared navbar at the top of each page. Put local section navigation inside the page body instead of adding a second header.', '2026-10-30 · A page-specific header sat beneath the Frame navbar and duplicated the page title.', 'The shared Frame owns the page navbar.', t('spaceMd','tap')],
['single-navbar','Navigation','One navbar per page', 'Render one shared navbar at the top of each page. Put local section navigation inside the page body instead of adding a second header.', '2026-10-30 · A page-specific header sat beneath the Frame navbar and duplicated the page title.', 'The shared Frame owns the page navbar.', t('spaceMd','tap')],
['bottom-tabs','Navigation','Bottom tabs without overlap', 'On phones, reserve space for a bottom tab bar in the scrollable content. Keep the last item and focused controls clear of the fixed bar and safe area.', '2026-10-30 · The fixed phone tabs covered the final rows and hid the focused control.', 'Scrollable content has enough bottom inset for the tabs and safe area.', t('tap','spaceMd','durationBase')],
['bottom-tabs','Navigation','Bottom tabs without overlap', 'On phones, reserve space for a bottom tab bar in the scrollable content. Keep the last item and focused controls clear of the fixed bar and safe area.', '2026-10-30 · The fixed phone tabs covered the final rows and hid the focused control.', 'Scrollable content has enough bottom inset for the tabs and safe area.', t('tap','spaceMd','durationBase')],
+ ['decor-stays-out','Layout that holds','Decorations stay out of content', 'A decoration (a seasonal fall, confetti, a sparkle) moves only inside the bar or element it belongs to, and settles on that edge. It never crosses a heading, a field or text, and reduced motion turns it off.', '2026-10-30 · Once a day the mark’s leaves fell 120–280 px from the brand, across the page heading on phones (Shared 4.50.1).', 'A flourish lands on its own bar’s edge and fades there.', t('durationSlow','easingStandard','durationReduced')],
+ ['shared-columns','Layout that holds','One set of columns for a list', 'When a phone folds a table into stacked rows, every row uses the same columns. A grid each row sizes for itself makes names and numbers jump from row to row.', '2026-10-30 · Bench’s ratings rows each sized their own grid, so model names started at a different place in every row (Bench #35).', 'Rows of one list line up with each other.', t('spaceMd')],
+ ['numbers-align','Layout that holds','Numbers line up under their header', 'A column of numbers is right-aligned, with tabular figures, and its header aligns the same way. Check that a broader rule does not override the alignment with higher specificity.', '2026-10-30 · Vote counts sat left-aligned under a right-aligned header, because a table-wide td rule outranked the numeric class (Bench #35).', 'Digits stack, and the header sits over them.', t('spaceMd','text')],
+ ['tables-scroll-inside','Layout that holds','Tables scroll inside themselves', 'On a narrow screen, a table keeps its words whole and scrolls sideways inside its own box. The page never scrolls sideways, and a cell never breaks a word mid-letter.', '2026-10-30 · An answer’s table on a phone broke “Provider” and “Hetzner” across lines, because prose text could wrap anywhere (Actor #194).', 'Whole words in cells; only the table scrolls.', t('spaceMd')],
+ ['measure-at-rest','Layout that holds','Measure at rest', 'Read an element’s box only after its entrance animation has ended. getBoundingClientRect includes transforms, so an element measured mid-entrance looks smaller or further away than it is.', '2026-10-30 · A Dock card measured 362 × 506 instead of 400 × 560, because it was read during its scale-in (Shared 4.51.0).', 'Wait for the motion to settle before you measure.', t('durationBase','easingStandard')],
+ ['drag-or-tap','Layout that holds','Drag or tap, never both', 'A press becomes a drag only after the pointer moves 6 px. Listen to the moves on the window, because a quick drag leaves the handle before then. Swallow the click that ends a drag, so a drag that began on a tab does not select it.', '2026-10-30 · A fast drag from a Dock tab never started (the moves left the head before 6 px), and a slow one would have changed tabs (Shared 4.51.0).', 'Small moves are taps; real moves are drags.', t('tap','durationFast')],
+ ['floating-clears-toasts','Layout that holds','Floating surfaces clear the toasts', 'A floating control or card in a corner tells the page how much room it takes (a CSS variable). Toasts in that corner stack above it instead of covering it.', '2026-10-30 · The Dock’s pill and desktop toasts both lived in the bottom-right corner (Shared 4.51.0).', 'Two things in one corner never cover each other.', t('spaceMd','durationBase','easingStandard')],
['mark-system','Brand','The mark studies', 'Treat the mark as a working component with a loading state and a per-site morph. Compare the five studies in the live study page before choosing the mark used by the kit.', '2026-10-30 · A static logo could not communicate loading or adapt to a site identity.', 'The mark carries its identity through loading and site changes.', t('durationSlow','easingStandard','easingBack')],
['mark-system','Brand','The mark studies', 'Treat the mark as a working component with a loading state and a per-site morph. Compare the five studies in the live study page before choosing the mark used by the kit.', '2026-10-30 · A static logo could not communicate loading or adapt to a site identity.', 'The mark carries its identity through loading and site changes.', t('durationSlow','easingStandard','easingBack')],
].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));
].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));