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)
Changes
@@@ -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; } }nnew file mode 100644@@@ -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();++})();@@@ -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.' },];@@@ -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());@@@ -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',@@@ -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: [nnew file mode 100644@@@ -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 };@@@ -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();})();
@@ -418,3 +418,19 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -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 { margin-top: 12px; }
.ds-editor .ds-try a { min-height: 40px; padding: 0 12px; font-size: .86rem; }
.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; } }
@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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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();
+})();
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
const static_ = [
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: '/', 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: '/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: '/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.' },
{ 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.' },
];
];
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
const express = require('express');
const express = require('express');
const views = require('../views');
const views = require('../views');
const tokensView = require('../views/tokens');
const tokensView = require('../views/tokens');
+const lookView = require('../views/look');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -165,6 +166,12 @@ function createPageRoutes(ctx) {
@@ -165,6 +166,12 @@ function createPageRoutes(ctx) {
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
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) ──
// ── Tokens: any public theme as code (views/tokens.js) ──
r.get('/tokens', async (req, res) => {
r.get('/tokens', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
@@ -25,6 +25,7 @@ const NAV = [
@@ -25,6 +25,7 @@ const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', 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: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
@@ -45,7 +46,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -45,7 +46,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
service: 'design',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
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 },
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -119,7 +119,7 @@ function home({ themes } = {}) {
@@ -119,7 +119,7 @@ function home({ themes } = {}) {
explore: { title: 'More on Design', items: [
explore: { title: 'More on Design', items: [
{ title: 'Tokens', text: 'Any theme as CSS, Compose, SwiftUI, Flutter or Figma', href: '/tokens', icon: 'ov:code' },
{ 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: '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>` : '',
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: [
faq: { items: [
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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 };
@@ -89,6 +89,18 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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');
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();
await t.close();
done();
done();
})();
})();