openvibers/openvibe.design Commits
Themes and Your look: the layouts on a phone, one control per choice (#230)
Changes
@@@ -674,11 +674,12 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef/* /look: the icon styles side by side (server/views/look.js iconStyles): three small phone screens drawn with thereal glyphs. Each is a button that picks its style; the picked one wears the accent. */.lk-styles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:2px 14px 16px}--.lk-style{all:unset;box-sizing:border-box;display:flex;flex-direction:column;gap:8px;min-width:0;cursor:pointer;-webkit-tap-highlight-color:transparent}--.lk-style:focus-visible .lk-st-screen{outline:2px solid var(--accent);outline-offset:3px}++.lk-style input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}++.lk-style{all:unset;position:relative;box-sizing:border-box;display:flex;flex-direction:column;gap:8px;min-width:0;cursor:pointer;-webkit-tap-highlight-color:transparent}++.lk-style:has(input:focus-visible) .lk-st-screen{outline:2px solid var(--accent);outline-offset:3px}.lk-st-screen{display:flex;flex-direction:column;gap:7px;padding:10px 10px 0;border-radius:18px;border:1px solid var(--border,color-mix(in srgb, var(--text-primary, #fff) 10%, transparent));background:var(--bg-primary);color:var(--text-primary);overflow:hidden;transition:border-color var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out),transform var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out)}.lk-style:hover .lk-st-screen{border-color:color-mix(in srgb,var(--accent) 55%,var(--border,transparent))}--.lk-style[aria-pressed="true"] .lk-st-screen{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}++.lk-style:has(input:checked) .lk-st-screen{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.lk-style:active .lk-st-screen{transform:scale(.98)}.lk-st-head{display:flex;align-items:center;gap:8px;font-size:21px;padding-bottom:2px}.lk-st-title{flex:1 1 auto;height:9px;border-radius:5px;background:color-mix(in srgb,var(--text-primary) 70%,transparent);max-width:48%}@@@ -693,7 +694,7 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.lk-st-cap{display:flex;flex-direction:column;gap:2px;padding:0 2px}.lk-st-cap b{font-size:14px}.lk-st-cap small{color:var(--text-muted);font-size:12px;line-height:1.35}--.lk-style[aria-pressed="true"] .lk-st-cap b{color:var(--accent)}++.lk-style:has(input:checked) .lk-st-cap b{color:var(--accent)}@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}@@@ -50,7 +50,8 @@.lk-toast button { min-height: 40px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-weight: 700; cursor: pointer; }@media (max-width: 759px) {-- .ds-studio { padding-bottom: calc(84px + var(--ovnav-appbar-h, 0px)); }++ /* The bar is fixed over the page, so the page's last thing (the footer) is what must clear it. */++ body:has(.ds-studio) main ~ footer { padding-bottom: 96px; }.ds-actions { position: fixed; z-index: 45; left: 0; right: 0; bottom: var(--ovnav-appbar-h, 0px); display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; margin: 0; padding: 8px max(12px, env(safe-area-inset-left)) 8px max(12px, env(safe-area-inset-right)); border-top: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 -8px 24px #0003; }html:has(#ov-try-bar) .ds-actions { bottom: calc(var(--ovnav-appbar-h, 0px) + 68px); }.ds-actions .ovk-btn { flex-direction: column; gap: 2px; min-width: 52px; min-height: 48px; padding: 3px 5px; font-size: .67rem; line-height: 1.15; text-align: center; }@@@ -61,3 +62,49 @@.ds-actions [data-ovk-sheet] { font-size: .67rem; }.ds-actions [data-ovk-sheet] .ovk-i { width: 18px; height: 18px; }}++++/* ── The Themes page's layout (2026-11-09: the owner flagged it; openvibe.design/rules) ── */++/* The preview is a <section>: the room's section spacing is for reading pages, not for a card in this grid. */++.ds .ds-grid > section { margin-top: 0; }++.ds-count { margin-left: 4px; font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); }++/* The status line belongs to the buttons it answers: above them, and no gap while it has nothing to say. */++.ds-actions .ds-status { grid-column: 1 / -1; flex-basis: 100%; min-height: 0; margin: 0; }++.ds-actions .ds-status:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }++/* Five sites, five even tiles. */++.ds-editor .ds-try-sites { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }++.ds-editor .ds-try .ds-try-sites a { max-width: none; padding: 6px 1px; font-size: .6rem; }++.ds-editor .ds-try-sites a span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++@media (min-width: 760px) {++ /* One button to use it, then three even ones: no ragged rows. */++ .ds-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }++ .ds-actions #ds-use { grid-column: 1 / -1; }++ .ds-actions .ovk-btn, .ds-actions [data-ovk-sheet] { box-sizing: border-box; width: 100%; min-width: 0; padding-inline: 6px; }++}++@media (min-width: 1100px) { .ds-grid { grid-template-columns: 250px minmax(0, 1fr) 340px; } }++@media (max-width: 1099px) {++ /* The themes as a rail of small tiles: three and the edge of a fourth on a phone, so the rail says it goes on. */++ .ds-gal-list { grid-auto-columns: 112px; gap: 8px; margin-inline: -14px; padding: 3px 14px; scroll-padding-inline: 14px; scrollbar-width: none; }++ .ds-gal-list::-webkit-scrollbar { display: none; }++ .ds-gal-list .ds-swatch { grid-template-columns: minmax(0, 1fr); align-content: center; gap: 7px; min-height: 68px; padding: 10px 10px 9px; }++ .ds-gal-list .ds-swatch-name { font-size: .84rem; }++ .ds-gal-list .ds-swatch-mode { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }++ .ds-gal-list .ds-swatch { position: relative; }++}++@media (max-width: 759px) {++ /* The three sites side by side to swipe through, so the colours to edit are one screen below the preview, not three. */++ .ds-stage .ds-previews { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 86%; gap: 10px; margin-inline: -14px; padding-inline: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 14px; scrollbar-width: none; }++ .ds-stage .ds-previews::-webkit-scrollbar { display: none; }++ .ds-stage .ds-previews > .pv-frame { scroll-snap-align: start; }++ .ds-stage .pv-video { aspect-ratio: 16 / 6.5; }++ .ds-stage .pv-composer { margin-top: auto; }++ .ds-stage .pv-shoe-pic { aspect-ratio: 16 / 7; }++}++++/* ── Your look ── */++/* The icon styles: the three screens are the choice. */++.lk-icons { border-top: 1px solid var(--ovk-line); padding-top: 14px; }++.lk-icons > .ovk-set-text { display: block; padding: 0 14px 10px; }++.lk .ovk-seg label span { text-align: center; }++/* The theme's colours as four separate dots: two overlapping ones read as a switch. */++.lk-theme .tk-dots { gap: 5px; }++.lk-theme .tk-dots .tk-dot + .tk-dot { margin-left: 0; }@@@ -39,8 +39,8 @@if (dots && t) {dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));}-- const studio = $('#lk-studio');-- if (studio) studio.href = `/themes?theme=${encodeURIComponent(id)}`;++ const change = $('#lk-change');++ if (change) change.href = `/themes?theme=${encodeURIComponent(id)}`;const code = $('#lk-code');if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;}@@@ -55,18 +55,10 @@check('text', d.text);check('motion', d.motion);check('icons', d.icons || 'auto');-- root.querySelectorAll('[data-pick-icons]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-pick-icons') === (d.icons || 'auto'))));const s = root.querySelector('input[name="seasonal"]');if (s) s.checked = d.seasonal !== 'off';for (const k of Object.keys(COMFORT)) { const c = root.querySelector(`input[name="${k}"]`); if (c) c.checked = d[k] === COMFORT[k][0]; }}-- // A screen in the icon styles picks its style, the same as the Icons choice above it.-- root.addEventListener('click', (e) => {-- const b = e.target.closest && e.target.closest('[data-pick-icons]');-- if (!b) return;-- const r = root.querySelector(`input[name="icons"][value="${b.getAttribute('data-pick-icons')}"]`);-- if (r && !r.checked) r.click();-- });root.addEventListener('change', (e) => {const i = e.target;if (!i || !i.name) return;@@@ -76,6 +76,10 @@ const RULES = Object.freeze(['2026-11-09', 'A model page was mostly "not available yet".'),// ── Loading and motion ──++ R('say-it-once', 'length', 'One choice, one control; one action, one button.',++ 'A setting has a single control, and an action a single button on the page. When a picture of the choices can be pressed, the pictures are the control: no second switch beside them saying the same thing.',++ '2026-11-09', 'Your look had the icon style as a row of three words and again as three screens; Themes asked people to sign in twice on one screen.',++ [{ label: 'Your look', href: '/look' }]),R('no-spinners', 'motion', 'No spinning rings. Things move only while something is actually happening.','Loading shows the shape of what is coming (a shimmer placeholder of the right size) or the mark at work. A finished or idle thing is still.','2026-10-31', 'Spinning rings around icons everywhere looked cheap and said nothing.',@@@ -123,6 +127,14 @@ const RULES = Object.freeze(['Icon buttons included; spacing is separate from the hit area.','2026-10-30', 'Dense icon rows were hard to hit on a phone.',[{ label: '44 px targets', href: '/patterns/touch-targets' }]),++ R('answer-beside-the-action', 'phone', 'What a button did is said beside that button.',++ 'The line that says "Saved" or "Not saved" sits with the buttons it answers. On a phone, where the buttons are a bar at the bottom of the screen, the answer is in that bar.',++ '2026-11-09', 'Themes answered a press on the bottom bar in a line two screens further down the page.',++ [{ label: 'Themes', href: '/themes' }]),++ R('swipe-not-stack', 'phone', 'Views of one thing sit side by side to swipe, with the next one showing at the edge.',++ 'Three previews of the same theme, or three sizes of one design, are one row on a phone: each a screen wide less a sliver, so the edge of the next says there is more. What you change stays one screen away from what it changes.',++ '2026-11-09', 'Three stacked previews put the colours to edit 1,500 pixels below the theme they edit.',++ [{ label: 'Themes', href: '/themes' }]),R('keep-the-picture', 'phone', 'Controls that act on a picture keep the picture in view.','Camera and drive pad on screen together; the picture sticks under the bar while the controls scroll.','2026-10-16', 'Driving a robot meant scrolling its camera away.'),@@@ -7,14 +7,14 @@ const { layout, esc } = require('../render/layout');const link = (name) => `/components/${name}`;const demo = {-- navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Studio</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,++ navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Themes</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),-- popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', label: 'Open Studio', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),++ popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', label: 'Themes', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),keyboard: () => `<label class="cr-field">A keyboard-aware field <input type="text" placeholder="Focus this field"></label><p class="ds-fine">Tab moves forward; Escape closes a menu or sheet and returns focus.</p>`,'kit-row': () => kit.list([kit.row({ href: '/themes', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),'kit-card': () => kit.card({ href: '/themes', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),@@@ -160,7 +160,7 @@ function home({ themes } = {}) {faq: { items: [['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],-- ['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the studio\'s history brings any of the last fifty back.'],++ ['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the history on the Themes page brings any of the last fifty back.'],['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],['What is coming next?', 'Publishing a design straight to OpenVibe.Website and OpenVibe.Space, brand kits that dress a creator\'s channel, overlays and shop in one go, and the kit\'s components as native Android and iOS views.'],] },@@@ -210,11 +210,11 @@ function studio({ themes, me = {} } = {}) {const gallery = all.map((t) => `<li>${swatch(t, { tag: 'button' })}</li>`).join('');const data = JSON.stringify(all.map((t) => ({ id: t.id, name: t.name, mode: t.mode, variables: t.variables || {} }))).replace(/</g, '\\u003c');const body = `${CVD}<div class="ds-studio" data-signed-in="${signedIn ? '1' : '0'}">--<header class="ds-studio-h"><div><p class="ds-kicker">Theme studio</p><h1>Make it yours</h1>++<header class="ds-studio-h"><div><p class="ds-kicker">Themes</p><h1>Make it yours</h1><p class="ds-lede">Pick a theme, change any colour, and see it on three sites at once. Every check must pass before it can be used.</p></div>--${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.user.display_name || me.user.username)) || 'you')}</b>: Use changes your look on every OpenVibe site.</p>` : `<p class="ds-sign">${kit.button({ href: '/auth/login?next=%2Fstudio', label: 'Sign in to use a theme', kind: 'quiet', small: true })}</p>`}</header>++${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.user.display_name || me.user.username)) || 'you')}</b>: Use changes your look on every OpenVibe site.</p>` : ''}</header><div class="ds-grid">--<aside class="ds-gallery" aria-labelledby="ds-gal-h"><div class="ds-gal-head"><h2 id="ds-gal-h">Themes</h2>++<aside class="ds-gallery" aria-labelledby="ds-gal-h"><div class="ds-gal-head"><h2 id="ds-gal-h">Themes${all.length ? ` <span class="ds-count">${all.length}</span>` : ''}</h2><div class="ds-seg" role="group" aria-label="Show"><button type="button" data-show="all" aria-pressed="true">All</button><button type="button" data-show="dark" aria-pressed="false">Dark</button><button type="button" data-show="light" aria-pressed="false">Light</button></div></div><ul class="ds-gal-list">${gallery || '<li class="ds-fine">The catalog is not answering. Try again in a minute.</li>'}</ul></aside><section class="ds-stage" aria-labelledby="ds-stage-h"><div class="ds-stage-head"><h2 id="ds-stage-h"><span id="ds-current">Your current theme</span></h2>@@@ -223,27 +223,26 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u<aside class="ds-editor" aria-labelledby="ds-ed-h"><h2 id="ds-ed-h">Edit</h2><div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div><h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul>--<div class="ds-actions" aria-label="Theme actions"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${kit.icon('check')}<span>${signedIn ? 'Use this theme' : 'Sign in to use it'}</span></button>++<div class="ds-actions" aria-label="Theme actions"><p class="ds-status" id="ds-status" role="status"></p><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${kit.icon('check')}<span>${signedIn ? 'Use this theme' : 'Sign in to use it'}</span></button><button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled aria-label="Undo my edits" title="Undo my edits">${kit.icon('retry')}<span>Undo</span></button><a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" data-tokens-src="${esc(ovServe.url('tokens.js'))}" aria-label="This theme as code" title="This theme, with your edits, for the web, Android, iOS, Flutter and Figma">${kit.icon('code')}<span>Code</span></a>${kit.sheetButton({ sheet: 'ds-share-sheet', label: 'Share', icon: 'send', iconOnly: false })}</div>${codeSheet()}<div id="ds-try" hidden>${tryOn('')}</div>--<p class="ds-status" id="ds-status" role="status"></p>${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<form id="ds-submit-form"><div class="ds-share-preview" aria-hidden="true"><span class="tk-dots" id="ds-share-dots"></span><strong id="ds-share-theme">Your theme</strong></div><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label><label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label><button type="submit" class="ovk-btn ovk-btn--primary" ${signedIn ? '' : 'disabled'}>${kit.icon('send')} Send for review</button><p class="ds-fine">A person reviews each theme before anyone else sees it.</p></form>` })}${signedIn ? `<details class="ds-history" id="ds-history"><summary>${kit.icon('clock')}<span>History</span></summary><ol id="ds-history-list"><li class="ds-history-empty"><span aria-hidden="true">◌</span><p>Your earlier looks appear here.</p></li></ol></details>` : ''}</aside></div></div><script type="application/json" id="ds-themes">${data}</script>`;-- return layout({ title: 'Theme studio', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });++ return layout({ title: 'Themes', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });}function theme({ theme: t } = {}) {const v = (t && t.variables) || {};const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}--<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>++<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Edit this theme' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/themes' });@@@ -515,7 +514,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}}function notFound() {-- const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;++ const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">See every theme</a> or <a href="/">go to the home page</a>.</p></header>`;return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });}@@@ -19,8 +19,8 @@ const SITES = [['live', 'OpenVibe.Live'], ['chat', 'OpenVibe.Chat'], ['games', '* The three icon styles side by side, each a small phone screen drawn with the real glyphs (ov-glyphs.js): a header* with icons above 18 px, rows with small ones and an app bar whose current tab is solid. Line and Solid force their* style on every icon (.ovg-line, .ovg-solid); Auto resets the person's own choice (--ovg-pref) so it shows what-- * Auto does: solid where an icon is small, lines where it is large. A screen is a button that picks its style-- * (public/js/look.js), the same as the Icons choice above it.++ * Auto does: solid where an icon is small, lines where it is large. The three screens ARE the choice (one radio group,++ * name "icons"): a second control beside them said the same thing twice.*/function iconStyles() {const STYLES = [@@@ -35,7 +35,7 @@ function iconStyles() {const bar = ['fa-house', 'fa-compass', 'fa-bell', 'fa-user'].map((n, k) => `<span class="lk-st-tab"${k === 0 ? ' aria-current="page"' : ''}>${i(n, force)}</span>`).join('');return `<span class="lk-st-screen"${style === 'auto' ? ' style="--ovg-pref:18.4px"' : ''}><span class="lk-st-head"><span class="lk-st-title"></span>${i('fa-magnifying-glass', force)}${i('fa-bell', force)}</span>${rows}<span class="lk-st-bar">${bar}</span></span>`;};-- return `<div class="lk-styles" role="group" aria-label="The icon styles, side by side">${STYLES.map(([id, name, what]) => `<button type="button" class="lk-style" data-pick-icons="${id}" aria-pressed="${id === 'auto' ? 'true' : 'false'}">${screen(id)}<span class="lk-st-cap"><b>${name}</b><small>${what}</small></span></button>`).join('')}</div>`;++ return `<div class="lk-icons"><span class="ovk-set-text" id="lk-icons-h">Icons</span><div class="lk-styles" role="radiogroup" aria-labelledby="lk-icons-h">${STYLES.map(([id, name, what]) => `<label class="lk-style"><input type="radio" name="icons" value="${id}"${id === 'auto' ? ' checked' : ''}>${screen(id)}<span class="lk-st-cap"><b>${name}</b><small>${what}</small></span></label>`).join('')}</div></div>`;}function lookPage({ themes = [], me = {} } = {}) {@@@ -43,18 +43,17 @@ function lookPage({ themes = [], me = {} } = {}) {const signedIn = !!me.signedIn;const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2><div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>--<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;++<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change your theme', attrs: { id: 'lk-change' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;const settings = kit.section({id: 'lk-settings',title: 'How every site looks to you',note: signedIn ? 'Each change applies at once and is saved to your account, so every OpenVibe site follows.' : 'Each change applies at once on this device. Sign in and it follows you to every OpenVibe site.',-- body: kit.seg({ name: 'scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Match my device']], value: 'auto' })++ body: kit.seg({ name: 'scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Automatic']], value: 'auto' })+ kit.seg({ name: 'text', label: 'Text size', options: [['100', 'Standard'], ['112', 'Larger'], ['125', 'Largest']], value: '100' })+ kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })// Icons (openvibe-shared 4.71.0, plan L56; the owner, 2026-11-01: "a setting to prefer a certain icon type…// configurable for users in most cases"): the sample under it is ordinary page markup, drawn by ov-glyphs.js, so// it changes as the choice does.-- + kit.seg({ name: 'icons', label: 'Icons', options: [['auto', 'Auto'], ['line', 'Line'], ['solid', 'Solid']], value: 'auto' })+ iconStyles()+ kit.switchRow({ name: 'seasonal', label: 'The season\'s themes', hint: 'Halloween and winter stand in for the default theme while they last', on: true }),});@@@ -17,7 +17,7 @@ function list() {{id: 'design-page',title: 'Designing a page',-- used: 'Create, the Studio and the Arena: every page, screen, component or logo a model draws.',++ used: 'Create and the Arena: every page, screen, component or logo a model draws.',source: 'server/arena/ai.js',parts: [{ label: 'System prompt (the category changes per request)', text: systemPrompt('{category}') },@@@ -28,14 +28,14 @@ function list() {{id: 'design-theme',title: 'Drawing it in your theme',-- used: 'Create and the Studio, when a design is drawn in the theme you picked.',++ used: 'Create, when a design is drawn in the theme you picked.',source: 'server/designs.js',parts: [{ label: 'The request', text: themePrompt('{your prompt}', ['{the theme\'s colour variables}']) }],},{id: 'design-edit',title: 'Editing a design',-- used: 'The Studio, when you ask for a change in words.',++ used: 'Create, when you ask for a change in words.',source: 'server/designs.js',parts: [{ label: 'The request', text: editPrompt('{your instruction}', '{the current page}', '{the theme\'s colour variables}') }],},@@@ -32,7 +32,10 @@ const { assetVersion } = require('../server/assets');assert.strictEqual(r.status, 200);assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);-- assert.match(r.text, /Sign in to use a theme/);++ // One way to sign in on the page: the action bar's own button (a second one in the hero said it twice).++ assert.match(r.text, /<span>Sign in to use it<\/span>/);++ assert.doesNotMatch(r.text, /Sign in to use a theme/);++ assert.match(r.text, /<div class="ds-actions"[^>]*><p class="ds-status" id="ds-status" role="status"><\/p>/, 'the status line sits with the buttons');assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');const me = await t.get('/themes', { as: user });assert.strictEqual(me.status, 200);@@@ -98,8 +98,10 @@ const { boot, check, done } = require('./helpers/boot');assert.match(r.text, /Your look, everywhere/);for (const name of ['scheme', 'text', 'motion']) assert.match(r.text, new RegExp(`role="radiogroup"[^>]*>(<label><input type="radio" name="${name}"[^<]*<span>[^<]+</span></label>)+`), name);assert.match(r.text, /name="seasonal"/);-- // The icon styles side by side (plan L56): three screens drawn with the real glyphs, each a button for its style.-- for (const id of ['auto', 'line', 'solid']) assert.match(r.text, new RegExp(`<button type="button" class="lk-style" data-pick-icons="${id}" aria-pressed="(true|false)">`), id);++ // The icon styles side by side (plan L56): three screens drawn with the real glyphs. They are the choice itself,++ // one radio group; a segmented control beside them would be a second control for the same setting.++ for (const id of ['auto', 'line', 'solid']) assert.match(r.text, new RegExp(`<label class="lk-style"><input type="radio" name="icons" value="${id}"( checked)?>`), id);++ assert.strictEqual((r.text.match(/name="icons"/g) || []).length, 3, 'one control for the icon style');assert.match(r.text, /<span class="lk-st-screen" style="--ovg-pref:18\.4px">/, 'Auto shows Auto whatever the person chose');assert.match(r.text, /class="fa-solid fa-bell ovg-line"/, 'Line forces lines');assert.match(r.text, /class="fa-solid fa-bell ovg-solid"/, 'Solid forces solids');
@@ -674,11 +674,12 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -674,11 +674,12 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
/* /look: the icon styles side by side (server/views/look.js iconStyles): three small phone screens drawn with the
/* /look: the icon styles side by side (server/views/look.js iconStyles): three small phone screens drawn with the
real glyphs. Each is a button that picks its style; the picked one wears the accent. */
real glyphs. Each is a button that picks its style; the picked one wears the accent. */
.lk-styles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:2px 14px 16px}
.lk-styles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:2px 14px 16px}
-.lk-style{all:unset;box-sizing:border-box;display:flex;flex-direction:column;gap:8px;min-width:0;cursor:pointer;-webkit-tap-highlight-color:transparent}
-.lk-style:focus-visible .lk-st-screen{outline:2px solid var(--accent);outline-offset:3px}
+.lk-style input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
+.lk-style{all:unset;position:relative;box-sizing:border-box;display:flex;flex-direction:column;gap:8px;min-width:0;cursor:pointer;-webkit-tap-highlight-color:transparent}
+.lk-style:has(input:focus-visible) .lk-st-screen{outline:2px solid var(--accent);outline-offset:3px}
.lk-st-screen{display:flex;flex-direction:column;gap:7px;padding:10px 10px 0;border-radius:18px;border:1px solid var(--border,color-mix(in srgb, var(--text-primary, #fff) 10%, transparent));background:var(--bg-primary);color:var(--text-primary);overflow:hidden;transition:border-color var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out),transform var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out)}
.lk-st-screen{display:flex;flex-direction:column;gap:7px;padding:10px 10px 0;border-radius:18px;border:1px solid var(--border,color-mix(in srgb, var(--text-primary, #fff) 10%, transparent));background:var(--bg-primary);color:var(--text-primary);overflow:hidden;transition:border-color var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out),transform var(--ov-dur-2,.2s) var(--ov-ease-out,ease-out)}
.lk-style:hover .lk-st-screen{border-color:color-mix(in srgb,var(--accent) 55%,var(--border,transparent))}
.lk-style:hover .lk-st-screen{border-color:color-mix(in srgb,var(--accent) 55%,var(--border,transparent))}
-.lk-style[aria-pressed="true"] .lk-st-screen{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
+.lk-style:has(input:checked) .lk-st-screen{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.lk-style:active .lk-st-screen{transform:scale(.98)}
.lk-style:active .lk-st-screen{transform:scale(.98)}
.lk-st-head{display:flex;align-items:center;gap:8px;font-size:21px;padding-bottom:2px}
.lk-st-head{display:flex;align-items:center;gap:8px;font-size:21px;padding-bottom:2px}
.lk-st-title{flex:1 1 auto;height:9px;border-radius:5px;background:color-mix(in srgb,var(--text-primary) 70%,transparent);max-width:48%}
.lk-st-title{flex:1 1 auto;height:9px;border-radius:5px;background:color-mix(in srgb,var(--text-primary) 70%,transparent);max-width:48%}
@@ -693,7 +694,7 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -693,7 +694,7 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.lk-st-cap{display:flex;flex-direction:column;gap:2px;padding:0 2px}
.lk-st-cap{display:flex;flex-direction:column;gap:2px;padding:0 2px}
.lk-st-cap b{font-size:14px}
.lk-st-cap b{font-size:14px}
.lk-st-cap small{color:var(--text-muted);font-size:12px;line-height:1.35}
.lk-st-cap small{color:var(--text-muted);font-size:12px;line-height:1.35}
-.lk-style[aria-pressed="true"] .lk-st-cap b{color:var(--accent)}
+.lk-style:has(input:checked) .lk-st-cap b{color:var(--accent)}
@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}
@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}
@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}
@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}
@@ -50,7 +50,8 @@
@@ -50,7 +50,8 @@
.lk-toast button { min-height: 40px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-weight: 700; cursor: pointer; }
.lk-toast button { min-height: 40px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-weight: 700; cursor: pointer; }
@media (max-width: 759px) {
@media (max-width: 759px) {
- .ds-studio { padding-bottom: calc(84px + var(--ovnav-appbar-h, 0px)); }
+ /* The bar is fixed over the page, so the page's last thing (the footer) is what must clear it. */
+ body:has(.ds-studio) main ~ footer { padding-bottom: 96px; }
.ds-actions { position: fixed; z-index: 45; left: 0; right: 0; bottom: var(--ovnav-appbar-h, 0px); display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; margin: 0; padding: 8px max(12px, env(safe-area-inset-left)) 8px max(12px, env(safe-area-inset-right)); border-top: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 -8px 24px #0003; }
.ds-actions { position: fixed; z-index: 45; left: 0; right: 0; bottom: var(--ovnav-appbar-h, 0px); display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; margin: 0; padding: 8px max(12px, env(safe-area-inset-left)) 8px max(12px, env(safe-area-inset-right)); border-top: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 -8px 24px #0003; }
html:has(#ov-try-bar) .ds-actions { bottom: calc(var(--ovnav-appbar-h, 0px) + 68px); }
html:has(#ov-try-bar) .ds-actions { bottom: calc(var(--ovnav-appbar-h, 0px) + 68px); }
.ds-actions .ovk-btn { flex-direction: column; gap: 2px; min-width: 52px; min-height: 48px; padding: 3px 5px; font-size: .67rem; line-height: 1.15; text-align: center; }
.ds-actions .ovk-btn { flex-direction: column; gap: 2px; min-width: 52px; min-height: 48px; padding: 3px 5px; font-size: .67rem; line-height: 1.15; text-align: center; }
@@ -61,3 +62,49 @@
@@ -61,3 +62,49 @@
.ds-actions [data-ovk-sheet] { font-size: .67rem; }
.ds-actions [data-ovk-sheet] { font-size: .67rem; }
.ds-actions [data-ovk-sheet] .ovk-i { width: 18px; height: 18px; }
.ds-actions [data-ovk-sheet] .ovk-i { width: 18px; height: 18px; }
}
}
+
+/* ── The Themes page's layout (2026-11-09: the owner flagged it; openvibe.design/rules) ── */
+/* The preview is a <section>: the room's section spacing is for reading pages, not for a card in this grid. */
+.ds .ds-grid > section { margin-top: 0; }
+.ds-count { margin-left: 4px; font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); }
+/* The status line belongs to the buttons it answers: above them, and no gap while it has nothing to say. */
+.ds-actions .ds-status { grid-column: 1 / -1; flex-basis: 100%; min-height: 0; margin: 0; }
+.ds-actions .ds-status:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
+/* Five sites, five even tiles. */
+.ds-editor .ds-try-sites { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.ds-editor .ds-try .ds-try-sites a { max-width: none; padding: 6px 1px; font-size: .6rem; }
+.ds-editor .ds-try-sites a span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+@media (min-width: 760px) {
+ /* One button to use it, then three even ones: no ragged rows. */
+ .ds-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .ds-actions #ds-use { grid-column: 1 / -1; }
+ .ds-actions .ovk-btn, .ds-actions [data-ovk-sheet] { box-sizing: border-box; width: 100%; min-width: 0; padding-inline: 6px; }
+}
+@media (min-width: 1100px) { .ds-grid { grid-template-columns: 250px minmax(0, 1fr) 340px; } }
+@media (max-width: 1099px) {
+ /* The themes as a rail of small tiles: three and the edge of a fourth on a phone, so the rail says it goes on. */
+ .ds-gal-list { grid-auto-columns: 112px; gap: 8px; margin-inline: -14px; padding: 3px 14px; scroll-padding-inline: 14px; scrollbar-width: none; }
+ .ds-gal-list::-webkit-scrollbar { display: none; }
+ .ds-gal-list .ds-swatch { grid-template-columns: minmax(0, 1fr); align-content: center; gap: 7px; min-height: 68px; padding: 10px 10px 9px; }
+ .ds-gal-list .ds-swatch-name { font-size: .84rem; }
+ .ds-gal-list .ds-swatch-mode { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+ .ds-gal-list .ds-swatch { position: relative; }
+}
+@media (max-width: 759px) {
+ /* The three sites side by side to swipe through, so the colours to edit are one screen below the preview, not three. */
+ .ds-stage .ds-previews { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 86%; gap: 10px; margin-inline: -14px; padding-inline: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 14px; scrollbar-width: none; }
+ .ds-stage .ds-previews::-webkit-scrollbar { display: none; }
+ .ds-stage .ds-previews > .pv-frame { scroll-snap-align: start; }
+ .ds-stage .pv-video { aspect-ratio: 16 / 6.5; }
+ .ds-stage .pv-composer { margin-top: auto; }
+ .ds-stage .pv-shoe-pic { aspect-ratio: 16 / 7; }
+}
+
+/* ── Your look ── */
+/* The icon styles: the three screens are the choice. */
+.lk-icons { border-top: 1px solid var(--ovk-line); padding-top: 14px; }
+.lk-icons > .ovk-set-text { display: block; padding: 0 14px 10px; }
+.lk .ovk-seg label span { text-align: center; }
+/* The theme's colours as four separate dots: two overlapping ones read as a switch. */
+.lk-theme .tk-dots { gap: 5px; }
+.lk-theme .tk-dots .tk-dot + .tk-dot { margin-left: 0; }
@@ -39,8 +39,8 @@
@@ -39,8 +39,8 @@
if (dots && t) {
if (dots && t) {
dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));
dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));
}
}
- const studio = $('#lk-studio');
- if (studio) studio.href = `/themes?theme=${encodeURIComponent(id)}`;
+ const change = $('#lk-change');
+ if (change) change.href = `/themes?theme=${encodeURIComponent(id)}`;
const code = $('#lk-code');
const code = $('#lk-code');
if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
}
}
@@ -55,18 +55,10 @@
@@ -55,18 +55,10 @@
check('text', d.text);
check('text', d.text);
check('motion', d.motion);
check('motion', d.motion);
check('icons', d.icons || 'auto');
check('icons', d.icons || 'auto');
- root.querySelectorAll('[data-pick-icons]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-pick-icons') === (d.icons || 'auto'))));
const s = root.querySelector('input[name="seasonal"]');
const s = root.querySelector('input[name="seasonal"]');
if (s) s.checked = d.seasonal !== 'off';
if (s) s.checked = d.seasonal !== 'off';
for (const k of Object.keys(COMFORT)) { const c = root.querySelector(`input[name="${k}"]`); if (c) c.checked = d[k] === COMFORT[k][0]; }
for (const k of Object.keys(COMFORT)) { const c = root.querySelector(`input[name="${k}"]`); if (c) c.checked = d[k] === COMFORT[k][0]; }
}
}
- // A screen in the icon styles picks its style, the same as the Icons choice above it.
- root.addEventListener('click', (e) => {
- const b = e.target.closest && e.target.closest('[data-pick-icons]');
- if (!b) return;
- const r = root.querySelector(`input[name="icons"][value="${b.getAttribute('data-pick-icons')}"]`);
- if (r && !r.checked) r.click();
- });
root.addEventListener('change', (e) => {
root.addEventListener('change', (e) => {
const i = e.target;
const i = e.target;
if (!i || !i.name) return;
if (!i || !i.name) return;
@@ -76,6 +76,10 @@ const RULES = Object.freeze([
@@ -76,6 +76,10 @@ const RULES = Object.freeze([
'2026-11-09', 'A model page was mostly "not available yet".'),
'2026-11-09', 'A model page was mostly "not available yet".'),
// ── Loading and motion ──
// ── Loading and motion ──
+ R('say-it-once', 'length', 'One choice, one control; one action, one button.',
+ 'A setting has a single control, and an action a single button on the page. When a picture of the choices can be pressed, the pictures are the control: no second switch beside them saying the same thing.',
+ '2026-11-09', 'Your look had the icon style as a row of three words and again as three screens; Themes asked people to sign in twice on one screen.',
+ [{ label: 'Your look', href: '/look' }]),
R('no-spinners', 'motion', 'No spinning rings. Things move only while something is actually happening.',
R('no-spinners', 'motion', 'No spinning rings. Things move only while something is actually happening.',
'Loading shows the shape of what is coming (a shimmer placeholder of the right size) or the mark at work. A finished or idle thing is still.',
'Loading shows the shape of what is coming (a shimmer placeholder of the right size) or the mark at work. A finished or idle thing is still.',
'2026-10-31', 'Spinning rings around icons everywhere looked cheap and said nothing.',
'2026-10-31', 'Spinning rings around icons everywhere looked cheap and said nothing.',
@@ -123,6 +127,14 @@ const RULES = Object.freeze([
@@ -123,6 +127,14 @@ const RULES = Object.freeze([
'Icon buttons included; spacing is separate from the hit area.',
'Icon buttons included; spacing is separate from the hit area.',
'2026-10-30', 'Dense icon rows were hard to hit on a phone.',
'2026-10-30', 'Dense icon rows were hard to hit on a phone.',
[{ label: '44 px targets', href: '/patterns/touch-targets' }]),
[{ label: '44 px targets', href: '/patterns/touch-targets' }]),
+ R('answer-beside-the-action', 'phone', 'What a button did is said beside that button.',
+ 'The line that says "Saved" or "Not saved" sits with the buttons it answers. On a phone, where the buttons are a bar at the bottom of the screen, the answer is in that bar.',
+ '2026-11-09', 'Themes answered a press on the bottom bar in a line two screens further down the page.',
+ [{ label: 'Themes', href: '/themes' }]),
+ R('swipe-not-stack', 'phone', 'Views of one thing sit side by side to swipe, with the next one showing at the edge.',
+ 'Three previews of the same theme, or three sizes of one design, are one row on a phone: each a screen wide less a sliver, so the edge of the next says there is more. What you change stays one screen away from what it changes.',
+ '2026-11-09', 'Three stacked previews put the colours to edit 1,500 pixels below the theme they edit.',
+ [{ label: 'Themes', href: '/themes' }]),
R('keep-the-picture', 'phone', 'Controls that act on a picture keep the picture in view.',
R('keep-the-picture', 'phone', 'Controls that act on a picture keep the picture in view.',
'Camera and drive pad on screen together; the picture sticks under the bar while the controls scroll.',
'Camera and drive pad on screen together; the picture sticks under the bar while the controls scroll.',
'2026-10-16', 'Driving a robot meant scrolling its camera away.'),
'2026-10-16', 'Driving a robot meant scrolling its camera away.'),
@@ -7,14 +7,14 @@ const { layout, esc } = require('../render/layout');
@@ -7,14 +7,14 @@ const { layout, esc } = require('../render/layout');
const link = (name) => `/components/${name}`;
const link = (name) => `/components/${name}`;
const demo = {
const demo = {
- navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Studio</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,
+ navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Themes</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,
menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,
menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,
'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),
'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),
dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),
dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),
'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,
'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,
'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),
sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),
- popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', label: 'Open Studio', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),
+ popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', label: 'Themes', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),
keyboard: () => `<label class="cr-field">A keyboard-aware field <input type="text" placeholder="Focus this field"></label><p class="ds-fine">Tab moves forward; Escape closes a menu or sheet and returns focus.</p>`,
keyboard: () => `<label class="cr-field">A keyboard-aware field <input type="text" placeholder="Focus this field"></label><p class="ds-fine">Tab moves forward; Escape closes a menu or sheet and returns focus.</p>`,
'kit-row': () => kit.list([kit.row({ href: '/themes', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),
'kit-row': () => kit.list([kit.row({ href: '/themes', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),
'kit-card': () => kit.card({ href: '/themes', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),
'kit-card': () => kit.card({ href: '/themes', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),
@@ -160,7 +160,7 @@ function home({ themes } = {}) {
@@ -160,7 +160,7 @@ function home({ themes } = {}) {
faq: { items: [
faq: { items: [
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],
['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],
- ['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the studio\'s history brings any of the last fifty back.'],
+ ['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the history on the Themes page brings any of the last fifty back.'],
['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],
['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],
['What is coming next?', 'Publishing a design straight to OpenVibe.Website and OpenVibe.Space, brand kits that dress a creator\'s channel, overlays and shop in one go, and the kit\'s components as native Android and iOS views.'],
['What is coming next?', 'Publishing a design straight to OpenVibe.Website and OpenVibe.Space, brand kits that dress a creator\'s channel, overlays and shop in one go, and the kit\'s components as native Android and iOS views.'],
] },
] },
@@ -210,11 +210,11 @@ function studio({ themes, me = {} } = {}) {
@@ -210,11 +210,11 @@ function studio({ themes, me = {} } = {}) {
const gallery = all.map((t) => `<li>${swatch(t, { tag: 'button' })}</li>`).join('');
const gallery = all.map((t) => `<li>${swatch(t, { tag: 'button' })}</li>`).join('');
const data = JSON.stringify(all.map((t) => ({ id: t.id, name: t.name, mode: t.mode, variables: t.variables || {} }))).replace(/</g, '\\u003c');
const data = JSON.stringify(all.map((t) => ({ id: t.id, name: t.name, mode: t.mode, variables: t.variables || {} }))).replace(/</g, '\\u003c');
const body = `${CVD}<div class="ds-studio" data-signed-in="${signedIn ? '1' : '0'}">
const body = `${CVD}<div class="ds-studio" data-signed-in="${signedIn ? '1' : '0'}">
-<header class="ds-studio-h"><div><p class="ds-kicker">Theme studio</p><h1>Make it yours</h1>
+<header class="ds-studio-h"><div><p class="ds-kicker">Themes</p><h1>Make it yours</h1>
<p class="ds-lede">Pick a theme, change any colour, and see it on three sites at once. Every check must pass before it can be used.</p></div>
<p class="ds-lede">Pick a theme, change any colour, and see it on three sites at once. Every check must pass before it can be used.</p></div>
-${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.user.display_name || me.user.username)) || 'you')}</b>: Use changes your look on every OpenVibe site.</p>` : `<p class="ds-sign">${kit.button({ href: '/auth/login?next=%2Fstudio', label: 'Sign in to use a theme', kind: 'quiet', small: true })}</p>`}</header>
+${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.user.display_name || me.user.username)) || 'you')}</b>: Use changes your look on every OpenVibe site.</p>` : ''}</header>
<div class="ds-grid">
<div class="ds-grid">
-<aside class="ds-gallery" aria-labelledby="ds-gal-h"><div class="ds-gal-head"><h2 id="ds-gal-h">Themes</h2>
+<aside class="ds-gallery" aria-labelledby="ds-gal-h"><div class="ds-gal-head"><h2 id="ds-gal-h">Themes${all.length ? ` <span class="ds-count">${all.length}</span>` : ''}</h2>
<div class="ds-seg" role="group" aria-label="Show"><button type="button" data-show="all" aria-pressed="true">All</button><button type="button" data-show="dark" aria-pressed="false">Dark</button><button type="button" data-show="light" aria-pressed="false">Light</button></div></div>
<div class="ds-seg" role="group" aria-label="Show"><button type="button" data-show="all" aria-pressed="true">All</button><button type="button" data-show="dark" aria-pressed="false">Dark</button><button type="button" data-show="light" aria-pressed="false">Light</button></div></div>
<ul class="ds-gal-list">${gallery || '<li class="ds-fine">The catalog is not answering. Try again in a minute.</li>'}</ul></aside>
<ul class="ds-gal-list">${gallery || '<li class="ds-fine">The catalog is not answering. Try again in a minute.</li>'}</ul></aside>
<section class="ds-stage" aria-labelledby="ds-stage-h"><div class="ds-stage-head"><h2 id="ds-stage-h"><span id="ds-current">Your current theme</span></h2>
<section class="ds-stage" aria-labelledby="ds-stage-h"><div class="ds-stage-head"><h2 id="ds-stage-h"><span id="ds-current">Your current theme</span></h2>
@@ -223,27 +223,26 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u
@@ -223,27 +223,26 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u
<aside class="ds-editor" aria-labelledby="ds-ed-h"><h2 id="ds-ed-h">Edit</h2>
<aside class="ds-editor" aria-labelledby="ds-ed-h"><h2 id="ds-ed-h">Edit</h2>
<div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div>
<div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div>
<h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul>
<h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul>
-<div class="ds-actions" aria-label="Theme actions"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${kit.icon('check')}<span>${signedIn ? 'Use this theme' : 'Sign in to use it'}</span></button>
+<div class="ds-actions" aria-label="Theme actions"><p class="ds-status" id="ds-status" role="status"></p><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${kit.icon('check')}<span>${signedIn ? 'Use this theme' : 'Sign in to use it'}</span></button>
<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled aria-label="Undo my edits" title="Undo my edits">${kit.icon('retry')}<span>Undo</span></button>
<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled aria-label="Undo my edits" title="Undo my edits">${kit.icon('retry')}<span>Undo</span></button>
<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" data-tokens-src="${esc(ovServe.url('tokens.js'))}" aria-label="This theme as code" title="This theme, with your edits, for the web, Android, iOS, Flutter and Figma">${kit.icon('code')}<span>Code</span></a>
<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" data-tokens-src="${esc(ovServe.url('tokens.js'))}" aria-label="This theme as code" title="This theme, with your edits, for the web, Android, iOS, Flutter and Figma">${kit.icon('code')}<span>Code</span></a>
${kit.sheetButton({ sheet: 'ds-share-sheet', label: 'Share', icon: 'send', iconOnly: false })}</div>
${kit.sheetButton({ sheet: 'ds-share-sheet', label: 'Share', icon: 'send', iconOnly: false })}</div>
${codeSheet()}
${codeSheet()}
<div id="ds-try" hidden>${tryOn('')}</div>
<div id="ds-try" hidden>${tryOn('')}</div>
-<p class="ds-status" id="ds-status" role="status"></p>
${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<form id="ds-submit-form"><div class="ds-share-preview" aria-hidden="true"><span class="tk-dots" id="ds-share-dots"></span><strong id="ds-share-theme">Your theme</strong></div><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label>
${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<form id="ds-submit-form"><div class="ds-share-preview" aria-hidden="true"><span class="tk-dots" id="ds-share-dots"></span><strong id="ds-share-theme">Your theme</strong></div><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label>
<label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label>
<label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label>
<button type="submit" class="ovk-btn ovk-btn--primary" ${signedIn ? '' : 'disabled'}>${kit.icon('send')} Send for review</button><p class="ds-fine">A person reviews each theme before anyone else sees it.</p></form>` })}
<button type="submit" class="ovk-btn ovk-btn--primary" ${signedIn ? '' : 'disabled'}>${kit.icon('send')} Send for review</button><p class="ds-fine">A person reviews each theme before anyone else sees it.</p></form>` })}
${signedIn ? `<details class="ds-history" id="ds-history"><summary>${kit.icon('clock')}<span>History</span></summary><ol id="ds-history-list"><li class="ds-history-empty"><span aria-hidden="true">◌</span><p>Your earlier looks appear here.</p></li></ol></details>` : ''}
${signedIn ? `<details class="ds-history" id="ds-history"><summary>${kit.icon('clock')}<span>History</span></summary><ol id="ds-history-list"><li class="ds-history-empty"><span aria-hidden="true">◌</span><p>Your earlier looks appear here.</p></li></ol></details>` : ''}
</aside></div></div>
</aside></div></div>
<script type="application/json" id="ds-themes">${data}</script>`;
<script type="application/json" id="ds-themes">${data}</script>`;
- return layout({ title: 'Theme studio', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });
+ return layout({ title: 'Themes', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });
}
}
function theme({ theme: t } = {}) {
function theme({ theme: t } = {}) {
const v = (t && t.variables) || {};
const v = (t && t.variables) || {};
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
-<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>
+<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Edit this theme' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/themes' });
return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/themes' });
@@ -515,7 +514,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}
@@ -515,7 +514,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}
}
}
function notFound() {
function notFound() {
- const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;
+ const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">See every theme</a> or <a href="/">go to the home page</a>.</p></header>`;
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
}
}
@@ -19,8 +19,8 @@ const SITES = [['live', 'OpenVibe.Live'], ['chat', 'OpenVibe.Chat'], ['games', '
@@ -19,8 +19,8 @@ const SITES = [['live', 'OpenVibe.Live'], ['chat', 'OpenVibe.Chat'], ['games', '
* The three icon styles side by side, each a small phone screen drawn with the real glyphs (ov-glyphs.js): a header
* The three icon styles side by side, each a small phone screen drawn with the real glyphs (ov-glyphs.js): a header
* with icons above 18 px, rows with small ones and an app bar whose current tab is solid. Line and Solid force their
* with icons above 18 px, rows with small ones and an app bar whose current tab is solid. Line and Solid force their
* style on every icon (.ovg-line, .ovg-solid); Auto resets the person's own choice (--ovg-pref) so it shows what
* style on every icon (.ovg-line, .ovg-solid); Auto resets the person's own choice (--ovg-pref) so it shows what
- * Auto does: solid where an icon is small, lines where it is large. A screen is a button that picks its style
- * (public/js/look.js), the same as the Icons choice above it.
+ * Auto does: solid where an icon is small, lines where it is large. The three screens ARE the choice (one radio group,
+ * name "icons"): a second control beside them said the same thing twice.
*/
*/
function iconStyles() {
function iconStyles() {
const STYLES = [
const STYLES = [
@@ -35,7 +35,7 @@ function iconStyles() {
@@ -35,7 +35,7 @@ function iconStyles() {
const bar = ['fa-house', 'fa-compass', 'fa-bell', 'fa-user'].map((n, k) => `<span class="lk-st-tab"${k === 0 ? ' aria-current="page"' : ''}>${i(n, force)}</span>`).join('');
const bar = ['fa-house', 'fa-compass', 'fa-bell', 'fa-user'].map((n, k) => `<span class="lk-st-tab"${k === 0 ? ' aria-current="page"' : ''}>${i(n, force)}</span>`).join('');
return `<span class="lk-st-screen"${style === 'auto' ? ' style="--ovg-pref:18.4px"' : ''}><span class="lk-st-head"><span class="lk-st-title"></span>${i('fa-magnifying-glass', force)}${i('fa-bell', force)}</span>${rows}<span class="lk-st-bar">${bar}</span></span>`;
return `<span class="lk-st-screen"${style === 'auto' ? ' style="--ovg-pref:18.4px"' : ''}><span class="lk-st-head"><span class="lk-st-title"></span>${i('fa-magnifying-glass', force)}${i('fa-bell', force)}</span>${rows}<span class="lk-st-bar">${bar}</span></span>`;
};
};
- return `<div class="lk-styles" role="group" aria-label="The icon styles, side by side">${STYLES.map(([id, name, what]) => `<button type="button" class="lk-style" data-pick-icons="${id}" aria-pressed="${id === 'auto' ? 'true' : 'false'}">${screen(id)}<span class="lk-st-cap"><b>${name}</b><small>${what}</small></span></button>`).join('')}</div>`;
+ return `<div class="lk-icons"><span class="ovk-set-text" id="lk-icons-h">Icons</span><div class="lk-styles" role="radiogroup" aria-labelledby="lk-icons-h">${STYLES.map(([id, name, what]) => `<label class="lk-style"><input type="radio" name="icons" value="${id}"${id === 'auto' ? ' checked' : ''}>${screen(id)}<span class="lk-st-cap"><b>${name}</b><small>${what}</small></span></label>`).join('')}</div></div>`;
}
}
function lookPage({ themes = [], me = {} } = {}) {
function lookPage({ themes = [], me = {} } = {}) {
@@ -43,18 +43,17 @@ function lookPage({ themes = [], me = {} } = {}) {
@@ -43,18 +43,17 @@ function lookPage({ themes = [], me = {} } = {}) {
const signedIn = !!me.signedIn;
const signedIn = !!me.signedIn;
const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2>
const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2>
<div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>
<div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>
-<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;
+<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change your theme', attrs: { id: 'lk-change' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;
const settings = kit.section({
const settings = kit.section({
id: 'lk-settings',
id: 'lk-settings',
title: 'How every site looks to you',
title: 'How every site looks to you',
note: signedIn ? 'Each change applies at once and is saved to your account, so every OpenVibe site follows.' : 'Each change applies at once on this device. Sign in and it follows you to every OpenVibe site.',
note: signedIn ? 'Each change applies at once and is saved to your account, so every OpenVibe site follows.' : 'Each change applies at once on this device. Sign in and it follows you to every OpenVibe site.',
- body: kit.seg({ name: 'scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Match my device']], value: 'auto' })
+ body: kit.seg({ name: 'scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Automatic']], value: 'auto' })
+ kit.seg({ name: 'text', label: 'Text size', options: [['100', 'Standard'], ['112', 'Larger'], ['125', 'Largest']], value: '100' })
+ kit.seg({ name: 'text', label: 'Text size', options: [['100', 'Standard'], ['112', 'Larger'], ['125', 'Largest']], value: '100' })
+ kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })
+ kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })
// Icons (openvibe-shared 4.71.0, plan L56; the owner, 2026-11-01: "a setting to prefer a certain icon type…
// Icons (openvibe-shared 4.71.0, plan L56; the owner, 2026-11-01: "a setting to prefer a certain icon type…
// configurable for users in most cases"): the sample under it is ordinary page markup, drawn by ov-glyphs.js, so
// configurable for users in most cases"): the sample under it is ordinary page markup, drawn by ov-glyphs.js, so
// it changes as the choice does.
// it changes as the choice does.
- + kit.seg({ name: 'icons', label: 'Icons', options: [['auto', 'Auto'], ['line', 'Line'], ['solid', 'Solid']], value: 'auto' })
+ iconStyles()
+ iconStyles()
+ kit.switchRow({ name: 'seasonal', label: 'The season\'s themes', hint: 'Halloween and winter stand in for the default theme while they last', on: true }),
+ kit.switchRow({ name: 'seasonal', label: 'The season\'s themes', hint: 'Halloween and winter stand in for the default theme while they last', on: true }),
});
});
@@ -17,7 +17,7 @@ function list() {
@@ -17,7 +17,7 @@ function list() {
{
{
id: 'design-page',
id: 'design-page',
title: 'Designing a page',
title: 'Designing a page',
- used: 'Create, the Studio and the Arena: every page, screen, component or logo a model draws.',
+ used: 'Create and the Arena: every page, screen, component or logo a model draws.',
source: 'server/arena/ai.js',
source: 'server/arena/ai.js',
parts: [
parts: [
{ label: 'System prompt (the category changes per request)', text: systemPrompt('{category}') },
{ label: 'System prompt (the category changes per request)', text: systemPrompt('{category}') },
@@ -28,14 +28,14 @@ function list() {
@@ -28,14 +28,14 @@ function list() {
{
{
id: 'design-theme',
id: 'design-theme',
title: 'Drawing it in your theme',
title: 'Drawing it in your theme',
- used: 'Create and the Studio, when a design is drawn in the theme you picked.',
+ used: 'Create, when a design is drawn in the theme you picked.',
source: 'server/designs.js',
source: 'server/designs.js',
parts: [{ label: 'The request', text: themePrompt('{your prompt}', ['{the theme\'s colour variables}']) }],
parts: [{ label: 'The request', text: themePrompt('{your prompt}', ['{the theme\'s colour variables}']) }],
},
},
{
{
id: 'design-edit',
id: 'design-edit',
title: 'Editing a design',
title: 'Editing a design',
- used: 'The Studio, when you ask for a change in words.',
+ used: 'Create, when you ask for a change in words.',
source: 'server/designs.js',
source: 'server/designs.js',
parts: [{ label: 'The request', text: editPrompt('{your instruction}', '{the current page}', '{the theme\'s colour variables}') }],
parts: [{ label: 'The request', text: editPrompt('{your instruction}', '{the current page}', '{the theme\'s colour variables}') }],
},
},
@@ -32,7 +32,10 @@ const { assetVersion } = require('../server/assets');
@@ -32,7 +32,10 @@ const { assetVersion } = require('../server/assets');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);
- assert.match(r.text, /Sign in to use a theme/);
+ // One way to sign in on the page: the action bar's own button (a second one in the hero said it twice).
+ assert.match(r.text, /<span>Sign in to use it<\/span>/);
+ assert.doesNotMatch(r.text, /Sign in to use a theme/);
+ assert.match(r.text, /<div class="ds-actions"[^>]*><p class="ds-status" id="ds-status" role="status"><\/p>/, 'the status line sits with the buttons');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
const me = await t.get('/themes', { as: user });
const me = await t.get('/themes', { as: user });
assert.strictEqual(me.status, 200);
assert.strictEqual(me.status, 200);
@@ -98,8 +98,10 @@ const { boot, check, done } = require('./helpers/boot');
@@ -98,8 +98,10 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(r.text, /Your look, everywhere/);
assert.match(r.text, /Your look, everywhere/);
for (const name of ['scheme', 'text', 'motion']) assert.match(r.text, new RegExp(`role="radiogroup"[^>]*>(<label><input type="radio" name="${name}"[^<]*<span>[^<]+</span></label>)+`), name);
for (const name of ['scheme', 'text', 'motion']) assert.match(r.text, new RegExp(`role="radiogroup"[^>]*>(<label><input type="radio" name="${name}"[^<]*<span>[^<]+</span></label>)+`), name);
assert.match(r.text, /name="seasonal"/);
assert.match(r.text, /name="seasonal"/);
- // The icon styles side by side (plan L56): three screens drawn with the real glyphs, each a button for its style.
- for (const id of ['auto', 'line', 'solid']) assert.match(r.text, new RegExp(`<button type="button" class="lk-style" data-pick-icons="${id}" aria-pressed="(true|false)">`), id);
+ // The icon styles side by side (plan L56): three screens drawn with the real glyphs. They are the choice itself,
+ // one radio group; a segmented control beside them would be a second control for the same setting.
+ for (const id of ['auto', 'line', 'solid']) assert.match(r.text, new RegExp(`<label class="lk-style"><input type="radio" name="icons" value="${id}"( checked)?>`), id);
+ assert.strictEqual((r.text.match(/name="icons"/g) || []).length, 3, 'one control for the icon style');
assert.match(r.text, /<span class="lk-st-screen" style="--ovg-pref:18\.4px">/, 'Auto shows Auto whatever the person chose');
assert.match(r.text, /<span class="lk-st-screen" style="--ovg-pref:18\.4px">/, 'Auto shows Auto whatever the person chose');
assert.match(r.text, /class="fa-solid fa-bell ovg-line"/, 'Line forces lines');
assert.match(r.text, /class="fa-solid fa-bell ovg-line"/, 'Line forces lines');
assert.match(r.text, /class="fa-solid fa-bell ovg-solid"/, 'Solid forces solids');
assert.match(r.text, /class="fa-solid fa-bell ovg-solid"/, 'Solid forces solids');