openvibers/openvibe.design Commits

Themes and Your look: the layouts on a phone, one control per choice (#230)

OP OpenVibers committed 3 days ago · 2ee8d8c059 · 10 files, +93 −38

Themes: the three previews sit side by side to swipe, so the colours to edit are one screen below the theme and not
three; the themes are a rail of small tiles with their count; the preview card lines up with its neighbours; one
sign-in button, not two; the status line sits with the buttons it answers (the bottom bar on a phone); the buttons and
the five sites are even rows; no empty screen above the footer. The page is called Themes everywhere.

Your look: the three icon screens are the choice (one radio group, the segmented row beside them is gone); Automatic
fits its segment; the theme's colours are separate dots.

Three rules join the register: say it once, the answer beside the action, swipe not stack.

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex df331a4..f7cd6ab 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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
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-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}}
ddiff --git a/public/css/studio-look.css b/public/css/studio-look.css
iindex 87b7561..ed8b349 100644
---- a/public/css/studio-look.css
++++ b/public/css/studio-look.css
@@@ -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; }
ddiff --git a/public/js/look.js b/public/js/look.js
iindex 5620bf7..b18dc48 100644
---- a/public/js/look.js
++++ b/public/js/look.js
@@@ -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;
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex fad3111..4ea34be 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -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.'),
ddiff --git a/server/views/component-registry.js b/server/views/component-registry.js
iindex 66ca809..47a938b 100644
---- a/server/views/component-registry.js
++++ b/server/views/component-registry.js
@@@ -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' }),
ddiff --git a/server/views/index.js b/server/views/index.js
iindex bc5ebc3..5d5541e 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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: '/' });
}
ddiff --git a/server/views/look.js b/server/views/look.js
iindex b8114a6..2812cf5 100644
---- a/server/views/look.js
++++ b/server/views/look.js
@@@ -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 }),
});
ddiff --git a/server/views/prompts.js b/server/views/prompts.js
iindex c76ebe3..cd58696 100644
---- a/server/views/prompts.js
++++ b/server/views/prompts.js
@@@ -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}') }],
},
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 336c964..d3ac6f3 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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);
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 443458f..ffd123e 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -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');