openvibers/openvibe.design Commits

Nine patterns from 11-07: the menu, your things, status dots, phone sheets, drop and tray, even grids (#173)

OP OpenVibers committed 6 days ago · fb21923349 · 3 files, +94 −0

Changes

ddiff --git a/public/css/patterns.css b/public/css/patterns.css
iindex 9350670..8e90f73 100644
---- a/public/css/patterns.css
++++ b/public/css/patterns.css
@@@ -64,3 +64,44 @@
.mixed-word{padding:2px 8px;border-radius:999px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;border:1px solid var(--hue);color:color-mix(in srgb,var(--hue) 50%,var(--panel-text))}
.mixed-demo.broken .mixed-word{color:var(--hue)}
@media (prefers-reduced-motion: reduce){.places-node{transition:none}}
++
++/* 11-07 patterns: the menu, site cards, your things, status dots, the phone sheet, drop and tray, card or row, even grids, six places. */
++.menu-demo,.card-demo,.phone-demo,.drop-demo,.cr-demo,.even-demo,.six-demo,.things-bar{border:1px solid var(--border);border-radius:14px;background:var(--bg-primary)}
++.menu-bar,.things-bar,.six-demo{display:flex;align-items:center;gap:8px;min-height:52px;padding:0 10px}
++.menu-gap{flex:1}
++.menu-panel{display:none;list-style:none;margin:0;padding:8px;border-top:1px solid var(--border);counter-reset:m}
++.menu-demo.open .menu-panel{display:grid;gap:4px;animation:row-in var(--ov-dur-2,180ms) ease-out both}
++.menu-panel li{counter-increment:m;padding:8px 10px;border-radius:10px;background:var(--bg-secondary)}
++.menu-panel li::before{content:counter(m) " ";color:var(--text-muted);font-weight:800}
++.card-demo{padding:12px;display:grid;gap:8px}.card-head{display:flex;align-items:baseline;gap:8px}
++.card-rows{margin:0;padding:0 0 0 20px;display:grid;gap:6px}.card-rows li b,.card-rows li small{display:block}.card-rows li small{color:var(--text-muted)}
++.things-bar.out .meta-chip,.things-bar.out .things-msgs,.things-bar.out .things-face{display:none}
++.things-msgs{position:relative;display:inline-grid;place-items:center;width:36px;height:36px;border-radius:999px;border:1px solid var(--border)}
++.things-msgs b{position:absolute;top:-4px;right:-4px;min-width:18px;padding:0 5px;border-radius:999px;background:var(--danger);color:#fff;font-size:11px;text-align:center}.things-msgs b:empty{display:none}
++.things-face{position:relative;display:inline-grid;place-items:center;width:36px;height:36px;border-radius:50%;background:linear-gradient(145deg,var(--accent),var(--accent-light));color:#fff;font-weight:800}
++.things-face.big{width:56px;height:56px;font-size:20px}
++.dot-demo{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border-radius:50%;border:2.5px solid var(--bg-primary);background:#22c55e}
++.things-face.big .dot-demo{width:16px;height:16px}
++.dot-demo[data-state=away]{background:radial-gradient(circle at 70% 30%,var(--bg-primary) 0 2.4px,#f59e0b 2.8px)}
++.dot-demo[data-state=dnd]{background:#ef4444}.dot-demo[data-state=dnd]::after{content:"";position:absolute;left:2px;right:2px;top:50%;height:2px;margin-top:-1px;border-radius:2px;background:var(--bg-primary)}
++.dot-demo[data-state=live]{background:#ef4444}.dot-demo[data-state=none]{display:none}
++.phone-demo{position:relative;height:220px;overflow:hidden;padding:12px 14px;max-width:300px}
++.phone-demo.broken::before{content:"Upload form · Folder form · Backup link";display:block;padding:40px 10px;margin:-4px 0 8px;border:1px dashed var(--border);border-radius:10px;color:var(--text-muted);font-size:12px}
++.fab-demo{position:absolute;right:12px;bottom:12px;border-radius:16px}
++.sheet-demo{position:absolute;left:0;right:0;bottom:0;padding:10px;border-radius:18px 18px 0 0;background:var(--bg-secondary);border-top:1px solid var(--border);transform:translateY(100%);transition:transform var(--ov-dur-2,260ms) cubic-bezier(.2,.8,.2,1);box-shadow:0 0 0 400px color-mix(in srgb,#000 0%,transparent)}
++.phone-demo.open .sheet-demo{transform:none;box-shadow:0 0 0 400px color-mix(in srgb,#000 40%,transparent)}
++.sheet-row{padding:12px;border-radius:10px}.sheet-row+.sheet-row{border-top:1px solid var(--border)}
++.drop-demo{display:grid;place-items:center;min-height:90px;border-style:dashed;color:var(--text-muted)}
++.tray-demo{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:6px}
++.tray-demo li{position:relative;display:flex;justify-content:space-between;padding:8px 10px;border-radius:10px;background:var(--bg-secondary);overflow:hidden}
++.tray-demo li i{position:absolute;left:0;bottom:0;height:3px;width:0;background:var(--accent);transition:width .2s}.tray-demo li.done i{background:var(--success)}
++.cr-demo{list-style:none;margin:0;padding:10px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
++.cr-demo li{display:grid;gap:6px;padding:8px;border-radius:10px;background:var(--bg-secondary);min-width:0;font-size:12.5px;overflow-wrap:anywhere}
++.cr-thumb{display:grid;place-items:center;aspect-ratio:4/3;border-radius:8px;background:color-mix(in srgb,var(--accent) 14%,transparent);font-weight:800;font-size:11px}
++.cr-demo.rows{grid-template-columns:minmax(0,1fr);gap:0}.cr-demo.rows li{grid-template-columns:40px minmax(0,1fr);align-items:center;border-radius:0;background:none;border-bottom:1px solid var(--border)}.cr-demo.rows .cr-thumb{aspect-ratio:1}
++.even-demo{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:8px;padding:10px}
++.even-demo span{display:grid;place-items:center;height:44px;border-radius:10px;background:var(--bg-secondary);font-weight:800}
++.six-demo{flex-wrap:wrap;padding:8px 10px;font-size:13px}.six-demo strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}
++.six-demo span{padding:4px 8px;border-radius:999px;color:var(--text-secondary)}.six-more{border:1px solid var(--border)}
++@media (prefers-reduced-motion:reduce){.sheet-demo,.tray-demo li i{transition:none}.menu-demo.open .menu-panel{animation:none}}
++.status-word{font-weight:700;font-size:15px}
ddiff --git a/public/js/patterns.js b/public/js/patterns.js
iindex 6e50645..c6d9079 100644
---- a/public/js/patterns.js
++++ b/public/js/patterns.js
@@@ -167,6 +167,50 @@
widget.append(row);
control('Mixed with the text', () => { row.classList.remove('broken'); say('Half the hue, half the text: readable on both.'); }, true);
control('The bug', () => { row.classList.add('broken'); say('The pure hue: fine on dark, 1.7:1 for gold on white.'); });
++ } else if (id === 'one-menu') {
++ const box = node('div', 'menu-demo'); const bar = node('div', 'menu-bar'); const orb = button('✦', () => open('orb')); orb.setAttribute('aria-label', 'Open from the orb'); const burger = button('☰', () => open('burger')); burger.setAttribute('aria-label', 'Open from the burger');
++ bar.append(orb, node('strong', '', 'OpenVibe.Live'), node('span', 'menu-gap'), burger); const panel = node('ol', 'menu-panel'); box.append(bar, panel); widget.append(box);
++ const order = { burger: ['This site’s pages', 'Live now', 'Your places', 'Your workspace', 'From the sites', 'What’s new', 'Every site (54)', 'Jump back in'], orb: ['Your places', 'Live now', 'Your workspace', 'From the sites', 'What’s new', 'This site’s pages', 'Every site (54)', 'Jump back in'] };
++ function open(from) { panel.replaceChildren(...order[from].map((t) => node('li', '', t))); box.classList.add('open'); say(from === 'burger' ? 'From the burger: this site’s pages first.' : 'From the orb: your places first.'); }
++ control('Open from the burger', () => open('burger'), true); control('Back', () => { box.classList.remove('open'); say('Back closed the menu; the page stayed.'); });
++ } else if (id === 'site-cards') {
++ const card = node('div', 'card-demo'); const head = node('div', 'card-head'); head.append(node('strong', '', 'Trending'), node('span', 'pmeta', 'Video')); const list = node('ol', 'card-rows'); card.append(head, list); widget.append(card);
++ const add = (title, meta, ok) => { if (!ok) { say('Dropped: the entry’s link is not https on an OpenVibe host.'); return; } const li = node('li', ''); li.append(node('b', '', title), node('small', '', meta)); list.append(li); card.hidden = false; say(`${list.children.length} real entr${list.children.length === 1 ? 'y' : 'ies'}.`); };
++ card.hidden = true; say('No entries yet: the card is not shown.');
++ control('Publish an entry', () => add(['A rainy night walk', 'Speedrun practice', 'Cozy build stream'][list.children.length % 3], `${20 - list.children.length} views this week`, true), true);
++ control('A bad link', () => add('Example', 'javascript:', false));
++ } else if (id === 'your-things') {
++ const bar = node('div', 'things-bar'); const coins = node('span', 'meta-chip', '● 1,240'); const vibes = node('span', 'meta-chip', '● 30'); const msgs = node('span', 'things-msgs', '✉'); const n = node('b', '', ''); msgs.append(n); const face = node('span', 'things-face', 'A'); const dot = node('i', 'dot-demo'); dot.dataset.state = 'online'; face.append(dot);
++ bar.append(node('strong', '', 'Any site'), node('span', 'menu-gap'), coins, vibes, msgs, face); widget.append(bar); let count = 0;
++ control('A new message', () => { count += 1; n.textContent = String(count); say(`${count} unread: the same count on every site.`); }, true);
++ control('Sign out', () => { bar.classList.toggle('out'); say(bar.classList.contains('out') ? 'Signed out: none of it shows, nothing is asked.' : 'Signed in again.'); });
++ } else if (id === 'status-dot') {
++ const row = node('div', 'demo-row'); const face = node('span', 'things-face big', 'K'); const dot = node('i', 'dot-demo'); face.append(dot); const word = node('span', 'status-word', ''); row.append(face, word); widget.append(row);
++ const set = (state, label, others) => { dot.dataset.state = state; word.textContent = label; say(others); };
++ set('online', 'Online', 'Others see a green dot and the word Online.');
++ [['online', 'Online', 'Others see a green dot and the word Online.'], ['away', 'Away', 'A crescent after ten minutes without input.'], ['dnd', 'Do not disturb', 'A bar across the dot; pings stay quiet.'], ['live', 'Live now', 'Live outranks the rest while streaming.'], ['none', 'Invisible', 'Others see no dot at all, and never “last seen”.']].forEach(([s2, l, o], i) => control(l, () => set(s2, l, o), i === 0));
++ } else if (id === 'fab-sheet') {
++ const phone = node('div', 'phone-demo'); phone.append(node('p', '', 'My files'), node('p', 'pmeta', '24 files')); const fab = button('+ New', () => { phone.classList.add('open'); say('The sheet opens from the bottom.'); }, true); fab.className += ' fab-demo';
++ const sheet = node('div', 'sheet-demo'); ['Upload files', 'New folder', 'Back up a folder'].forEach((t) => sheet.append(node('div', 'sheet-row', t))); phone.append(fab, sheet); widget.append(phone);
++ control('Back', () => { phone.classList.remove('open'); say('Back closed the sheet first.'); });
++ control('The bug', () => { phone.classList.toggle('broken'); say(phone.classList.contains('broken') ? 'Three forms above the list: the files start a screen down.' : 'One button; the files start at the top.'); });
++ } else if (id === 'drop-anywhere') {
++ const zone = node('div', 'drop-demo', 'Drop files anywhere here'); const tray = node('ul', 'tray-demo'); widget.append(zone, tray); let n = 0;
++ const drop = () => { n += 1; const li = node('li', ''); const bar = node('i', ''); li.append(node('span', '', `photo-${n}.jpg`), bar); tray.append(li); let pct = 0; const tick = setInterval(() => { pct = Math.min(100, pct + 25); bar.style.width = `${pct}%`; if (pct === 100) { clearInterval(tick); li.classList.add('done'); say('In: the list updates in place.'); } }, 220); say('Uploading into the folder on screen.'); };
++ zone.addEventListener('dragover', (e) => e.preventDefault()); zone.addEventListener('drop', (e) => { e.preventDefault(); drop(); });
++ control('Drop a file', drop, true);
++ } else if (id === 'card-or-row') {
++ const list = node('ul', 'cr-demo'); ['Report.pdf', 'Budget.xlsx', 'Trailer.mp4', 'Notes.md'].forEach((t) => { const li = node('li', ''); li.append(node('span', 'cr-thumb', t.split('.').pop().toUpperCase()), node('span', '', t)); list.append(li); }); widget.append(list);
++ control('Grid', () => { list.classList.remove('rows'); say('The same items as cards.'); }, true); control('List', () => { list.classList.add('rows'); say('The same items as rows: nothing re-rendered.'); });
++ } else if (id === 'even-grid') {
++ const grid = node('div', 'even-demo'); const fill = (k) => grid.replaceChildren(...Array.from({ length: k }, (_, i) => node('span', '', String(i + 1)))); fill(7); grid.style.setProperty('--cols', 3); widget.append(grid); say('Seven in three columns: one alone.');
++ control('Eight in four', () => { fill(8); grid.style.setProperty('--cols', 4); say('Eight in four: two even rows.'); }, true);
++ control('The bug', () => { fill(7); grid.style.setProperty('--cols', 3); say('Seven in three columns: one alone.'); });
++ } else if (id === 'six-places') {
++ const bar = node('div', 'six-demo'); const all = ['Models', 'Chat', 'Arena', 'Rankings', 'Start', 'API', 'Why this model', 'Sell compute', 'Your endpoint', 'Uncensored', 'AI in OpenVibe', 'Questions', 'Status'];
++ const draw = (k) => { bar.replaceChildren(node('strong', '', 'OpenVibe.Exchange'), ...all.slice(0, k).map((t) => node('span', '', t)), ...(k < all.length ? [node('span', 'six-more', `More (${all.length - k})`)] : [])); };
++ draw(all.length); widget.append(bar); say('Thirteen links wrap and cut the name.');
++ control('Six, the rest under More', () => { draw(6); say('Six places, one line, the name whole.'); }, true); control('The bug', () => { draw(all.length); say('Thirteen links wrap and cut the name.'); });
} 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';});
}
ddiff --git a/server/patterns/catalog.js b/server/patterns/catalog.js
iindex 4f6becf..898ebdd 100644
---- a/server/patterns/catalog.js
++++ b/server/patterns/catalog.js
@@@ -48,6 +48,15 @@ const patterns = [
['own-places','Layout that holds','The hero draws its own places', 'A home’s first picture says what this site is: an orbit of its own places, or its own live numbers, every node a link. A generic picture shared by many sites says nothing.', '2026-11-01 · Six homes drew the same rings around the mark, and Search left a tall empty band where its numbers belonged.', 'The first screen shows this site, not the network’s wallpaper.', t('durationSlow','easingStandard','accent')],
['mixed-words','Elements','Coloured words mix with the text', 'A word in a hue (a rarity, a status, an AI label) is the hue mixed with the theme’s text, about half and half. It holds on dark and light themes; fills and borders keep the pure hue.', '2026-11-01 · Gold, violet and green words read at 1.4 to 2.5:1 on Daylight.', 'One rule for words in colour, on every theme.', t('text','accent','surface')],
['mark-system','Brand','The mark at work', 'Treat the mark as a working component: it shows a page on its way, work, done, failed and live in one shape, and moves only when it has something to say. The mark is the Pair (2026-10-31); its moves, forms and files are on /brand.', '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')],
++ ['one-menu','Navigation','One menu, opened from either end','The burger and the orb open the same OpenVibe menu. From the burger, this site’s pages lead; from the orb, your places do. Then who is live, your places, your workspace, what the sites put forward, what is new since your last visit, tools, every site behind a count, and Jump back in last. On a phone it fills the screen, and Back closes it before it leaves the page.', '2026-11-07 · A right-hand drawer and the OpenVibe menu both listed the site’s pages; the site list cut its labels, and Back on a phone left the page with the menu still open.', 'One menu, two doors; the order follows the door.', t('durationBase','easingStandard','tap')],
++ ['site-cards','Navigation','Sites put their own content in the menu','A site publishes what it wants in the menu as data (frame.menu-card@1 at /.well-known/openvibe-menu.json): a title, up to eight entries with a link and one short line, and a More link. The Network checks every entry and drops any that does not validate. A card with no real entries is not shown, and never filled with examples.', '2026-11-07 · The menu linked to Video, Games and Live but showed nothing of what was on them, so people opened a site to find out.', 'Real entries from the site itself, checked, or nothing.', t('spaceMd','radiusPill')],
++ ['your-things','Navigation','Your things in every bar','For a signed-in person, every site’s bar carries the same three things: Messages with an unread count (they open in place), your balances, and your status on your picture. A site may say no to one, or bring its own, but never a second copy.', '2026-11-07 · DMs and balances showed on Live and Chat only, so on every other site a message meant leaving for another tab.', 'Messages, balances and your status, the same on every site.', t('tap','radiusPill')],
++ ['status-dot','Elements','Status is a dot and a word','Show a person’s status as a dot on their picture and a word beside it: online, away, do not disturb, live. To other people, offline and invisible show no dot at all; never show “last seen”. The dot is decoration (aria-hidden), and the picture’s name carries the word.', '2026-11-07 · A profile said “Live now” for someone who was only online, because there was no way to say online.', 'A dot for the eye, a word for everyone, and nothing when away from it.', t('spaceMd','text')],
++ ['fab-sheet','Layout that holds','On a phone, New is a floating button over a sheet','A page whose main act is adding (files, posts, worlds) puts New in a floating button above the tab bar. It opens a sheet from the bottom with the two or three ways to add; a tap outside, Escape or Back closes it. On a computer the same New sits at the top of the sidebar and opens a small card.', '2026-11-07 · The drive stacked an upload form, a folder form and a backup link above every list, so the files began a screen down.', 'Adding is one button; its choices wait in a sheet.', t('tap','durationBase','easingStandard')],
++ ['drop-anywhere','Elements','Drop anywhere, watch a tray','Where people keep files, a file dropped anywhere on the page uploads into the place on screen. A tray in the corner lists each file with its progress, says what failed and why, and the list updates in place when they are in. Choosing files starts them; no second Upload button.', '2026-11-07 · The drive took one file at a time through a form, and then left the page for that file, so ten files meant ten round trips.', 'The page is the drop target; the tray is the receipt.', t('durationFast','spaceMd')],
++ ['card-or-row','Layout that holds','One card that is also a row','A grid of cards and a list of rows are the same markup laid out two ways, so switching view never re-renders, loses a menu, or changes what is listed. Remember the view the person chose.', '2026-11-07 · Two templates for grid and list drifted apart: the list lost the thumbnail, the grid lost the menu.', 'One item, two layouts, one switch.', t('spaceMd','durationFast')],
++ ['even-grid','Layout that holds','An even grid: no card alone on a row','Count the cards before choosing the columns. Eight cards are two or four to a row, six are two or three, and a card left alone on the last row is either a ninth worth adding or a column count to change.', '2026-11-07 · Seven doors in three columns left one card alone on the Exchange home.', 'Columns that divide the cards.', t('spaceMd')],
++ ['six-places','Navigation','Six places in the bar, the rest under More','A site’s bar holds at most six places, each with an icon and one or two words. Everything else goes under More, and the brand is the way home. A label never wraps onto a second line.', '2026-11-07 · Fourteen links wrapped into two lines (“Why this model”, “Sell compute”) and cut the site’s name to “OpenVibe…”.', 'Six in the bar, the rest one tap away.', t('tap','spaceMd')],
].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }