openvibers/openvibe.design Commits
Publish the design.component@1 component registry and rebuild the theme pages around the kit (#208)
Changes
nnew file mode 100644@@@ -0,0 +1,64 @@++.cr { padding-bottom: 36px; }++.cr h2 { font-size: 1.2rem; margin: 24px 0 10px; }++.cr h3 { font-size: 1rem; margin: 0 0 8px; }++.cr-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }++.cr-layout > section { min-width: 0; }++.cr-demo { min-height: 110px; padding: 18px; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); overflow: visible; }++.cr-demo .ovk-dock { position: static; margin: 0; }++.cr-demo .ovk-section { margin: 0; }++.cr-demo .ovk-card { max-width: 290px; }++.cr-demo .ovk-list { margin: 0; }++.cr-demo .ovt-composer { max-width: none; }++.cr-mini-nav, .cr-dock-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.cr-mini-nav a, .cr-mini-nav summary, .cr-dock-tabs a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border-radius: 12px; color: var(--ovk-text); text-decoration: none; cursor: pointer; }++.cr-mini-nav details { position: relative; }++.cr-mini-nav details[open] { display: flex; flex-direction: column; }++.cr-mini-nav details a { background: var(--ovk-panel-2); }++.cr-mini-nav .ovk-i, .cr-dock-tabs .ovk-i { width: 18px; height: 18px; }++.cr-dock-tabs a[aria-current] { background: color-mix(in srgb, var(--ovk-accent) 18%, transparent); color: var(--ovk-accent); }++.cr-menu summary { display: inline-flex; align-items: center; gap: 7px; list-style: none; cursor: pointer; }++.cr-menu summary::-webkit-details-marker { display: none; }++.cr-menu .ovk-row { margin-top: 8px; }++.cr-field { display: grid; gap: 8px; font-weight: 700; }++.cr-field input { min-height: 44px; padding: 8px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }++.cr-anatomy { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }++.cr-anatomy li { display: grid; gap: 3px; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }++.cr-anatomy span, .cr-props dd { color: var(--ovk-muted); }++.cr-props, .cr-facts { margin: 0; }++.cr-props div, .cr-facts div { display: grid; grid-template-columns: minmax(95px, .25fr) minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--ovk-line); }++.cr-props dt, .cr-facts dt { font-weight: 700; }++.cr-props dd, .cr-facts dd { margin: 0; overflow-wrap: anywhere; }++.cr-links { display: flex; flex-wrap: wrap; gap: 6px 16px; }++.cr-group { margin-top: 26px; }++.cr-group h2 { margin: 0 0 10px; }++.cr-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 10px; }++.cr-card { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 12px 13px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.cr-card-link { display: grid; gap: 4px; color: var(--ovk-text); text-decoration: none; }++.cr-card-link b { font-size: 1rem; }++.cr-card-link span { color: var(--ovk-muted); font-size: .87rem; line-height: 1.35; }++.cr-card-link:hover b, .cr-card-link:focus-visible b { text-decoration: underline; }++.cr-card-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; }++.cr-card-platforms { display: inline-flex; flex-wrap: wrap; gap: 4px; }++.cr-card-platforms span { padding: 2px 7px; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-muted); font-size: .72rem; }++.cr-card-meta a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 8px; border-radius: 8px; color: var(--text-muted, var(--ovk-muted)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem; font-weight: 650; text-decoration: none; }++.cr-card-meta a:hover { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 10%, transparent); }++.cr-native { padding: 15px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.cr-native p { margin: 0 0 8px; }++.cr-index { display: flex; flex-wrap: wrap; gap: 8px; }++.cr-index a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; border: 1px solid var(--ovk-line); border-radius: 999px; color: var(--ovk-text); text-decoration: none; }++.cr-index a:hover, .cr-index a:focus-visible { border-color: var(--ovk-accent); }++.cr-upload { display: grid; gap: 10px; }++.cr-upload > span { display: flex; align-items: center; gap: 8px; }++.cr-upload small { margin-left: auto; color: var(--ovk-muted); }++.cr-upload progress { width: 100%; accent-color: var(--ovk-accent); }++.cr-upload button { justify-self: start; min-height: 44px; }++.ov-ai-choice { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; cursor: pointer; }++.ov-ai-choice .ovk-i { width: 18px; height: 18px; }++.cr-motion-samples { display: grid; gap: 6px; margin: 12px 0 0; }++.cr-motion-samples i { display: block; width: 42px; height: 12px; border-radius: 999px; background: var(--ovk-accent); transform: translateX(0); }++.cr-animated { display: inline-grid; place-items: center; min-width: 70px; min-height: 44px; padding: 8px; margin-left: 12px; border-radius: 12px; background: var(--ovk-panel-2); }++.cr-motion-notice { display: grid; gap: 8px; margin-bottom: 12px; padding: 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }++.cr-motion-notice[hidden] { display: none; }++.cr-motion-notice div { display: flex; flex-wrap: wrap; gap: 8px; }++.cr-motion-notice button { min-height: 44px; }++@media (max-width: 720px) { .cr-layout { grid-template-columns: minmax(0, 1fr); } .cr-props div, .cr-facts div { grid-template-columns: minmax(0, 1fr); gap: 2px; } }nnew file mode 100644@@@ -0,0 +1,5 @@++.ov-demo-motion-notice { display: grid; gap: 10px; margin: 12px auto 20px; width: min(100%, 1080px); padding: 12px 14px; border: 1px solid var(--ovk-line, var(--border)); border-radius: 14px; background: var(--ovk-panel, var(--bg-card)); color: var(--ovk-text, var(--text-primary)); }++.ov-demo-motion-notice[hidden] { display: none; }++.ov-demo-motion-notice > span { font-weight: 700; }++.ov-demo-motion-notice > div { display: flex; flex-wrap: wrap; gap: 8px; }++.ov-demo-motion-notice button { min-height: 44px; }nnew file mode 100644@@@ -0,0 +1,63 @@++/* Theme actions and site choices use the same kit controls on phones and computers. */++.ds-actions { align-items: stretch; }++.ds-actions .ovk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; }++.ds-actions .ovk-i { width: 18px; height: 18px; }++.ds-actions [data-ovk-sheet] { min-height: 44px; }++.ds-try { display: block; }++.ds-try > span { display: block; }++.ds-try-sites { display: flex; flex-wrap: wrap; gap: 5px; padding: 4px 0 8px; }++.ds-try .ds-try-sites a { display: inline-flex; flex: 1 1 52px; flex-direction: column; justify-content: center; gap: 3px; min-width: 0; max-width: 74px; min-height: 68px; padding: 6px 2px; border-radius: 14px; font-size: .62rem; line-height: 1.15; text-align: center; }++.ds-try-sites img { width: 26px; height: 26px; border-radius: 8px; }++.ds-share-preview { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.ds-share-preview .tk-dots { display: flex; }++#ds-submit-form { display: grid; gap: 12px; }++#ds-submit-form label { display: grid; gap: 5px; font-weight: 700; }++#ds-submit-form input { width: 100%; min-height: 44px; }++#ds-submit-form .ovk-btn { min-height: 44px; }++.ds-history summary { display: flex; gap: 8px; min-height: 44px; align-items: center; }++.ds-history summary .ovk-i { width: 18px; height: 18px; }++.ds-history ol { display: flex; gap: 10px; overflow-x: auto; padding: 4px 0 10px; scroll-snap-type: x proximity; }++.ds-history li { flex: 0 0 148px; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-height: 130px; padding: 10px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); scroll-snap-align: start; }++.ds-history li .ds-hist-dot { display: block; width: 100%; height: 34px; border-radius: 8px; }++.ds-history li b { line-height: 1.25; }++.ds-history li small { grid-column: auto; }++.ds-history li button { grid-column: auto; grid-row: auto; min-height: 44px; margin-top: auto; }++.ds-history li.ds-history-empty { flex: 1 0 100%; min-height: 90px; flex-direction: row; align-items: center; color: var(--ovk-muted); }++.ds-history-empty > span { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--ovk-panel-2); font-size: 1.8rem; }++.ds-history-empty p { margin: 0; }++++.lk-sites { padding: 0 14px; }++.lk-sites li { position: relative; display: flex; justify-content: flex-start; gap: 12px; min-height: 68px; padding: 8px 0; }++.lk-site-icon { flex: none; border-radius: 11px; object-fit: cover; }++.lk-site-text { display: grid; gap: 3px; min-width: 0; }++.lk-site-text b { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++.lk-site-text small { display: flex; align-items: center; gap: 6px; color: var(--ovk-muted); }++.lk-site-swatch { width: 13px; height: 13px; border-radius: 50%; border: 1px solid var(--ovk-line); }++.lk-site-menu { position: relative; margin-left: auto; }++.lk-site-menu summary { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 11px; cursor: pointer; font-size: 1.4rem; list-style: none; }++.lk-site-menu summary::-webkit-details-marker { display: none; }++.lk-site-menu[open] summary { background: var(--ovk-panel-2); }++.lk-site-menu button { position: absolute; z-index: 2; right: 0; top: 46px; min-width: 190px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); color: var(--ovk-text); text-align: left; box-shadow: 0 10px 24px #0004; cursor: pointer; }++.lk-add { padding: 10px 14px 14px; }++.lk-add .ovk-btn { min-height: 44px; }++.lk-site-form { display: grid; gap: 14px; padding: 0; }++.lk-site-form label, .lk-site-form select { width: 100%; max-width: none; }++.lk-site-form .ovk-btn { justify-self: start; }++.lk-history-link a { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; }++.lk-history-link .ovk-i { width: 18px; height: 18px; }++.lk-toast { position: fixed; z-index: 60; left: 50%; bottom: calc(var(--ovnav-appbar-h, 0px) + 12px); transform: translateX(-50%); display: flex; align-items: center; gap: 12px; width: max-content; max-width: calc(100vw - 28px); min-height: 48px; padding: 8px 10px 8px 16px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); color: var(--ovk-text); box-shadow: 0 12px 30px #0005; }++.lk-toast[hidden], .lk-toast button[hidden] { display: none; }++.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)); }++ .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; }++ .ds-actions #ds-use { flex-direction: row; min-width: 0; padding: 6px 8px; font-size: .82rem; }++ .ds-actions #ds-use span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++ /* Undo, Code and Share are icon buttons on a phone; their labels stay for a screen reader and appear on a computer. */++ .ds-actions #ds-reset span, .ds-actions #ds-code span, .ds-actions [data-ovk-sheet] span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }++ .ds-actions [data-ovk-sheet] { font-size: .67rem; }++ .ds-actions [data-ovk-sheet] .ovk-i { width: 18px; height: 18px; }++}nnew file mode 100644@@@ -0,0 +1,87 @@++(function () {++ 'use strict';++ const root = document.querySelector('.cr');++ if (!root) return;++ root.querySelectorAll('form[action="#"]').forEach((form) => form.addEventListener('submit', (event) => event.preventDefault()));++ const cancel = document.querySelector('#cr-upload-cancel');++ if (cancel) cancel.addEventListener('click', () => {++ const progress = document.querySelector('.cr-upload progress');++ if (progress) progress.value = 0;++ const state = document.querySelector('#cr-upload-state');++ if (state) state.textContent = 'Cancelled';++ cancel.disabled = true;++ });++++ const buttons = Array.from(root.querySelectorAll('[data-cr-play]'));++ if (!buttons.length) return;++ const loader = window.OpenVibeThemeLoader;++ const media = window.matchMedia('(prefers-reduced-motion: reduce)');++ const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');++ let playHere = false;++++ // The demos use the shared motion tokens (openvibe-shared/ov-fx.js); Play reads them at click time, after++ // Calm's zero durations have been lifted for this page only.++ const tokenMs = (name, fallback) => {++ const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();++ const value = parseFloat(raw);++ if (!Number.isFinite(value) || value <= 0) return fallback;++ return raw.endsWith('ms') ? value : value * 1000;++ };++ const tokenCurve = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;++++ const notice = document.createElement('div');++ notice.className = 'cr-motion-notice';++ notice.hidden = true;++ notice.innerHTML = '<span></span><div><button type="button" class="ovk-btn ovk-btn--quiet" data-play-here>Play animations here</button><button type="button" class="ovk-btn ovk-btn--quiet" data-turn-on>Turn animations on</button></div>';++ buttons[0].closest('.cr-demo').before(notice);++++ const refresh = () => {++ notice.hidden = playHere || !(calm() || media.matches);++ notice.querySelector('span').textContent = calm()++ ? 'Animations are off: your Motion setting is Calm.'++ : 'Animations are off: your device asks for less motion.';++ };++ notice.querySelector('[data-play-here]').addEventListener('click', () => {++ playHere = true;++ // This page only: the saved display setting is untouched.++ document.documentElement.removeAttribute('data-ov-motion');++ document.documentElement.classList.add('ov-demo-play');++ refresh();++ buttons[0].click();++ });++ notice.querySelector('[data-turn-on]').addEventListener('click', () => {++ if (loader && loader.display) loader.display.set({ motion: 'auto' });++ refresh();++ });++ media.addEventListener('change', refresh);++ new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });++++ const frames = (kind) => kind === 'pop' ? [{ transform: 'scale(.7)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }]++ : kind === 'sheet' ? [{ transform: 'translateY(28px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }]++ : kind === 'menu' ? [{ transform: 'translateX(-20px)', opacity: 0 }, { transform: 'translateX(0)', opacity: 1 }]++ : null;++ const play = (button) => {++ const kind = button.getAttribute('data-cr-play');++ if (kind === 'tokens') {++ root.querySelectorAll('[data-cr-bar]').forEach((bar) => {++ if (!bar.animate) return;++ const step = Number(bar.getAttribute('data-cr-bar'));++ bar.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(180px)' }, { transform: 'translateX(0)' }], {++ duration: tokenMs(`--ov-dur-${step}`, 120 * step), easing: tokenCurve('--ov-ease-out', 'cubic-bezier(.2,.8,.2,1)'),++ });++ });++ return;++ }++ const target = button.nextElementSibling;++ if (!target || !target.animate || !frames(kind)) return;++ target.animate(frames(kind), {++ duration: tokenMs(kind === 'sheet' ? '--ov-dur-3' : '--ov-dur-2', kind === 'sheet' ? 320 : 200),++ easing: tokenCurve(kind === 'pop' ? '--ov-ease-spring' : '--ov-ease-out', 'cubic-bezier(.2,.8,.2,1)'),++ });++ };++ buttons.forEach((button) => button.addEventListener('click', () => {++ if (!playHere && (calm() || media.matches)) return;++ play(button);++ }));++ refresh();++})();nnew file mode 100644@@@ -0,0 +1,71 @@++(function () {++ 'use strict';++ const brand = document.querySelector('.br');++ const motion = document.querySelector('.mo');++ const icon = document.querySelector('.gp #move');++ const page = brand || motion || icon;++ if (!page) return;++ const anchor = brand ? brand.querySelector('.br-hero') : motion ? motion.querySelector('.mo-hero') : icon.querySelector('.mv-grid');++ if (!anchor) return;++ const loader = window.OpenVibeThemeLoader;++ const media = window.matchMedia('(prefers-reduced-motion: reduce)');++ const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');++ const reduced = () => calm() || media.matches;++ let localPlay = false;++++ const notice = document.createElement('div');++ notice.className = 'ov-demo-motion-notice';++ notice.hidden = true;++ notice.innerHTML = '<span></span><div><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-play>Play animations here</button><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-auto>Turn animations on</button></div>';++ anchor.before(notice);++++ const animate = (target) => {++ if (!target || !target.animate) return;++ target.animate([{ transform: 'translateY(10px) scale(.92)', opacity: .55 }, { transform: 'translateY(0) scale(1)', opacity: 1 }], {++ duration: 520, easing: 'cubic-bezier(.34,1.56,.64,1)',++ });++ };++ const refresh = () => {++ const off = reduced() && !localPlay;++ notice.hidden = !off;++ notice.querySelector('span').textContent = calm()++ ? 'Animations are off: your Motion setting is Calm.'++ : 'Animations are off: your device asks for less motion.';++ document.querySelectorAll('.br-motion-video').forEach((video) => { if (off) video.pause(); });++ };++ const play = () => {++ const target = page.querySelector('.ov-mark, .mv-tile svg');++ if (window.OVMark && window.OVMark.intro) page.querySelectorAll('.ov-mark, [data-br-hero], [data-mo-hero]').forEach((mark) => window.OVMark.intro(mark));++ document.querySelectorAll('[data-br-replay], [data-mo-replay]').forEach((button) => { if (!button.hidden) button.click(); });++ const run = page.querySelector('[data-mo-run]');++ if (run && !run.hidden) run.click();++ const tile = page.querySelector('[data-mv]');++ if (tile) tile.click();++ document.querySelectorAll('.br-motion-video').forEach((video) => video.play().catch(() => {}));++ animate(target);++ page.querySelectorAll('.br-chip, .mo-btn, .mo-card').forEach((control, i) => {++ if (i < 6) setTimeout(() => animate(control.querySelector('.ov-mark, svg') || control), i * 90);++ });++ };++ notice.querySelector('[data-ov-demo-play]').addEventListener('click', () => {++ localPlay = true;++ // This page only: the saved Motion setting is untouched. Removing the attribute lets the shared tokens and++ // the page's own CSS run again; the notice stays away until the next visit.++ document.documentElement.removeAttribute('data-ov-motion');++ document.documentElement.classList.add('ov-demo-play');++ refresh();++ play();++ });++ notice.querySelector('[data-ov-demo-auto]').addEventListener('click', () => {++ if (loader && loader.display) loader.display.set({ motion: 'auto' });++ refresh();++ });++ page.addEventListener('click', (event) => {++ if (!localPlay) return;++ const control = event.target.closest('.br-chip, .br-replay, .mo-replay, .mo-btn, .mo-card, .mv-tile');++ if (control) animate(control.querySelector('.ov-mark, svg') || control);++ });++ media.addEventListener('change', refresh);++ new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });++ refresh();++})();@@@ -11,7 +11,22 @@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; };++ let undoAction = null;++ const say = (message, undo = null) => {++ const toast = $('#lk-toast');++ const label = $('#lk-status');++ const button = $('#lk-undo');++ if (!toast || !label || !button) return;++ label.textContent = message;++ undoAction = undo;++ button.hidden = !undo;++ toast.hidden = !message;++ };++ $('#lk-undo').addEventListener('click', () => {++ const action = undoAction;++ undoAction = null;++ if (action) Promise.resolve(action()).then(() => say('Undone.')).catch(() => say('That did not work. Try again.'));++ });function showTheme() {const id = document.documentElement.getAttribute('data-theme') || (L.getCurrent && L.getCurrent()) || 'vibe';@@@ -55,32 +70,51 @@root.addEventListener('change', (e) => {const i = e.target;if (!i || !i.name) return;++ if (!['scheme', 'seasonal', 'text', 'motion', 'icons', ...Object.keys(COMFORT)].includes(i.name)) return;++ const previous = i.name === 'scheme' ? L.scheme.get() : L.display.get()[i.name];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' || i.name === 'icons') L.display.set({ [i.name]: i.value });else if (COMFORT[i.name]) L.display.set({ [i.name]: COMFORT[i.name][i.checked ? 0 : 1] });showTheme();showSettings();-- say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.');++ say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.', () => {++ if (i.name === 'scheme') L.scheme.set(previous);++ else L.display.set({ [i.name]: previous });++ showTheme(); showSettings();++ });});// ── A different look on one site (signed in): list, add, remove, through /api/me/theme/overrides ──const siteList = $('#lk-site-list');-- const siteName = (id) => { const o = root.querySelector(`#lk-site-form select[name="service"] option[value="${id}"]`); return o ? o.textContent : id; };++ const siteName = (id) => { const o = Array.from(root.querySelectorAll('#lk-site-form select[name="service"] option')).find((x) => x.value === id); return o ? o.textContent : id; };const api = (path, opts = {}) => fetch(path, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts.headers || {}) } }).then((r) => (r.ok ? r.json().catch(() => ({})) : Promise.reject(r)));++ let currentSites = [];function drawSites(items) {if (!siteList) return;++ currentSites = items;siteList.replaceChildren();-- if (!items.length) { const li = document.createElement('li'); li.className = 'lk-none'; li.textContent = 'None yet: every site wears your theme.'; siteList.appendChild(li); return; }++ if (!items.length) { const li = document.createElement('li'); li.className = 'lk-none'; li.textContent = 'Every site wears your theme. Add one to give it a different look.'; siteList.appendChild(li); return; }for (const o of items) {const li = document.createElement('li');const t = byId.get(o.theme_id);-- const txt = document.createElement('span');++ const icon = document.createElement('img');++ icon.className = 'lk-site-icon'; icon.width = 40; icon.height = 40; icon.alt = '';++ const siteOption = Array.from(root.querySelectorAll('#lk-site-form select[name="service"] option')).find((x) => x.value === o.service);++ if (siteOption && siteOption.dataset.appIcon) icon.src = siteOption.dataset.appIcon;++ else icon.hidden = true;++ const txt = document.createElement('span'); txt.className = 'lk-site-text';const b = document.createElement('b'); b.textContent = siteName(o.service);-- txt.append(b, ` wears ${(t && t.name) || o.theme_name || o.theme_id}`);++ const theme = document.createElement('small'); theme.textContent = (t && t.name) || o.theme_name || o.theme_id;++ const dot = document.createElement('i'); dot.className = 'lk-site-swatch';++ if (t && (t.dots[2] || t.dots[0])) dot.style.background = t.dots[2] || t.dots[0];++ theme.prepend(dot); txt.append(b, theme);++ const menu = document.createElement('details'); menu.className = 'lk-site-menu';++ const summary = document.createElement('summary'); summary.textContent = '⋯'; summary.setAttribute('aria-label', `Options for ${siteName(o.service)}`);const rm = document.createElement('button');-- rm.type = 'button'; rm.className = 'ovk-btn ovk-btn--quiet ovk-btn--small'; rm.textContent = 'Remove';-- rm.addEventListener('click', () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'DELETE' }).then(loadSites).then(() => say(`${siteName(o.service)} wears your theme again.`)).catch(() => say('That did not work. Try again.')));-- li.append(txt, rm);++ rm.type = 'button'; rm.textContent = 'Use my main theme';++ rm.addEventListener('click', () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'DELETE' }).then(loadSites).then(() => say(`${siteName(o.service)} wears your main theme.`, () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: o.theme_id }) }).then(loadSites))).catch(() => say('That did not work. Try again.')));++ menu.append(summary, rm);++ li.append(icon, txt, menu);siteList.appendChild(li);}}@@@ -88,12 +122,19 @@const siteForm = $('#lk-site-form');if (siteForm && siteList) {loadSites();++ document.addEventListener('click', (e) => {++ siteList.querySelectorAll('.lk-site-menu[open]').forEach((menu) => { if (!menu.contains(e.target)) menu.open = false; });++ });++ document.addEventListener('keydown', (e) => {++ if (e.key === 'Escape') siteList.querySelectorAll('.lk-site-menu[open]').forEach((menu) => { menu.open = false; menu.querySelector('summary').focus(); });++ });siteForm.addEventListener('submit', (e) => {e.preventDefault();const service = siteForm.elements.service.value;const themeId = siteForm.elements.theme_id.value;++ const earlier = currentSites.find((x) => x.service === service);api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) })-- .then(loadSites).then(() => say(`${siteName(service)} wears ${(byId.get(themeId) || {}).name || themeId} from its next page.`))++ .then(loadSites).then(() => { $('#lk-site-sheet').close(); say(`${siteName(service)} now wears ${(byId.get(themeId) || {}).name || themeId}.`, () => api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, earlier ? { method: 'PUT', body: JSON.stringify({ theme_id: earlier.theme_id }) } : { method: 'DELETE' }).then(loadSites)); }).catch(() => say('That did not work. Try again.'));});}@@@ -54,7 +54,16 @@let base = null; // the theme pickedlet edited = null; // core overrides, or nullconst coreOf = (v) => ({ '--bg-primary': v['--bg-primary'], '--bg-card': v['--bg-card'] || v['--bg-secondary'], '--text-primary': v['--text-primary'], '--text-muted': v['--text-muted'] || v['--text-secondary'], '--accent': v['--accent'], '--border': v['--border'] });-- const current = () => { if (!base) return null; if (!edited) return base.variables; return Object.assign({}, base.variables, derive(Object.assign(coreOf(base.variables), edited)) || {}); };++ const current = () => {++ if (!base) return null;++ // A theme may carry only its core colours (or an older catalog only a few); fill the rest from the shared++ // built-in of the same id, then the theme's own values, so the checks, the previews and the code agree.++ const builtin = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.THEMES && window.OpenVibeThemeLoader.THEMES[base.id]) || {};++ const source = Object.assign({}, builtin, base.variables);++ const full = Object.assign(derive(coreOf(source)) || {}, source);++ if (!edited) return full;++ return Object.assign(full, derive(Object.assign(coreOf(source), edited)) || {});++ };function paint() {const v = current();@@@ -64,6 +73,14 @@$$('.ds-field input[type=color]').forEach((inp) => { const x = hex(core[inp.getAttribute('data-var')]); if (x) inp.value = toHex(x); });$$('.ds-field input[data-hex]').forEach((inp) => { if (document.activeElement !== inp) { const x = hex(core[inp.getAttribute('data-hex')]); inp.value = x ? toHex(x) : ''; } });$('#ds-current').textContent = base.name + (edited ? ' (edited)' : '');++ const shareName = $('#ds-share-theme');++ if (shareName) shareName.textContent = base.name + (edited ? ' · edited' : '');++ const dots = $('#ds-share-dots');++ if (dots) dots.replaceChildren(...['--bg-primary', '--bg-card', '--accent', '--text-primary'].map((key) => {++ const dot = document.createElement('i'); dot.className = 'tk-dot';++ if (hex(v[key])) dot.style.background = toHex(hex(v[key]));++ return dot;++ }));$('#ds-reset').disabled = !edited;check(v);}@@@ -202,6 +219,7 @@await api('/api/themes/submit', { method: 'POST', body: JSON.stringify({ name, slug, description: $('#ds-desc').value.trim(), mode: v['--color-scheme'] === 'light' ? 'light' : 'dark', variables: v, tags: [] }) });say(`${name} is waiting for review. You can use it yourself right away.`);form.reset();++ $('#ds-share-sheet').close();} catch (err) { say(`Not sent: ${err.message}`); }});@@@ -225,12 +243,12 @@let h = [];try { h = (await api('/api/me/theme/history')).history || []; } catch (e) { return; }list.textContent = '';-- if (!h.length) { const li = document.createElement('li'); li.className = 'ds-fine'; li.textContent = 'Your earlier looks appear here after your first change.'; list.appendChild(li); return; }++ if (!h.length) { const li = document.createElement('li'); li.className = 'ds-history-empty'; li.innerHTML = '<span aria-hidden="true">◌</span><p>Your earlier looks appear here after your first change.</p>'; list.appendChild(li); return; }for (const x of h.slice(0, 20)) {const li = document.createElement('li');const t = byId.get(x.theme_id);const dot = document.createElement('i'); dot.className = 'ds-hist-dot';-- const accent = (x.custom_variables && x.custom_variables['--accent']) || (t && t.variables['--accent']);++ const accent = (x.custom_variables && x.custom_variables['--accent']) || (t && (t.variables['--accent'] || t.variables['--bg-primary']));if (hex(accent)) dot.style.background = toHex(hex(accent));const b = document.createElement('b'); b.textContent = (x.theme_name || x.theme_id) + (x.custom_variables ? ' (edited)' : '');const when = document.createElement('small'); when.textContent = whenOf(x.created_at);@@@ -12,6 +12,7 @@*/const { serviceAuth } = require('openvibe-contracts');const { sanitizeHtml } = require('./sanitize');++const { vocabulary } = require('../components/registry');class ArenaAiError extends Error {constructor(status, code, detail) {@@@ -41,6 +42,8 @@ function systemPrompt(category) {'Rules: return only the HTML document, starting with <!doctype html>. Use inline CSS in a <style> tag or style attributes.','No JavaScript, no event-handler attributes, no external stylesheets, fonts, scripts or images. Images may be inline SVG or data: URIs.','Make it responsive enough to be judged at 390 px and 1280 px wide. Use real, plausible content, not lorem ipsum. No explanations outside the HTML.',++ 'Use OpenVibe registry components as the interface vocabulary; mark each major component with data-ov-component set to its registry name.',++ vocabulary(),].join('\n');}nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++++// Public design.component@1 vocabulary. These definitions describe one interface across++// platforms; a native app renders the parts with its own toolkit and the exported tokens.++const shared = 'openvibe-shared';++const behavior = { keyboardAware: false, outsideTapCloses: false, scrollLock: false, reducedMotion: 'Remove movement; keep the state change.' };++const define = (name, title, summary, anatomy, props, states, tokens, rules, web, android, ios, example) => Object.freeze({++ schema: 'design.component@1', name, title, summary,++ anatomy: anatomy.map(([part, role]) => ({ part, role })), props, states, tokens,++ behaviors: { ...behavior, ...Object.fromEntries(Object.entries(rules).filter(([key]) => key !== 'accessibility')) },++ accessibility: { name: title, target: 'At least 44 × 44 dp or pt for every touch action', focus: 'Visible keyboard focus; follow reading order', ...(rules.accessibility || {}) },++ platforms: {++ web: { module: `${shared}/${web[0]}`, markup: web[1], demo: `/components/${name}` },++ android: { toolkit: 'Jetpack Compose', material3: android[0], specification: android[1] },++ ios: { toolkit: 'SwiftUI', specification: ios },++ },++ examples: [{ title: 'Default', props: example }],++});++++const COMPONENTS = Object.freeze([++ define('navbar', 'Navbar', 'The site identity and navigation with lead and action slots.', [['lead', 'brand and page context'], ['links', 'primary destinations'], ['actions', 'account and page controls']], { lead: 'string', links: 'link[]', actions: 'action[]', active: 'string' }, ['default', 'menu-open', 'scrolled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Tab through lead, destinations and actions; Escape closes the menu' } }, ['navbar', 'OpenVibeNavbar with lead and action slots'], ['SurfaceContainer', 'Use a TopAppBar with navigationIcon and actions slots; place overflow destinations in a modal menu.'], 'Use a toolbar with leading and trailing ToolbarItems, and a Menu for overflow.', { lead: 'OpenVibe', active: 'Design' }),++ define('menu', 'Menu', 'One navigation sheet with its You home and app destinations.', [['trigger', 'opens navigation'], ['you', 'account home'], ['apps', 'destination rows']], { open: 'boolean', person: 'person?', destinations: 'link[]' }, ['closed', 'open', 'signed-out'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Move focus into the menu; Escape returns it to the trigger' } }, ['navbar', 'OpenVibeNavbar menu and You home'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer with a You header and app rows.'], 'Use a sheet with a NavigationStack and You header.', { open: true }),++ define('you-home', 'You home', 'The personal starting view inside the menu.', [['identity', 'avatar and name'], ['shortcuts', 'your places'], ['settings', 'display and account']], { person: 'person?', shortcuts: 'link[]' }, ['signed-in', 'signed-out', 'loading'], ['color.bg.raised', 'color.text.secondary', 'size.tap'], { keyboardAware: true }, ['navbar', 'Menu You panel'], ['SurfaceContainerHigh', 'Use a Column of ListItem shortcuts under the account identity.'], 'Use a List with account identity and destination sections.', { person: 'You' }),++ define('dock', 'Dock', 'Persistent place for the current task, clear of the phone app bar and keyboard.', [['surface', 'bottom container'], ['content', 'current task'], ['action', 'send or confirm']], { content: 'string', action: 'action', busy: 'boolean' }, ['ready', 'busy', 'disabled'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Keep the action after the content in focus order' } }, ['kit', 'kit.dock({ action, name, placeholder, label })'], ['SurfaceContainer', 'Use Scaffold.bottomBar and imePadding; keep the primary action in a 48 dp control.'], 'Use safeAreaInset(edge: .bottom) with keyboard avoidance.', { content: 'Write a message', action: 'Send' }),++ define('dock-tabs', 'Dock tabs', 'The phone destinations attached to the bottom app bar.', [['tab', 'icon and label'], ['selection', 'active destination']], { items: 'link[]', active: 'string' }, ['default', 'selected', 'pressed'], ['color.accent.strong', 'color.text.muted', 'size.tap'], { keyboardAware: true, accessibility: { name: 'Each tab announces its label and selected state' } }, ['appbar', 'OpenVibeAppbar from the site navigation'], ['SurfaceContainer', 'Reference: OvTabs in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use NavigationBar with NavigationBarItem and selected state.'], 'Use TabView with system tab items.', { active: 'Studio' }),++ define('thread-composer', 'Thread composer', 'A multiline message field with Options, attachments and send or stop.', [['field', 'message text'], ['options', 'model and attachment choices'], ['send', 'submit or stop']], { action: 'url', placeholder: 'string', options: 'action[]', attach: 'boolean', busy: 'boolean' }, ['empty', 'composing', 'uploading', 'busy'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Enter sends when the field permits; Shift+Enter adds a line; Options has a spoken label' } }, ['thread', 'thread.composer({ action, placeholder, attach })'], ['SurfaceContainerHigh', 'Use an OutlinedTextField, Options icon button and send or stop button; imePadding on the dock.'], 'Use TextEditor with an Options Menu and send or stop Button above the keyboard.', { placeholder: 'Ask anything', attach: true }),++ define('sheet', 'Sheet', 'A modal task surface: one heading, one close action and scrollable content.', [['handle', 'touch affordance'], ['header', 'title and close'], ['body', 'task controls']], { title: 'string', open: 'boolean', body: 'content' }, ['closed', 'opening', 'open', 'closing'], ['color.bg.raised', 'color.line.strong', 'radius.lg'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Trap focus while open; Escape closes and returns focus' } }, ['kit', 'kit.sheet({ id, title, body })'], ['SurfaceContainerHigh', 'Use ModalBottomSheet with a heading, close control and sheet state.'], 'Use .sheet with a NavigationStack and a toolbar close button.', { title: 'Add a site' }),++ define('popover', 'Popover', 'A small anchored set of choices that closes on outside tap.', [['trigger', 'anchor'], ['choices', 'menu items']], { label: 'string', items: 'action[]', open: 'boolean' }, ['closed', 'open', 'selected'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Arrow keys move choices; Escape returns focus to trigger' } }, ['kit', 'kit.more({ label, items })'], ['SurfaceContainerHigh', 'Use DropdownMenu anchored to the trigger.'], 'Use Menu attached to the trigger Button.', { label: 'More' }),++ define('keyboard', 'Keyboard behavior', 'Shared focus, Escape and inset rules for controls near the screen edge.', [['focus', 'active input'], ['inset', 'visible keyboard space'], ['dismiss', 'return path']], { focused: 'boolean', returnFocus: 'string?' }, ['idle', 'keyboard-open', 'dismissed'], ['size.tap', 'space.md'], { keyboardAware: true, accessibility: { focus: 'Keep the focused control visible; dismiss without losing the place' } }, ['kit-web', 'Shared focus and form handling'], ['Surface', 'Use WindowInsets.ime and bringIntoViewRequester for focused fields.'], 'Use safeAreaInset and FocusState; scroll the focused field into view.', { focused: true }),++ define('kit-row', 'Kit row', 'One tappable item with identity, supporting text and a trailing fact or action.', [['mark', 'item identity'], ['text', 'title and supporting line'], ['trail', 'time, count or action']], { title: 'string', sub: 'string?', href: 'url?', mark: 'string?', trail: 'string?' }, ['default', 'hover', 'focused', 'unread'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Announce title, supporting text and trailing fact together' } }, ['kit', 'kit.row({ title, sub, href, mark, trail })'], ['SurfaceContainer', 'Reference: OvRow in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use ListItem with headlineContent, supportingContent and trailingContent.'], 'Use an HStack row inside a List with a full-row tap target.', { title: 'OpenVibe.Live', sub: 'Live now' }),++ define('kit-card', 'Kit card', 'One content object with media, title and context.', [['media', 'preview or glyph'], ['title', 'primary name'], ['meta', 'supporting facts']], { title: 'string', href: 'url', image: 'url?', badge: 'string?' }, ['default', 'hover', 'focused', 'loading'], ['color.bg.raised', 'color.text.primary', 'radius.md'], { accessibility: { name: 'The whole card is one link named by its title' } }, ['kit', 'kit.card({ title, href, image, badge })'], ['SurfaceContainer', 'Reference: OvCard in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use Card with a single onClick and semantic title.'], 'Use a Button wrapping a card-shaped VStack.', { title: 'Seattle at night', href: '/live' }),++ define('kit-section', 'Kit section', 'A named group with an optional note and count.', [['heading', 'group name'], ['note', 'help text'], ['body', 'group content']], { title: 'string', note: 'string?', body: 'content' }, ['default', 'empty'], ['color.bg.raised', 'color.text.primary', 'space.lg'], {}, ['kit', 'kit.section({ title, note, body })'], ['Surface', 'Reference: OvSection in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use a Column with a heading and grouped content.'], 'Use a Section with a header and optional footer note.', { title: 'Your rooms' }),++ define('empty-state', 'Empty state', 'A truthful explanation and one next action when a group has no items.', [['glyph', 'context icon'], ['title', 'what is empty'], ['action', 'next step']], { title: 'string', text: 'string?', action: 'action?' }, ['empty', 'loading-resolved'], ['color.text.muted', 'color.accent.strong', 'size.tap'], {}, ['kit', 'kit.empty({ title, text, action })'], ['Surface', 'Reference: OvEmpty in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use a Column with icon, title and one Button.'], 'Use ContentUnavailableView with one action Button.', { title: 'No sites yet', text: 'Add a site to give it a look.' }),++ define('switch-row', 'Switch row', 'One setting with a label, explanation and on or off control.', [['label', 'setting name'], ['hint', 'effect of setting'], ['switch', 'current value']], { name: 'string', label: 'string', hint: 'string?', on: 'boolean' }, ['off', 'on', 'disabled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Label and hint describe the switch; announce on or off' } }, ['kit', 'kit.switchRow({ name, label, hint, on })'], ['Surface', 'Use a ListItem with a Material 3 Switch trailing control.'], 'Use Toggle with a supporting text label.', { name: 'contrast', label: 'More contrast', on: false }),++ define('segmented-control', 'Segmented control', 'A small exclusive choice set with a visible current value.', [['label', 'choice purpose'], ['segments', 'mutually exclusive values']], { name: 'string', label: 'string', options: 'choice[]', value: 'string' }, ['default', 'selected', 'focused'], ['color.accent.strong', 'color.text.primary', 'size.tap'], { accessibility: { focus: 'Arrow keys move radio choices; announce selected value' } }, ['kit', 'kit.seg({ name, label, options, value })'], ['SecondaryContainer', 'Use SingleChoiceSegmentedButtonRow.'], 'Use Picker with .segmented style.', { name: 'scheme', label: 'Light or dark', value: 'auto' }),++ define('upload-progress', 'Upload and progress', 'A file transfer with a truthful determinate meter and cancel action.', [['file', 'name and type'], ['meter', 'bytes sent of total'], ['cancel', 'stop transfer']], { name: 'string', totalBytes: 'number', sentBytes: 'number', cancellable: 'boolean' }, ['queued', 'uploading', 'complete', 'failed', 'cancelled'], ['color.accent.strong', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Announce file name, percent and state without repeating every byte' } }, ['files', 'Shared upload control and progress meter'], ['SurfaceContainer', 'Use LinearProgressIndicator(progress) with state text and cancel IconButton.'], 'Use ProgressView(value:total:) with a cancel Button.', { name: 'photo.jpg', totalBytes: 100, sentBytes: 45 }),++ define('ai-choice', 'Model chip', 'The selected AI model as a compact choice that opens the model picker.', [['icon', 'model glyph'], ['name', 'selected model'], ['chevron', 'change affordance']], { model: 'string', options: 'choice[]', disabled: 'boolean' }, ['default', 'open', 'disabled'], ['color.bg.raised', 'color.accent.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { name: 'Announce selected model and that it opens choices' } }, ['ov-ai-choice', '.ov-ai-choice model chip'], ['SecondaryContainer', 'Use an AssistChip that opens a ModalBottomSheet with model choices.'], 'Use a Menu with a Label and selected model checkmark.', { model: 'Auto' }),++ define('motion-tokens', 'Motion tokens', 'The four shared durations and curves that give movement one pace.', [['duration', '120, 200, 320 or 520 ms'], ['curve', 'out, spring or in-out']], { duration: 'token', curve: 'token' }, ['auto', 'reduced'], ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4', '--ov-ease-out', '--ov-ease-spring', '--ov-ease-in-out'], { reducedMotion: 'Duration becomes zero for decorative motion.' }, ['ov-fx', '--ov-dur-1..4 and --ov-ease-out/-spring/-in-out'], ['Surface', 'Use MotionDuration tokens with reducedMotion from accessibility settings.'], 'Use the same durations with accessibilityReduceMotion.', { duration: '--ov-dur-2', curve: '--ov-ease-out' }),++ define('pop-motion', 'Pop motion', 'The shared small-scale entrance for a menu or affordance.', [['object', 'appearing surface'], ['origin', 'activation point']], { open: 'boolean' }, ['closed', 'open'], ['--ov-dur-2', '--ov-ease-spring'], { reducedMotion: 'Crossfade without scale.' }, ['navbar', 'OpenVibeNavbar.pop'], ['Surface', 'Animate scale and alpha with the spring token; reduced motion uses alpha only.'], 'Use scale and opacity with the spring token; reduced motion uses opacity only.', { open: true }),++ define('sheet-motion', 'Sheet motion', 'The bottom sheet entrance and exit without moving underlying content.', [['surface', 'sheet'], ['scrim', 'dimmed context']], { open: 'boolean' }, ['closed', 'opening', 'open', 'closing'], ['--ov-dur-3', '--ov-ease-out'], { scrollLock: true, reducedMotion: 'Crossfade the sheet and scrim.' }, ['kit', 'kit.sheet and ovk-sheet motion'], ['SurfaceContainerHigh', 'Use ModalBottomSheet sheetState animation with reduced-motion fallback.'], 'Use native sheet presentation; respect accessibilityReduceMotion.', { open: true }),++ define('menu-motion', 'Menu motion', 'The navigation menu entrance tied to its trigger.', [['panel', 'navigation'], ['trigger', 'menu button']], { open: 'boolean' }, ['closed', 'opening', 'open', 'closing'], ['--ov-dur-2', '--ov-ease-out'], { outsideTapCloses: true, reducedMotion: 'Reveal immediately without translation.' }, ['navbar', 'OpenVibeNavbar menu motion'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer animation; reduce motion when requested.'], 'Use a sheet presentation with reduced-motion support.', { open: true }),++]);++++const byName = new Map(COMPONENTS.map((component) => [component.name, component]));++const get = (name) => byName.get(String(name || '')) || null;++const vocabulary = () => COMPONENTS.map((c) => [++ `${c.name} — ${c.summary}`,++ `Parts: ${c.anatomy.map((a) => a.part).join(', ')}. Props: ${Object.entries(c.props).map(([k, t]) => `${k}:${t}`).join(', ')}.`,++ `States: ${c.states.join(', ')}. Tokens: ${c.tokens.join(', ')}.`,++ `Keyboard-aware: ${c.behaviors.keyboardAware ? 'yes' : 'no'}; outside tap closes: ${c.behaviors.outsideTapCloses ? 'yes' : 'no'}; scroll lock: ${c.behaviors.scrollLock ? 'yes' : 'no'}; reduced motion: ${c.behaviors.reducedMotion}`,++ `Web: ${c.platforms.web.markup}. Android: ${c.platforms.android.material3} — ${c.platforms.android.specification} iOS: ${c.platforms.ios.specification}`,++].join(' ')).join('\n');++++module.exports = { COMPONENTS, get, vocabulary };@@@ -3,6 +3,7 @@const crypto = require('crypto');const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');const { validateChange, applyChange } = require('./design-edit');++const { vocabulary } = require('./components/registry');const KINDS = ['page', 'screen', 'component'];const random = () => crypto.randomBytes(24).toString('base64url');@@@ -79,7 +80,7 @@ function wrap(html, variables) {// ── The prompts this service sends (published word for word at openvibe.design/prompts) ──/** A design drawn in the person's theme: their words, then the theme's colour variables. */function themePrompt(prompt, variableNames) {-- return `${prompt}\nUse ONLY these theme CSS custom properties for all colors: ${variableNames.join(', ')}. No external requests. Mobile first. Return one complete HTML document.`;++ return `${prompt}\nUse ONLY these theme CSS custom properties for all colors: ${variableNames.join(', ')}. No external requests. Mobile first. Return one complete HTML document.\nBuild the interface from these OpenVibe component definitions. Mark each major component with data-ov-component set to its registry name. Keep the anatomy, state and keyboard behavior described by the definition.\n${vocabulary()}`;}/** An edit of a drawn design: the person's instruction, the current document, and the theme's colour variables. */function editPrompt(instruction, html, names) {@@@ -89,6 +90,7 @@ function editPrompt(instruction, html, names) {'The current document is between the markers. Keep its theme, its content and its structure unless the instruction asks otherwise.',`<<<CURRENT_DESIGN\n${html}\nCURRENT_DESIGN>>>`,`Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,++ `Keep using the OpenVibe component registry. Mark each major part with data-ov-component set to its registry name.\n${vocabulary()}`,].join('\n\n');}@@@ -31,6 +31,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');const themeTokens = require('../tokens');const components = require('openvibe-shared/components');++const registry = require('../components/registry');/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */function passThrough(res, r) {@@@ -112,7 +113,13 @@ function createApi(ctx) {// ── Components: the contract as JSON, and a kit document checked against it (public; nothing is stored) ──r.get('/v1/components', (req, res) => {res.set('Cache-Control', 'public, max-age=3600');-- return res.json({ version: components.VERSION, limits: components.LIMITS, preamble: components.PREAMBLE, components: components.SPECS, docs: 'https://openvibe.design/components' });++ return res.json({ schema: 'design.component@1', version: 1, components: registry.COMPONENTS, kitComponents: components.SPECS, limits: components.LIMITS, preamble: components.PREAMBLE, docs: 'https://openvibe.design/components/registry', llms: 'https://openvibe.design/llms.txt' });++ });++ r.get('/v1/components/:name', (req, res) => {++ const component = registry.get(req.params.name);++ if (!component) return http.sendProblem(res, 404, 'design.component_not_found', { detail: 'No component by that name.', ctx: req.ov });++ res.set('Cache-Control', 'public, max-age=3600');++ return res.json({ schema: component.schema, component, examples: component.examples });});r.post('/v1/components/validate', (req, res) => {const doc = req.body && req.body.doc;@@@ -11,6 +11,7 @@ const fs = require('fs');const path = require('path');const seo = require('openvibe-shared/seo');const glyphs = require('openvibe-shared/glyphs');++const { COMPONENTS } = require('../components/registry');const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Design';@@@ -33,6 +34,7 @@ function publicPages(themes = []) {{ 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: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },++ { path: '/components/registry', changefreq: 'weekly', priority: 0.8, title: 'Component registry', text: 'Every shared OpenVibe interface as one design.component@1 definition: navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion, for web, Android and iOS.' },{ 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: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },@@@ -62,7 +64,11 @@ function publicPages(themes = []) {text: `The ${n.replace(/-/g, ' ')} icon from the OpenVibe glyphs: SVG, PNG at any size and colour, and the font.`,images: [`/icons/${n}.svg`, `/icons/${n}.png?size=512`],}));-- return [...static_, ...themePages, ...glyphPages];++ const componentPages = COMPONENTS.map((component) => ({++ path: `/components/${component.name}`, changefreq: 'weekly', priority: 0.6,++ title: `${component.title}: OpenVibe component`, text: component.summary,++ }));++ return [...static_, ...componentPages, ...themePages, ...glyphPages];}function createDiscoveryRoutes(ctx) {@@@ -110,10 +116,15 @@ function createDiscoveryRoutes(ctx) {{ title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },] },{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },++ { title: 'Component registry', links: [++ { title: 'The component registry', url: abs('/components/registry'), note: 'every shared interface as a design.component@1 definition, for web, Android and iOS' },++ ...COMPONENTS.map((c) => ({ title: c.title, url: abs(`/components/${c.name}`), note: `${c.summary} Definition: ${abs(`/api/v1/components/${c.name}`)}` })),++ ] },// The data an agent came for is a main section, not something to skip.{ title: 'Machine-readable', links: [{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },{ title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },++ { title: 'The component registry (JSON)', url: abs('/api/v1/components'), note: 'design.component@1 definitions for every shared interface; one at /api/v1/components/<name>; the SDK exposes ov.design.components()' },{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },] },@@@ -248,6 +248,11 @@ function createPageRoutes(ctx) {return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);});r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));++ r.get('/components/registry', (req, res) => page(res, 200, require('../views/component-registry').registryPage(), req.viewer));++ r.get('/components/:name', (req, res) => {++ const html = require('../views/component-registry').componentPage(req.params.name);++ return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);++ });// ── Tokens: any public theme as code (views/tokens.js) ──r.get('/tokens', async (req, res) => {@@@ -207,8 +207,8 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',path: '/brand',active: '/brand',-- styles: ['css/brand.css'],-- scripts: ['js/brand.js'],++ styles: ['css/brand.css', 'css/demo-motion-controls.css'],++ scripts: ['js/brand.js', 'js/demo-motion-controls.js'],body,});}nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');++const { COMPONENTS, get } = require('../components/registry');++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="/studio">Studio</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: '/studio', 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: '/studio', 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="/studio" 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: '/studio', label: 'Open Studio', 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: '/studio', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),++ 'kit-card': () => kit.card({ href: '/studio', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),++ 'kit-section': () => kit.section({ title: 'Your places', note: 'A named group of related actions.', body: kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings' }) }),++ 'empty-state': () => kit.empty({ title: 'No sites yet', text: 'Add a site to give it a look of its own.', action: { href: '/look', label: 'Add a site' }, icon: 'plus' }),++ 'switch-row': () => kit.switchRow({ name: 'cr-contrast', label: 'More contrast', hint: 'Text and borders stand out more.', on: false }),++ 'segmented-control': () => kit.seg({ name: 'cr-scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Auto']], value: 'auto' }),++ 'upload-progress': () => `<div class="cr-upload"><span>${kit.icon('file')}<b>photo.jpg</b><small id="cr-upload-state">45% uploaded</small></span><progress value="45" max="100" aria-label="photo.jpg upload progress">45%</progress><button type="button" class="ovk-btn ovk-btn--quiet" id="cr-upload-cancel">Cancel</button></div>`,++ 'ai-choice': () => `<button type="button" class="ov-ai-choice" data-ovk-sheet="#cr-model-sheet">${kit.icon('spark')}<span>Auto</span>${kit.icon('chevron')}</button>` + kit.sheet({ id: 'cr-model-sheet', title: 'Choose a model', body: kit.seg({ name: 'cr-model-choice', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),++ 'motion-tokens': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="tokens">${kit.icon('play')} Play the four timings</button><div class="cr-motion-samples" aria-hidden="true"><i data-cr-bar="1"></i><i data-cr-bar="2"></i><i data-cr-bar="3"></i><i data-cr-bar="4"></i></div>`,++ 'pop-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="pop">${kit.icon('play')} Play pop</button><span class="cr-animated cr-pop">${kit.icon('star')}</span>`,++ 'sheet-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="sheet">${kit.icon('play')} Play sheet</button><span class="cr-animated cr-slide">Sheet</span>`,++ 'menu-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="menu">${kit.icon('play')} Play menu</button><span class="cr-animated cr-reveal">Menu</span>`,++};++++function registryLinks() {++ return `<nav class="cr-index" aria-label="Component definitions">${COMPONENTS.map((c) => `<a href="${link(c.name)}">${esc(c.title)}</a>`).join('')}</nav>`;++}++++const GROUPS = [++ ['Navigation', ['navbar', 'menu', 'you-home', 'dock', 'dock-tabs']],++ ['Writing and choosing', ['thread-composer', 'sheet', 'popover', 'keyboard']],++ ['Content', ['kit-row', 'kit-card', 'kit-section', 'empty-state']],++ ['Settings', ['switch-row', 'segmented-control']],++ ['Files and AI', ['upload-progress', 'ai-choice']],++ ['Motion', ['motion-tokens', 'pop-motion', 'sheet-motion', 'menu-motion']],++];++++/** /components/registry: the whole vocabulary in one crawlable page, with each definition's own page and JSON. */++function registryPage() {++ const cards = (names) => names.map((name) => {++ const c = get(name);++ const platforms = ['web', 'android', 'ios'].map((p) => `<span>${p === 'web' ? 'Web' : p === 'android' ? 'Android' : 'iOS'}</span>`).join('');++ return `<li class="cr-card"><a class="cr-card-link" href="${link(name)}"><b>${esc(c.title)}</b><span>${esc(c.summary)}</span></a><div class="cr-card-meta"><span class="cr-card-platforms">${platforms}</span><a href="/api/v1/components/${esc(name)}" aria-label="${esc(c.title)} as JSON">JSON</a></div></li>`;++ }).join('');++ const sections = GROUPS.map(([title, names]) => `<section class="cr-group"><h2>${esc(title)}</h2><ul class="cr-cards">${cards(names)}</ul></section>`).join('');++ const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>One vocabulary, three platforms</h1><p class="ds-lede">Every shared OpenVibe interface as one definition: its anatomy, props, states, tokens, keyboard and dismissal rules, accessibility, and how it is built on the web, in Jetpack Compose and in SwiftUI. The same definitions are the vocabulary Design and Space give their AI generators.</p>++<p class="cr-links"><a href="/api/v1/components">All definitions as JSON</a> · <a href="/llms.txt">llms.txt</a> · <a href="/api/v1/components/sheet">Example: sheet</a></p></header>++${sections}++<p class="ds-fine">A definition is a promise about behaviour, not a screenshot: the live demo on each page is the web implementation, and the Android and iOS sections name the container role and the interaction each platform owns.</p>`;++ return layout({ title: 'Component registry: OpenVibe design system', description: 'Platform-neutral definitions of the OpenVibe navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion: web, Android and iOS.', path: '/components/registry', body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'] });++}++++function componentPage(name) {++ const c = get(name);++ if (!c) return null;++ const anatomy = c.anatomy.map((part) => `<li><b>${esc(part.part)}</b><span>${esc(part.role)}</span></li>`).join('');++ const facts = [++ ['States', c.states.join(' · ')],++ ['Tokens', c.tokens.join(' · ')],++ ['Keyboard', c.behaviors.keyboardAware ? 'Keeps the focused control in view' : 'Standard focus order'],++ ['Outside tap', c.behaviors.outsideTapCloses ? 'Closes the surface' : 'No dismissal'],++ ['Scroll lock', c.behaviors.scrollLock ? 'Locks the page while open' : 'Page remains scrollable'],++ ['Reduced motion', c.behaviors.reducedMotion],++ ['Accessibility', `${c.accessibility.name}. ${c.accessibility.focus}. ${c.accessibility.target}.`],++ ];++ const details = `<dl class="cr-facts">${facts.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;++ const native = (platform) => `<section class="cr-native"><h3>${platform === 'android' ? 'Android · Jetpack Compose' : 'iOS · SwiftUI'}</h3><p>${esc(platform === 'android' ? c.platforms.android.specification : c.platforms.ios.specification)}</p>${platform === 'android' ? `<p>Material 3 container: <code>${esc(c.platforms.android.material3)}</code></p>` : ''}</section>`;++ const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>${esc(c.title)}</h1><p class="ds-lede">${esc(c.summary)}</p><p><a href="/components/registry">All components</a> · <a href="/components">Kit documents</a> · <a href="/api/v1/components/${esc(c.name)}">Definition as JSON</a></p></header>++<div class="cr-layout"><section><h2>Live web demo</h2><div class="cr-demo cr-demo--${esc(c.name)}" data-ov-component="${esc(c.name)}">${demo[c.name]()}</div><p class="ds-fine">Web source: <code>${esc(c.platforms.web.module)}</code> · ${esc(c.platforms.web.markup)}</p></section>++<section><h2>Anatomy</h2><ul class="cr-anatomy">${anatomy}</ul><h2>Props</h2><dl class="cr-props">${Object.entries(c.props).map(([key, type]) => `<div><dt><code>${esc(key)}</code></dt><dd>${esc(type)}</dd></div>`).join('')}</dl></section></div>++<section><h2>Behavior and accessibility</h2>${details}</section>++<div class="cr-layout">${native('android')}${native('ios')}</div>++<section><h2>Example definition</h2>${kit.snippet({ code: JSON.stringify({ schema: c.schema, name: c.name, props: c.examples[0].props }, null, 2), id: 'cr-example' })}</section>++<h2>Other components</h2>${registryLinks()}`;++ return layout({ title: `${c.title}: OpenVibe component`, description: c.summary, path: link(c.name), body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'], scripts: ['js/component-registry.js'], sharedStyles: c.name === 'thread-composer' ? ['thread.css'] : [], shared: c.name === 'thread-composer' ? ['ov-thread.js'] : [] });++}++++module.exports = { componentPage, registryPage, registryLinks };@@@ -13,6 +13,7 @@ const components = require('openvibe-shared/components');const tokens = require('openvibe-shared/tokens');const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');const { layout, esc } = require('../render/layout');++const { registryLinks } = require('./component-registry');// A sample kit document for each component: what the page shows rendered, and what the Web tab prints.const card = (n, title, extra = {}) => ({ type: 'card', props: { href: `/components?c=card#${n}`, title, type: 'stream', ...extra } });@@@ -86,6 +87,7 @@ ${kit.tabs({ base: '/components', param: 'c', active: spec.id, label: 'Component${kit.tabs({ base: `${base}`, param: 'p', active: p, label: 'Platform', items: PLATFORMS.map((x) => ({ ...x, href: `${base}&p=${x.key}#cp-code` })) })}<p class="tk-how">${esc(how)}</p>${kit.snippet({ code, id: 'cp-code-snip', label: 'Copy the code' })}</section>++<section class="cr"><h2>Shared interfaces</h2><p>Web, Android and iOS definitions for the navigation, composer, sheets, settings and motion used across OpenVibe. <a href="/components/registry">See the whole registry</a>, or read it as JSON at <code>/api/v1/components</code>.</p>${registryLinks()}</section>${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}`;return layout({title: `${spec.name}: an OpenVibe component for web, Android and iOS`,@@@ -93,6 +95,7 @@ ${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>Apath: `/components?c=${encodeURIComponent(spec.id)}`,body: `<div class="ds-wrap tk">${body}</div>`,active: '/components',++ styles: ['css/component-registry.css'],});}@@@ -170,7 +170,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()return layout({title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),-- path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css'], scripts: ['js/icon.js'], jsonLd,++ path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css', 'css/demo-motion-controls.css'], scripts: ['js/icon.js', 'js/demo-motion-controls.js'], jsonLd,image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,body: `<article class="ic gp">${hero}${maker}${use}${moving}${rel}</article>`,});@@@ -22,6 +22,7 @@ const { iframeHtml } = require('../arena/sanitize');const glyphs = require('openvibe-shared/glyphs');const { DESIGN_KINDS, DEFAULT_KIND, kindOf } = require('../arena/kinds');const ovServe = require('openvibe-shared/serve');++const appIcons = require('openvibe-shared/app-icons');const { TARGETS } = require('openvibe-shared/tokens');const { layout, esc, NAME } = layoutLib;@@@ -46,7 +47,7 @@ const TRY_SITES = [['Live', 'https://openvibe.live/'], ['Chat', 'https://openvib/** "Try it on" links for a catalog theme (id: its slug); data-try lets the studio point them at the picked theme. */function tryOn(id) {const q = id ? `?ov-try=${encodeURIComponent(id)}` : '';-- return `<p class="ds-try"><span>Try it on the real sites, nothing saved:</span>${TRY_SITES.map(([name, url]) => `<a href="${esc(url + q)}" data-try="${esc(url)}" rel="noopener">${esc(name)}</a>`).join('')}</p>`;++ return `<div class="ds-try"><span>Try it on a site · preview only</span><div class="ds-try-sites">${TRY_SITES.map(([name, url]) => `<a href="${esc(url + q)}" data-try="${esc(url)}" rel="noopener" target="_blank">${appIcons.url(name.toLowerCase(), 192) ? `<img src="${esc(appIcons.url(name.toLowerCase(), 192))}" width="28" height="28" alt="">` : kit.icon('globe')}<span>${esc(name)}</span></a>`).join('')}</div></div>`;}/** A theme as a swatch button (the studio) or link (the home and gallery without JavaScript). */@@@ -222,19 +223,20 @@ ${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"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${signedIn ? 'Use this theme' : 'Sign in to use it'}</button>--<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button>--<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" data-tokens-src="${esc(ovServe.url('tokens.js'))}" title="This theme, with your edits, for the web, Android, iOS, Flutter and Figma">As code</a></div>++<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>++<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>--<details class="ds-submit"><summary>Share it in the gallery</summary><form id="ds-submit-form"><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>--<button type="submit" class="ovk-btn ovk-btn--quiet" ${signedIn ? '' : 'disabled'}>Send for review</button><p class="ds-fine">A person reviews each theme before anyone else sees it.</p></form></details>--${signedIn ? '<details class="ds-history" id="ds-history"><summary>History</summary><ol id="ds-history-list"><li class="ds-fine">Your earlier looks appear here.</li></ol></details>' : ''}++<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: '/studio', body: `<div class="ds-wrap">${body}</div>`, active: '/studio', scripts: ['js/studio.js'] });++ 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: '/studio', body: `<div class="ds-wrap">${body}</div>`, active: '/studio', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });}function theme({ theme: t } = {}) {@@@ -9,6 +9,7 @@* lookPage({ themes, me })*/const kit = require('openvibe-shared/kit');++const appIcons = require('openvibe-shared/app-icons');const { layout, esc } = require('../render/layout');// The sites a person can give a look of their own (service ids, as openvibe-shared/shell writes them on <html>).@@@ -75,17 +76,18 @@ function lookPage({ themes = [], me = {} } = {}) {title: 'A different look on one site',note: 'Wear another theme on one site only: Games in Neon Tokyo, Live in Daylight. Everywhere else keeps your theme.',body: `<ul class="lk-sites" id="lk-site-list" aria-live="polite"></ul>--<form class="lk-site-form" id="lk-site-form"><label><span>Site</span><select name="service">${SITES.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join('')}</select></label>--<label><span>Theme</span><select name="theme_id">${themeOpts}</select></label>${kit.button({ type: 'submit', label: 'Add', small: true })}</form>`,++<div class="lk-add">${kit.sheetButton({ sheet: 'lk-site-sheet', label: 'Add a site', icon: 'plus', iconOnly: false })}</div>++${kit.sheet({ id: 'lk-site-sheet', title: 'Add a site', body: `<form class="lk-site-form" id="lk-site-form"><label><span>Site</span><select name="service">${SITES.map(([id, name]) => `<option value="${esc(id)}" data-app-icon="${esc(appIcons.url(id, 192) || '')}">${esc(name)}</option>`).join('')}</select></label>++<label><span>Theme</span><select name="theme_id">${themeOpts}</select></label>${kit.button({ type: 'submit', label: 'Add site' })}</form>` })}`,}) : '';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}${comfort}${sites}--<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>++<div class="lk-toast" id="lk-toast" role="status" aria-live="polite" hidden><span id="lk-status"></span><button type="button" id="lk-undo" hidden>Undo</button></div>++${signedIn ? `<p class="lk-history-link"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/studio#ds-history">${kit.icon('clock')}<span>Your history</span></a></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, seasonal themes and comfort settings (contrast, a readable font, colour-blind-safe colours, true black) for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'] });++ return layout({ title: 'Your look', description: 'Your theme, light or dark, text size, motion, seasonal themes and comfort settings (contrast, a readable font, colour-blind-safe colours, true black) for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'], styles: ['css/studio-look.css'] });}module.exports = { lookPage, esc, SITES };@@@ -137,8 +137,8 @@ OVFx.scan(document.querySelector('#feed'));`;description: 'How OpenVibe moves: the mark’s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules, and the code to use it anywhere.',path: '/motion',active: '/motion',-- styles: ['css/motion.css'],-- scripts: ['js/motion.js'],++ styles: ['css/motion.css', 'css/demo-motion-controls.css'],++ scripts: ['js/motion.js', 'js/demo-motion-controls.js'],body,});}@@@ -128,8 +128,29 @@ const { boot, check, done } = require('./helpers/boot');await check('the components API: the contract as JSON, and kit documents checked against it', async () => {const spec = (await t.get('/api/v1/components')).json();-- assert.strictEqual(spec.components.length, Object.keys(require('openvibe-shared/components').SPECS).length, 'every component openvibe-shared has');-- assert.ok(spec.components.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));++ assert.strictEqual(spec.schema, 'design.component@1');++ assert.ok(spec.components.find((c) => c.name === 'thread-composer').platforms.android.specification.includes('OutlinedTextField'));++ assert.ok(spec.kitComponents.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));++ const one = (await t.get('/api/v1/components/sheet')).json();++ assert.strictEqual(one.component.name, 'sheet');++ assert.strictEqual(one.component.behaviors.scrollLock, true);++ assert.strictEqual((await t.get('/api/v1/components/no-such-part')).status, 404);++ const page = await t.get('/components/sheet');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /id="cr-sheet"/);++ assert.match(page.text, /data-ov-component="sheet"/);++ for (const c of spec.components) {++ assert.ok(c.anatomy.length && c.states.length && c.tokens.length, `${c.name} describes its parts, states and tokens`);++ assert.ok(c.behaviors && typeof c.behaviors.scrollLock === 'boolean', `${c.name} states its behaviour`);++ assert.ok(c.accessibility.name && c.accessibility.target, `${c.name} states its accessibility`);++ assert.ok(c.platforms.web.markup && c.platforms.android.material3 && c.platforms.ios.specification, `${c.name} names all three platforms`);++ }++ const registry = await t.get('/components/registry');++ assert.strictEqual(registry.status, 200);++ assert.match(registry.text, /One vocabulary, three platforms/);++ assert.strictEqual((registry.text.match(/class="cr-card-link"/g) || []).length, spec.components.length);++ assert.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/components\/registry/);++ assert.strictEqual((await t.get('/sitemap.xml')).text.includes('/components/registry'), true);const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });assert.strictEqual(good.status, 200);assert.deepStrictEqual(good.json(), { ok: true, errors: [] });@@@ -142,7 +163,7 @@ const { boot, check, done } = require('./helpers/boot');const kim = t.network.addUser('kim-sites');const page = await t.get('/look', { as: kim });assert.match(page.text, /A different look on one site/);-- assert.match(page.text, /<select name="service"><option value="live">OpenVibe\.Live<\/option>/);++ assert.match(page.text, /<select name="service"><option value="live" data-app-icon="\/shared\/icons\/[^\"]+">OpenVibe\.Live<\/option>/);assert.doesNotMatch((await t.get('/look')).text, /A different look on one site/, 'signed out: no section');const origin = 'https://openvibe.design';const put = await t.get('/api/me/theme/overrides/games', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin });
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+.cr { padding-bottom: 36px; }
+.cr h2 { font-size: 1.2rem; margin: 24px 0 10px; }
+.cr h3 { font-size: 1rem; margin: 0 0 8px; }
+.cr-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
+.cr-layout > section { min-width: 0; }
+.cr-demo { min-height: 110px; padding: 18px; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); overflow: visible; }
+.cr-demo .ovk-dock { position: static; margin: 0; }
+.cr-demo .ovk-section { margin: 0; }
+.cr-demo .ovk-card { max-width: 290px; }
+.cr-demo .ovk-list { margin: 0; }
+.cr-demo .ovt-composer { max-width: none; }
+.cr-mini-nav, .cr-dock-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.cr-mini-nav a, .cr-mini-nav summary, .cr-dock-tabs a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border-radius: 12px; color: var(--ovk-text); text-decoration: none; cursor: pointer; }
+.cr-mini-nav details { position: relative; }
+.cr-mini-nav details[open] { display: flex; flex-direction: column; }
+.cr-mini-nav details a { background: var(--ovk-panel-2); }
+.cr-mini-nav .ovk-i, .cr-dock-tabs .ovk-i { width: 18px; height: 18px; }
+.cr-dock-tabs a[aria-current] { background: color-mix(in srgb, var(--ovk-accent) 18%, transparent); color: var(--ovk-accent); }
+.cr-menu summary { display: inline-flex; align-items: center; gap: 7px; list-style: none; cursor: pointer; }
+.cr-menu summary::-webkit-details-marker { display: none; }
+.cr-menu .ovk-row { margin-top: 8px; }
+.cr-field { display: grid; gap: 8px; font-weight: 700; }
+.cr-field input { min-height: 44px; padding: 8px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }
+.cr-anatomy { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.cr-anatomy li { display: grid; gap: 3px; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
+.cr-anatomy span, .cr-props dd { color: var(--ovk-muted); }
+.cr-props, .cr-facts { margin: 0; }
+.cr-props div, .cr-facts div { display: grid; grid-template-columns: minmax(95px, .25fr) minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--ovk-line); }
+.cr-props dt, .cr-facts dt { font-weight: 700; }
+.cr-props dd, .cr-facts dd { margin: 0; overflow-wrap: anywhere; }
+.cr-links { display: flex; flex-wrap: wrap; gap: 6px 16px; }
+.cr-group { margin-top: 26px; }
+.cr-group h2 { margin: 0 0 10px; }
+.cr-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 10px; }
+.cr-card { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 12px 13px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.cr-card-link { display: grid; gap: 4px; color: var(--ovk-text); text-decoration: none; }
+.cr-card-link b { font-size: 1rem; }
+.cr-card-link span { color: var(--ovk-muted); font-size: .87rem; line-height: 1.35; }
+.cr-card-link:hover b, .cr-card-link:focus-visible b { text-decoration: underline; }
+.cr-card-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; }
+.cr-card-platforms { display: inline-flex; flex-wrap: wrap; gap: 4px; }
+.cr-card-platforms span { padding: 2px 7px; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-muted); font-size: .72rem; }
+.cr-card-meta a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 8px; border-radius: 8px; color: var(--text-muted, var(--ovk-muted)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem; font-weight: 650; text-decoration: none; }
+.cr-card-meta a:hover { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 10%, transparent); }
+.cr-native { padding: 15px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.cr-native p { margin: 0 0 8px; }
+.cr-index { display: flex; flex-wrap: wrap; gap: 8px; }
+.cr-index a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; border: 1px solid var(--ovk-line); border-radius: 999px; color: var(--ovk-text); text-decoration: none; }
+.cr-index a:hover, .cr-index a:focus-visible { border-color: var(--ovk-accent); }
+.cr-upload { display: grid; gap: 10px; }
+.cr-upload > span { display: flex; align-items: center; gap: 8px; }
+.cr-upload small { margin-left: auto; color: var(--ovk-muted); }
+.cr-upload progress { width: 100%; accent-color: var(--ovk-accent); }
+.cr-upload button { justify-self: start; min-height: 44px; }
+.ov-ai-choice { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; cursor: pointer; }
+.ov-ai-choice .ovk-i { width: 18px; height: 18px; }
+.cr-motion-samples { display: grid; gap: 6px; margin: 12px 0 0; }
+.cr-motion-samples i { display: block; width: 42px; height: 12px; border-radius: 999px; background: var(--ovk-accent); transform: translateX(0); }
+.cr-animated { display: inline-grid; place-items: center; min-width: 70px; min-height: 44px; padding: 8px; margin-left: 12px; border-radius: 12px; background: var(--ovk-panel-2); }
+.cr-motion-notice { display: grid; gap: 8px; margin-bottom: 12px; padding: 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
+.cr-motion-notice[hidden] { display: none; }
+.cr-motion-notice div { display: flex; flex-wrap: wrap; gap: 8px; }
+.cr-motion-notice button { min-height: 44px; }
+@media (max-width: 720px) { .cr-layout { grid-template-columns: minmax(0, 1fr); } .cr-props div, .cr-facts div { grid-template-columns: minmax(0, 1fr); gap: 2px; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+.ov-demo-motion-notice { display: grid; gap: 10px; margin: 12px auto 20px; width: min(100%, 1080px); padding: 12px 14px; border: 1px solid var(--ovk-line, var(--border)); border-radius: 14px; background: var(--ovk-panel, var(--bg-card)); color: var(--ovk-text, var(--text-primary)); }
+.ov-demo-motion-notice[hidden] { display: none; }
+.ov-demo-motion-notice > span { font-weight: 700; }
+.ov-demo-motion-notice > div { display: flex; flex-wrap: wrap; gap: 8px; }
+.ov-demo-motion-notice button { min-height: 44px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+/* Theme actions and site choices use the same kit controls on phones and computers. */
+.ds-actions { align-items: stretch; }
+.ds-actions .ovk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; }
+.ds-actions .ovk-i { width: 18px; height: 18px; }
+.ds-actions [data-ovk-sheet] { min-height: 44px; }
+.ds-try { display: block; }
+.ds-try > span { display: block; }
+.ds-try-sites { display: flex; flex-wrap: wrap; gap: 5px; padding: 4px 0 8px; }
+.ds-try .ds-try-sites a { display: inline-flex; flex: 1 1 52px; flex-direction: column; justify-content: center; gap: 3px; min-width: 0; max-width: 74px; min-height: 68px; padding: 6px 2px; border-radius: 14px; font-size: .62rem; line-height: 1.15; text-align: center; }
+.ds-try-sites img { width: 26px; height: 26px; border-radius: 8px; }
+.ds-share-preview { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.ds-share-preview .tk-dots { display: flex; }
+#ds-submit-form { display: grid; gap: 12px; }
+#ds-submit-form label { display: grid; gap: 5px; font-weight: 700; }
+#ds-submit-form input { width: 100%; min-height: 44px; }
+#ds-submit-form .ovk-btn { min-height: 44px; }
+.ds-history summary { display: flex; gap: 8px; min-height: 44px; align-items: center; }
+.ds-history summary .ovk-i { width: 18px; height: 18px; }
+.ds-history ol { display: flex; gap: 10px; overflow-x: auto; padding: 4px 0 10px; scroll-snap-type: x proximity; }
+.ds-history li { flex: 0 0 148px; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-height: 130px; padding: 10px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); scroll-snap-align: start; }
+.ds-history li .ds-hist-dot { display: block; width: 100%; height: 34px; border-radius: 8px; }
+.ds-history li b { line-height: 1.25; }
+.ds-history li small { grid-column: auto; }
+.ds-history li button { grid-column: auto; grid-row: auto; min-height: 44px; margin-top: auto; }
+.ds-history li.ds-history-empty { flex: 1 0 100%; min-height: 90px; flex-direction: row; align-items: center; color: var(--ovk-muted); }
+.ds-history-empty > span { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--ovk-panel-2); font-size: 1.8rem; }
+.ds-history-empty p { margin: 0; }
+
+.lk-sites { padding: 0 14px; }
+.lk-sites li { position: relative; display: flex; justify-content: flex-start; gap: 12px; min-height: 68px; padding: 8px 0; }
+.lk-site-icon { flex: none; border-radius: 11px; object-fit: cover; }
+.lk-site-text { display: grid; gap: 3px; min-width: 0; }
+.lk-site-text b { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.lk-site-text small { display: flex; align-items: center; gap: 6px; color: var(--ovk-muted); }
+.lk-site-swatch { width: 13px; height: 13px; border-radius: 50%; border: 1px solid var(--ovk-line); }
+.lk-site-menu { position: relative; margin-left: auto; }
+.lk-site-menu summary { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 11px; cursor: pointer; font-size: 1.4rem; list-style: none; }
+.lk-site-menu summary::-webkit-details-marker { display: none; }
+.lk-site-menu[open] summary { background: var(--ovk-panel-2); }
+.lk-site-menu button { position: absolute; z-index: 2; right: 0; top: 46px; min-width: 190px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); color: var(--ovk-text); text-align: left; box-shadow: 0 10px 24px #0004; cursor: pointer; }
+.lk-add { padding: 10px 14px 14px; }
+.lk-add .ovk-btn { min-height: 44px; }
+.lk-site-form { display: grid; gap: 14px; padding: 0; }
+.lk-site-form label, .lk-site-form select { width: 100%; max-width: none; }
+.lk-site-form .ovk-btn { justify-self: start; }
+.lk-history-link a { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; }
+.lk-history-link .ovk-i { width: 18px; height: 18px; }
+.lk-toast { position: fixed; z-index: 60; left: 50%; bottom: calc(var(--ovnav-appbar-h, 0px) + 12px); transform: translateX(-50%); display: flex; align-items: center; gap: 12px; width: max-content; max-width: calc(100vw - 28px); min-height: 48px; padding: 8px 10px 8px 16px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); color: var(--ovk-text); box-shadow: 0 12px 30px #0005; }
+.lk-toast[hidden], .lk-toast button[hidden] { display: none; }
+.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)); }
+ .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; }
+ .ds-actions #ds-use { flex-direction: row; min-width: 0; padding: 6px 8px; font-size: .82rem; }
+ .ds-actions #ds-use span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+ /* Undo, Code and Share are icon buttons on a phone; their labels stay for a screen reader and appear on a computer. */
+ .ds-actions #ds-reset span, .ds-actions #ds-code span, .ds-actions [data-ovk-sheet] span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+ .ds-actions [data-ovk-sheet] { font-size: .67rem; }
+ .ds-actions [data-ovk-sheet] .ovk-i { width: 18px; height: 18px; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+(function () {
+ 'use strict';
+ const root = document.querySelector('.cr');
+ if (!root) return;
+ root.querySelectorAll('form[action="#"]').forEach((form) => form.addEventListener('submit', (event) => event.preventDefault()));
+ const cancel = document.querySelector('#cr-upload-cancel');
+ if (cancel) cancel.addEventListener('click', () => {
+ const progress = document.querySelector('.cr-upload progress');
+ if (progress) progress.value = 0;
+ const state = document.querySelector('#cr-upload-state');
+ if (state) state.textContent = 'Cancelled';
+ cancel.disabled = true;
+ });
+
+ const buttons = Array.from(root.querySelectorAll('[data-cr-play]'));
+ if (!buttons.length) return;
+ const loader = window.OpenVibeThemeLoader;
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');
+ let playHere = false;
+
+ // The demos use the shared motion tokens (openvibe-shared/ov-fx.js); Play reads them at click time, after
+ // Calm's zero durations have been lifted for this page only.
+ const tokenMs = (name, fallback) => {
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ const value = parseFloat(raw);
+ if (!Number.isFinite(value) || value <= 0) return fallback;
+ return raw.endsWith('ms') ? value : value * 1000;
+ };
+ const tokenCurve = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
+
+ const notice = document.createElement('div');
+ notice.className = 'cr-motion-notice';
+ notice.hidden = true;
+ notice.innerHTML = '<span></span><div><button type="button" class="ovk-btn ovk-btn--quiet" data-play-here>Play animations here</button><button type="button" class="ovk-btn ovk-btn--quiet" data-turn-on>Turn animations on</button></div>';
+ buttons[0].closest('.cr-demo').before(notice);
+
+ const refresh = () => {
+ notice.hidden = playHere || !(calm() || media.matches);
+ notice.querySelector('span').textContent = calm()
+ ? 'Animations are off: your Motion setting is Calm.'
+ : 'Animations are off: your device asks for less motion.';
+ };
+ notice.querySelector('[data-play-here]').addEventListener('click', () => {
+ playHere = true;
+ // This page only: the saved display setting is untouched.
+ document.documentElement.removeAttribute('data-ov-motion');
+ document.documentElement.classList.add('ov-demo-play');
+ refresh();
+ buttons[0].click();
+ });
+ notice.querySelector('[data-turn-on]').addEventListener('click', () => {
+ if (loader && loader.display) loader.display.set({ motion: 'auto' });
+ refresh();
+ });
+ media.addEventListener('change', refresh);
+ new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });
+
+ const frames = (kind) => kind === 'pop' ? [{ transform: 'scale(.7)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }]
+ : kind === 'sheet' ? [{ transform: 'translateY(28px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }]
+ : kind === 'menu' ? [{ transform: 'translateX(-20px)', opacity: 0 }, { transform: 'translateX(0)', opacity: 1 }]
+ : null;
+ const play = (button) => {
+ const kind = button.getAttribute('data-cr-play');
+ if (kind === 'tokens') {
+ root.querySelectorAll('[data-cr-bar]').forEach((bar) => {
+ if (!bar.animate) return;
+ const step = Number(bar.getAttribute('data-cr-bar'));
+ bar.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(180px)' }, { transform: 'translateX(0)' }], {
+ duration: tokenMs(`--ov-dur-${step}`, 120 * step), easing: tokenCurve('--ov-ease-out', 'cubic-bezier(.2,.8,.2,1)'),
+ });
+ });
+ return;
+ }
+ const target = button.nextElementSibling;
+ if (!target || !target.animate || !frames(kind)) return;
+ target.animate(frames(kind), {
+ duration: tokenMs(kind === 'sheet' ? '--ov-dur-3' : '--ov-dur-2', kind === 'sheet' ? 320 : 200),
+ easing: tokenCurve(kind === 'pop' ? '--ov-ease-spring' : '--ov-ease-out', 'cubic-bezier(.2,.8,.2,1)'),
+ });
+ };
+ buttons.forEach((button) => button.addEventListener('click', () => {
+ if (!playHere && (calm() || media.matches)) return;
+ play(button);
+ }));
+ refresh();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+(function () {
+ 'use strict';
+ const brand = document.querySelector('.br');
+ const motion = document.querySelector('.mo');
+ const icon = document.querySelector('.gp #move');
+ const page = brand || motion || icon;
+ if (!page) return;
+ const anchor = brand ? brand.querySelector('.br-hero') : motion ? motion.querySelector('.mo-hero') : icon.querySelector('.mv-grid');
+ if (!anchor) return;
+ const loader = window.OpenVibeThemeLoader;
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const calm = () => Boolean(loader && loader.display && loader.display.get().motion === 'reduced');
+ const reduced = () => calm() || media.matches;
+ let localPlay = false;
+
+ const notice = document.createElement('div');
+ notice.className = 'ov-demo-motion-notice';
+ notice.hidden = true;
+ notice.innerHTML = '<span></span><div><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-play>Play animations here</button><button type="button" class="ovk-btn ovk-btn--quiet" data-ov-demo-auto>Turn animations on</button></div>';
+ anchor.before(notice);
+
+ const animate = (target) => {
+ if (!target || !target.animate) return;
+ target.animate([{ transform: 'translateY(10px) scale(.92)', opacity: .55 }, { transform: 'translateY(0) scale(1)', opacity: 1 }], {
+ duration: 520, easing: 'cubic-bezier(.34,1.56,.64,1)',
+ });
+ };
+ const refresh = () => {
+ const off = reduced() && !localPlay;
+ notice.hidden = !off;
+ notice.querySelector('span').textContent = calm()
+ ? 'Animations are off: your Motion setting is Calm.'
+ : 'Animations are off: your device asks for less motion.';
+ document.querySelectorAll('.br-motion-video').forEach((video) => { if (off) video.pause(); });
+ };
+ const play = () => {
+ const target = page.querySelector('.ov-mark, .mv-tile svg');
+ if (window.OVMark && window.OVMark.intro) page.querySelectorAll('.ov-mark, [data-br-hero], [data-mo-hero]').forEach((mark) => window.OVMark.intro(mark));
+ document.querySelectorAll('[data-br-replay], [data-mo-replay]').forEach((button) => { if (!button.hidden) button.click(); });
+ const run = page.querySelector('[data-mo-run]');
+ if (run && !run.hidden) run.click();
+ const tile = page.querySelector('[data-mv]');
+ if (tile) tile.click();
+ document.querySelectorAll('.br-motion-video').forEach((video) => video.play().catch(() => {}));
+ animate(target);
+ page.querySelectorAll('.br-chip, .mo-btn, .mo-card').forEach((control, i) => {
+ if (i < 6) setTimeout(() => animate(control.querySelector('.ov-mark, svg') || control), i * 90);
+ });
+ };
+ notice.querySelector('[data-ov-demo-play]').addEventListener('click', () => {
+ localPlay = true;
+ // This page only: the saved Motion setting is untouched. Removing the attribute lets the shared tokens and
+ // the page's own CSS run again; the notice stays away until the next visit.
+ document.documentElement.removeAttribute('data-ov-motion');
+ document.documentElement.classList.add('ov-demo-play');
+ refresh();
+ play();
+ });
+ notice.querySelector('[data-ov-demo-auto]').addEventListener('click', () => {
+ if (loader && loader.display) loader.display.set({ motion: 'auto' });
+ refresh();
+ });
+ page.addEventListener('click', (event) => {
+ if (!localPlay) return;
+ const control = event.target.closest('.br-chip, .br-replay, .mo-replay, .mo-btn, .mo-card, .mv-tile');
+ if (control) animate(control.querySelector('.ov-mark, svg') || control);
+ });
+ media.addEventListener('change', refresh);
+ new MutationObserver(refresh).observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-motion'] });
+ refresh();
+})();
@@ -11,7 +11,22 @@
@@ -11,7 +11,22 @@
const $ = (s) => document.querySelector(s);
const $ = (s) => document.querySelector(s);
const themes = (() => { try { return JSON.parse(($('#lk-themes') || {}).textContent || '[]'); } catch (e) { return []; } })();
const themes = (() => { try { return JSON.parse(($('#lk-themes') || {}).textContent || '[]'); } catch (e) { return []; } })();
const byId = new Map(themes.map((t) => [t.id, t]));
const byId = new Map(themes.map((t) => [t.id, t]));
- const say = (text) => { const s = $('#lk-status'); if (s) s.textContent = text; };
+ let undoAction = null;
+ const say = (message, undo = null) => {
+ const toast = $('#lk-toast');
+ const label = $('#lk-status');
+ const button = $('#lk-undo');
+ if (!toast || !label || !button) return;
+ label.textContent = message;
+ undoAction = undo;
+ button.hidden = !undo;
+ toast.hidden = !message;
+ };
+ $('#lk-undo').addEventListener('click', () => {
+ const action = undoAction;
+ undoAction = null;
+ if (action) Promise.resolve(action()).then(() => say('Undone.')).catch(() => say('That did not work. Try again.'));
+ });
function showTheme() {
function showTheme() {
const id = document.documentElement.getAttribute('data-theme') || (L.getCurrent && L.getCurrent()) || 'vibe';
const id = document.documentElement.getAttribute('data-theme') || (L.getCurrent && L.getCurrent()) || 'vibe';
@@ -55,32 +70,51 @@
@@ -55,32 +70,51 @@
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;
+ if (!['scheme', 'seasonal', 'text', 'motion', 'icons', ...Object.keys(COMFORT)].includes(i.name)) return;
+ const previous = i.name === 'scheme' ? L.scheme.get() : L.display.get()[i.name];
if (i.name === 'scheme') L.scheme.set(i.value);
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 === 'seasonal') L.display.set({ seasonal: i.checked ? 'on' : 'off' });
else if (i.name === 'text' || i.name === 'motion' || i.name === 'icons') L.display.set({ [i.name]: i.value });
else if (i.name === 'text' || i.name === 'motion' || i.name === 'icons') L.display.set({ [i.name]: i.value });
else if (COMFORT[i.name]) L.display.set({ [i.name]: COMFORT[i.name][i.checked ? 0 : 1] });
else if (COMFORT[i.name]) L.display.set({ [i.name]: COMFORT[i.name][i.checked ? 0 : 1] });
showTheme();
showTheme();
showSettings();
showSettings();
- say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.');
+ say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.', () => {
+ if (i.name === 'scheme') L.scheme.set(previous);
+ else L.display.set({ [i.name]: previous });
+ showTheme(); showSettings();
+ });
});
});
// ── A different look on one site (signed in): list, add, remove, through /api/me/theme/overrides ──
// ── A different look on one site (signed in): list, add, remove, through /api/me/theme/overrides ──
const siteList = $('#lk-site-list');
const siteList = $('#lk-site-list');
- const siteName = (id) => { const o = root.querySelector(`#lk-site-form select[name="service"] option[value="${id}"]`); return o ? o.textContent : id; };
+ const siteName = (id) => { const o = Array.from(root.querySelectorAll('#lk-site-form select[name="service"] option')).find((x) => x.value === id); return o ? o.textContent : id; };
const api = (path, opts = {}) => fetch(path, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts.headers || {}) } }).then((r) => (r.ok ? r.json().catch(() => ({})) : Promise.reject(r)));
const api = (path, opts = {}) => fetch(path, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts.headers || {}) } }).then((r) => (r.ok ? r.json().catch(() => ({})) : Promise.reject(r)));
+ let currentSites = [];
function drawSites(items) {
function drawSites(items) {
if (!siteList) return;
if (!siteList) return;
+ currentSites = items;
siteList.replaceChildren();
siteList.replaceChildren();
- if (!items.length) { const li = document.createElement('li'); li.className = 'lk-none'; li.textContent = 'None yet: every site wears your theme.'; siteList.appendChild(li); return; }
+ if (!items.length) { const li = document.createElement('li'); li.className = 'lk-none'; li.textContent = 'Every site wears your theme. Add one to give it a different look.'; siteList.appendChild(li); return; }
for (const o of items) {
for (const o of items) {
const li = document.createElement('li');
const li = document.createElement('li');
const t = byId.get(o.theme_id);
const t = byId.get(o.theme_id);
- const txt = document.createElement('span');
+ const icon = document.createElement('img');
+ icon.className = 'lk-site-icon'; icon.width = 40; icon.height = 40; icon.alt = '';
+ const siteOption = Array.from(root.querySelectorAll('#lk-site-form select[name="service"] option')).find((x) => x.value === o.service);
+ if (siteOption && siteOption.dataset.appIcon) icon.src = siteOption.dataset.appIcon;
+ else icon.hidden = true;
+ const txt = document.createElement('span'); txt.className = 'lk-site-text';
const b = document.createElement('b'); b.textContent = siteName(o.service);
const b = document.createElement('b'); b.textContent = siteName(o.service);
- txt.append(b, ` wears ${(t && t.name) || o.theme_name || o.theme_id}`);
+ const theme = document.createElement('small'); theme.textContent = (t && t.name) || o.theme_name || o.theme_id;
+ const dot = document.createElement('i'); dot.className = 'lk-site-swatch';
+ if (t && (t.dots[2] || t.dots[0])) dot.style.background = t.dots[2] || t.dots[0];
+ theme.prepend(dot); txt.append(b, theme);
+ const menu = document.createElement('details'); menu.className = 'lk-site-menu';
+ const summary = document.createElement('summary'); summary.textContent = '⋯'; summary.setAttribute('aria-label', `Options for ${siteName(o.service)}`);
const rm = document.createElement('button');
const rm = document.createElement('button');
- rm.type = 'button'; rm.className = 'ovk-btn ovk-btn--quiet ovk-btn--small'; rm.textContent = 'Remove';
- rm.addEventListener('click', () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'DELETE' }).then(loadSites).then(() => say(`${siteName(o.service)} wears your theme again.`)).catch(() => say('That did not work. Try again.')));
- li.append(txt, rm);
+ rm.type = 'button'; rm.textContent = 'Use my main theme';
+ rm.addEventListener('click', () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'DELETE' }).then(loadSites).then(() => say(`${siteName(o.service)} wears your main theme.`, () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: o.theme_id }) }).then(loadSites))).catch(() => say('That did not work. Try again.')));
+ menu.append(summary, rm);
+ li.append(icon, txt, menu);
siteList.appendChild(li);
siteList.appendChild(li);
}
}
}
}
@@ -88,12 +122,19 @@
@@ -88,12 +122,19 @@
const siteForm = $('#lk-site-form');
const siteForm = $('#lk-site-form');
if (siteForm && siteList) {
if (siteForm && siteList) {
loadSites();
loadSites();
+ document.addEventListener('click', (e) => {
+ siteList.querySelectorAll('.lk-site-menu[open]').forEach((menu) => { if (!menu.contains(e.target)) menu.open = false; });
+ });
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') siteList.querySelectorAll('.lk-site-menu[open]').forEach((menu) => { menu.open = false; menu.querySelector('summary').focus(); });
+ });
siteForm.addEventListener('submit', (e) => {
siteForm.addEventListener('submit', (e) => {
e.preventDefault();
e.preventDefault();
const service = siteForm.elements.service.value;
const service = siteForm.elements.service.value;
const themeId = siteForm.elements.theme_id.value;
const themeId = siteForm.elements.theme_id.value;
+ const earlier = currentSites.find((x) => x.service === service);
api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) })
api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) })
- .then(loadSites).then(() => say(`${siteName(service)} wears ${(byId.get(themeId) || {}).name || themeId} from its next page.`))
+ .then(loadSites).then(() => { $('#lk-site-sheet').close(); say(`${siteName(service)} now wears ${(byId.get(themeId) || {}).name || themeId}.`, () => api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, earlier ? { method: 'PUT', body: JSON.stringify({ theme_id: earlier.theme_id }) } : { method: 'DELETE' }).then(loadSites)); })
.catch(() => say('That did not work. Try again.'));
.catch(() => say('That did not work. Try again.'));
});
});
}
}
@@ -54,7 +54,16 @@
@@ -54,7 +54,16 @@
let base = null; // the theme picked
let base = null; // the theme picked
let edited = null; // core overrides, or null
let edited = null; // core overrides, or null
const coreOf = (v) => ({ '--bg-primary': v['--bg-primary'], '--bg-card': v['--bg-card'] || v['--bg-secondary'], '--text-primary': v['--text-primary'], '--text-muted': v['--text-muted'] || v['--text-secondary'], '--accent': v['--accent'], '--border': v['--border'] });
const coreOf = (v) => ({ '--bg-primary': v['--bg-primary'], '--bg-card': v['--bg-card'] || v['--bg-secondary'], '--text-primary': v['--text-primary'], '--text-muted': v['--text-muted'] || v['--text-secondary'], '--accent': v['--accent'], '--border': v['--border'] });
- const current = () => { if (!base) return null; if (!edited) return base.variables; return Object.assign({}, base.variables, derive(Object.assign(coreOf(base.variables), edited)) || {}); };
+ const current = () => {
+ if (!base) return null;
+ // A theme may carry only its core colours (or an older catalog only a few); fill the rest from the shared
+ // built-in of the same id, then the theme's own values, so the checks, the previews and the code agree.
+ const builtin = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.THEMES && window.OpenVibeThemeLoader.THEMES[base.id]) || {};
+ const source = Object.assign({}, builtin, base.variables);
+ const full = Object.assign(derive(coreOf(source)) || {}, source);
+ if (!edited) return full;
+ return Object.assign(full, derive(Object.assign(coreOf(source), edited)) || {});
+ };
function paint() {
function paint() {
const v = current();
const v = current();
@@ -64,6 +73,14 @@
@@ -64,6 +73,14 @@
$$('.ds-field input[type=color]').forEach((inp) => { const x = hex(core[inp.getAttribute('data-var')]); if (x) inp.value = toHex(x); });
$$('.ds-field input[type=color]').forEach((inp) => { const x = hex(core[inp.getAttribute('data-var')]); if (x) inp.value = toHex(x); });
$$('.ds-field input[data-hex]').forEach((inp) => { if (document.activeElement !== inp) { const x = hex(core[inp.getAttribute('data-hex')]); inp.value = x ? toHex(x) : ''; } });
$$('.ds-field input[data-hex]').forEach((inp) => { if (document.activeElement !== inp) { const x = hex(core[inp.getAttribute('data-hex')]); inp.value = x ? toHex(x) : ''; } });
$('#ds-current').textContent = base.name + (edited ? ' (edited)' : '');
$('#ds-current').textContent = base.name + (edited ? ' (edited)' : '');
+ const shareName = $('#ds-share-theme');
+ if (shareName) shareName.textContent = base.name + (edited ? ' · edited' : '');
+ const dots = $('#ds-share-dots');
+ if (dots) dots.replaceChildren(...['--bg-primary', '--bg-card', '--accent', '--text-primary'].map((key) => {
+ const dot = document.createElement('i'); dot.className = 'tk-dot';
+ if (hex(v[key])) dot.style.background = toHex(hex(v[key]));
+ return dot;
+ }));
$('#ds-reset').disabled = !edited;
$('#ds-reset').disabled = !edited;
check(v);
check(v);
}
}
@@ -202,6 +219,7 @@
@@ -202,6 +219,7 @@
await api('/api/themes/submit', { method: 'POST', body: JSON.stringify({ name, slug, description: $('#ds-desc').value.trim(), mode: v['--color-scheme'] === 'light' ? 'light' : 'dark', variables: v, tags: [] }) });
await api('/api/themes/submit', { method: 'POST', body: JSON.stringify({ name, slug, description: $('#ds-desc').value.trim(), mode: v['--color-scheme'] === 'light' ? 'light' : 'dark', variables: v, tags: [] }) });
say(`${name} is waiting for review. You can use it yourself right away.`);
say(`${name} is waiting for review. You can use it yourself right away.`);
form.reset();
form.reset();
+ $('#ds-share-sheet').close();
} catch (err) { say(`Not sent: ${err.message}`); }
} catch (err) { say(`Not sent: ${err.message}`); }
});
});
@@ -225,12 +243,12 @@
@@ -225,12 +243,12 @@
let h = [];
let h = [];
try { h = (await api('/api/me/theme/history')).history || []; } catch (e) { return; }
try { h = (await api('/api/me/theme/history')).history || []; } catch (e) { return; }
list.textContent = '';
list.textContent = '';
- if (!h.length) { const li = document.createElement('li'); li.className = 'ds-fine'; li.textContent = 'Your earlier looks appear here after your first change.'; list.appendChild(li); return; }
+ if (!h.length) { const li = document.createElement('li'); li.className = 'ds-history-empty'; li.innerHTML = '<span aria-hidden="true">◌</span><p>Your earlier looks appear here after your first change.</p>'; list.appendChild(li); return; }
for (const x of h.slice(0, 20)) {
for (const x of h.slice(0, 20)) {
const li = document.createElement('li');
const li = document.createElement('li');
const t = byId.get(x.theme_id);
const t = byId.get(x.theme_id);
const dot = document.createElement('i'); dot.className = 'ds-hist-dot';
const dot = document.createElement('i'); dot.className = 'ds-hist-dot';
- const accent = (x.custom_variables && x.custom_variables['--accent']) || (t && t.variables['--accent']);
+ const accent = (x.custom_variables && x.custom_variables['--accent']) || (t && (t.variables['--accent'] || t.variables['--bg-primary']));
if (hex(accent)) dot.style.background = toHex(hex(accent));
if (hex(accent)) dot.style.background = toHex(hex(accent));
const b = document.createElement('b'); b.textContent = (x.theme_name || x.theme_id) + (x.custom_variables ? ' (edited)' : '');
const b = document.createElement('b'); b.textContent = (x.theme_name || x.theme_id) + (x.custom_variables ? ' (edited)' : '');
const when = document.createElement('small'); when.textContent = whenOf(x.created_at);
const when = document.createElement('small'); when.textContent = whenOf(x.created_at);
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
*/
*/
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { sanitizeHtml } = require('./sanitize');
const { sanitizeHtml } = require('./sanitize');
+const { vocabulary } = require('../components/registry');
class ArenaAiError extends Error {
class ArenaAiError extends Error {
constructor(status, code, detail) {
constructor(status, code, detail) {
@@ -41,6 +42,8 @@ function systemPrompt(category) {
@@ -41,6 +42,8 @@ function systemPrompt(category) {
'Rules: return only the HTML document, starting with <!doctype html>. Use inline CSS in a <style> tag or style attributes.',
'Rules: return only the HTML document, starting with <!doctype html>. Use inline CSS in a <style> tag or style attributes.',
'No JavaScript, no event-handler attributes, no external stylesheets, fonts, scripts or images. Images may be inline SVG or data: URIs.',
'No JavaScript, no event-handler attributes, no external stylesheets, fonts, scripts or images. Images may be inline SVG or data: URIs.',
'Make it responsive enough to be judged at 390 px and 1280 px wide. Use real, plausible content, not lorem ipsum. No explanations outside the HTML.',
'Make it responsive enough to be judged at 390 px and 1280 px wide. Use real, plausible content, not lorem ipsum. No explanations outside the HTML.',
+ 'Use OpenVibe registry components as the interface vocabulary; mark each major component with data-ov-component set to its registry name.',
+ vocabulary(),
].join('\n');
].join('\n');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+
+// Public design.component@1 vocabulary. These definitions describe one interface across
+// platforms; a native app renders the parts with its own toolkit and the exported tokens.
+const shared = 'openvibe-shared';
+const behavior = { keyboardAware: false, outsideTapCloses: false, scrollLock: false, reducedMotion: 'Remove movement; keep the state change.' };
+const define = (name, title, summary, anatomy, props, states, tokens, rules, web, android, ios, example) => Object.freeze({
+ schema: 'design.component@1', name, title, summary,
+ anatomy: anatomy.map(([part, role]) => ({ part, role })), props, states, tokens,
+ behaviors: { ...behavior, ...Object.fromEntries(Object.entries(rules).filter(([key]) => key !== 'accessibility')) },
+ accessibility: { name: title, target: 'At least 44 × 44 dp or pt for every touch action', focus: 'Visible keyboard focus; follow reading order', ...(rules.accessibility || {}) },
+ platforms: {
+ web: { module: `${shared}/${web[0]}`, markup: web[1], demo: `/components/${name}` },
+ android: { toolkit: 'Jetpack Compose', material3: android[0], specification: android[1] },
+ ios: { toolkit: 'SwiftUI', specification: ios },
+ },
+ examples: [{ title: 'Default', props: example }],
+});
+
+const COMPONENTS = Object.freeze([
+ define('navbar', 'Navbar', 'The site identity and navigation with lead and action slots.', [['lead', 'brand and page context'], ['links', 'primary destinations'], ['actions', 'account and page controls']], { lead: 'string', links: 'link[]', actions: 'action[]', active: 'string' }, ['default', 'menu-open', 'scrolled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Tab through lead, destinations and actions; Escape closes the menu' } }, ['navbar', 'OpenVibeNavbar with lead and action slots'], ['SurfaceContainer', 'Use a TopAppBar with navigationIcon and actions slots; place overflow destinations in a modal menu.'], 'Use a toolbar with leading and trailing ToolbarItems, and a Menu for overflow.', { lead: 'OpenVibe', active: 'Design' }),
+ define('menu', 'Menu', 'One navigation sheet with its You home and app destinations.', [['trigger', 'opens navigation'], ['you', 'account home'], ['apps', 'destination rows']], { open: 'boolean', person: 'person?', destinations: 'link[]' }, ['closed', 'open', 'signed-out'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Move focus into the menu; Escape returns it to the trigger' } }, ['navbar', 'OpenVibeNavbar menu and You home'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer with a You header and app rows.'], 'Use a sheet with a NavigationStack and You header.', { open: true }),
+ define('you-home', 'You home', 'The personal starting view inside the menu.', [['identity', 'avatar and name'], ['shortcuts', 'your places'], ['settings', 'display and account']], { person: 'person?', shortcuts: 'link[]' }, ['signed-in', 'signed-out', 'loading'], ['color.bg.raised', 'color.text.secondary', 'size.tap'], { keyboardAware: true }, ['navbar', 'Menu You panel'], ['SurfaceContainerHigh', 'Use a Column of ListItem shortcuts under the account identity.'], 'Use a List with account identity and destination sections.', { person: 'You' }),
+ define('dock', 'Dock', 'Persistent place for the current task, clear of the phone app bar and keyboard.', [['surface', 'bottom container'], ['content', 'current task'], ['action', 'send or confirm']], { content: 'string', action: 'action', busy: 'boolean' }, ['ready', 'busy', 'disabled'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Keep the action after the content in focus order' } }, ['kit', 'kit.dock({ action, name, placeholder, label })'], ['SurfaceContainer', 'Use Scaffold.bottomBar and imePadding; keep the primary action in a 48 dp control.'], 'Use safeAreaInset(edge: .bottom) with keyboard avoidance.', { content: 'Write a message', action: 'Send' }),
+ define('dock-tabs', 'Dock tabs', 'The phone destinations attached to the bottom app bar.', [['tab', 'icon and label'], ['selection', 'active destination']], { items: 'link[]', active: 'string' }, ['default', 'selected', 'pressed'], ['color.accent.strong', 'color.text.muted', 'size.tap'], { keyboardAware: true, accessibility: { name: 'Each tab announces its label and selected state' } }, ['appbar', 'OpenVibeAppbar from the site navigation'], ['SurfaceContainer', 'Reference: OvTabs in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use NavigationBar with NavigationBarItem and selected state.'], 'Use TabView with system tab items.', { active: 'Studio' }),
+ define('thread-composer', 'Thread composer', 'A multiline message field with Options, attachments and send or stop.', [['field', 'message text'], ['options', 'model and attachment choices'], ['send', 'submit or stop']], { action: 'url', placeholder: 'string', options: 'action[]', attach: 'boolean', busy: 'boolean' }, ['empty', 'composing', 'uploading', 'busy'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Enter sends when the field permits; Shift+Enter adds a line; Options has a spoken label' } }, ['thread', 'thread.composer({ action, placeholder, attach })'], ['SurfaceContainerHigh', 'Use an OutlinedTextField, Options icon button and send or stop button; imePadding on the dock.'], 'Use TextEditor with an Options Menu and send or stop Button above the keyboard.', { placeholder: 'Ask anything', attach: true }),
+ define('sheet', 'Sheet', 'A modal task surface: one heading, one close action and scrollable content.', [['handle', 'touch affordance'], ['header', 'title and close'], ['body', 'task controls']], { title: 'string', open: 'boolean', body: 'content' }, ['closed', 'opening', 'open', 'closing'], ['color.bg.raised', 'color.line.strong', 'radius.lg'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Trap focus while open; Escape closes and returns focus' } }, ['kit', 'kit.sheet({ id, title, body })'], ['SurfaceContainerHigh', 'Use ModalBottomSheet with a heading, close control and sheet state.'], 'Use .sheet with a NavigationStack and a toolbar close button.', { title: 'Add a site' }),
+ define('popover', 'Popover', 'A small anchored set of choices that closes on outside tap.', [['trigger', 'anchor'], ['choices', 'menu items']], { label: 'string', items: 'action[]', open: 'boolean' }, ['closed', 'open', 'selected'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Arrow keys move choices; Escape returns focus to trigger' } }, ['kit', 'kit.more({ label, items })'], ['SurfaceContainerHigh', 'Use DropdownMenu anchored to the trigger.'], 'Use Menu attached to the trigger Button.', { label: 'More' }),
+ define('keyboard', 'Keyboard behavior', 'Shared focus, Escape and inset rules for controls near the screen edge.', [['focus', 'active input'], ['inset', 'visible keyboard space'], ['dismiss', 'return path']], { focused: 'boolean', returnFocus: 'string?' }, ['idle', 'keyboard-open', 'dismissed'], ['size.tap', 'space.md'], { keyboardAware: true, accessibility: { focus: 'Keep the focused control visible; dismiss without losing the place' } }, ['kit-web', 'Shared focus and form handling'], ['Surface', 'Use WindowInsets.ime and bringIntoViewRequester for focused fields.'], 'Use safeAreaInset and FocusState; scroll the focused field into view.', { focused: true }),
+ define('kit-row', 'Kit row', 'One tappable item with identity, supporting text and a trailing fact or action.', [['mark', 'item identity'], ['text', 'title and supporting line'], ['trail', 'time, count or action']], { title: 'string', sub: 'string?', href: 'url?', mark: 'string?', trail: 'string?' }, ['default', 'hover', 'focused', 'unread'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Announce title, supporting text and trailing fact together' } }, ['kit', 'kit.row({ title, sub, href, mark, trail })'], ['SurfaceContainer', 'Reference: OvRow in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use ListItem with headlineContent, supportingContent and trailingContent.'], 'Use an HStack row inside a List with a full-row tap target.', { title: 'OpenVibe.Live', sub: 'Live now' }),
+ define('kit-card', 'Kit card', 'One content object with media, title and context.', [['media', 'preview or glyph'], ['title', 'primary name'], ['meta', 'supporting facts']], { title: 'string', href: 'url', image: 'url?', badge: 'string?' }, ['default', 'hover', 'focused', 'loading'], ['color.bg.raised', 'color.text.primary', 'radius.md'], { accessibility: { name: 'The whole card is one link named by its title' } }, ['kit', 'kit.card({ title, href, image, badge })'], ['SurfaceContainer', 'Reference: OvCard in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use Card with a single onClick and semantic title.'], 'Use a Button wrapping a card-shaped VStack.', { title: 'Seattle at night', href: '/live' }),
+ define('kit-section', 'Kit section', 'A named group with an optional note and count.', [['heading', 'group name'], ['note', 'help text'], ['body', 'group content']], { title: 'string', note: 'string?', body: 'content' }, ['default', 'empty'], ['color.bg.raised', 'color.text.primary', 'space.lg'], {}, ['kit', 'kit.section({ title, note, body })'], ['Surface', 'Reference: OvSection in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use a Column with a heading and grouped content.'], 'Use a Section with a header and optional footer note.', { title: 'Your rooms' }),
+ define('empty-state', 'Empty state', 'A truthful explanation and one next action when a group has no items.', [['glyph', 'context icon'], ['title', 'what is empty'], ['action', 'next step']], { title: 'string', text: 'string?', action: 'action?' }, ['empty', 'loading-resolved'], ['color.text.muted', 'color.accent.strong', 'size.tap'], {}, ['kit', 'kit.empty({ title, text, action })'], ['Surface', 'Reference: OvEmpty in OpenVibe.Node\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use a Column with icon, title and one Button.'], 'Use ContentUnavailableView with one action Button.', { title: 'No sites yet', text: 'Add a site to give it a look.' }),
+ define('switch-row', 'Switch row', 'One setting with a label, explanation and on or off control.', [['label', 'setting name'], ['hint', 'effect of setting'], ['switch', 'current value']], { name: 'string', label: 'string', hint: 'string?', on: 'boolean' }, ['off', 'on', 'disabled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Label and hint describe the switch; announce on or off' } }, ['kit', 'kit.switchRow({ name, label, hint, on })'], ['Surface', 'Use a ListItem with a Material 3 Switch trailing control.'], 'Use Toggle with a supporting text label.', { name: 'contrast', label: 'More contrast', on: false }),
+ define('segmented-control', 'Segmented control', 'A small exclusive choice set with a visible current value.', [['label', 'choice purpose'], ['segments', 'mutually exclusive values']], { name: 'string', label: 'string', options: 'choice[]', value: 'string' }, ['default', 'selected', 'focused'], ['color.accent.strong', 'color.text.primary', 'size.tap'], { accessibility: { focus: 'Arrow keys move radio choices; announce selected value' } }, ['kit', 'kit.seg({ name, label, options, value })'], ['SecondaryContainer', 'Use SingleChoiceSegmentedButtonRow.'], 'Use Picker with .segmented style.', { name: 'scheme', label: 'Light or dark', value: 'auto' }),
+ define('upload-progress', 'Upload and progress', 'A file transfer with a truthful determinate meter and cancel action.', [['file', 'name and type'], ['meter', 'bytes sent of total'], ['cancel', 'stop transfer']], { name: 'string', totalBytes: 'number', sentBytes: 'number', cancellable: 'boolean' }, ['queued', 'uploading', 'complete', 'failed', 'cancelled'], ['color.accent.strong', 'color.text.primary', 'size.tap'], { accessibility: { name: 'Announce file name, percent and state without repeating every byte' } }, ['files', 'Shared upload control and progress meter'], ['SurfaceContainer', 'Use LinearProgressIndicator(progress) with state text and cancel IconButton.'], 'Use ProgressView(value:total:) with a cancel Button.', { name: 'photo.jpg', totalBytes: 100, sentBytes: 45 }),
+ define('ai-choice', 'Model chip', 'The selected AI model as a compact choice that opens the model picker.', [['icon', 'model glyph'], ['name', 'selected model'], ['chevron', 'change affordance']], { model: 'string', options: 'choice[]', disabled: 'boolean' }, ['default', 'open', 'disabled'], ['color.bg.raised', 'color.accent.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { name: 'Announce selected model and that it opens choices' } }, ['ov-ai-choice', '.ov-ai-choice model chip'], ['SecondaryContainer', 'Use an AssistChip that opens a ModalBottomSheet with model choices.'], 'Use a Menu with a Label and selected model checkmark.', { model: 'Auto' }),
+ define('motion-tokens', 'Motion tokens', 'The four shared durations and curves that give movement one pace.', [['duration', '120, 200, 320 or 520 ms'], ['curve', 'out, spring or in-out']], { duration: 'token', curve: 'token' }, ['auto', 'reduced'], ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4', '--ov-ease-out', '--ov-ease-spring', '--ov-ease-in-out'], { reducedMotion: 'Duration becomes zero for decorative motion.' }, ['ov-fx', '--ov-dur-1..4 and --ov-ease-out/-spring/-in-out'], ['Surface', 'Use MotionDuration tokens with reducedMotion from accessibility settings.'], 'Use the same durations with accessibilityReduceMotion.', { duration: '--ov-dur-2', curve: '--ov-ease-out' }),
+ define('pop-motion', 'Pop motion', 'The shared small-scale entrance for a menu or affordance.', [['object', 'appearing surface'], ['origin', 'activation point']], { open: 'boolean' }, ['closed', 'open'], ['--ov-dur-2', '--ov-ease-spring'], { reducedMotion: 'Crossfade without scale.' }, ['navbar', 'OpenVibeNavbar.pop'], ['Surface', 'Animate scale and alpha with the spring token; reduced motion uses alpha only.'], 'Use scale and opacity with the spring token; reduced motion uses opacity only.', { open: true }),
+ define('sheet-motion', 'Sheet motion', 'The bottom sheet entrance and exit without moving underlying content.', [['surface', 'sheet'], ['scrim', 'dimmed context']], { open: 'boolean' }, ['closed', 'opening', 'open', 'closing'], ['--ov-dur-3', '--ov-ease-out'], { scrollLock: true, reducedMotion: 'Crossfade the sheet and scrim.' }, ['kit', 'kit.sheet and ovk-sheet motion'], ['SurfaceContainerHigh', 'Use ModalBottomSheet sheetState animation with reduced-motion fallback.'], 'Use native sheet presentation; respect accessibilityReduceMotion.', { open: true }),
+ define('menu-motion', 'Menu motion', 'The navigation menu entrance tied to its trigger.', [['panel', 'navigation'], ['trigger', 'menu button']], { open: 'boolean' }, ['closed', 'opening', 'open', 'closing'], ['--ov-dur-2', '--ov-ease-out'], { outsideTapCloses: true, reducedMotion: 'Reveal immediately without translation.' }, ['navbar', 'OpenVibeNavbar menu motion'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer animation; reduce motion when requested.'], 'Use a sheet presentation with reduced-motion support.', { open: true }),
+]);
+
+const byName = new Map(COMPONENTS.map((component) => [component.name, component]));
+const get = (name) => byName.get(String(name || '')) || null;
+const vocabulary = () => COMPONENTS.map((c) => [
+ `${c.name} — ${c.summary}`,
+ `Parts: ${c.anatomy.map((a) => a.part).join(', ')}. Props: ${Object.entries(c.props).map(([k, t]) => `${k}:${t}`).join(', ')}.`,
+ `States: ${c.states.join(', ')}. Tokens: ${c.tokens.join(', ')}.`,
+ `Keyboard-aware: ${c.behaviors.keyboardAware ? 'yes' : 'no'}; outside tap closes: ${c.behaviors.outsideTapCloses ? 'yes' : 'no'}; scroll lock: ${c.behaviors.scrollLock ? 'yes' : 'no'}; reduced motion: ${c.behaviors.reducedMotion}`,
+ `Web: ${c.platforms.web.markup}. Android: ${c.platforms.android.material3} — ${c.platforms.android.specification} iOS: ${c.platforms.ios.specification}`,
+].join(' ')).join('\n');
+
+module.exports = { COMPONENTS, get, vocabulary };
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
const { validateChange, applyChange } = require('./design-edit');
const { validateChange, applyChange } = require('./design-edit');
+const { vocabulary } = require('./components/registry');
const KINDS = ['page', 'screen', 'component'];
const KINDS = ['page', 'screen', 'component'];
const random = () => crypto.randomBytes(24).toString('base64url');
const random = () => crypto.randomBytes(24).toString('base64url');
@@ -79,7 +80,7 @@ function wrap(html, variables) {
@@ -79,7 +80,7 @@ function wrap(html, variables) {
// ── The prompts this service sends (published word for word at openvibe.design/prompts) ──
// ── The prompts this service sends (published word for word at openvibe.design/prompts) ──
/** A design drawn in the person's theme: their words, then the theme's colour variables. */
/** A design drawn in the person's theme: their words, then the theme's colour variables. */
function themePrompt(prompt, variableNames) {
function themePrompt(prompt, variableNames) {
- return `${prompt}\nUse ONLY these theme CSS custom properties for all colors: ${variableNames.join(', ')}. No external requests. Mobile first. Return one complete HTML document.`;
+ return `${prompt}\nUse ONLY these theme CSS custom properties for all colors: ${variableNames.join(', ')}. No external requests. Mobile first. Return one complete HTML document.\nBuild the interface from these OpenVibe component definitions. Mark each major component with data-ov-component set to its registry name. Keep the anatomy, state and keyboard behavior described by the definition.\n${vocabulary()}`;
}
}
/** An edit of a drawn design: the person's instruction, the current document, and the theme's colour variables. */
/** An edit of a drawn design: the person's instruction, the current document, and the theme's colour variables. */
function editPrompt(instruction, html, names) {
function editPrompt(instruction, html, names) {
@@ -89,6 +90,7 @@ function editPrompt(instruction, html, names) {
@@ -89,6 +90,7 @@ function editPrompt(instruction, html, names) {
'The current document is between the markers. Keep its theme, its content and its structure unless the instruction asks otherwise.',
'The current document is between the markers. Keep its theme, its content and its structure unless the instruction asks otherwise.',
`<<<CURRENT_DESIGN\n${html}\nCURRENT_DESIGN>>>`,
`<<<CURRENT_DESIGN\n${html}\nCURRENT_DESIGN>>>`,
`Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,
`Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,
+ `Keep using the OpenVibe component registry. Mark each major part with data-ov-component set to its registry name.\n${vocabulary()}`,
].join('\n\n');
].join('\n\n');
}
}
@@ -31,6 +31,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');
@@ -31,6 +31,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const components = require('openvibe-shared/components');
const components = require('openvibe-shared/components');
+const registry = require('../components/registry');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
function passThrough(res, r) {
@@ -112,7 +113,13 @@ function createApi(ctx) {
@@ -112,7 +113,13 @@ function createApi(ctx) {
// ── Components: the contract as JSON, and a kit document checked against it (public; nothing is stored) ──
// ── Components: the contract as JSON, and a kit document checked against it (public; nothing is stored) ──
r.get('/v1/components', (req, res) => {
r.get('/v1/components', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.set('Cache-Control', 'public, max-age=3600');
- return res.json({ version: components.VERSION, limits: components.LIMITS, preamble: components.PREAMBLE, components: components.SPECS, docs: 'https://openvibe.design/components' });
+ return res.json({ schema: 'design.component@1', version: 1, components: registry.COMPONENTS, kitComponents: components.SPECS, limits: components.LIMITS, preamble: components.PREAMBLE, docs: 'https://openvibe.design/components/registry', llms: 'https://openvibe.design/llms.txt' });
+ });
+ r.get('/v1/components/:name', (req, res) => {
+ const component = registry.get(req.params.name);
+ if (!component) return http.sendProblem(res, 404, 'design.component_not_found', { detail: 'No component by that name.', ctx: req.ov });
+ res.set('Cache-Control', 'public, max-age=3600');
+ return res.json({ schema: component.schema, component, examples: component.examples });
});
});
r.post('/v1/components/validate', (req, res) => {
r.post('/v1/components/validate', (req, res) => {
const doc = req.body && req.body.doc;
const doc = req.body && req.body.doc;
@@ -11,6 +11,7 @@ const fs = require('fs');
@@ -11,6 +11,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
+const { COMPONENTS } = require('../components/registry');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Design';
const SITE_NAME = 'OpenVibe.Design';
@@ -33,6 +34,7 @@ function publicPages(themes = []) {
@@ -33,6 +34,7 @@ function publicPages(themes = []) {
{ 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: '/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: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
{ path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
+ { path: '/components/registry', changefreq: 'weekly', priority: 0.8, title: 'Component registry', text: 'Every shared OpenVibe interface as one design.component@1 definition: navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion, for web, Android and iOS.' },
{ 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: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
@@ -62,7 +64,11 @@ function publicPages(themes = []) {
@@ -62,7 +64,11 @@ function publicPages(themes = []) {
text: `The ${n.replace(/-/g, ' ')} icon from the OpenVibe glyphs: SVG, PNG at any size and colour, and the font.`,
text: `The ${n.replace(/-/g, ' ')} icon from the OpenVibe glyphs: SVG, PNG at any size and colour, and the font.`,
images: [`/icons/${n}.svg`, `/icons/${n}.png?size=512`],
images: [`/icons/${n}.svg`, `/icons/${n}.png?size=512`],
}));
}));
- return [...static_, ...themePages, ...glyphPages];
+ const componentPages = COMPONENTS.map((component) => ({
+ path: `/components/${component.name}`, changefreq: 'weekly', priority: 0.6,
+ title: `${component.title}: OpenVibe component`, text: component.summary,
+ }));
+ return [...static_, ...componentPages, ...themePages, ...glyphPages];
}
}
function createDiscoveryRoutes(ctx) {
function createDiscoveryRoutes(ctx) {
@@ -110,10 +116,15 @@ function createDiscoveryRoutes(ctx) {
@@ -110,10 +116,15 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },
{ title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },
] },
] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
+ { title: 'Component registry', links: [
+ { title: 'The component registry', url: abs('/components/registry'), note: 'every shared interface as a design.component@1 definition, for web, Android and iOS' },
+ ...COMPONENTS.map((c) => ({ title: c.title, url: abs(`/components/${c.name}`), note: `${c.summary} Definition: ${abs(`/api/v1/components/${c.name}`)}` })),
+ ] },
// The data an agent came for is a main section, not something to skip.
// The data an agent came for is a main section, not something to skip.
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },
{ title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },
+ { title: 'The component registry (JSON)', url: abs('/api/v1/components'), note: 'design.component@1 definitions for every shared interface; one at /api/v1/components/<name>; the SDK exposes ov.design.components()' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
] },
] },
@@ -248,6 +248,11 @@ function createPageRoutes(ctx) {
@@ -248,6 +248,11 @@ function createPageRoutes(ctx) {
return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
});
});
r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
+ r.get('/components/registry', (req, res) => page(res, 200, require('../views/component-registry').registryPage(), req.viewer));
+ r.get('/components/:name', (req, res) => {
+ const html = require('../views/component-registry').componentPage(req.params.name);
+ return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), 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) => {
@@ -207,8 +207,8 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;
@@ -207,8 +207,8 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;
description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',
description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',
path: '/brand',
path: '/brand',
active: '/brand',
active: '/brand',
- styles: ['css/brand.css'],
- scripts: ['js/brand.js'],
+ styles: ['css/brand.css', 'css/demo-motion-controls.css'],
+ scripts: ['js/brand.js', 'js/demo-motion-controls.js'],
body,
body,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
+const { COMPONENTS, get } = require('../components/registry');
+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="/studio">Studio</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: '/studio', 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: '/studio', 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="/studio" 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: '/studio', label: 'Open Studio', 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: '/studio', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),
+ 'kit-card': () => kit.card({ href: '/studio', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),
+ 'kit-section': () => kit.section({ title: 'Your places', note: 'A named group of related actions.', body: kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings' }) }),
+ 'empty-state': () => kit.empty({ title: 'No sites yet', text: 'Add a site to give it a look of its own.', action: { href: '/look', label: 'Add a site' }, icon: 'plus' }),
+ 'switch-row': () => kit.switchRow({ name: 'cr-contrast', label: 'More contrast', hint: 'Text and borders stand out more.', on: false }),
+ 'segmented-control': () => kit.seg({ name: 'cr-scheme', label: 'Light or dark', options: [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Auto']], value: 'auto' }),
+ 'upload-progress': () => `<div class="cr-upload"><span>${kit.icon('file')}<b>photo.jpg</b><small id="cr-upload-state">45% uploaded</small></span><progress value="45" max="100" aria-label="photo.jpg upload progress">45%</progress><button type="button" class="ovk-btn ovk-btn--quiet" id="cr-upload-cancel">Cancel</button></div>`,
+ 'ai-choice': () => `<button type="button" class="ov-ai-choice" data-ovk-sheet="#cr-model-sheet">${kit.icon('spark')}<span>Auto</span>${kit.icon('chevron')}</button>` + kit.sheet({ id: 'cr-model-sheet', title: 'Choose a model', body: kit.seg({ name: 'cr-model-choice', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
+ 'motion-tokens': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="tokens">${kit.icon('play')} Play the four timings</button><div class="cr-motion-samples" aria-hidden="true"><i data-cr-bar="1"></i><i data-cr-bar="2"></i><i data-cr-bar="3"></i><i data-cr-bar="4"></i></div>`,
+ 'pop-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="pop">${kit.icon('play')} Play pop</button><span class="cr-animated cr-pop">${kit.icon('star')}</span>`,
+ 'sheet-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="sheet">${kit.icon('play')} Play sheet</button><span class="cr-animated cr-slide">Sheet</span>`,
+ 'menu-motion': () => `<button type="button" class="ovk-btn ovk-btn--quiet" data-cr-play="menu">${kit.icon('play')} Play menu</button><span class="cr-animated cr-reveal">Menu</span>`,
+};
+
+function registryLinks() {
+ return `<nav class="cr-index" aria-label="Component definitions">${COMPONENTS.map((c) => `<a href="${link(c.name)}">${esc(c.title)}</a>`).join('')}</nav>`;
+}
+
+const GROUPS = [
+ ['Navigation', ['navbar', 'menu', 'you-home', 'dock', 'dock-tabs']],
+ ['Writing and choosing', ['thread-composer', 'sheet', 'popover', 'keyboard']],
+ ['Content', ['kit-row', 'kit-card', 'kit-section', 'empty-state']],
+ ['Settings', ['switch-row', 'segmented-control']],
+ ['Files and AI', ['upload-progress', 'ai-choice']],
+ ['Motion', ['motion-tokens', 'pop-motion', 'sheet-motion', 'menu-motion']],
+];
+
+/** /components/registry: the whole vocabulary in one crawlable page, with each definition's own page and JSON. */
+function registryPage() {
+ const cards = (names) => names.map((name) => {
+ const c = get(name);
+ const platforms = ['web', 'android', 'ios'].map((p) => `<span>${p === 'web' ? 'Web' : p === 'android' ? 'Android' : 'iOS'}</span>`).join('');
+ return `<li class="cr-card"><a class="cr-card-link" href="${link(name)}"><b>${esc(c.title)}</b><span>${esc(c.summary)}</span></a><div class="cr-card-meta"><span class="cr-card-platforms">${platforms}</span><a href="/api/v1/components/${esc(name)}" aria-label="${esc(c.title)} as JSON">JSON</a></div></li>`;
+ }).join('');
+ const sections = GROUPS.map(([title, names]) => `<section class="cr-group"><h2>${esc(title)}</h2><ul class="cr-cards">${cards(names)}</ul></section>`).join('');
+ const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>One vocabulary, three platforms</h1><p class="ds-lede">Every shared OpenVibe interface as one definition: its anatomy, props, states, tokens, keyboard and dismissal rules, accessibility, and how it is built on the web, in Jetpack Compose and in SwiftUI. The same definitions are the vocabulary Design and Space give their AI generators.</p>
+<p class="cr-links"><a href="/api/v1/components">All definitions as JSON</a> · <a href="/llms.txt">llms.txt</a> · <a href="/api/v1/components/sheet">Example: sheet</a></p></header>
+${sections}
+<p class="ds-fine">A definition is a promise about behaviour, not a screenshot: the live demo on each page is the web implementation, and the Android and iOS sections name the container role and the interaction each platform owns.</p>`;
+ return layout({ title: 'Component registry: OpenVibe design system', description: 'Platform-neutral definitions of the OpenVibe navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion: web, Android and iOS.', path: '/components/registry', body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'] });
+}
+
+function componentPage(name) {
+ const c = get(name);
+ if (!c) return null;
+ const anatomy = c.anatomy.map((part) => `<li><b>${esc(part.part)}</b><span>${esc(part.role)}</span></li>`).join('');
+ const facts = [
+ ['States', c.states.join(' · ')],
+ ['Tokens', c.tokens.join(' · ')],
+ ['Keyboard', c.behaviors.keyboardAware ? 'Keeps the focused control in view' : 'Standard focus order'],
+ ['Outside tap', c.behaviors.outsideTapCloses ? 'Closes the surface' : 'No dismissal'],
+ ['Scroll lock', c.behaviors.scrollLock ? 'Locks the page while open' : 'Page remains scrollable'],
+ ['Reduced motion', c.behaviors.reducedMotion],
+ ['Accessibility', `${c.accessibility.name}. ${c.accessibility.focus}. ${c.accessibility.target}.`],
+ ];
+ const details = `<dl class="cr-facts">${facts.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;
+ const native = (platform) => `<section class="cr-native"><h3>${platform === 'android' ? 'Android · Jetpack Compose' : 'iOS · SwiftUI'}</h3><p>${esc(platform === 'android' ? c.platforms.android.specification : c.platforms.ios.specification)}</p>${platform === 'android' ? `<p>Material 3 container: <code>${esc(c.platforms.android.material3)}</code></p>` : ''}</section>`;
+ const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>${esc(c.title)}</h1><p class="ds-lede">${esc(c.summary)}</p><p><a href="/components/registry">All components</a> · <a href="/components">Kit documents</a> · <a href="/api/v1/components/${esc(c.name)}">Definition as JSON</a></p></header>
+<div class="cr-layout"><section><h2>Live web demo</h2><div class="cr-demo cr-demo--${esc(c.name)}" data-ov-component="${esc(c.name)}">${demo[c.name]()}</div><p class="ds-fine">Web source: <code>${esc(c.platforms.web.module)}</code> · ${esc(c.platforms.web.markup)}</p></section>
+<section><h2>Anatomy</h2><ul class="cr-anatomy">${anatomy}</ul><h2>Props</h2><dl class="cr-props">${Object.entries(c.props).map(([key, type]) => `<div><dt><code>${esc(key)}</code></dt><dd>${esc(type)}</dd></div>`).join('')}</dl></section></div>
+<section><h2>Behavior and accessibility</h2>${details}</section>
+<div class="cr-layout">${native('android')}${native('ios')}</div>
+<section><h2>Example definition</h2>${kit.snippet({ code: JSON.stringify({ schema: c.schema, name: c.name, props: c.examples[0].props }, null, 2), id: 'cr-example' })}</section>
+<h2>Other components</h2>${registryLinks()}`;
+ return layout({ title: `${c.title}: OpenVibe component`, description: c.summary, path: link(c.name), body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'], scripts: ['js/component-registry.js'], sharedStyles: c.name === 'thread-composer' ? ['thread.css'] : [], shared: c.name === 'thread-composer' ? ['ov-thread.js'] : [] });
+}
+
+module.exports = { componentPage, registryPage, registryLinks };
@@ -13,6 +13,7 @@ const components = require('openvibe-shared/components');
@@ -13,6 +13,7 @@ const components = require('openvibe-shared/components');
const tokens = require('openvibe-shared/tokens');
const tokens = require('openvibe-shared/tokens');
const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
+const { registryLinks } = require('./component-registry');
// A sample kit document for each component: what the page shows rendered, and what the Web tab prints.
// A sample kit document for each component: what the page shows rendered, and what the Web tab prints.
const card = (n, title, extra = {}) => ({ type: 'card', props: { href: `/components?c=card#${n}`, title, type: 'stream', ...extra } });
const card = (n, title, extra = {}) => ({ type: 'card', props: { href: `/components?c=card#${n}`, title, type: 'stream', ...extra } });
@@ -86,6 +87,7 @@ ${kit.tabs({ base: '/components', param: 'c', active: spec.id, label: 'Component
@@ -86,6 +87,7 @@ ${kit.tabs({ base: '/components', param: 'c', active: spec.id, label: 'Component
${kit.tabs({ base: `${base}`, param: 'p', active: p, label: 'Platform', items: PLATFORMS.map((x) => ({ ...x, href: `${base}&p=${x.key}#cp-code` })) })}
${kit.tabs({ base: `${base}`, param: 'p', active: p, label: 'Platform', items: PLATFORMS.map((x) => ({ ...x, href: `${base}&p=${x.key}#cp-code` })) })}
<p class="tk-how">${esc(how)}</p>
<p class="tk-how">${esc(how)}</p>
${kit.snippet({ code, id: 'cp-code-snip', label: 'Copy the code' })}</section>
${kit.snippet({ code, id: 'cp-code-snip', label: 'Copy the code' })}</section>
+<section class="cr"><h2>Shared interfaces</h2><p>Web, Android and iOS definitions for the navigation, composer, sheets, settings and motion used across OpenVibe. <a href="/components/registry">See the whole registry</a>, or read it as JSON at <code>/api/v1/components</code>.</p>${registryLinks()}</section>
${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}`;
${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}`;
return layout({
return layout({
title: `${spec.name}: an OpenVibe component for web, Android and iOS`,
title: `${spec.name}: an OpenVibe component for web, Android and iOS`,
@@ -93,6 +95,7 @@ ${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A
@@ -93,6 +95,7 @@ ${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A
path: `/components?c=${encodeURIComponent(spec.id)}`,
path: `/components?c=${encodeURIComponent(spec.id)}`,
body: `<div class="ds-wrap tk">${body}</div>`,
body: `<div class="ds-wrap tk">${body}</div>`,
active: '/components',
active: '/components',
+ styles: ['css/component-registry.css'],
});
});
}
}
@@ -170,7 +170,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -170,7 +170,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
return layout({
return layout({
title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,
title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,
description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),
description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),
- path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css'], scripts: ['js/icon.js'], jsonLd,
+ path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css', 'css/demo-motion-controls.css'], scripts: ['js/icon.js', 'js/demo-motion-controls.js'], jsonLd,
image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,
image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,
body: `<article class="ic gp">${hero}${maker}${use}${moving}${rel}</article>`,
body: `<article class="ic gp">${hero}${maker}${use}${moving}${rel}</article>`,
});
});
@@ -22,6 +22,7 @@ const { iframeHtml } = require('../arena/sanitize');
@@ -22,6 +22,7 @@ const { iframeHtml } = require('../arena/sanitize');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const { DESIGN_KINDS, DEFAULT_KIND, kindOf } = require('../arena/kinds');
const { DESIGN_KINDS, DEFAULT_KIND, kindOf } = require('../arena/kinds');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const appIcons = require('openvibe-shared/app-icons');
const { TARGETS } = require('openvibe-shared/tokens');
const { TARGETS } = require('openvibe-shared/tokens');
const { layout, esc, NAME } = layoutLib;
const { layout, esc, NAME } = layoutLib;
@@ -46,7 +47,7 @@ const TRY_SITES = [['Live', 'https://openvibe.live/'], ['Chat', 'https://openvib
@@ -46,7 +47,7 @@ const TRY_SITES = [['Live', 'https://openvibe.live/'], ['Chat', 'https://openvib
/** "Try it on" links for a catalog theme (id: its slug); data-try lets the studio point them at the picked theme. */
/** "Try it on" links for a catalog theme (id: its slug); data-try lets the studio point them at the picked theme. */
function tryOn(id) {
function tryOn(id) {
const q = id ? `?ov-try=${encodeURIComponent(id)}` : '';
const q = id ? `?ov-try=${encodeURIComponent(id)}` : '';
- return `<p class="ds-try"><span>Try it on the real sites, nothing saved:</span>${TRY_SITES.map(([name, url]) => `<a href="${esc(url + q)}" data-try="${esc(url)}" rel="noopener">${esc(name)}</a>`).join('')}</p>`;
+ return `<div class="ds-try"><span>Try it on a site · preview only</span><div class="ds-try-sites">${TRY_SITES.map(([name, url]) => `<a href="${esc(url + q)}" data-try="${esc(url)}" rel="noopener" target="_blank">${appIcons.url(name.toLowerCase(), 192) ? `<img src="${esc(appIcons.url(name.toLowerCase(), 192))}" width="28" height="28" alt="">` : kit.icon('globe')}<span>${esc(name)}</span></a>`).join('')}</div></div>`;
}
}
/** A theme as a swatch button (the studio) or link (the home and gallery without JavaScript). */
/** A theme as a swatch button (the studio) or link (the home and gallery without JavaScript). */
@@ -222,19 +223,20 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u
@@ -222,19 +223,20 @@ ${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"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${signedIn ? 'Use this theme' : 'Sign in to use it'}</button>
-<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button>
-<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" data-tokens-src="${esc(ovServe.url('tokens.js'))}" title="This theme, with your edits, for the web, Android, iOS, Flutter and Figma">As code</a></div>
+<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>
+<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()}
${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>
<p class="ds-status" id="ds-status" role="status"></p>
-<details class="ds-submit"><summary>Share it in the gallery</summary><form id="ds-submit-form"><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--quiet" ${signedIn ? '' : 'disabled'}>Send for review</button><p class="ds-fine">A person reviews each theme before anyone else sees it.</p></form></details>
-${signedIn ? '<details class="ds-history" id="ds-history"><summary>History</summary><ol id="ds-history-list"><li class="ds-fine">Your earlier looks appear here.</li></ol></details>' : ''}
+<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>
</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: '/studio', body: `<div class="ds-wrap">${body}</div>`, active: '/studio', scripts: ['js/studio.js'] });
+ 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: '/studio', body: `<div class="ds-wrap">${body}</div>`, active: '/studio', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });
}
}
function theme({ theme: t } = {}) {
function theme({ theme: t } = {}) {
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* lookPage({ themes, me })
* lookPage({ themes, me })
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const appIcons = require('openvibe-shared/app-icons');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
// The sites a person can give a look of their own (service ids, as openvibe-shared/shell writes them on <html>).
// The sites a person can give a look of their own (service ids, as openvibe-shared/shell writes them on <html>).
@@ -75,17 +76,18 @@ function lookPage({ themes = [], me = {} } = {}) {
@@ -75,17 +76,18 @@ function lookPage({ themes = [], me = {} } = {}) {
title: 'A different look on one site',
title: 'A different look on one site',
note: 'Wear another theme on one site only: Games in Neon Tokyo, Live in Daylight. Everywhere else keeps your theme.',
note: 'Wear another theme on one site only: Games in Neon Tokyo, Live in Daylight. Everywhere else keeps your theme.',
body: `<ul class="lk-sites" id="lk-site-list" aria-live="polite"></ul>
body: `<ul class="lk-sites" id="lk-site-list" aria-live="polite"></ul>
-<form class="lk-site-form" id="lk-site-form"><label><span>Site</span><select name="service">${SITES.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join('')}</select></label>
-<label><span>Theme</span><select name="theme_id">${themeOpts}</select></label>${kit.button({ type: 'submit', label: 'Add', small: true })}</form>`,
+<div class="lk-add">${kit.sheetButton({ sheet: 'lk-site-sheet', label: 'Add a site', icon: 'plus', iconOnly: false })}</div>
+${kit.sheet({ id: 'lk-site-sheet', title: 'Add a site', body: `<form class="lk-site-form" id="lk-site-form"><label><span>Site</span><select name="service">${SITES.map(([id, name]) => `<option value="${esc(id)}" data-app-icon="${esc(appIcons.url(id, 192) || '')}">${esc(name)}</option>`).join('')}</select></label>
+<label><span>Theme</span><select name="theme_id">${themeOpts}</select></label>${kit.button({ type: 'submit', label: 'Add site' })}</form>` })}`,
}) : '';
}) : '';
const body = `<header class="ds-page-h"><p class="ds-kicker">Your look</p><h1>Your look, everywhere</h1>
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>
<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>
<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}${comfort}${sites}
<div class="lk" data-signed-in="${signedIn ? '1' : '0'}">${current}${settings}${comfort}${sites}
-<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>
+<div class="lk-toast" id="lk-toast" role="status" aria-live="polite" hidden><span id="lk-status"></span><button type="button" id="lk-undo" hidden>Undo</button></div>
+${signedIn ? `<p class="lk-history-link"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/studio#ds-history">${kit.icon('clock')}<span>Your history</span></a></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>`;
<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, seasonal themes and comfort settings (contrast, a readable font, colour-blind-safe colours, true black) for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'] });
+ return layout({ title: 'Your look', description: 'Your theme, light or dark, text size, motion, seasonal themes and comfort settings (contrast, a readable font, colour-blind-safe colours, true black) for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'], styles: ['css/studio-look.css'] });
}
}
module.exports = { lookPage, esc, SITES };
module.exports = { lookPage, esc, SITES };
@@ -137,8 +137,8 @@ OVFx.scan(document.querySelector('#feed'));`;
@@ -137,8 +137,8 @@ OVFx.scan(document.querySelector('#feed'));`;
description: 'How OpenVibe moves: the mark’s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules, and the code to use it anywhere.',
description: 'How OpenVibe moves: the mark’s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules, and the code to use it anywhere.',
path: '/motion',
path: '/motion',
active: '/motion',
active: '/motion',
- styles: ['css/motion.css'],
- scripts: ['js/motion.js'],
+ styles: ['css/motion.css', 'css/demo-motion-controls.css'],
+ scripts: ['js/motion.js', 'js/demo-motion-controls.js'],
body,
body,
});
});
}
}
@@ -128,8 +128,29 @@ const { boot, check, done } = require('./helpers/boot');
@@ -128,8 +128,29 @@ const { boot, check, done } = require('./helpers/boot');
await check('the components API: the contract as JSON, and kit documents checked against it', async () => {
await check('the components API: the contract as JSON, and kit documents checked against it', async () => {
const spec = (await t.get('/api/v1/components')).json();
const spec = (await t.get('/api/v1/components')).json();
- assert.strictEqual(spec.components.length, Object.keys(require('openvibe-shared/components').SPECS).length, 'every component openvibe-shared has');
- assert.ok(spec.components.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
+ assert.strictEqual(spec.schema, 'design.component@1');
+ assert.ok(spec.components.find((c) => c.name === 'thread-composer').platforms.android.specification.includes('OutlinedTextField'));
+ assert.ok(spec.kitComponents.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
+ const one = (await t.get('/api/v1/components/sheet')).json();
+ assert.strictEqual(one.component.name, 'sheet');
+ assert.strictEqual(one.component.behaviors.scrollLock, true);
+ assert.strictEqual((await t.get('/api/v1/components/no-such-part')).status, 404);
+ const page = await t.get('/components/sheet');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /id="cr-sheet"/);
+ assert.match(page.text, /data-ov-component="sheet"/);
+ for (const c of spec.components) {
+ assert.ok(c.anatomy.length && c.states.length && c.tokens.length, `${c.name} describes its parts, states and tokens`);
+ assert.ok(c.behaviors && typeof c.behaviors.scrollLock === 'boolean', `${c.name} states its behaviour`);
+ assert.ok(c.accessibility.name && c.accessibility.target, `${c.name} states its accessibility`);
+ assert.ok(c.platforms.web.markup && c.platforms.android.material3 && c.platforms.ios.specification, `${c.name} names all three platforms`);
+ }
+ const registry = await t.get('/components/registry');
+ assert.strictEqual(registry.status, 200);
+ assert.match(registry.text, /One vocabulary, three platforms/);
+ assert.strictEqual((registry.text.match(/class="cr-card-link"/g) || []).length, spec.components.length);
+ assert.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/components\/registry/);
+ assert.strictEqual((await t.get('/sitemap.xml')).text.includes('/components/registry'), true);
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });
assert.strictEqual(good.status, 200);
assert.strictEqual(good.status, 200);
assert.deepStrictEqual(good.json(), { ok: true, errors: [] });
assert.deepStrictEqual(good.json(), { ok: true, errors: [] });
@@ -142,7 +163,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -142,7 +163,7 @@ const { boot, check, done } = require('./helpers/boot');
const kim = t.network.addUser('kim-sites');
const kim = t.network.addUser('kim-sites');
const page = await t.get('/look', { as: kim });
const page = await t.get('/look', { as: kim });
assert.match(page.text, /A different look on one site/);
assert.match(page.text, /A different look on one site/);
- assert.match(page.text, /<select name="service"><option value="live">OpenVibe\.Live<\/option>/);
+ assert.match(page.text, /<select name="service"><option value="live" data-app-icon="\/shared\/icons\/[^\"]+">OpenVibe\.Live<\/option>/);
assert.doesNotMatch((await t.get('/look')).text, /A different look on one site/, 'signed out: no section');
assert.doesNotMatch((await t.get('/look')).text, /A different look on one site/, 'signed out: no section');
const origin = 'https://openvibe.design';
const origin = 'https://openvibe.design';
const put = await t.get('/api/me/theme/overrides/games', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin });
const put = await t.get('/api/me/theme/overrides/games', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin });