openvibers/openvibe.design Commits

Design: /look — your look on every OpenVibe site in one place (the theme worn, light or dark, text size, motion, seasons) (#17)

OP OpenVibers committed 15 days ago · c19429d671 · 8 files, +135 −2

The settings belong to theme-loader (applied at once, saved to the account when signed in, so every site follows); look.js wires the kit's settings rows to it. In the nav and app bar, the home's More on Design, and the sitemap.

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 57ad479..f72ef07 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -418,3 +418,19 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.ds-editor .ds-try { margin-top: 12px; }
.ds-editor .ds-try a { min-height: 40px; padding: 0 12px; font-size: .86rem; }
@media (pointer: coarse) { .ds-editor .ds-try a { min-height: 44px; } }
++
++/* ── /look: your look on every site (views/look.js) ── */
++.lk .ovk-section { margin-top: 14px; }
++.lk-theme { display: flex; align-items: center; gap: 12px; padding: 6px 14px 4px; }
++.lk-theme b { display: block; font-size: 1.05rem; }
++.lk-theme small { color: var(--ovk-muted); }
++.lk-theme .tk-dot { width: 22px; height: 22px; }
++.lk-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 10px 14px 14px; }
++.lk-status { min-height: 1.4em; margin: 10px 0 0; color: var(--ovk-ok); font-weight: 700; }
++/* The settings' segmented choices stay on one line on a phone: the label above, the choices below. */
++@media (max-width: 599px) {
++ .lk .ovk-set-row:has(.ovk-seg) { flex-direction: column; align-items: stretch; gap: 8px; padding-top: 12px; padding-bottom: 12px; }
++ .lk .ovk-seg { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; }
++}
++.lk .ovk-seg label span { display: flex; align-items: center; justify-content: center; min-height: 40px; }
++@media (pointer: coarse) { .lk .ovk-seg label span { min-height: 44px; } }
ddiff --git a/public/js/look.js b/public/js/look.js
nnew file mode 100644
iindex 0000000..dc7631f
---- /dev/null
++++ b/public/js/look.js
@@@ -0,0 +1,58 @@
++/**
++ * /look's controls, wired to the network's theme loader (openvibe-shared theme-loader.js: OpenVibeThemeLoader). It shows
++ * the theme being worn and sets Light / Dark / Auto, text size, motion and the season's themes. The loader applies each at
++ * once and saves it to the account when signed in; this file only reads and writes through it.
++ */
++(function () {
++ 'use strict';
++ const L = window.OpenVibeThemeLoader;
++ const root = document.querySelector('.lk');
++ if (!L || !root) return;
++ const $ = (s) => document.querySelector(s);
++ const themes = (() => { try { return JSON.parse(($('#lk-themes') || {}).textContent || '[]'); } catch (e) { return []; } })();
++ const byId = new Map(themes.map((t) => [t.id, t]));
++ const say = (text) => { const s = $('#lk-status'); if (s) s.textContent = text; };
++
++ function showTheme() {
++ const id = document.documentElement.getAttribute('data-theme') || (L.getCurrent && L.getCurrent()) || 'vibe';
++ const t = byId.get(id);
++ const name = $('#lk-name');
++ if (name) name.textContent = t ? t.name : id.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
++ const mode = $('#lk-mode');
++ if (mode) mode.textContent = (t ? t.mode : L.modeOf(id)) === 'light' ? 'Light' : 'Dark';
++ const dots = $('#lk-dots');
++ 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 = `/studio?theme=${encodeURIComponent(id)}`;
++ const code = $('#lk-code');
++ if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
++ }
++ function check(name, value) {
++ root.querySelectorAll(`input[name="${name}"]`).forEach((i) => { if (i.type === 'radio') i.checked = i.value === value; });
++ }
++ function showSettings() {
++ const d = L.display.get();
++ check('scheme', L.scheme.get());
++ check('text', d.text);
++ check('motion', d.motion);
++ const s = root.querySelector('input[name="seasonal"]');
++ if (s) s.checked = d.seasonal !== 'off';
++ }
++ root.addEventListener('change', (e) => {
++ const i = e.target;
++ if (!i || !i.name) return;
++ if (i.name === 'scheme') L.scheme.set(i.value);
++ else if (i.name === 'seasonal') L.display.set({ seasonal: i.checked ? 'on' : 'off' });
++ else if (i.name === 'text' || i.name === 'motion') L.display.set({ [i.name]: i.value });
++ showTheme();
++ showSettings();
++ say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.');
++ });
++ // The account's answer, another tab or the device can change the look while the page is open.
++ window.addEventListener('storage', () => { showTheme(); showSettings(); });
++ new MutationObserver(showTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
++ showTheme();
++ showSettings();
++})();
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 27431e7..44e12c2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -29,6 +29,7 @@ function publicPages(themes = []) {
const static_ = [
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
++ { path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7aa1c1a..62c0b1e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -17,6 +17,7 @@
const express = require('express');
const views = require('../views');
const tokensView = require('../views/tokens');
++const lookView = require('../views/look');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const { asyncRouter } = require('./router');
@@@ -165,6 +166,12 @@ function createPageRoutes(ctx) {
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
});
++ // ── Your look: the theme worn and the comfort settings, for every site (views/look.js) ──
++ r.get('/look', async (req, res) => {
++ const list = await safe(() => catalog.list());
++ return page(res, 200, lookView.lookPage({ themes: list ? list.themes : [], me: meOf(req.viewer) }), req.viewer);
++ });
++
// ── Tokens: any public theme as code (views/tokens.js) ──
r.get('/tokens', async (req, res) => {
const list = await safe(() => catalog.list());
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex fc4c64f..7858dcc 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -25,6 +25,7 @@ const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
++ { label: 'Your look', href: '/look', icon: 'fa-sliders' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
@@@ -45,7 +46,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Your look', href: '/look', icon: 'fa-sliders' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/server/views/index.js b/server/views/index.js
iindex c3848c7..a517120 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -119,7 +119,7 @@ function home({ themes } = {}) {
explore: { title: 'More on Design', items: [
{ title: 'Tokens', text: 'Any theme as CSS, Compose, SwiftUI, Flutter or Figma', href: '/tokens', icon: 'ov:code' },
{ title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },
-- { title: 'Your settings', text: 'Text size, motion and display on Network', href: 'https://openvibe.network/my', icon: 'ov:account' },
++ { title: 'Your look', text: 'Light or dark, text size, motion and seasons, for every site', href: '/look', icon: 'ov:account' },
] },
html: strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/studio">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : '',
faq: { items: [
ddiff --git a/server/views/look.js b/server/views/look.js
nnew file mode 100644
iindex 0000000..8ebc87b
---- /dev/null
++++ b/server/views/look.js
@@@ -0,0 +1,38 @@
++'use strict';
++/**
++ * /look: your look on every OpenVibe site, in one place. The theme you wear (and the way to change it), Light / Dark /
++ * Auto, text size, motion and the season's themes. The settings belong to theme-loader.js (openvibe-shared), which
++ * applies them at once on this device and saves them to the account when the person is signed in, so every OpenVibe
++ * site follows. public/js/look.js only wires these controls to it. Without JavaScript the page says so.
++ *
++ * lookPage({ themes, me })
++ */
++const kit = require('openvibe-shared/kit');
++const { layout, esc } = require('../render/layout');
++
++function lookPage({ themes = [], me = {} } = {}) {
++ const list = (themes || []).filter((t) => t && t.id).map((t) => ({ id: t.id, name: t.name, mode: t.mode, dots: ['--bg-primary', '--bg-card', '--accent', '--text-primary'].map((k) => String((t.variables || {})[k] || '')).filter((v) => /^#[0-9a-f]{3,8}$/i.test(v)) }));
++ 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: '/studio', 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>`;
++ 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' })
++ + 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.switchRow({ name: 'seasonal', label: 'The season\'s themes', hint: 'Halloween and winter stand in for the default theme while they last', on: true }),
++ });
++ const body = `<header class="ds-page-h"><p class="ds-kicker">Your look</p><h1>Your look, everywhere</h1>
++<p class="ds-lede">One theme and one set of comfort settings for every OpenVibe site: Live, Chat, Games, the shops and the rest.</p></header>
++<noscript>${kit.notice({ kind: 'info', text: 'These settings need JavaScript in this browser. Your theme still follows your account.' })}</noscript>
++<div class="lk" data-signed-in="${signedIn ? '1' : '0'}">${current}${settings}
++<p class="lk-status" id="lk-status" role="status"></p>
++${signedIn ? `<p class="ds-fine">Every change keeps the look it replaced: <a href="/studio#ds-history">your history</a> brings any of the last fifty back.</p>` : `<p>${kit.button({ href: '/auth/login?next=%2Flook', label: 'Sign in so it follows you', kind: 'quiet', small: true })}</p>`}</div>
++<script type="application/json" id="lk-themes">${JSON.stringify(list).replace(/</g, '\\u003c')}</script>`;
++ return layout({ title: 'Your look', description: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'] });
++}
++
++module.exports = { lookPage, esc };
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 47229b3..34f8237 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -89,6 +89,18 @@ const { boot, check, done } = require('./helpers/boot');
assert.match((await t.get('/studio')).text, /<div id="ds-try" hidden>[\s\S]*data-try="https:\/\/openvibe\.live\/"/, 'the studio points them at the picked theme');
});
++ await check('/look: the theme worn and the comfort settings, wired to the theme loader', async () => {
++ const r = await t.get('/look');
++ assert.strictEqual(r.status, 200);
++ 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"/);
++ assert.match(r.text, /\/js\/look\.js\?v=[0-9a-f]{10}/);
++ assert.match(r.text, /<script type="application\/json" id="lk-themes">\[\{"id":"vibe"/);
++ assert.match(r.text, /Sign in so it follows you/);
++ assert.match((await t.get('/')).text, /href="\/look"/, 'the nav and the home lead to it');
++ });
++
await t.close();
done();
})();