openvibers/openvibe.design Commits
Rules, the System map, and Design organized by its own rules (#224)
Changes
nnew file mode 100644@@@ -0,0 +1,90 @@++/* /rules: the decision register. The house style of Design's reference pages (motion, brand): Nunito headings,++ two-column section heads, the theme's own tokens. */++.rl{--rl-face:'OV Brand',Nunito,Inter,system-ui,sans-serif;display:grid;max-width:1120px;margin:0 auto;padding:0 clamp(16px,3vw,32px) 96px;color:var(--text-primary)}++.rl>*{min-width:0}++.rl a{color:inherit}++++.rl-hero{display:grid;gap:14px;padding:clamp(18px,4vw,48px) 0 clamp(18px,3vw,28px);position:relative}++.rl-hero::before{content:"";position:absolute;inset:-30px 0 auto;height:260px;z-index:-1;pointer-events:none;++ background:radial-gradient(50% 90% at 12% 0,color-mix(in srgb,var(--accent) 18%,transparent),transparent 70%),radial-gradient(40% 80% at 88% 10%,color-mix(in srgb,#8b5cf6 14%,transparent),transparent 70%)}++.rl-eye{margin:0;font-size:.78rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}++.rl-h1{margin:0;font-family:var(--rl-face);font-weight:900;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.02em;text-wrap:balance}++.rl-lede{margin:0;max-width:64ch;color:var(--text-secondary);font-size:clamp(1rem,1.7vw,1.12rem);line-height:1.55}++.rl-lede a{color:var(--text-primary);text-underline-offset:3px}++.rl-hero .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));max-width:620px;overflow:visible;margin-inline:0;padding-inline:0}++++.rl-find{display:flex;align-items:center;gap:10px;max-width:620px;min-height:48px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card)}++.rl-find:focus-within{outline:2px solid var(--accent);outline-offset:1px}++.rl-find input{flex:1;min-width:0;border:0;background:transparent;color:var(--text-primary);font:inherit;font-size:1rem;outline:0}++.rl-find output{flex:none;color:var(--text-muted);font-size:.85rem;font-variant-numeric:tabular-nums}++.rl-find svg{flex:none;width:18px;height:18px;color:var(--text-muted)}++++.rl-areas{position:sticky;top:calc(var(--ov-nav-h,56px) + 6px);z-index:2;margin:4px 0 8px;padding:8px 0;background:linear-gradient(var(--bg-primary,#0b1220) 70%,transparent)}++.rl-areas ul{list-style:none;margin:0;padding:0;display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}++.rl-areas ul::-webkit-scrollbar{display:none}++.rl-areas a{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card);text-decoration:none;white-space:nowrap;font-size:.9rem;scroll-snap-align:start}++.rl-areas a:hover{border-color:var(--accent);color:var(--accent)}++.rl-areas span,.rl-n{display:inline-grid;place-items:center;min-width:1.6em;height:1.6em;padding:0 .35em;border-radius:999px;background:color-mix(in srgb,var(--text-primary) 8%,transparent);color:var(--text-secondary);font-size:.75rem;font-weight:700;font-variant-numeric:tabular-nums}++++.rl-sec{padding:clamp(24px,4vw,40px) 0;border-top:1px solid var(--border);scroll-margin-top:120px}++.rl-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 32px;align-items:baseline;margin-bottom:18px}++.rl-sec-head h2{display:flex;align-items:center;gap:10px;margin:0;font-family:var(--rl-face);font-weight:900;font-size:clamp(1.35rem,3vw,1.85rem);line-height:1.15}++.rl-sec-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.5}++++.rl-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:12px}++.rl-rule{display:grid;align-content:start;gap:10px;padding:18px;border:1px solid var(--border);border-radius:18px;background:var(--bg-card);scroll-margin-top:120px;min-width:0}++.rl-rule:target{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}++.rl-rule-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}++.rl-rule h3{margin:0;font-size:1.06rem;line-height:1.35;font-weight:750}++.rl-rule h3 a{text-decoration:none}++.rl-rule h3 a:hover{text-decoration:underline;text-underline-offset:3px}++.rl-rule time{flex:none;color:var(--text-muted);font-size:.8rem;font-variant-numeric:tabular-nums;white-space:nowrap}++.rl-detail{margin:0;color:var(--text-secondary);line-height:1.55}++.rl-why{margin:0;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--text-primary) 4%,transparent);color:var(--text-secondary);font-size:.9rem;line-height:1.45}++.rl-why span{margin-right:6px;font-size:.72rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}++.rl-links{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0}++.rl-label{margin-right:2px;color:var(--text-muted);font-size:.72rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase}++.rl-label:not(:first-child){margin-left:8px}++.rl-chip{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--border);border-radius:999px;font-size:.84rem;text-decoration:none;color:var(--text-primary)}++a.rl-chip:hover{border-color:var(--accent);color:var(--accent)}++.rl-chip--check{color:var(--text-secondary);background:color-mix(in srgb,var(--success,#22c55e) 8%,transparent);border-color:color-mix(in srgb,var(--success,#22c55e) 30%,var(--border))}++.rl-chip svg{width:14px;height:14px}++++.rl-recent-list{list-style:none;margin:0;padding:0;display:grid;gap:2px;counter-reset:none}++.rl-recent-list a{display:grid;grid-template-columns:7.5rem minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 4px;border-bottom:1px solid var(--border);text-decoration:none}++.rl-recent-list a:hover b{color:var(--accent)}++.rl-recent-list time{color:var(--text-muted);font-size:.85rem;font-variant-numeric:tabular-nums}++.rl-recent-list b{font-weight:650}++.rl-data .rl-links{gap:10px}++++.rl-sec[data-rl-empty]{display:none}++++@media (max-width:720px){++ .rl-sec-head{grid-template-columns:minmax(0,1fr)}++ .rl-recent-list a{grid-template-columns:minmax(0,1fr);gap:2px}++ .rl-rule-head{flex-direction:column;gap:4px}++}++++/* /system: the map. The cards are the home's (.ds-sys); a group's columns divide its cards. */++.sy{display:grid;gap:6px;max-width:1120px;margin:0 auto;padding:0 clamp(16px,3vw,32px) 96px;color:var(--text-primary)}++.sy>*{min-width:0}++.sy-hero{display:grid;gap:12px;padding:clamp(18px,4vw,44px) 0 clamp(10px,2vw,18px)}++.sy-eye{margin:0;font-size:.78rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}++.sy-h1{margin:0;font-family:'OV Brand',Nunito,Inter,system-ui,sans-serif;font-weight:900;font-size:clamp(2rem,5.5vw,3.3rem);line-height:1.04;letter-spacing:-.02em;text-wrap:balance}++.sy-lede{margin:0;max-width:64ch;color:var(--text-secondary);font-size:clamp(1rem,1.7vw,1.1rem);line-height:1.55}++.sy-group{padding:clamp(20px,3.5vw,32px) 0;border-top:1px solid var(--border);scroll-margin-top:90px}++.sy-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 32px;align-items:baseline;margin-bottom:16px}++.sy-head h2{margin:0;font-family:'OV Brand',Nunito,Inter,system-ui,sans-serif;font-weight:900;font-size:clamp(1.3rem,3vw,1.7rem)}++.sy-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.5}++.sy-grid.ds-system-grid{grid-template-columns:repeat(var(--sy-cols,3),minmax(0,1fr))}++@media (max-width:860px){.sy-grid.ds-system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}++@media (max-width:560px){.sy-grid.ds-system-grid{grid-template-columns:minmax(0,1fr)}.sy-head{grid-template-columns:minmax(0,1fr)}}++++/* A rule is a sentence to read, not an eyebrow: the room's h3 style (capitals, accent) does not apply here. */++.rl .rl-rule h3{text-transform:none;letter-spacing:0;color:var(--text-primary);font-size:1.06rem;font-weight:750;line-height:1.35}++.rl .rl-rule h3 a{color:inherit}++.rl .rl-recent-list b{color:var(--text-primary)}++.rl .rl-recent-list a:hover b{color:var(--accent)}++.rl-lede a{color:var(--text-primary)}++.rl-hero>*{min-width:0}++@media (max-width:560px){.rl-hero .ovk-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.rl-hero .ovk-stats>div:last-child:nth-child(odd){grid-column:1/-1}}@@@ -40,7 +40,7 @@dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));}const studio = $('#lk-studio');-- if (studio) studio.href = `/studio?theme=${encodeURIComponent(id)}`;++ if (studio) studio.href = `/themes?theme=${encodeURIComponent(id)}`;const code = $('#lk-code');if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;}nnew file mode 100644@@@ -0,0 +1,28 @@++// /rules: the filter. Without it every rule shows; with it, typing narrows the register and says how many match.++(function () {++ 'use strict';++ var form = document.querySelector('[data-rl-find]');++ if (!form) return;++ var input = form.querySelector('input');++ var count = form.querySelector('[data-rl-count]');++ var rules = Array.prototype.slice.call(document.querySelectorAll('.rl-rule[data-rl-text]'));++ var areas = Array.prototype.slice.call(document.querySelectorAll('[data-rl-area]'));++ form.hidden = false;++ form.addEventListener('submit', function (e) { e.preventDefault(); });++ function apply() {++ var words = String(input.value || '').toLowerCase().split(/\s+/).filter(Boolean);++ var shown = 0;++ rules.forEach(function (r) {++ var text = r.getAttribute('data-rl-text');++ var match = words.every(function (w) { return text.indexOf(w) !== -1; });++ r.hidden = !match;++ if (match) shown += 1;++ });++ areas.forEach(function (a) {++ var any = a.querySelector('.rl-rule:not([hidden])');++ if (any) a.removeAttribute('data-rl-empty'); else a.setAttribute('data-rl-empty', '');++ });++ count.textContent = words.length ? shown + ' of ' + rules.length : '';++ }++ input.addEventListener('input', apply);++})();@@@ -128,7 +128,7 @@// Try it on the real sites: the picked catalog theme (edits are tried here, in the previews).const tryBox = $('#ds-try');if (tryBox) { tryBox.hidden = false; $$('#ds-try a[data-try]').forEach((a) => { a.href = `${a.getAttribute('data-try')}?ov-try=${encodeURIComponent(id)}`; }); }-- if (!quiet) { try { history.replaceState(null, '', `/studio?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }++ if (!quiet) { try { history.replaceState(null, '', `/themes?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }say('');}root.addEventListener('click', (e) => {@@@ -12,7 +12,7 @@ const express = require('express');const agentLayer = require('openvibe-shared/agent-layer');// /developers (the theme API) is on the same page router: an agent's most useful page here (11-03).--const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|motion|studio|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;++const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|motion|rules|system|themes|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;/** A response the page code can write to; the first body written is the page's HTML. */function mockRes() {@@@ -289,6 +289,8 @@ async function createApp(opts = {}) {app.use('/api/v1/arena', createArenaApi(ctx));app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });++ // The decision register (openvibe.design/rules) as data: open to any origin, for code and AI assistants.++ app.get('/api/v1/rules', (req, res) => { res.set({ 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); res.json(require('./views/rules').rulesJson()); });app.use(createIconRoutes(ctx));// The public page routers are built before they are mounted: the Markdown twins render through the same routers// (server/agent-twins.js), so a twin cannot drift from the page a browser gets.@@@ -4,7 +4,7 @@* Crawl artifacts for openvibe.design, built with openvibe-shared/seo: robots.txt, sitemap.xml and llms.txt.* The public pages are for search engines and AI crawlers; the studio's writes, the API and sign-in are not.*-- * The sitemap is /, /studio and one page per public theme in the catalogue. A Network failure leaves the static++ * The sitemap is /, /themes and one page per public theme in the catalogue. A Network failure leaves the static* pages in place rather than failing the crawl artifact.*/const fs = require('fs');@@@ -30,7 +30,9 @@ function siteUpdated() {function publicPages(themes = []) {const static_ = [{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },-- { path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },++ { path: '/themes', changefreq: 'weekly', priority: 0.9, title: 'Themes', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },++ { path: '/system', changefreq: 'weekly', priority: 0.8, title: 'The design system', text: 'Everything OpenVibe is made of in one map: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; apps and developer tools.' },++ { path: '/rules', changefreq: 'weekly', priority: 0.8, title: 'How OpenVibe decides', text: 'Every style, design and organization rule behind OpenVibe, with the day it was decided, why, and what carries it out. Also as data at /api/v1/rules.' },{ 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 component registry as one plain list: every Shared kit component as a design.component@1 entry with its props, states, example markup and Android and iOS notes.' },@@@ -100,7 +102,9 @@ function createDiscoveryRoutes(ctx) {sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },-- { title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },++ { title: 'Themes', url: abs('/themes'), note: 'try, edit, use, submit and roll back a theme' },++ { title: 'Rules', url: abs('/rules'), note: 'every style, design and organization decision behind OpenVibe, with the day and the reason; read it before designing for OpenVibe. JSON: GET /api/v1/rules' },++ { title: 'The design system', url: abs('/system'), note: 'every reference page in four groups: foundations, components, guidance, platforms' },{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },@@@ -3,7 +3,9 @@/*** The pages:* / the theme catalogue's front door-- * /studio the theme studio++ * /themes the theme editor and gallery (/studio answers 301)++ * /rules the decision register (its Markdown twin /rules.md; data at /api/v1/rules)++ * /system the design system's map* /t/:slug one public theme* /arena the Arena leaderboard and its front door* /arena/new start a round (the form also posts here, so it works without JavaScript)@@@ -214,10 +216,25 @@ function createPageRoutes(ctx) {return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);});-- r.get('/studio', async (req, res) => {++ r.get('/themes', async (req, res) => {const list = await safe(() => catalog.list());return page(res, 200, views.studio({ themes: list ? list.themes : null, me: meOf(req.viewer) }), req.viewer);});++ // The theme editor was /studio until 2026-11-09; openvibe.studio is a separate product (photo, video, sound), so++ // Design's page is named for what it holds.++ r.get('/studio', (req, res) => {++ const q = req.originalUrl.indexOf('?');++ return res.redirect(301, `/themes${q === -1 ? '' : req.originalUrl.slice(q)}`);++ });++++ // The decision register and the system map.++ const rulesView = require('../views/rules');++ r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));++ r.get('/system', async (req, res) => {++ const list = await safe(() => catalog.list());++ const n = list && Array.isArray(list.themes) ? list.themes.length : null;++ return page(res, 200, require('../views/system').systemPage({ themes: n }), req.viewer);++ });r.get('/t/:slug', async (req, res) => {let up;@@@ -29,7 +29,7 @@ const patterns = [['status-badges','Elements','Status badges', 'Use a compact badge with an icon and a clear status word. Give the badge a semantic status color while preserving readable contrast.', '2026-10-30 · Color alone made live, complete, and failed states ambiguous.', 'Status always has a word as well as a color.', t('spaceMd','radiusPill','text')],['metadata-chips','Elements','Metadata chips', 'Present secondary metadata as short, labeled chips rather than raw URLs or long unbroken strings. Let chips wrap without widening the page.', '2026-10-30 · Raw provider URLs widened the run row and competed with its title.', 'Keep secondary metadata compact and breakable.', t('spaceMd','radiusPill','text')],['empty-state','Elements','One-line empty states', 'Say what is empty in one line and offer one direct action. Keep the action visible beside the explanation when space allows.', '2026-10-30 · Empty panels became dead ends with a paragraph of explanation and no next step.', 'Explain the empty state and give one way forward.', t('tap','spaceMd','accent')],-- ['tabs-not-folds','Elements','Tabs, not folds', 'Use tabs when people switch between peer views, and folds when they reveal details in the current view. Do not put tabs inside a fold for the same choice.', '2026-10-30 · Nested tabs and folds repeated the same navigation and hid the active view.', 'Choose one navigation model for a set of peer views.', t('tap','durationFast','easingStandard')],++ ['tabs-not-folds','Elements','Tabs, not folds', 'Use tabs only for settings and explainers whose parts are peers, each tab with its own address; never split content (lists, rails, sections) into tabs, because content in a tab is content people never find: shorten it with sideways rails, More (N) and folds that show their count. Use folds for details in the current view, and do not put tabs inside a fold for the same choice.', '2026-10-30 · Nested tabs and folds repeated the same navigation and hid the active view.', 'Choose one navigation model for a set of peer views.', t('tap','durationFast','easingStandard')],['touch-targets','Elements','44 px targets', 'Give interactive controls a minimum 44 by 44 px target, including icon controls. Keep visual spacing separate from the hit area.', '2026-10-30 · Dense icon rows were difficult to operate on a phone despite their visible glyphs.', 'Every interactive target is at least 44 px.', t('tap','spaceMd')],['motion','Motion','Motion tokens', 'Use the shared 180, 260, and 360 ms duration scale with standard easing for ordinary transitions. Honor reduced motion everywhere, and reserve back easing for playful feedback.', '2026-10-30 · Components used unrelated timings and kept moving after reduced motion was requested.', 'Share timings across platforms and respect the system setting.', t('durationFast','durationBase','durationSlow','durationReduced','easingStandard','easingBack')],['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],@@@ -7,7 +7,7 @@** - <head>: the shared app icon, the room's stylesheets, this site's content-hashed design.css* - the navbar's session probe (/auth/me), sign-in and sign-out URLs and silent sign-in, as every site's-- * - the product's own script only where a page offers an in-browser convenience (/studio)++ * - the product's own script only where a page offers an in-browser convenience (/themes)*/const shell = require('openvibe-shared/shell');const ovServe = require('openvibe-shared/serve');@@@ -22,26 +22,29 @@ const ROOM = rooms.get('design');const esc = kit.esc;const NAV = [++ // Six places in the bar (the six-places rule); everything else under More, in the System page's order.{ label: 'Home', href: '/', icon: 'fa-house' },-- { label: 'Create', href: '/create', icon: 'fa-palette' },-- { label: 'Studio', href: '/studio', icon: 'fa-palette' },++ { label: 'Create', href: '/create', icon: 'fa-wand-magic-sparkles' },++ { label: 'Themes', href: '/themes', icon: 'fa-palette' },{ label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' },{ label: 'Arena', href: '/arena', icon: 'fa-crown' },-- { label: 'Tokens', href: '/tokens', icon: 'fa-code' },++ { label: 'System', href: '/system', icon: 'fa-layer-group' },++ { label: 'Tokens', href: '/tokens', icon: 'fa-code', more: true },++ { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },++ { label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },-- { label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },++ { label: 'Motion', href: '/motion', icon: 'fa-wind', more: true },++ { label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },++ { label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },++ { label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },-- { label: 'Progress', href: '/progress', icon: 'fa-bars-progress', more: true },++ { label: 'Progress', href: '/progress', icon: 'fa-gauge-high', more: true },{ label: 'Uploader', href: '/uploader', icon: 'fa-upload', more: true },-- { label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },-- { label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },-- { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },-- { label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },-- { label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },-- { label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },-- { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-display', more: true },-- { label: 'Developers', href: '/developers', icon: 'fa-code', more: true },++ { label: 'Rules', href: '/rules', icon: 'fa-scale-balanced', more: true },++ { label: 'Patterns', href: '/patterns', icon: 'fa-table-cells', more: true },{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },++ { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-mobile-screen', more: true },++ { label: 'Developers', href: '/developers', icon: 'fa-terminal', more: true },];let RELEASE = 'dev';@@@ -59,7 +62,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',service: 'design',apiBase: NETWORK,links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Create', href: '/create', icon: 'fa-wand-magic-sparkles' }, { label: 'Themes', href: '/themes', icon: 'fa-palette' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }, { label: 'System', href: '/system', icon: 'fa-layer-group' }],history: { type: 'page', title: title || NAME },silentLogin: `${BASE}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',nnew file mode 100644@@@ -0,0 +1,254 @@++'use strict';++/**++ * The decision register: every style, design and organization rule OpenVibe follows, in one place, with the day it++ * was decided, why, where it applies, what carries it out (a pattern, a kit part, a library) and what checks it.++ * openvibe.design/rules renders it; /api/v1/rules and /rules.md serve it as data.++ *++ * A rule is one sentence a person can act on. `why` says what went wrong, or what the rule protects, in a line.++ * `see` links to the pattern or part that implements it; `checked` names the test or tool that catches a breach.++ * Add a rule when a decision is made; change `decided` when it is revised, and say what changed in `why`.++ */++++const AREAS = Object.freeze([++ { id: 'honest', title: 'Honest pages', lede: 'What a page shows is true, counted and sourced.' },++ { id: 'length', title: 'Length without hiding', lede: 'Pages get shorter by arranging, never by removing.' },++ { id: 'motion', title: 'Loading and motion', lede: 'Movement means something is happening.' },++ { id: 'navigation', title: 'Navigation and the Frame', lede: 'One bar, one menu, the same on every site.' },++ { id: 'phone', title: 'Phones first', lede: 'Designed on a phone, checked at 320 px.' },++ { id: 'words', title: 'Words', lede: 'Plain words, true claims, names that mean something.' },++ { id: 'look', title: 'Look and marks', lede: 'One look, many themes, real marks.' },++ { id: 'data', title: 'Data, AI and consent', lede: 'Where numbers come from, and who agreed.' },++ { id: 'agents', title: 'Agents', lede: 'How an agent works for a person without wasting their attention.' },++ { id: 'products', title: 'Products and their jobs', lede: 'One clear job per product and domain.' },++ { id: 'building', title: 'Building it', lede: 'The engineering choices that keep the design whole.' },++]);++++const R = (id, area, rule, detail, decided, why, see = [], checked = []) => ({ id, area, rule, detail, decided, why, see, checked });++++const RULES = Object.freeze([++ // ── Honest pages ──++ R('no-fake-content', 'honest', 'Never show content that is not real.',++ 'No sample rows, invented counts, filler testimonials or "coming soon" sections dressed as content. An empty place says it is empty and offers one way forward.',++ '2026-10-31', 'Placeholder sections and invented numbers made pages look full and told people nothing.',++ [{ label: 'One-line empty states', href: '/patterns/empty-state' }]),++ R('count-what-it-says', 'honest', 'A number counts exactly what its label says.',++ 'A count next to a list equals the length of that list. "4 agents working now" must count agents at work, not the agent catalogue.',++ '2026-11-01', 'A home page counted its catalogue as "working now".',++ [], ['site sweep: count checks']),++ R('missing-is-not-zero', 'honest', 'A missing number is left out or said in words, never shown or sorted as zero.',++ 'When a source did not report a value, the page says "No traffic yet" or "Not measured", and sorting and picks skip it. In code, null must not become 0.',++ '2026-11-09', 'A model with no measured speed was named "Fastest" because null had become 0 ms.',++ [{ label: 'Open stats', href: 'https://openvibe.network/stats' }], ['Report test: picks use measured values']),++ R('number-has-source', 'honest', 'Every number names its source, its date and how much it rests on.',++ 'A rating says how many games; a price says whose page and when it was checked; a speed says it is from real traffic over seven days. One tap shows it.',++ '2026-11-09', 'Comparison pages showed numbers nobody could check.'),++ R('not-a-rating-yet', 'honest', 'A starting value is not a result.',++ 'A model with no votes shows "Not rated yet", not the 1000 every entrant starts at. The same goes for any score with no samples behind it.',++ '2026-11-09', 'The leaderboard rated models at 1000 on boards that said "No votes yet".'),++ R('columns-earn-their-place', 'honest', 'A column or section appears when something fills it.',++ 'A table shows a column only when some row has a value for it; a section with nothing in it is not drawn. When everything is missing, one plain line says why and what would fill it.',++ '2026-11-09', 'Twelve columns of "Not measured yet" buried the three numbers that were real.'),++ R('state-in-words', 'honest', 'Status is a word, never colour alone.',++ 'A dot or colour helps the eye; the word carries the meaning for everyone.',++ '2026-10-30', 'Colour alone made live, done and failed look alike.',++ [{ label: 'Status badges', href: '/patterns/status-badges' }, { label: 'Status is a dot and a word', href: '/patterns/status-dot' }]),++ R('bands-not-false-ranks', 'honest', 'When the data cannot separate two things, they share a rank.',++ 'Ranks come with their uncertainty: overlapping intervals share a band ("1–3"), and a tie is shown as a tie.',++ '2026-11-09', 'A rank order implied differences the votes could not support.'),++++ // ── Length without hiding ──++ R('never-hide-content', 'length', 'Shorten a page by arranging it, never by hiding or removing content.',++ 'No display:none on content for phones, no "newest only" cuts, no content behind tabs. Use sideways rails, "More (N)" pagers and folds that show their count.',++ '2026-10-31', 'Pages made shorter lost their videos and clips; every cut had to be put back.',++ [{ label: 'One card that is also a row', href: '/patterns/card-or-row' }]),++ R('tabs-for-settings', 'length', 'Tabs are for settings and explainers; content is never split into tabs.',++ 'A settings page may use server-rendered tabs (each with its own address). Lists, rails and sections of content stay on the page.',++ '2026-10-31', 'Content in tabs was content people never found.',++ [{ label: 'Tabs, not folds', href: '/patterns/tabs-not-folds' }]),++ R('taste-and-door', 'length', 'A front page is a taste and a door: about six phone screens.',++ 'Two hero actions, figures as one strip, cards as sideways rows, explainers in one fold, and no closing call that repeats the hero.',++ '2026-10-29', 'Homes ran to fifteen screens on a phone.'),++ R('placeholders-are-not-sections', 'length', 'Do not keep a section whose only content is that it has none.',++ '"Not published yet" four times is length, not information. Keep the sections that hold something; name what is pending in one line.',++ '2026-11-09', 'A model page was mostly "not available yet".'),++++ // ── Loading and motion ──++ R('no-spinners', 'motion', 'No spinning rings. Things move only while something is actually happening.',++ 'Loading shows the shape of what is coming (a shimmer placeholder of the right size) or the mark at work. A finished or idle thing is still.',++ '2026-10-31', 'Spinning rings around icons everywhere looked cheap and said nothing.',++ [{ label: 'Loading', href: '/loading' }, { label: 'The mark at work', href: '/patterns/mark-system' }], ['test/no-spin.test.js']),++ R('streams-hold-still', 'motion', 'A live list changes only where it changed.',++ 'Patch rows in place, keep the reader’s place, follow the bottom only when they were there, and fade in only what is new.',++ '2026-10-30', 'Rebuilding a stream on every event reset what people were reading.',++ [{ label: 'Patch in place', href: '/patterns/patch-in-place' }, { label: 'Keep the anchor', href: '/patterns/keep-anchor' }]),++ R('shared-timing', 'motion', 'Every site moves on the same timings and honours reduced motion.',++ '180, 260 and 360 ms with the shared curves; the system setting for reduced motion turns entrances off.',++ '2026-10-30', 'Components used unrelated durations and felt like different products.',++ [{ label: 'Motion', href: '/motion' }]),++++ // ── Navigation and the Frame ──++ R('one-menu-button', 'navigation', 'One menu button on every site: the orb.',++ 'The nine-dot orb opens the OpenVibe menu; there is never a second three-line button that does the same thing.',++ '2026-11-08', 'Two buttons opened the same menu, and the custom orb had been hidden.',++ [{ label: 'One menu, opened from either end', href: '/patterns/one-menu' }]),++ R('one-bar', 'navigation', 'One navbar per page, owned by the shared Frame.',++ 'A site’s own section links go in the page body, never in a second header under the bar.',++ '2026-10-30', 'Page headers under the bar doubled the navigation.',++ [{ label: 'One navbar per page', href: '/patterns/single-navbar' }]),++ R('six-places', 'navigation', 'Six places in a site’s bar; the rest under More.',++ 'Each place has an icon and one or two words that never wrap; the brand is the way home.',++ '2026-11-01', 'Bars grew until labels wrapped and buttons shrank.',++ [{ label: 'Six places in the bar', href: '/patterns/six-places' }]),++ R('one-account-ui', 'navigation', 'Signing in and out happens in the shared bar only.',++ 'A site’s own account links go into the bar’s menu; its own account bar exists only for no-JavaScript visitors.',++ '2026-09-24', 'Sites showed "Sign in" while the bar said you were signed in.'),++ R('your-things-everywhere', 'navigation', 'Your messages, balances and status are in every bar.',++ 'The same three things in the same places on every site, for anyone signed in.',++ '2026-11-01', 'Each site showed a different subset, so nothing was where people looked.',++ [{ label: 'Your things in every bar', href: '/patterns/your-things' }]),++ R('ask-with-a-button', 'navigation', 'A page that needs an account says why in one sentence and offers one button.',++ 'The button signs in and comes straight back to the same place. Never a bare link inside a sentence.',++ '2026-11-01', 'Ten sites hid sign-in inside paragraphs.',++ [{ label: 'Ask with a button', href: '/patterns/ask-with-a-button' }]),++++ // ── Phones first ──++ R('works-at-320', 'phone', 'Every page fits a 320 px screen without sideways scrolling.',++ 'Grids use minmax(min(N, 100%), 1fr); one-column grids use minmax(0, 1fr); long words may break.',++ '2026-10-17', 'Six homes scrolled sideways on small Android phones.',++ [], ['browser smoke at 320, 390, 768, 1280']),++ R('targets-44', 'phone', 'Everything you can press is at least 44 px.',++ 'Icon buttons included; spacing is separate from the hit area.',++ '2026-10-30', 'Dense icon rows were hard to hit on a phone.',++ [{ label: '44 px targets', href: '/patterns/touch-targets' }]),++ R('keep-the-picture', 'phone', 'Controls that act on a picture keep the picture in view.',++ 'Camera and drive pad on screen together; the picture sticks under the bar while the controls scroll.',++ '2026-10-16', 'Driving a robot meant scrolling its camera away.'),++ R('watch-owns-the-screen', 'phone', 'Watching or going live owns the phone’s screen.',++ 'No tab bar and no floating buttons over the picture; its own controls take the edges.',++ '2026-10-31', 'Bars and buttons covered the stream.',++ [{ label: 'Watch and camera screens own the screen', href: '/patterns/media-owns-the-screen' }]),++ R('tables-become-cards', 'phone', 'On a phone, a wide table becomes labelled cards.',++ 'Each row is a card; its first cell is the title and every other value says what it is. Nothing is dropped.',++ '2026-10-30', 'Phones cut columns or scrolled the page sideways.',++ [{ label: 'Wide tables become labelled cards', href: '/patterns/labelled-cards' }]),++++ // ── Words ──++ R('plain-words', 'words', 'Use plain words; keep machinery out of people’s screens.',++ 'No runtime, harness, lease, placement or vendor tables on pages people use. Say what happens to them.',++ '2026-11-09', 'A vendor table of runtimes confused the person it was for.'),++ R('meaningful-names', 'words', 'Name things after what they are to OpenVibe, not borrowed jargon.',++ 'The navbar and footer are the OpenVibe Frame, not "chrome". Renames go through every layer at once.',++ '2026-09-24', 'A half-done rename looked like the request was ignored.'),++ R('names-say-the-domain', 'words', 'A site is named the way its address reads.',++ 'If the domain says VIP, the site says VIP.',++ '2026-10-27', 'Names that differed from addresses made people unsure they were in the right place.'),++ R('the-answer-is-the-answer', 'words', 'An AI answer shows only the answer.',++ 'A model’s self-check, its plan and its reasoning stay in a Details sheet, folded.',++ '2026-11-01', 'Answers arrived wrapped in the model’s notes to itself.'),++ R('true-claims', 'words', 'Say "we never" only where it is true, and say who decides.',++ 'Tell the truth about today, what we hope for later and who chooses. Friendly, and never a promise the code does not keep.',++ '2026-10-31', 'Blanket "never" claims limited what people could choose to allow.'),++ R('headline-stays-visible', 'words', 'What people come for stays in the first screen and the page’s title.',++ 'Going live from a browser with no OBS is Live’s headline: never folded behind a toggle, never dropped from titles in a redesign.',++ '2026-09-28', 'It is what people ask assistants for, and what they recommend us for.'),++++ // ── Look and marks ──++ R('blue-by-default', 'look', 'The default look is blue; other themes are a choice.',++ 'Vibe (deep blue) is everyone’s default; the violet original is its own theme, Violet Hour.',++ '2026-09-19', 'The owner prefers blue as the platform default.',++ [{ label: 'Themes', href: '/themes' }]),++ R('seasons-are-optional', 'look', 'Seasonal looks dress the default and can be turned off.',++ 'A season shows only for people on the default theme, as a touch on the mark, never a badge taking bar space.',++ '2026-10-25', 'A pumpkin badge wasted bar space and the season’s palette did not read well.'),++ R('the-pair', 'look', 'The mark is the Pair: a ring O and a round V, with names in Nunito.',++ 'The gap where the V crosses the ring is a mask, so it holds on any ground; a heavier form serves 22 px and below.',++ '2026-10-31', 'Chosen from the mark studies as the network’s logo.',++ [{ label: 'The mark', href: '/brand' }]),++ R('real-maker-marks', 'look', 'An AI maker is shown by its own mark, never initials in a circle.',++ 'Unknown makers get the neutral spark; a mark is chosen by the maker’s id, so a name saying "OpenAI-compatible" never draws OpenAI’s.',++ '2026-11-09', 'Home-drawn model glyphs read as "what is it even supposed to be?"'),++ R('coloured-words-mix', 'look', 'A word in colour is the colour mixed with the text, about half and half.',++ 'It reads on dark and light themes; fills and borders keep the pure hue.',++ '2026-11-01', 'Pure hues failed contrast on one side or the other.',++ [{ label: 'Coloured words mix with the text', href: '/patterns/mixed-words' }]),++ R('native-ready', 'look', 'Design so real Android and iOS apps can follow, not web views in a frame.',++ 'One token source compiled to CSS, Compose and SwiftUI; components specified by anatomy, states and roles, not HTML.',++ '2026-10-29', 'A web-only kit would force a kiosk app.',++ [{ label: 'Tokens', href: '/tokens' }, { label: 'Apps', href: '/native' }]),++++ // ── Data, AI and consent ──++ R('own-page-prices', 'data', 'A price comes from the provider’s own page, with the day it was checked.',++ 'Never an aggregator’s price; a price older than its check window says it may have changed.',++ '2026-11-08', 'Second-hand prices drifted from what providers charged.'),++ R('measured-on-real-traffic', 'data', 'Speed, errors and uptime are measured on real requests.',++ 'Synthetic probes fill gaps only where traffic is thin, and say so.',++ '2026-11-09', 'Real traffic is the number a person will actually get.'),++ R('opt-in-consent', 'data', 'AI uses of a person’s data are opt-in, per person, off by default.',++ 'Training on their content, a public dataset, recognising their face: each is a yes they gave and can take back.',++ '2026-10-31', 'Blanket bans limited people; silent use would break their trust.'),++ R('adult-floor', 'data', 'The uncensored tier is 18+ and keeps its floor.',++ 'Lawful adult content is fine for adults; anything sexual involving a minor is always blocked.',++ '2026-10-31', 'Adult freedom and child safety both hold, without exceptions.'),++++ // ── Agents ──++ R('one-question', 'agents', 'An agent asks one concrete question at a time.',++ 'In a structured form, where the person asked, and it keeps working on what it may do meanwhile.',++ '2026-11-09', 'Questions buried in long reports never reached the person.'),++ R('six-states', 'agents', 'Agent work reports one of six honest states.',++ 'Working, waiting, ready, blocked, failed, dropped. Opening a pull request is not "ready"; a draft is "blocked".',++ '2026-11-09', 'Work looked finished when it was not.'),++ R('observe-before-acting', 'agents', 'Anything an agent does unasked starts by recording what it would do.',++ 'The person reads those decisions, then turns acting on.',++ '2026-11-09', 'Proactive work that acts at once spends other people’s attention.'),++ R('quiet-by-default', 'agents', 'An agent speaks when a person must act or can use a result.',++ 'One confirmation that names the task, silence while working, then the result, a decision or a failure.',++ '2026-11-09', 'An agent for a team can waste the whole team’s attention.'),++ R('levels-with-a-floor', 'agents', 'An agent’s freedom is four levels in plain words, above a floor that never moves.',++ 'Asks first, edits on its own, a second model checks, full trust; the floor of what it never does is always in view.',++ '2026-11-01', 'Permission screens in jargon were skipped, not understood.',++ [{ label: 'Levels with a floor', href: '/patterns/levels-with-a-floor' }]),++++ // ── Products and their jobs ──++ R('one-job-per-product', 'products', 'Each product does one job, and its domain says which.',++ 'Report reads (rankings, numbers, evidence); Exchange does (try a model, get a key, route). Design makes interfaces, icons, fonts and themes; Studio edits photos, video and sound. Codes is coding with any agent; Services is the developer console.',++ '2026-11-07', 'Merged products muddle both markets and both pages.'),++ R('one-human-home', 'products', 'A product has one human-facing home.',++ 'AI’s pages, console and per-person settings live on openvibe.exchange; ai.openvibe.services keeps only the API.',++ '2026-11-08', 'A console on a machine host sent people to the wrong place.'),++ R('build-the-stated-core', 'products', 'Build what was asked first; never ship an easier stand-in as the feature.',++ 'Party is a shared desktop people drive together, not an embed sync; the stated core comes before extras.',++ '2026-11-08', 'A safer variant shipped first and wasted the time the real one needed.'),++ R('clean-cuts', 'products', 'Prefer clean cuts to compatibility layers.',++ 'With few users, rename and move in one pass; keep a redirect for addresses, not code that carries both shapes.',++ '2026-09-28', 'Aliases and remnants outlived their reasons.'),++++ // ── Building it ──++ R('kit-first', 'building', 'Build pages from the shared kit, and keep its names.',++ 'Buttons are ovk-btn ovk-btn--primary or --quiet; sections, tables, stats and folds come from the kit, so every site moves together.',++ '2026-11-09', 'Hand-made variants (and misspelt class names) drifted from the system.',++ [{ label: 'Kit', href: '/kit' }, { label: 'Components', href: '/components' }]),++ R('reserved-prefixes', 'building', 'Never name a site class after a shared one.',++ 'ovg belongs to the glyphs and ovf to the footer; a site’s own class takes its own prefix.',++ '2026-11-09', 'A bare .ovg pinned every icon to a corner; a Feed took the footer’s margins.'),++ R('own-tokens', 'building', 'A site styles with its own theme tokens, never invented names with fallbacks.',++ 'A variable the site does not define falls back silently: cards turned white on the dark theme.',++ '2026-11-09', 'A sheet written against names the site never defines drew white cards on dark.'),++ R('first-paint-alone', 'building', 'First paint never depends on another service.',++ 'The theme’s first frame is inline; shared scripts load deferred, so a Network deploy cannot blank another site.',++ '2026-09-17', 'Pages opened white while the Network restarted.'),++ R('content-hashed-assets', 'building', 'Assets are addressed by their content, so people always get the current one.',++ 'Hashed file names, never a hand-written ?v=; a shared sibling file is busted by its own hash.',++ '2026-11-07', 'Menus shipped but browsers kept the old files.'),++ R('cascade-in-order', 'building', 'Check a style in the order the page loads it.',++ 'A preview that appends a rule last proves nothing about a rule that loads first; measure on the real page.',++ '2026-10-25', 'A phone rule looked right in a preview and lost to a later file in production.'),++]);++++function byArea() {++ return AREAS.map((a) => ({ ...a, rules: RULES.filter((r) => r.area === a.id) })).filter((a) => a.rules.length);++}++function latest() {++ return RULES.map((r) => r.decided).sort().pop() || null;++}++++module.exports = { AREAS, RULES, byArea, latest };@@@ -7,17 +7,17 @@ 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' }) })]) }),++ navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Studio</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,++ menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,++ 'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),-- 'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/studio" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,++ 'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),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' }] }),++ popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', 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-row': () => kit.list([kit.row({ href: '/themes', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),++ 'kit-card': () => kit.card({ href: '/themes', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),'kit-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 }),@@@ -99,12 +99,12 @@ function home({ themes } = {}) {const glyphNames = (() => { try { return require('openvibe-shared/glyphs').NAMES.length; } catch { return 0; } })();const componentCount = (() => { try { return Object.keys(require('./components').SAMPLES).length; } catch { return 0; } })();const SYSTEM = [-- { href: '/studio', name: 'Themes', count: all.length, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },++ { href: '/themes', name: 'Themes', count: all.length, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },{ href: '/icons', name: 'Icons', count: glyphNames, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },{ href: '/components', name: 'Components', count: componentCount, unit: 'parts', text: 'Each drawn for the web, Jetpack Compose and SwiftUI from the same spec.', glyph: 'puzzle' },-- { href: '/patterns', name: 'Patterns', count: patterns.length, unit: 'rules', text: 'What production taught, each with its bug, a live demo and native code.', glyph: 'rules' },-- { href: '/motion', name: 'Motion', count: 3, unit: 'timings', text: 'Glint, glow, lift, press, ripple: one kit of effects and one set of curves.', glyph: 'motion' },-- { href: '/forms', name: 'Forms', count: 0, unit: '', text: 'Fields, choices and errors that read the same on every site.', glyph: 'forms' },++ { href: '/rules', name: 'Rules', count: (() => { try { return require('../rules/register').RULES.length; } catch { return 0; } })(), unit: 'rules', text: 'Every style, design and organization decision, with the day and the reason.', glyph: 'rules' },++ { href: '/patterns', name: 'Patterns', count: patterns.length, unit: 'patterns', text: 'What production taught, each with its bug, a live demo and native code.', glyph: 'rules' },++ { href: '/motion', name: 'Motion', count: (() => { try { return require('./motion').EFFECTS.length; } catch { return 0; } })(), unit: 'effects', text: 'Glint, glow, lift, press, ripple: one kit of effects and one set of curves.', glyph: 'motion' },{ href: '/tokens', name: 'Tokens', count: 7, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },{ href: '/native', name: 'Apps', count: 3, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },];@@@ -119,7 +119,7 @@ function home({ themes } = {}) {apps: 'M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM10.5 18h3',};const glyphSvg = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${GLYPH[k]}"/></svg>`;-- const system = `<section class="ds-system" aria-labelledby="ds-system-h"><div class="ds-head-row"><h2 id="ds-system-h">The whole system</h2><a class="ds-more" href="/kit">The kit${kit.icon('chevron')}</a></div>++ const system = `<section class="ds-system" aria-labelledby="ds-system-h"><div class="ds-head-row"><h2 id="ds-system-h">The whole system</h2><a class="ds-more" href="/system">Everything${kit.icon('chevron')}</a></div><p class="ds-fine ds-system-lede">Everything an OpenVibe interface is made of, in one place: the same parts on every site, and in the apps.</p><ul class="ds-system-grid">${SYSTEM.map((x) => `<li><a class="ds-sys" href="${esc(x.href)}"><span class="ds-sys-ic">${glyphSvg(x.glyph)}</span><span class="ds-sys-t"><b>${esc(x.name)}</b>${x.count ? `<em>${esc(String(x.count))} ${esc(x.unit)}</em>` : ''}</span><small>${esc(x.text)}</small></a></li>`).join('')}</ul></section>`;const room = sc.room({@@@ -127,8 +127,8 @@ function home({ themes } = {}) {hero: {title: 'Your look, everywhere',lede: 'Pick or make a theme once and every OpenVibe site wears it. Take it into your own app as code for the web, Android and iOS, and design pages on it with AI.',-- start: { href: '/studio', label: 'Change your theme' },-- chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/studio?theme=${encodeURIComponent(t.id)}` })),++ start: { href: '/themes', label: 'Change your theme' },++ chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/themes?theme=${encodeURIComponent(t.id)}` })),motif: orb,},proof: [@@@ -137,7 +137,7 @@ function home({ themes } = {}) {{ value: 7, label: 'platforms to export to' },].filter((p) => p.value),scenes: [-- { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back. Comfort settings ride on top of any theme: more contrast, a readable font, colour-blind-safe colours, true black.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Open the studio' } },++ { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back. Comfort settings ride on top of any theme: more contrast, a readable font, colour-blind-safe colours, true black.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/themes', label: 'Open the studio' } },{ title: 'Make it with AI, on your theme', text: 'Describe what you need and three AI designers draft it at once. Every draft wears your theme, so it already looks like it belongs.', html: make, action: { href: '/create', label: 'Make something' } },{ title: 'See which AI designs best', text: 'The Arena sends one prompt to three models, shows two drafts without their names, and ranks the designers by the votes, with what each draft cost and how long it took. Every prompt, page and vote is open data.', html: vs, action: { href: '/arena', label: 'Vote in the Arena' } },],@@@ -156,7 +156,7 @@ function home({ themes } = {}) {{ title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },{ title: 'Your look', text: 'Light or dark, text size, motion, seasons and comfort settings, for every site', href: '/look', icon: 'ov:account' },] },-- html: system + (strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/studio">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : ''),++ html: system + (strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/themes">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : ''),faq: { items: [['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],@@@ -236,17 +236,17 @@ ${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<fo${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'], styles: ['css/studio-look.css'] });++ return layout({ title: 'Theme studio', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });}function theme({ theme: t } = {}) {const v = (t && t.variables) || {};const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}--<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>++<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;-- return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/studio' });++ return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/themes' });}function developers() {@@@ -515,7 +515,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}}function notFound() {-- const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/studio">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;++ const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });}@@@ -43,7 +43,7 @@ function lookPage({ themes = [], me = {} } = {}) {const signedIn = !!me.signedIn;const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2><div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>--<p class="lk-acts">${kit.button({ href: '/studio', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;++<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;const settings = kit.section({id: 'lk-settings',title: 'How every site looks to you',@@@ -85,7 +85,7 @@ ${kit.sheet({ id: 'lk-site-sheet', title: 'Add a site', body: `<form class="lk-s<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-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>++${signedIn ? `<p class="lk-history-link"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/themes#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'], styles: ['css/studio-look.css'] });}nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * /rules: how OpenVibe decides. The decision register (server/rules/register.js) as a page: every style, design and++ * organization rule in one sentence, grouped by area, each with the day it was decided, why, what carries it out and++ * what checks it. Whole without JavaScript; rules.js only adds the filter.++ *++ * rulesPage() the page++ * rulesJson() the same register as data (/api/v1/rules)++ */++const kit = require('openvibe-shared/kit');++const { layout, esc, baseUrl } = require('../render/layout');++const { AREAS, RULES, byArea, latest } = require('../rules/register');++++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++const day = (iso) => { const [y, m, d] = String(iso || '').split('-').map(Number); return y ? `${d} ${MONTHS[m - 1]} ${y}` : ''; };++const isExternal = (href) => /^https?:\/\//.test(href);++++function rule(r) {++ const see = r.see.map((s) => `<a class="rl-chip" href="${esc(s.href)}"${isExternal(s.href) ? ' rel="noopener"' : ''}>${esc(s.label)}</a>`).join('');++ const checked = r.checked.map((c) => `<span class="rl-chip rl-chip--check">${kit.icon('check')}${esc(c)}</span>`).join('');++ return `<article class="rl-rule" id="${esc(r.id)}" data-rl-text="${esc(`${r.rule} ${r.detail} ${r.why}`.toLowerCase())}">++ <header class="rl-rule-head"><h3><a href="#${esc(r.id)}">${esc(r.rule)}</a></h3><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time></header>++ <p class="rl-detail">${esc(r.detail)}</p>++ <p class="rl-why"><span>Why</span> ${esc(r.why)}</p>++ ${see || checked ? `<p class="rl-links">${see ? `<span class="rl-label">See</span>${see}` : ''}${checked ? `<span class="rl-label">Checked by</span>${checked}` : ''}</p>` : ''}++</article>`;++}++++function rulesPage() {++ const areas = byArea();++ const newest = latest();++ const recent = [...RULES].sort((a, b) => (a.decided < b.decided ? 1 : -1)).slice(0, 5);++ const hero = `<header class="rl-hero">++ <p class="rl-eye">Rules</p>++ <h1 class="rl-h1">How OpenVibe decides.</h1>++ <p class="rl-lede">Every style, design and organization decision behind OpenVibe, one sentence each, with the day it was made and what went wrong without it. <a href="/patterns">Patterns</a> show how a rule looks in a page; this says what the rule is and why.</p>++ ${kit.stats([[RULES.length, 'rules'], [areas.length, 'areas'], [day(newest), 'last decided']], { label: 'The register in numbers' })}++ <form class="rl-find" role="search" action="#" data-rl-find hidden><label for="rl-q" class="ovk-sr">Find a rule</label>${kit.icon('search')}<input id="rl-q" type="search" placeholder="Find a rule: tabs, spinner, price, phone…" autocomplete="off"><output data-rl-count></output></form>++</header>`;++ const index = `<nav class="rl-areas" aria-label="Areas"><ul>${areas.map((a) => `<li><a href="#area-${esc(a.id)}">${esc(a.title)}<span>${a.rules.length}</span></a></li>`).join('')}</ul></nav>`;++ const latestBlock = `<section class="rl-sec rl-recent" aria-labelledby="rl-recent-h"><div class="rl-sec-head"><h2 id="rl-recent-h">Decided lately</h2><p>The newest rules first; each links to its place below.</p></div>++ <ol class="rl-recent-list">${recent.map((r) => `<li><a href="#${esc(r.id)}"><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time><b>${esc(r.rule)}</b></a></li>`).join('')}</ol></section>`;++ const sections = areas.map((a) => `<section class="rl-sec" id="area-${esc(a.id)}" aria-labelledby="area-${esc(a.id)}-h" data-rl-area>++ <div class="rl-sec-head"><h2 id="area-${esc(a.id)}-h">${esc(a.title)}<span class="rl-n">${a.rules.length}</span></h2><p>${esc(a.lede)}</p></div>++ <div class="rl-list">${a.rules.map(rule).join('')}</div>++</section>`).join('');++ const data = `<section class="rl-sec rl-data" aria-labelledby="rl-data-h"><div class="rl-sec-head"><h2 id="rl-data-h">Use the rules</h2><p>The register is data too: read it from code, hand it to an AI assistant before it designs for OpenVibe, or link one rule by its address.</p></div>++ <p class="rl-links">${kit.button({ href: '/api/v1/rules', label: 'As JSON', kind: 'quiet', icon: 'code' })}${kit.button({ href: '/rules.md', label: 'As Markdown', kind: 'quiet', icon: 'file' })}${kit.button({ href: '/patterns', label: 'The patterns', kind: 'quiet', icon: 'forward' })}</p></section>`;++ const jsonLd = [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'How OpenVibe decides', url: `${baseUrl()}/rules`, dateModified: newest, hasPart: RULES.map((r) => ({ '@type': 'CreativeWork', name: r.rule, url: `${baseUrl()}/rules#${r.id}`, dateCreated: r.decided })) }];++ return layout({++ title: 'Rules: how OpenVibe decides',++ description: `${RULES.length} style, design and organization rules behind OpenVibe, each with the day it was decided, why, and what carries it out: honest pages, length without hiding, motion, navigation, phones, words, marks, data, agents and products.`,++ path: '/rules',++ active: '/system',++ styles: ['css/rules.css'],++ scripts: ['js/rules.js'],++ jsonLd,++ body: `<article class="rl">${hero}${index}${latestBlock}${sections}${data}</article>`,++ });++}++++function rulesJson() {++ return { count: RULES.length, updated: latest(), areas: AREAS, rules: RULES.map((r) => ({ ...r, url: `${baseUrl()}/rules#${r.id}` })) };++}++++module.exports = { rulesPage, rulesJson };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * /system: the design system in one map. Every reference page of openvibe.design, grouped the way a builder looks for++ * it (foundations, components, guidance, platforms), each with what it holds counted from the source that holds it.++ * The bar's System place opens it; More lists the same pages in the same order.++ *++ * systemPage({ themes }) themes: the theme count (null when the catalogue did not answer: then no count shows)++ * GROUPS the map, for the nav and tests++ */++const { layout, esc } = require('../render/layout');++++const count = (fn) => { try { const n = fn(); return Number.isFinite(n) && n > 0 ? n : null; } catch { return null; } };++++const G = {++ palette: 'M12 3a9 9 0 1 0 0 18c1 0 1.6-.8 1.6-1.6 0-.5-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1 0-.9.7-1.6 1.6-1.6H16a5 5 0 0 0 5-5c0-4.1-4-7.6-9-7.6ZM7.5 12.5h.01M9.5 8h.01M14.5 8h.01M17 11.5h.01',++ code: 'M8.5 8 4.5 12l4 4M15.5 8l4 4-4 4M13.5 5.5l-3 13',++ font: 'M5 19 10.5 5h1L17 19M7.4 14h7.2M19 19v-5.5',++ shapes: 'M4 4h7v7H4zM17.5 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM14 20l3.5-6 3.5 6zM4 14h7v7H4z',++ mark: 'M9 12a5 5 0 1 0 0 .01M13 7l3.5 9L20 7',++ motion: 'M4 18c4 0 5-12 9-12s4 6 7 6M4 6h.01M20 18h.01',++ puzzle: 'M9 4h4v2.5a1.5 1.5 0 0 0 3 0V4h4v5h-2.5a1.5 1.5 0 0 0 0 3H20v8h-5v-2.5a1.5 1.5 0 0 0-3 0V20H4v-5h2.5a1.5 1.5 0 0 0 0-3H4V4z',++ kit: 'M4 7l8-4 8 4-8 4-8-4zM4 12l8 4 8-4M4 17l8 4 8-4',++ forms: 'M4 5h16v5H4zM4 14h16v5H4zM7 7.5h5M7 16.5h8',++ loading: 'M4 6h16M4 12h10M4 18h13',++ progress: 'M4 12h16M4 12h9M16 8l4 4-4 4',++ upload: 'M12 16V5M7.5 9.5 12 5l4.5 4.5M5 19h14',++ rules: 'M6 4h12v16H6zM9 9h6M9 13h6M9 17h3',++ patterns: 'M5 5h14M5 12h14M5 19h9M17 17l2 2 3-4',++ prompt: 'M5 6h14v9H9l-4 4z',++ apps: 'M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM10.5 18h3',++ api: 'M4 12h4l2-5 4 10 2-5h4',++};++const glyph = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${G[k]}"/></svg>`;++++function groups({ themes = null } = {}) {++ const n = {++ icons: count(() => require('openvibe-shared/glyphs').NAMES.length),++ formats: count(() => { const t = require('openvibe-shared/tokens').TARGETS; return Array.isArray(t) ? t.length : Object.keys(t).length; }),++ effects: count(() => require('./motion').EFFECTS.length),++ components: count(() => require('./components-list').GROUPS.reduce((s, g) => s + (Array.isArray(g[3]) ? g[3].length : 0), 0)),++ shapes: count(() => require('./loading').SHAPES.length),++ rules: count(() => require('../rules/register').RULES.length),++ patterns: count(() => require('../patterns/catalog').patterns.length),++ prompts: count(() => require('./prompts').list().length),++ // The web, Android and iOS: the platforms the tokens and components are drawn for.++ platforms: 3,++ };++ return [++ { id: 'foundations', title: 'Foundations', lede: 'What every interface is made of: colour, type, icons, the mark and how things move.', items: [++ { href: '/themes', name: 'Themes', n: themes, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },++ { href: '/tokens', name: 'Tokens', n: n.formats, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },++ { href: '/fonts', name: 'Fonts', text: 'Typefaces and pairings that read well on every screen.', glyph: 'font' },++ { href: '/icons', name: 'Icons', n: n.icons, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },++ { href: '/brand', name: 'The mark', text: 'The Pair: its forms, sizes, motion and the rules for using it.', glyph: 'mark' },++ { href: '/motion', name: 'Motion', n: n.effects, unit: 'effects', text: 'One kit of effects and one set of curves, shared by every site.', glyph: 'motion' },++ ] },++ { id: 'components', title: 'Components', lede: 'The parts pages are built from, each the same on every site and specified for native apps.', items: [++ { href: '/components', name: 'Components', n: n.components, unit: 'parts', text: 'Each defined once: anatomy, states and roles, for the web, Compose and SwiftUI.', glyph: 'puzzle' },++ { href: '/kit', name: 'Kit', text: 'Every piece an OpenVibe page is made of, live, with the code to use it.', glyph: 'kit' },++ { href: '/forms', name: 'Forms', text: 'Fields, choices and errors that read the same on every site.', glyph: 'forms' },++ { href: '/loading', name: 'Loading', n: n.shapes, unit: 'shapes', text: 'Placeholders the size of what replaces them. Never a spinning ring.', glyph: 'loading' },++ { href: '/progress', name: 'Progress', text: 'Every state of an upload or a job, in words and one bar.', glyph: 'progress' },++ { href: '/uploader', name: 'Uploader', text: 'The shared file picker and queue: choose a file and keep going.', glyph: 'upload' },++ ] },++ { id: 'guidance', title: 'Guidance', lede: 'What we decided and what production taught us, so new work starts from it.', items: [++ { href: '/rules', name: 'Rules', n: n.rules, unit: 'rules', text: 'Every style, design and organization decision, with the day and the reason.', glyph: 'rules' },++ { href: '/patterns', name: 'Patterns', n: n.patterns, unit: 'patterns', text: 'Each with the bug that taught it, a live demo and native code.', glyph: 'patterns' },++ { href: '/prompts', name: 'Prompts', n: n.prompts, unit: 'prompts', text: 'Every instruction this site sends an AI model, word for word.', glyph: 'prompt' },++ ] },++ { id: 'platforms', title: 'Platforms', lede: 'Taking the system off this site: into apps, code and AI assistants.', items: [++ { href: '/native', name: 'Apps', n: n.platforms, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },++ { href: '/developers', name: 'Developers', text: 'The API, MCP for AI assistants, and how to use any of it in your own app.', glyph: 'api' },++ ] },++ ];++}++++// Columns that divide the cards (the even-grid pattern): six and three go three across, four and two go in twos.++const colsFor = (k) => (k % 3 === 0 ? 3 : 2);++++function systemPage({ themes = null } = {}) {++ const gs = groups({ themes });++ const total = gs.reduce((s, g) => s + g.items.length, 0);++ const card = (x) => `<li><a class="ds-sys" href="${esc(x.href)}"><span class="ds-sys-ic">${glyph(x.glyph)}</span><span class="ds-sys-t"><b>${esc(x.name)}</b>${x.n ? `<em>${esc(x.n.toLocaleString('en-US'))} ${esc(x.unit)}</em>` : ''}</span><small>${esc(x.text)}</small></a></li>`;++ const body = `<article class="sy">++<header class="sy-hero"><p class="sy-eye">The system</p><h1 class="sy-h1">Everything OpenVibe is made of.</h1>++<p class="sy-lede">${total} references in four groups: the foundations, the components built on them, the rules and patterns that guide new work, and the ways to take it into your own app. The same parts are on every OpenVibe site.</p></header>++${gs.map((g) => `<section class="sy-group" id="${esc(g.id)}" aria-labelledby="sy-${esc(g.id)}-h"><div class="sy-head"><h2 id="sy-${esc(g.id)}-h">${esc(g.title)}</h2><p>${esc(g.lede)}</p></div><ul class="ds-system-grid sy-grid" style="--sy-cols:${colsFor(g.items.length)}">${g.items.map(card).join('')}</ul></section>`).join('')}++</article>`;++ return layout({++ title: 'The design system',++ description: 'Everything OpenVibe is made of: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; and apps and developer tools.',++ path: '/system',++ active: '/system',++ styles: ['css/rules.css'],++ body,++ });++}++++module.exports = { systemPage, groups };@@@ -94,7 +94,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>${filesRow(slug)}${kit.fold({ id: 'tk-all', title: 'Every colour token', meta: `${themeTokens.flatten(found.docs[0]).filter((t) => t.type === 'color').length} of them`, body: tokenTable(found) })}--${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/studio?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;++${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/themes?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;}const body = `<header class="ds-page-h"><p class="ds-kicker">OpenVibe Tokens</p><h1>Your theme, as code</h1><p class="ds-lede">Take any OpenVibe theme into your own app, its light and dark halves together: the web, Android, iOS, Flutter or Figma. The colours are the ones the network paints.</p></header>@@@ -1,6 +1,6 @@'use strict';/**-- * The pages and the plumbing: /, /studio, /t/:slug, /developers, the 404; public vs signed-in cache policy; the++ * The pages and the plumbing: /, /themes, /t/:slug, /developers, the 404; public vs signed-in cache policy; the* crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt); health and truthful readiness; the* release manifest and content-hashed static assets.*/@@@ -27,14 +27,14 @@ const { assetVersion } = require('../server/assets');assert.match(r.headers.get('vary'), /Cookie/i);});-- await check('/studio includes the studio script and stylesheet, and says who is signed in', async () => {-- const r = await t.get('/studio');++ await check('/themes includes the studio script and stylesheet, and says who is signed in', async () => {++ const r = await t.get('/themes');assert.strictEqual(r.status, 200);assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);assert.match(r.text, /Sign in to use a theme/);assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');-- const me = await t.get('/studio', { as: user });++ const me = await t.get('/themes', { as: user });assert.strictEqual(me.status, 200);assert.match(me.text, /kim/);assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'a signed-in page is never cached');@@@ -64,7 +64,9 @@ const { assetVersion } = require('../server/assets');assert.strictEqual(sitemap.status, 200);assert.match(sitemap.headers.get('content-type'), /application\/xml/);assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/<\/loc>/);-- assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/studio<\/loc>/);++ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/themes<\/loc>/);++ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/rules<\/loc>/);++ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/system<\/loc>/);assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/t\/aurora<\/loc>/);const llms = await t.get('/llms.txt');assert.strictEqual(llms.status, 200);nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++// The decision register (/rules), the system map (/system), the theme editor's move to /themes, and the bar.++const assert = require('node:assert/strict');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const { AREAS, RULES } = require('../server/rules/register');++const { patterns } = require('../server/patterns/catalog');++const { NAV } = require('../server/render/layout');++++(async () => {++ const t = await boot();++ try {++ await check('every rule is whole: a unique id, a known area, a real date, a sentence and a reason', async () => {++ const ids = new Set();++ const areas = new Set(AREAS.map((a) => a.id));++ const today = new Date().toISOString().slice(0, 10);++ for (const r of RULES) {++ assert.ok(/^[a-z0-9-]+$/.test(r.id), `${r.id} id`);++ assert.ok(!ids.has(r.id), `${r.id} is unique`);++ ids.add(r.id);++ assert.ok(areas.has(r.area), `${r.id} area ${r.area}`);++ assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(r.decided) && r.decided <= today, `${r.id} decided ${r.decided}`);++ assert.ok(/[.!?]$/.test(r.rule) && r.rule.length <= 120, `${r.id}: one sentence`);++ assert.ok(r.detail && r.why, `${r.id} detail and why`);++ }++ for (const a of AREAS) assert.ok(RULES.some((r) => r.area === a.id), `${a.id} has rules`);++ });++++ await check('what a rule points at exists: patterns by slug, pages by route, tests by file', async () => {++ const slugs = new Set(patterns.map((p) => p.slug));++ for (const r of RULES) {++ for (const s of r.see) {++ const m = s.href.match(/^\/patterns\/([a-z0-9-]+)$/);++ if (m) assert.ok(slugs.has(m[1]), `${r.id} → pattern ${m[1]}`);++ else if (s.href.startsWith('/')) assert.equal((await t.get(s.href)).status, 200, `${r.id} → ${s.href}`);++ }++ for (const c of r.checked) {++ const file = c.match(/^test\/[\w.-]+\.js$/);++ if (file) assert.ok(fs.existsSync(path.join(__dirname, '..', c)), `${r.id} checked by ${c}`);++ }++ }++ });++++ await check('/rules shows every rule under its area, with its date and an anchor', async () => {++ const res = await t.get('/rules');++ assert.equal(res.status, 200);++ for (const r of RULES) assert.ok(res.text.includes(`id="${r.id}"`), r.id);++ for (const a of AREAS) assert.ok(res.text.includes(`id="area-${a.id}"`), a.id);++ assert.ok(res.text.includes('How OpenVibe decides.'));++ assert.ok(res.text.includes(`${RULES.length}`), 'the count');++ assert.ok(!/spin/i.test(res.text.replace(/no-spin|spinning|spinner/gi, '')), 'no spinner on the rules page');++ });++++ await check('the register as Markdown and as JSON', async () => {++ const md = await t.get('/rules.md');++ assert.equal(md.status, 200);++ assert.match(md.headers.get('content-type'), /markdown/);++ for (const r of RULES) assert.ok(md.text.includes(r.rule), r.id);++ const js = await t.get('/api/v1/rules');++ assert.equal(js.status, 200);++ const body = js.json();++ assert.equal(body.count, RULES.length);++ assert.equal(body.rules.length, RULES.length);++ assert.ok(body.rules.every((r) => r.url.endsWith(`/rules#${r.id}`)));++ assert.equal(js.headers.get('access-control-allow-origin'), '*');++ });++++ await check('/system maps every reference page, and a count is shown only where it is counted', async () => {++ const res = await t.get('/system');++ assert.equal(res.status, 200);++ for (const href of ['/themes', '/tokens', '/fonts', '/icons', '/brand', '/motion', '/components', '/kit', '/forms', '/loading', '/progress', '/uploader', '/rules', '/patterns', '/prompts', '/native', '/developers']) {++ assert.ok(res.text.includes(`href="${href}"`), href);++ }++ assert.ok(res.text.includes(`${RULES.length} rules`), 'the rules count');++ assert.ok(res.text.includes(`${patterns.length} patterns`), 'the patterns count');++ assert.ok(!/<em>0 /.test(res.text), 'no zero counts');++ });++++ await check('the theme editor is /themes, and /studio answers 301 with its query', async () => {++ assert.equal((await t.get('/themes')).status, 200);++ const old = await t.get('/studio?theme=forest');++ assert.equal(old.status, 301);++ assert.equal(old.headers.get('location'), '/themes?theme=forest');++ assert.equal((await t.get('/studio')).headers.get('location'), '/themes');++ });++++ await check('the pages that moved point at /themes, and the rules page brings its own files', async () => {++ for (const asset of ['/js/rules.js', '/css/rules.css', '/js/look.js', '/js/studio.js']) assert.equal((await t.get(asset)).status, 200, asset);++ assert.ok((await t.get('/look')).text.includes('href="/themes"'), 'Your look sends people to /themes');++ assert.ok(!(await t.get('/components/registry')).text.includes('href="/studio'), 'the registry has no /studio link');++ assert.ok((await t.get('/patterns/tabs-not-folds')).text.includes('never split content'), 'tabs are for settings, never content');++ });++++ await check('the bar holds six places, the rest under More, and no two share an icon', async () => {++ const bar = NAV.filter((l) => !l.more);++ assert.equal(bar.length, 6, bar.map((l) => l.label).join(', '));++ assert.deepEqual(bar.map((l) => l.href), ['/', '/create', '/themes', '/look', '/arena', '/system']);++ const icons = NAV.map((l) => l.icon);++ assert.equal(new Set(icons).size, icons.length, 'one icon per place');++ assert.ok(NAV.every((l) => l.label.length <= 22), 'labels stay short');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((error) => { console.error(error); process.exitCode = 1; });@@@ -27,7 +27,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');'javascript:alert(1)', null, '', 12]) {assert.strictEqual(sanitizeNext(v, config), '/', JSON.stringify(v) + ' goes home');}-- assert.strictEqual(sanitizeNext('/studio?tab=contrast', config), '/studio?tab=contrast');++ assert.strictEqual(sanitizeNext('/themes?tab=contrast', config), '/themes?tab=contrast');// The SDK keeps a same-site address whole (it is this site's own origin, never another one).assert.strictEqual(sanitizeNext('https://openvibe.design/a/b?x=1#c', config), 'https://openvibe.design/a/b?x=1#c');for (const v of ['https://evil.com/a', 'https://openvibe.design.evil.com/a', 'https://[email protected]/', 'http://openvibe.design/a']) {@@@ -38,7 +38,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');const user = t.network.addUser('kim');await check('login redirects to Network with state, client id design and an S256 challenge', async () => {-- const r = await t.get('/auth/login?next=/studio');++ const r = await t.get('/auth/login?next=/themes');assert.strictEqual(r.status, 302);const loc = new URL(r.headers.get('location'));assert.strictEqual(loc.origin + loc.pathname, t.network.url + '/oauth/authorize');@@@ -53,7 +53,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');assert.ok(state, 'the flow cookie is set');assert.match(cookies(r).find((c) => c.includes('ov_oauth_state=')), /Path=\/auth.*HttpOnly/i);assert.ok(cookieNamed(r, 'ov_oauth_verifier'), 'the verifier is kept in its own cookie');-- assert.strictEqual(decodeURIComponent(cookieNamed(r, 'ov_oauth_next').split('=')[1]), '/studio');++ assert.strictEqual(decodeURIComponent(cookieNamed(r, 'ov_oauth_next').split('=')[1]), '/themes');});await check('silent sign-in adds prompt=none and next= only keeps this site', async () => {@@@ -66,13 +66,13 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');});await check('the callback exchanges the code and sets httpOnly session cookies', async () => {-- const login = await t.get('/auth/login?next=/studio');++ const login = await t.get('/auth/login?next=/themes');const loc = new URL(login.headers.get('location'));const flowCookie = cookieJar(login);const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));const r = await t.get('/auth/callback?code=' + code + '&state=' + loc.searchParams.get('state'), { cookie: flowCookie });assert.strictEqual(r.status, 302, r.text);-- assert.strictEqual(r.headers.get('location'), '/studio');++ assert.strictEqual(r.headers.get('location'), '/themes');const all = cookies(r).join('\n');assert.match(all, /ov_token=[^;]+;.*HttpOnly/i);assert.match(all, /ov_refresh=[^;]+;.*HttpOnly/i);@@@ -146,9 +146,9 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');});await check('sign-out clears the cookies and returns to next', async () => {-- const r = await t.get('/auth/logout?next=%2Fstudio', { headers: { 'sec-fetch-site': 'same-origin' } });++ const r = await t.get('/auth/logout?next=%2Fthemes', { headers: { 'sec-fetch-site': 'same-origin' } });assert.strictEqual(r.status, 303);-- assert.strictEqual(r.headers.get('location'), '/studio');++ assert.strictEqual(r.headers.get('location'), '/themes');assert.match(cookies(r).join('\n'), /design_at=;/);});@@@ -79,17 +79,17 @@ const { boot, check, done } = require('./helpers/boot');});await check('the studio and a theme page link to the theme as code and to trying it on the real sites', async () => {-- assert.match((await t.get('/studio')).text, /id="ds-code" href="\/tokens"/);++ assert.match((await t.get('/themes')).text, /id="ds-code" href="\/tokens"/);assert.match((await t.get('/t/vibe')).text, /href="\/tokens\?theme=vibe"/);assert.match((await t.get('/developers')).text, /GET \/api\/v1\/tokens\/:theme\?format=/);// Try it on the real sites (openvibe-shared 4.41.0 ?ov-try=): the theme page links each site with the theme's name.const page = (await t.get('/t/vibe')).text;assert.match(page, /href="https:\/\/openvibe\.live\/\?ov-try=vibe"/);assert.match(page, /href="https:\/\/openvibe\.chat\/\?ov-try=vibe"/);-- const studioPage = (await t.get('/studio')).text;++ const studioPage = (await t.get('/themes')).text;assert.match(studioPage, /id="ds-code" href="\/tokens" data-tokens-src="\/shared\/tokens\.js\?v=[0-9a-f]+"/, 'the studio can compile edits in the page');assert.match(studioPage, /<dialog class="ovk-sheet" id="ds-code-sheet"[\s\S]*<option value="compose">Android \(Jetpack Compose\)<\/option>/);-- assert.match((await t.get('/studio')).text, /<div id="ds-try" hidden>[\s\S]*data-try="https:\/\/openvibe\.live\/"/, 'the studio points them at the picked theme');++ assert.match((await t.get('/themes')).text, /<div id="ds-try" hidden>[\s\S]*data-try="https:\/\/openvibe\.live\/"/, 'the studio points them at the picked theme');});await check('/look: the theme worn and the comfort settings, wired to the theme loader', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+/* /rules: the decision register. The house style of Design's reference pages (motion, brand): Nunito headings,
+ two-column section heads, the theme's own tokens. */
+.rl{--rl-face:'OV Brand',Nunito,Inter,system-ui,sans-serif;display:grid;max-width:1120px;margin:0 auto;padding:0 clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
+.rl>*{min-width:0}
+.rl a{color:inherit}
+
+.rl-hero{display:grid;gap:14px;padding:clamp(18px,4vw,48px) 0 clamp(18px,3vw,28px);position:relative}
+.rl-hero::before{content:"";position:absolute;inset:-30px 0 auto;height:260px;z-index:-1;pointer-events:none;
+ background:radial-gradient(50% 90% at 12% 0,color-mix(in srgb,var(--accent) 18%,transparent),transparent 70%),radial-gradient(40% 80% at 88% 10%,color-mix(in srgb,#8b5cf6 14%,transparent),transparent 70%)}
+.rl-eye{margin:0;font-size:.78rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}
+.rl-h1{margin:0;font-family:var(--rl-face);font-weight:900;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.02em;text-wrap:balance}
+.rl-lede{margin:0;max-width:64ch;color:var(--text-secondary);font-size:clamp(1rem,1.7vw,1.12rem);line-height:1.55}
+.rl-lede a{color:var(--text-primary);text-underline-offset:3px}
+.rl-hero .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));max-width:620px;overflow:visible;margin-inline:0;padding-inline:0}
+
+.rl-find{display:flex;align-items:center;gap:10px;max-width:620px;min-height:48px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card)}
+.rl-find:focus-within{outline:2px solid var(--accent);outline-offset:1px}
+.rl-find input{flex:1;min-width:0;border:0;background:transparent;color:var(--text-primary);font:inherit;font-size:1rem;outline:0}
+.rl-find output{flex:none;color:var(--text-muted);font-size:.85rem;font-variant-numeric:tabular-nums}
+.rl-find svg{flex:none;width:18px;height:18px;color:var(--text-muted)}
+
+.rl-areas{position:sticky;top:calc(var(--ov-nav-h,56px) + 6px);z-index:2;margin:4px 0 8px;padding:8px 0;background:linear-gradient(var(--bg-primary,#0b1220) 70%,transparent)}
+.rl-areas ul{list-style:none;margin:0;padding:0;display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}
+.rl-areas ul::-webkit-scrollbar{display:none}
+.rl-areas a{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card);text-decoration:none;white-space:nowrap;font-size:.9rem;scroll-snap-align:start}
+.rl-areas a:hover{border-color:var(--accent);color:var(--accent)}
+.rl-areas span,.rl-n{display:inline-grid;place-items:center;min-width:1.6em;height:1.6em;padding:0 .35em;border-radius:999px;background:color-mix(in srgb,var(--text-primary) 8%,transparent);color:var(--text-secondary);font-size:.75rem;font-weight:700;font-variant-numeric:tabular-nums}
+
+.rl-sec{padding:clamp(24px,4vw,40px) 0;border-top:1px solid var(--border);scroll-margin-top:120px}
+.rl-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 32px;align-items:baseline;margin-bottom:18px}
+.rl-sec-head h2{display:flex;align-items:center;gap:10px;margin:0;font-family:var(--rl-face);font-weight:900;font-size:clamp(1.35rem,3vw,1.85rem);line-height:1.15}
+.rl-sec-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.5}
+
+.rl-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:12px}
+.rl-rule{display:grid;align-content:start;gap:10px;padding:18px;border:1px solid var(--border);border-radius:18px;background:var(--bg-card);scroll-margin-top:120px;min-width:0}
+.rl-rule:target{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}
+.rl-rule-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
+.rl-rule h3{margin:0;font-size:1.06rem;line-height:1.35;font-weight:750}
+.rl-rule h3 a{text-decoration:none}
+.rl-rule h3 a:hover{text-decoration:underline;text-underline-offset:3px}
+.rl-rule time{flex:none;color:var(--text-muted);font-size:.8rem;font-variant-numeric:tabular-nums;white-space:nowrap}
+.rl-detail{margin:0;color:var(--text-secondary);line-height:1.55}
+.rl-why{margin:0;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--text-primary) 4%,transparent);color:var(--text-secondary);font-size:.9rem;line-height:1.45}
+.rl-why span{margin-right:6px;font-size:.72rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
+.rl-links{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0}
+.rl-label{margin-right:2px;color:var(--text-muted);font-size:.72rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
+.rl-label:not(:first-child){margin-left:8px}
+.rl-chip{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--border);border-radius:999px;font-size:.84rem;text-decoration:none;color:var(--text-primary)}
+a.rl-chip:hover{border-color:var(--accent);color:var(--accent)}
+.rl-chip--check{color:var(--text-secondary);background:color-mix(in srgb,var(--success,#22c55e) 8%,transparent);border-color:color-mix(in srgb,var(--success,#22c55e) 30%,var(--border))}
+.rl-chip svg{width:14px;height:14px}
+
+.rl-recent-list{list-style:none;margin:0;padding:0;display:grid;gap:2px;counter-reset:none}
+.rl-recent-list a{display:grid;grid-template-columns:7.5rem minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 4px;border-bottom:1px solid var(--border);text-decoration:none}
+.rl-recent-list a:hover b{color:var(--accent)}
+.rl-recent-list time{color:var(--text-muted);font-size:.85rem;font-variant-numeric:tabular-nums}
+.rl-recent-list b{font-weight:650}
+.rl-data .rl-links{gap:10px}
+
+.rl-sec[data-rl-empty]{display:none}
+
+@media (max-width:720px){
+ .rl-sec-head{grid-template-columns:minmax(0,1fr)}
+ .rl-recent-list a{grid-template-columns:minmax(0,1fr);gap:2px}
+ .rl-rule-head{flex-direction:column;gap:4px}
+}
+
+/* /system: the map. The cards are the home's (.ds-sys); a group's columns divide its cards. */
+.sy{display:grid;gap:6px;max-width:1120px;margin:0 auto;padding:0 clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
+.sy>*{min-width:0}
+.sy-hero{display:grid;gap:12px;padding:clamp(18px,4vw,44px) 0 clamp(10px,2vw,18px)}
+.sy-eye{margin:0;font-size:.78rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}
+.sy-h1{margin:0;font-family:'OV Brand',Nunito,Inter,system-ui,sans-serif;font-weight:900;font-size:clamp(2rem,5.5vw,3.3rem);line-height:1.04;letter-spacing:-.02em;text-wrap:balance}
+.sy-lede{margin:0;max-width:64ch;color:var(--text-secondary);font-size:clamp(1rem,1.7vw,1.1rem);line-height:1.55}
+.sy-group{padding:clamp(20px,3.5vw,32px) 0;border-top:1px solid var(--border);scroll-margin-top:90px}
+.sy-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 32px;align-items:baseline;margin-bottom:16px}
+.sy-head h2{margin:0;font-family:'OV Brand',Nunito,Inter,system-ui,sans-serif;font-weight:900;font-size:clamp(1.3rem,3vw,1.7rem)}
+.sy-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.5}
+.sy-grid.ds-system-grid{grid-template-columns:repeat(var(--sy-cols,3),minmax(0,1fr))}
+@media (max-width:860px){.sy-grid.ds-system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media (max-width:560px){.sy-grid.ds-system-grid{grid-template-columns:minmax(0,1fr)}.sy-head{grid-template-columns:minmax(0,1fr)}}
+
+/* A rule is a sentence to read, not an eyebrow: the room's h3 style (capitals, accent) does not apply here. */
+.rl .rl-rule h3{text-transform:none;letter-spacing:0;color:var(--text-primary);font-size:1.06rem;font-weight:750;line-height:1.35}
+.rl .rl-rule h3 a{color:inherit}
+.rl .rl-recent-list b{color:var(--text-primary)}
+.rl .rl-recent-list a:hover b{color:var(--accent)}
+.rl-lede a{color:var(--text-primary)}
+.rl-hero>*{min-width:0}
+@media (max-width:560px){.rl-hero .ovk-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.rl-hero .ovk-stats>div:last-child:nth-child(odd){grid-column:1/-1}}
@@ -40,7 +40,7 @@
@@ -40,7 +40,7 @@
dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));
dots.replaceChildren(...t.dots.map((c) => { const i = document.createElement('i'); i.className = 'tk-dot'; i.style.background = c; return i; }));
}
}
const studio = $('#lk-studio');
const studio = $('#lk-studio');
- if (studio) studio.href = `/studio?theme=${encodeURIComponent(id)}`;
+ if (studio) studio.href = `/themes?theme=${encodeURIComponent(id)}`;
const code = $('#lk-code');
const code = $('#lk-code');
if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+// /rules: the filter. Without it every rule shows; with it, typing narrows the register and says how many match.
+(function () {
+ 'use strict';
+ var form = document.querySelector('[data-rl-find]');
+ if (!form) return;
+ var input = form.querySelector('input');
+ var count = form.querySelector('[data-rl-count]');
+ var rules = Array.prototype.slice.call(document.querySelectorAll('.rl-rule[data-rl-text]'));
+ var areas = Array.prototype.slice.call(document.querySelectorAll('[data-rl-area]'));
+ form.hidden = false;
+ form.addEventListener('submit', function (e) { e.preventDefault(); });
+ function apply() {
+ var words = String(input.value || '').toLowerCase().split(/\s+/).filter(Boolean);
+ var shown = 0;
+ rules.forEach(function (r) {
+ var text = r.getAttribute('data-rl-text');
+ var match = words.every(function (w) { return text.indexOf(w) !== -1; });
+ r.hidden = !match;
+ if (match) shown += 1;
+ });
+ areas.forEach(function (a) {
+ var any = a.querySelector('.rl-rule:not([hidden])');
+ if (any) a.removeAttribute('data-rl-empty'); else a.setAttribute('data-rl-empty', '');
+ });
+ count.textContent = words.length ? shown + ' of ' + rules.length : '';
+ }
+ input.addEventListener('input', apply);
+})();
@@ -128,7 +128,7 @@
@@ -128,7 +128,7 @@
// Try it on the real sites: the picked catalog theme (edits are tried here, in the previews).
// Try it on the real sites: the picked catalog theme (edits are tried here, in the previews).
const tryBox = $('#ds-try');
const tryBox = $('#ds-try');
if (tryBox) { tryBox.hidden = false; $$('#ds-try a[data-try]').forEach((a) => { a.href = `${a.getAttribute('data-try')}?ov-try=${encodeURIComponent(id)}`; }); }
if (tryBox) { tryBox.hidden = false; $$('#ds-try a[data-try]').forEach((a) => { a.href = `${a.getAttribute('data-try')}?ov-try=${encodeURIComponent(id)}`; }); }
- if (!quiet) { try { history.replaceState(null, '', `/studio?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }
+ if (!quiet) { try { history.replaceState(null, '', `/themes?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }
say('');
say('');
}
}
root.addEventListener('click', (e) => {
root.addEventListener('click', (e) => {
@@ -12,7 +12,7 @@ const express = require('express');
@@ -12,7 +12,7 @@ const express = require('express');
const agentLayer = require('openvibe-shared/agent-layer');
const agentLayer = require('openvibe-shared/agent-layer');
// /developers (the theme API) is on the same page router: an agent's most useful page here (11-03).
// /developers (the theme API) is on the same page router: an agent's most useful page here (11-03).
-const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|motion|studio|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;
+const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|motion|rules|system|themes|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;
/** A response the page code can write to; the first body written is the page's HTML. */
/** A response the page code can write to; the first body written is the page's HTML. */
function mockRes() {
function mockRes() {
@@ -289,6 +289,8 @@ async function createApp(opts = {}) {
@@ -289,6 +289,8 @@ async function createApp(opts = {}) {
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
+ // The decision register (openvibe.design/rules) as data: open to any origin, for code and AI assistants.
+ app.get('/api/v1/rules', (req, res) => { res.set({ 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); res.json(require('./views/rules').rulesJson()); });
app.use(createIconRoutes(ctx));
app.use(createIconRoutes(ctx));
// The public page routers are built before they are mounted: the Markdown twins render through the same routers
// The public page routers are built before they are mounted: the Markdown twins render through the same routers
// (server/agent-twins.js), so a twin cannot drift from the page a browser gets.
// (server/agent-twins.js), so a twin cannot drift from the page a browser gets.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* Crawl artifacts for openvibe.design, built with openvibe-shared/seo: robots.txt, sitemap.xml and llms.txt.
* Crawl artifacts for openvibe.design, built with openvibe-shared/seo: robots.txt, sitemap.xml and llms.txt.
* The public pages are for search engines and AI crawlers; the studio's writes, the API and sign-in are not.
* The public pages are for search engines and AI crawlers; the studio's writes, the API and sign-in are not.
*
*
- * The sitemap is /, /studio and one page per public theme in the catalogue. A Network failure leaves the static
+ * The sitemap is /, /themes and one page per public theme in the catalogue. A Network failure leaves the static
* pages in place rather than failing the crawl artifact.
* pages in place rather than failing the crawl artifact.
*/
*/
const fs = require('fs');
const fs = require('fs');
@@ -30,7 +30,9 @@ function siteUpdated() {
@@ -30,7 +30,9 @@ function siteUpdated() {
function publicPages(themes = []) {
function publicPages(themes = []) {
const static_ = [
const static_ = [
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
- { path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
+ { path: '/themes', changefreq: 'weekly', priority: 0.9, title: 'Themes', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
+ { path: '/system', changefreq: 'weekly', priority: 0.8, title: 'The design system', text: 'Everything OpenVibe is made of in one map: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; apps and developer tools.' },
+ { path: '/rules', changefreq: 'weekly', priority: 0.8, title: 'How OpenVibe decides', text: 'Every style, design and organization rule behind OpenVibe, with the day it was decided, why, and what carries it out. Also as data at /api/v1/rules.' },
{ 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 component registry as one plain list: every Shared kit component as a design.component@1 entry with its props, states, example markup and Android and iOS notes.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The component registry as one plain list: every Shared kit component as a design.component@1 entry with its props, states, example markup and Android and iOS notes.' },
@@ -100,7 +102,9 @@ function createDiscoveryRoutes(ctx) {
@@ -100,7 +102,9 @@ function createDiscoveryRoutes(ctx) {
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
- { title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
+ { title: 'Themes', url: abs('/themes'), note: 'try, edit, use, submit and roll back a theme' },
+ { title: 'Rules', url: abs('/rules'), note: 'every style, design and organization decision behind OpenVibe, with the day and the reason; read it before designing for OpenVibe. JSON: GET /api/v1/rules' },
+ { title: 'The design system', url: abs('/system'), note: 'every reference page in four groups: foundations, components, guidance, platforms' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
@@ -3,7 +3,9 @@
@@ -3,7 +3,9 @@
/**
/**
* The pages:
* The pages:
* / the theme catalogue's front door
* / the theme catalogue's front door
- * /studio the theme studio
+ * /themes the theme editor and gallery (/studio answers 301)
+ * /rules the decision register (its Markdown twin /rules.md; data at /api/v1/rules)
+ * /system the design system's map
* /t/:slug one public theme
* /t/:slug one public theme
* /arena the Arena leaderboard and its front door
* /arena the Arena leaderboard and its front door
* /arena/new start a round (the form also posts here, so it works without JavaScript)
* /arena/new start a round (the form also posts here, so it works without JavaScript)
@@ -214,10 +216,25 @@ function createPageRoutes(ctx) {
@@ -214,10 +216,25 @@ function createPageRoutes(ctx) {
return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);
return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);
});
});
- r.get('/studio', async (req, res) => {
+ r.get('/themes', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
return page(res, 200, views.studio({ themes: list ? list.themes : null, me: meOf(req.viewer) }), req.viewer);
return page(res, 200, views.studio({ themes: list ? list.themes : null, me: meOf(req.viewer) }), req.viewer);
});
});
+ // The theme editor was /studio until 2026-11-09; openvibe.studio is a separate product (photo, video, sound), so
+ // Design's page is named for what it holds.
+ r.get('/studio', (req, res) => {
+ const q = req.originalUrl.indexOf('?');
+ return res.redirect(301, `/themes${q === -1 ? '' : req.originalUrl.slice(q)}`);
+ });
+
+ // The decision register and the system map.
+ const rulesView = require('../views/rules');
+ r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));
+ r.get('/system', async (req, res) => {
+ const list = await safe(() => catalog.list());
+ const n = list && Array.isArray(list.themes) ? list.themes.length : null;
+ return page(res, 200, require('../views/system').systemPage({ themes: n }), req.viewer);
+ });
r.get('/t/:slug', async (req, res) => {
r.get('/t/:slug', async (req, res) => {
let up;
let up;
@@ -29,7 +29,7 @@ const patterns = [
@@ -29,7 +29,7 @@ const patterns = [
['status-badges','Elements','Status badges', 'Use a compact badge with an icon and a clear status word. Give the badge a semantic status color while preserving readable contrast.', '2026-10-30 · Color alone made live, complete, and failed states ambiguous.', 'Status always has a word as well as a color.', t('spaceMd','radiusPill','text')],
['status-badges','Elements','Status badges', 'Use a compact badge with an icon and a clear status word. Give the badge a semantic status color while preserving readable contrast.', '2026-10-30 · Color alone made live, complete, and failed states ambiguous.', 'Status always has a word as well as a color.', t('spaceMd','radiusPill','text')],
['metadata-chips','Elements','Metadata chips', 'Present secondary metadata as short, labeled chips rather than raw URLs or long unbroken strings. Let chips wrap without widening the page.', '2026-10-30 · Raw provider URLs widened the run row and competed with its title.', 'Keep secondary metadata compact and breakable.', t('spaceMd','radiusPill','text')],
['metadata-chips','Elements','Metadata chips', 'Present secondary metadata as short, labeled chips rather than raw URLs or long unbroken strings. Let chips wrap without widening the page.', '2026-10-30 · Raw provider URLs widened the run row and competed with its title.', 'Keep secondary metadata compact and breakable.', t('spaceMd','radiusPill','text')],
['empty-state','Elements','One-line empty states', 'Say what is empty in one line and offer one direct action. Keep the action visible beside the explanation when space allows.', '2026-10-30 · Empty panels became dead ends with a paragraph of explanation and no next step.', 'Explain the empty state and give one way forward.', t('tap','spaceMd','accent')],
['empty-state','Elements','One-line empty states', 'Say what is empty in one line and offer one direct action. Keep the action visible beside the explanation when space allows.', '2026-10-30 · Empty panels became dead ends with a paragraph of explanation and no next step.', 'Explain the empty state and give one way forward.', t('tap','spaceMd','accent')],
- ['tabs-not-folds','Elements','Tabs, not folds', 'Use tabs when people switch between peer views, and folds when they reveal details in the current view. Do not put tabs inside a fold for the same choice.', '2026-10-30 · Nested tabs and folds repeated the same navigation and hid the active view.', 'Choose one navigation model for a set of peer views.', t('tap','durationFast','easingStandard')],
+ ['tabs-not-folds','Elements','Tabs, not folds', 'Use tabs only for settings and explainers whose parts are peers, each tab with its own address; never split content (lists, rails, sections) into tabs, because content in a tab is content people never find: shorten it with sideways rails, More (N) and folds that show their count. Use folds for details in the current view, and do not put tabs inside a fold for the same choice.', '2026-10-30 · Nested tabs and folds repeated the same navigation and hid the active view.', 'Choose one navigation model for a set of peer views.', t('tap','durationFast','easingStandard')],
['touch-targets','Elements','44 px targets', 'Give interactive controls a minimum 44 by 44 px target, including icon controls. Keep visual spacing separate from the hit area.', '2026-10-30 · Dense icon rows were difficult to operate on a phone despite their visible glyphs.', 'Every interactive target is at least 44 px.', t('tap','spaceMd')],
['touch-targets','Elements','44 px targets', 'Give interactive controls a minimum 44 by 44 px target, including icon controls. Keep visual spacing separate from the hit area.', '2026-10-30 · Dense icon rows were difficult to operate on a phone despite their visible glyphs.', 'Every interactive target is at least 44 px.', t('tap','spaceMd')],
['motion','Motion','Motion tokens', 'Use the shared 180, 260, and 360 ms duration scale with standard easing for ordinary transitions. Honor reduced motion everywhere, and reserve back easing for playful feedback.', '2026-10-30 · Components used unrelated timings and kept moving after reduced motion was requested.', 'Share timings across platforms and respect the system setting.', t('durationFast','durationBase','durationSlow','durationReduced','easingStandard','easingBack')],
['motion','Motion','Motion tokens', 'Use the shared 180, 260, and 360 ms duration scale with standard easing for ordinary transitions. Honor reduced motion everywhere, and reserve back easing for playful feedback.', '2026-10-30 · Components used unrelated timings and kept moving after reduced motion was requested.', 'Share timings across platforms and respect the system setting.', t('durationFast','durationBase','durationSlow','durationReduced','easingStandard','easingBack')],
['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],
['site-switcher','Navigation','Site switcher', 'Open the shared site switcher from the single site navbar. Keep its focus, placement, and close behavior consistent across every page.', '2026-10-30 · A local site picker fork drifted from the shared switcher and behaved differently across services.', 'Use openvibe-shared/site-switcher.js and the shared navbar trigger.', t('tap','durationBase','easingStandard')],
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
*
*
* - <head>: the shared app icon, the room's stylesheets, this site's content-hashed design.css
* - <head>: the shared app icon, the room's stylesheets, this site's content-hashed design.css
* - the navbar's session probe (/auth/me), sign-in and sign-out URLs and silent sign-in, as every site's
* - the navbar's session probe (/auth/me), sign-in and sign-out URLs and silent sign-in, as every site's
- * - the product's own script only where a page offers an in-browser convenience (/studio)
+ * - the product's own script only where a page offers an in-browser convenience (/themes)
*/
*/
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -22,26 +22,29 @@ const ROOM = rooms.get('design');
@@ -22,26 +22,29 @@ const ROOM = rooms.get('design');
const esc = kit.esc;
const esc = kit.esc;
const NAV = [
const NAV = [
+ // Six places in the bar (the six-places rule); everything else under More, in the System page's order.
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
- { label: 'Create', href: '/create', icon: 'fa-palette' },
- { label: 'Studio', href: '/studio', icon: 'fa-palette' },
+ { label: 'Create', href: '/create', icon: 'fa-wand-magic-sparkles' },
+ { label: 'Themes', href: '/themes', icon: 'fa-palette' },
{ label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' },
{ label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
- { label: 'Tokens', href: '/tokens', icon: 'fa-code' },
+ { label: 'System', href: '/system', icon: 'fa-layer-group' },
+ { label: 'Tokens', href: '/tokens', icon: 'fa-code', more: true },
+ { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
+ { label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
- { label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },
+ { label: 'Motion', href: '/motion', icon: 'fa-wind', more: true },
+ { label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
+ { label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
+ { label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
- { label: 'Progress', href: '/progress', icon: 'fa-bars-progress', more: true },
+ { label: 'Progress', href: '/progress', icon: 'fa-gauge-high', more: true },
{ label: 'Uploader', href: '/uploader', icon: 'fa-upload', more: true },
{ label: 'Uploader', href: '/uploader', icon: 'fa-upload', more: true },
- { label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
- { label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
- { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
- { label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
- { label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
- { label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
- { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-display', more: true },
- { label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
+ { label: 'Rules', href: '/rules', icon: 'fa-scale-balanced', more: true },
+ { label: 'Patterns', href: '/patterns', icon: 'fa-table-cells', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
+ { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-mobile-screen', more: true },
+ { label: 'Developers', href: '/developers', icon: 'fa-terminal', more: true },
];
];
let RELEASE = 'dev';
let RELEASE = 'dev';
@@ -59,7 +62,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -59,7 +62,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
service: 'design',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Create', href: '/create', icon: 'fa-wand-magic-sparkles' }, { label: 'Themes', href: '/themes', icon: 'fa-palette' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }, { label: 'System', href: '/system', icon: 'fa-layer-group' }],
history: { type: 'page', title: title || NAME },
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -0,0 +1,254 @@
+'use strict';
+/**
+ * The decision register: every style, design and organization rule OpenVibe follows, in one place, with the day it
+ * was decided, why, where it applies, what carries it out (a pattern, a kit part, a library) and what checks it.
+ * openvibe.design/rules renders it; /api/v1/rules and /rules.md serve it as data.
+ *
+ * A rule is one sentence a person can act on. `why` says what went wrong, or what the rule protects, in a line.
+ * `see` links to the pattern or part that implements it; `checked` names the test or tool that catches a breach.
+ * Add a rule when a decision is made; change `decided` when it is revised, and say what changed in `why`.
+ */
+
+const AREAS = Object.freeze([
+ { id: 'honest', title: 'Honest pages', lede: 'What a page shows is true, counted and sourced.' },
+ { id: 'length', title: 'Length without hiding', lede: 'Pages get shorter by arranging, never by removing.' },
+ { id: 'motion', title: 'Loading and motion', lede: 'Movement means something is happening.' },
+ { id: 'navigation', title: 'Navigation and the Frame', lede: 'One bar, one menu, the same on every site.' },
+ { id: 'phone', title: 'Phones first', lede: 'Designed on a phone, checked at 320 px.' },
+ { id: 'words', title: 'Words', lede: 'Plain words, true claims, names that mean something.' },
+ { id: 'look', title: 'Look and marks', lede: 'One look, many themes, real marks.' },
+ { id: 'data', title: 'Data, AI and consent', lede: 'Where numbers come from, and who agreed.' },
+ { id: 'agents', title: 'Agents', lede: 'How an agent works for a person without wasting their attention.' },
+ { id: 'products', title: 'Products and their jobs', lede: 'One clear job per product and domain.' },
+ { id: 'building', title: 'Building it', lede: 'The engineering choices that keep the design whole.' },
+]);
+
+const R = (id, area, rule, detail, decided, why, see = [], checked = []) => ({ id, area, rule, detail, decided, why, see, checked });
+
+const RULES = Object.freeze([
+ // ── Honest pages ──
+ R('no-fake-content', 'honest', 'Never show content that is not real.',
+ 'No sample rows, invented counts, filler testimonials or "coming soon" sections dressed as content. An empty place says it is empty and offers one way forward.',
+ '2026-10-31', 'Placeholder sections and invented numbers made pages look full and told people nothing.',
+ [{ label: 'One-line empty states', href: '/patterns/empty-state' }]),
+ R('count-what-it-says', 'honest', 'A number counts exactly what its label says.',
+ 'A count next to a list equals the length of that list. "4 agents working now" must count agents at work, not the agent catalogue.',
+ '2026-11-01', 'A home page counted its catalogue as "working now".',
+ [], ['site sweep: count checks']),
+ R('missing-is-not-zero', 'honest', 'A missing number is left out or said in words, never shown or sorted as zero.',
+ 'When a source did not report a value, the page says "No traffic yet" or "Not measured", and sorting and picks skip it. In code, null must not become 0.',
+ '2026-11-09', 'A model with no measured speed was named "Fastest" because null had become 0 ms.',
+ [{ label: 'Open stats', href: 'https://openvibe.network/stats' }], ['Report test: picks use measured values']),
+ R('number-has-source', 'honest', 'Every number names its source, its date and how much it rests on.',
+ 'A rating says how many games; a price says whose page and when it was checked; a speed says it is from real traffic over seven days. One tap shows it.',
+ '2026-11-09', 'Comparison pages showed numbers nobody could check.'),
+ R('not-a-rating-yet', 'honest', 'A starting value is not a result.',
+ 'A model with no votes shows "Not rated yet", not the 1000 every entrant starts at. The same goes for any score with no samples behind it.',
+ '2026-11-09', 'The leaderboard rated models at 1000 on boards that said "No votes yet".'),
+ R('columns-earn-their-place', 'honest', 'A column or section appears when something fills it.',
+ 'A table shows a column only when some row has a value for it; a section with nothing in it is not drawn. When everything is missing, one plain line says why and what would fill it.',
+ '2026-11-09', 'Twelve columns of "Not measured yet" buried the three numbers that were real.'),
+ R('state-in-words', 'honest', 'Status is a word, never colour alone.',
+ 'A dot or colour helps the eye; the word carries the meaning for everyone.',
+ '2026-10-30', 'Colour alone made live, done and failed look alike.',
+ [{ label: 'Status badges', href: '/patterns/status-badges' }, { label: 'Status is a dot and a word', href: '/patterns/status-dot' }]),
+ R('bands-not-false-ranks', 'honest', 'When the data cannot separate two things, they share a rank.',
+ 'Ranks come with their uncertainty: overlapping intervals share a band ("1–3"), and a tie is shown as a tie.',
+ '2026-11-09', 'A rank order implied differences the votes could not support.'),
+
+ // ── Length without hiding ──
+ R('never-hide-content', 'length', 'Shorten a page by arranging it, never by hiding or removing content.',
+ 'No display:none on content for phones, no "newest only" cuts, no content behind tabs. Use sideways rails, "More (N)" pagers and folds that show their count.',
+ '2026-10-31', 'Pages made shorter lost their videos and clips; every cut had to be put back.',
+ [{ label: 'One card that is also a row', href: '/patterns/card-or-row' }]),
+ R('tabs-for-settings', 'length', 'Tabs are for settings and explainers; content is never split into tabs.',
+ 'A settings page may use server-rendered tabs (each with its own address). Lists, rails and sections of content stay on the page.',
+ '2026-10-31', 'Content in tabs was content people never found.',
+ [{ label: 'Tabs, not folds', href: '/patterns/tabs-not-folds' }]),
+ R('taste-and-door', 'length', 'A front page is a taste and a door: about six phone screens.',
+ 'Two hero actions, figures as one strip, cards as sideways rows, explainers in one fold, and no closing call that repeats the hero.',
+ '2026-10-29', 'Homes ran to fifteen screens on a phone.'),
+ R('placeholders-are-not-sections', 'length', 'Do not keep a section whose only content is that it has none.',
+ '"Not published yet" four times is length, not information. Keep the sections that hold something; name what is pending in one line.',
+ '2026-11-09', 'A model page was mostly "not available yet".'),
+
+ // ── Loading and motion ──
+ R('no-spinners', 'motion', 'No spinning rings. Things move only while something is actually happening.',
+ 'Loading shows the shape of what is coming (a shimmer placeholder of the right size) or the mark at work. A finished or idle thing is still.',
+ '2026-10-31', 'Spinning rings around icons everywhere looked cheap and said nothing.',
+ [{ label: 'Loading', href: '/loading' }, { label: 'The mark at work', href: '/patterns/mark-system' }], ['test/no-spin.test.js']),
+ R('streams-hold-still', 'motion', 'A live list changes only where it changed.',
+ 'Patch rows in place, keep the reader’s place, follow the bottom only when they were there, and fade in only what is new.',
+ '2026-10-30', 'Rebuilding a stream on every event reset what people were reading.',
+ [{ label: 'Patch in place', href: '/patterns/patch-in-place' }, { label: 'Keep the anchor', href: '/patterns/keep-anchor' }]),
+ R('shared-timing', 'motion', 'Every site moves on the same timings and honours reduced motion.',
+ '180, 260 and 360 ms with the shared curves; the system setting for reduced motion turns entrances off.',
+ '2026-10-30', 'Components used unrelated durations and felt like different products.',
+ [{ label: 'Motion', href: '/motion' }]),
+
+ // ── Navigation and the Frame ──
+ R('one-menu-button', 'navigation', 'One menu button on every site: the orb.',
+ 'The nine-dot orb opens the OpenVibe menu; there is never a second three-line button that does the same thing.',
+ '2026-11-08', 'Two buttons opened the same menu, and the custom orb had been hidden.',
+ [{ label: 'One menu, opened from either end', href: '/patterns/one-menu' }]),
+ R('one-bar', 'navigation', 'One navbar per page, owned by the shared Frame.',
+ 'A site’s own section links go in the page body, never in a second header under the bar.',
+ '2026-10-30', 'Page headers under the bar doubled the navigation.',
+ [{ label: 'One navbar per page', href: '/patterns/single-navbar' }]),
+ R('six-places', 'navigation', 'Six places in a site’s bar; the rest under More.',
+ 'Each place has an icon and one or two words that never wrap; the brand is the way home.',
+ '2026-11-01', 'Bars grew until labels wrapped and buttons shrank.',
+ [{ label: 'Six places in the bar', href: '/patterns/six-places' }]),
+ R('one-account-ui', 'navigation', 'Signing in and out happens in the shared bar only.',
+ 'A site’s own account links go into the bar’s menu; its own account bar exists only for no-JavaScript visitors.',
+ '2026-09-24', 'Sites showed "Sign in" while the bar said you were signed in.'),
+ R('your-things-everywhere', 'navigation', 'Your messages, balances and status are in every bar.',
+ 'The same three things in the same places on every site, for anyone signed in.',
+ '2026-11-01', 'Each site showed a different subset, so nothing was where people looked.',
+ [{ label: 'Your things in every bar', href: '/patterns/your-things' }]),
+ R('ask-with-a-button', 'navigation', 'A page that needs an account says why in one sentence and offers one button.',
+ 'The button signs in and comes straight back to the same place. Never a bare link inside a sentence.',
+ '2026-11-01', 'Ten sites hid sign-in inside paragraphs.',
+ [{ label: 'Ask with a button', href: '/patterns/ask-with-a-button' }]),
+
+ // ── Phones first ──
+ R('works-at-320', 'phone', 'Every page fits a 320 px screen without sideways scrolling.',
+ 'Grids use minmax(min(N, 100%), 1fr); one-column grids use minmax(0, 1fr); long words may break.',
+ '2026-10-17', 'Six homes scrolled sideways on small Android phones.',
+ [], ['browser smoke at 320, 390, 768, 1280']),
+ R('targets-44', 'phone', 'Everything you can press is at least 44 px.',
+ 'Icon buttons included; spacing is separate from the hit area.',
+ '2026-10-30', 'Dense icon rows were hard to hit on a phone.',
+ [{ label: '44 px targets', href: '/patterns/touch-targets' }]),
+ R('keep-the-picture', 'phone', 'Controls that act on a picture keep the picture in view.',
+ 'Camera and drive pad on screen together; the picture sticks under the bar while the controls scroll.',
+ '2026-10-16', 'Driving a robot meant scrolling its camera away.'),
+ R('watch-owns-the-screen', 'phone', 'Watching or going live owns the phone’s screen.',
+ 'No tab bar and no floating buttons over the picture; its own controls take the edges.',
+ '2026-10-31', 'Bars and buttons covered the stream.',
+ [{ label: 'Watch and camera screens own the screen', href: '/patterns/media-owns-the-screen' }]),
+ R('tables-become-cards', 'phone', 'On a phone, a wide table becomes labelled cards.',
+ 'Each row is a card; its first cell is the title and every other value says what it is. Nothing is dropped.',
+ '2026-10-30', 'Phones cut columns or scrolled the page sideways.',
+ [{ label: 'Wide tables become labelled cards', href: '/patterns/labelled-cards' }]),
+
+ // ── Words ──
+ R('plain-words', 'words', 'Use plain words; keep machinery out of people’s screens.',
+ 'No runtime, harness, lease, placement or vendor tables on pages people use. Say what happens to them.',
+ '2026-11-09', 'A vendor table of runtimes confused the person it was for.'),
+ R('meaningful-names', 'words', 'Name things after what they are to OpenVibe, not borrowed jargon.',
+ 'The navbar and footer are the OpenVibe Frame, not "chrome". Renames go through every layer at once.',
+ '2026-09-24', 'A half-done rename looked like the request was ignored.'),
+ R('names-say-the-domain', 'words', 'A site is named the way its address reads.',
+ 'If the domain says VIP, the site says VIP.',
+ '2026-10-27', 'Names that differed from addresses made people unsure they were in the right place.'),
+ R('the-answer-is-the-answer', 'words', 'An AI answer shows only the answer.',
+ 'A model’s self-check, its plan and its reasoning stay in a Details sheet, folded.',
+ '2026-11-01', 'Answers arrived wrapped in the model’s notes to itself.'),
+ R('true-claims', 'words', 'Say "we never" only where it is true, and say who decides.',
+ 'Tell the truth about today, what we hope for later and who chooses. Friendly, and never a promise the code does not keep.',
+ '2026-10-31', 'Blanket "never" claims limited what people could choose to allow.'),
+ R('headline-stays-visible', 'words', 'What people come for stays in the first screen and the page’s title.',
+ 'Going live from a browser with no OBS is Live’s headline: never folded behind a toggle, never dropped from titles in a redesign.',
+ '2026-09-28', 'It is what people ask assistants for, and what they recommend us for.'),
+
+ // ── Look and marks ──
+ R('blue-by-default', 'look', 'The default look is blue; other themes are a choice.',
+ 'Vibe (deep blue) is everyone’s default; the violet original is its own theme, Violet Hour.',
+ '2026-09-19', 'The owner prefers blue as the platform default.',
+ [{ label: 'Themes', href: '/themes' }]),
+ R('seasons-are-optional', 'look', 'Seasonal looks dress the default and can be turned off.',
+ 'A season shows only for people on the default theme, as a touch on the mark, never a badge taking bar space.',
+ '2026-10-25', 'A pumpkin badge wasted bar space and the season’s palette did not read well.'),
+ R('the-pair', 'look', 'The mark is the Pair: a ring O and a round V, with names in Nunito.',
+ 'The gap where the V crosses the ring is a mask, so it holds on any ground; a heavier form serves 22 px and below.',
+ '2026-10-31', 'Chosen from the mark studies as the network’s logo.',
+ [{ label: 'The mark', href: '/brand' }]),
+ R('real-maker-marks', 'look', 'An AI maker is shown by its own mark, never initials in a circle.',
+ 'Unknown makers get the neutral spark; a mark is chosen by the maker’s id, so a name saying "OpenAI-compatible" never draws OpenAI’s.',
+ '2026-11-09', 'Home-drawn model glyphs read as "what is it even supposed to be?"'),
+ R('coloured-words-mix', 'look', 'A word in colour is the colour mixed with the text, about half and half.',
+ 'It reads on dark and light themes; fills and borders keep the pure hue.',
+ '2026-11-01', 'Pure hues failed contrast on one side or the other.',
+ [{ label: 'Coloured words mix with the text', href: '/patterns/mixed-words' }]),
+ R('native-ready', 'look', 'Design so real Android and iOS apps can follow, not web views in a frame.',
+ 'One token source compiled to CSS, Compose and SwiftUI; components specified by anatomy, states and roles, not HTML.',
+ '2026-10-29', 'A web-only kit would force a kiosk app.',
+ [{ label: 'Tokens', href: '/tokens' }, { label: 'Apps', href: '/native' }]),
+
+ // ── Data, AI and consent ──
+ R('own-page-prices', 'data', 'A price comes from the provider’s own page, with the day it was checked.',
+ 'Never an aggregator’s price; a price older than its check window says it may have changed.',
+ '2026-11-08', 'Second-hand prices drifted from what providers charged.'),
+ R('measured-on-real-traffic', 'data', 'Speed, errors and uptime are measured on real requests.',
+ 'Synthetic probes fill gaps only where traffic is thin, and say so.',
+ '2026-11-09', 'Real traffic is the number a person will actually get.'),
+ R('opt-in-consent', 'data', 'AI uses of a person’s data are opt-in, per person, off by default.',
+ 'Training on their content, a public dataset, recognising their face: each is a yes they gave and can take back.',
+ '2026-10-31', 'Blanket bans limited people; silent use would break their trust.'),
+ R('adult-floor', 'data', 'The uncensored tier is 18+ and keeps its floor.',
+ 'Lawful adult content is fine for adults; anything sexual involving a minor is always blocked.',
+ '2026-10-31', 'Adult freedom and child safety both hold, without exceptions.'),
+
+ // ── Agents ──
+ R('one-question', 'agents', 'An agent asks one concrete question at a time.',
+ 'In a structured form, where the person asked, and it keeps working on what it may do meanwhile.',
+ '2026-11-09', 'Questions buried in long reports never reached the person.'),
+ R('six-states', 'agents', 'Agent work reports one of six honest states.',
+ 'Working, waiting, ready, blocked, failed, dropped. Opening a pull request is not "ready"; a draft is "blocked".',
+ '2026-11-09', 'Work looked finished when it was not.'),
+ R('observe-before-acting', 'agents', 'Anything an agent does unasked starts by recording what it would do.',
+ 'The person reads those decisions, then turns acting on.',
+ '2026-11-09', 'Proactive work that acts at once spends other people’s attention.'),
+ R('quiet-by-default', 'agents', 'An agent speaks when a person must act or can use a result.',
+ 'One confirmation that names the task, silence while working, then the result, a decision or a failure.',
+ '2026-11-09', 'An agent for a team can waste the whole team’s attention.'),
+ R('levels-with-a-floor', 'agents', 'An agent’s freedom is four levels in plain words, above a floor that never moves.',
+ 'Asks first, edits on its own, a second model checks, full trust; the floor of what it never does is always in view.',
+ '2026-11-01', 'Permission screens in jargon were skipped, not understood.',
+ [{ label: 'Levels with a floor', href: '/patterns/levels-with-a-floor' }]),
+
+ // ── Products and their jobs ──
+ R('one-job-per-product', 'products', 'Each product does one job, and its domain says which.',
+ 'Report reads (rankings, numbers, evidence); Exchange does (try a model, get a key, route). Design makes interfaces, icons, fonts and themes; Studio edits photos, video and sound. Codes is coding with any agent; Services is the developer console.',
+ '2026-11-07', 'Merged products muddle both markets and both pages.'),
+ R('one-human-home', 'products', 'A product has one human-facing home.',
+ 'AI’s pages, console and per-person settings live on openvibe.exchange; ai.openvibe.services keeps only the API.',
+ '2026-11-08', 'A console on a machine host sent people to the wrong place.'),
+ R('build-the-stated-core', 'products', 'Build what was asked first; never ship an easier stand-in as the feature.',
+ 'Party is a shared desktop people drive together, not an embed sync; the stated core comes before extras.',
+ '2026-11-08', 'A safer variant shipped first and wasted the time the real one needed.'),
+ R('clean-cuts', 'products', 'Prefer clean cuts to compatibility layers.',
+ 'With few users, rename and move in one pass; keep a redirect for addresses, not code that carries both shapes.',
+ '2026-09-28', 'Aliases and remnants outlived their reasons.'),
+
+ // ── Building it ──
+ R('kit-first', 'building', 'Build pages from the shared kit, and keep its names.',
+ 'Buttons are ovk-btn ovk-btn--primary or --quiet; sections, tables, stats and folds come from the kit, so every site moves together.',
+ '2026-11-09', 'Hand-made variants (and misspelt class names) drifted from the system.',
+ [{ label: 'Kit', href: '/kit' }, { label: 'Components', href: '/components' }]),
+ R('reserved-prefixes', 'building', 'Never name a site class after a shared one.',
+ 'ovg belongs to the glyphs and ovf to the footer; a site’s own class takes its own prefix.',
+ '2026-11-09', 'A bare .ovg pinned every icon to a corner; a Feed took the footer’s margins.'),
+ R('own-tokens', 'building', 'A site styles with its own theme tokens, never invented names with fallbacks.',
+ 'A variable the site does not define falls back silently: cards turned white on the dark theme.',
+ '2026-11-09', 'A sheet written against names the site never defines drew white cards on dark.'),
+ R('first-paint-alone', 'building', 'First paint never depends on another service.',
+ 'The theme’s first frame is inline; shared scripts load deferred, so a Network deploy cannot blank another site.',
+ '2026-09-17', 'Pages opened white while the Network restarted.'),
+ R('content-hashed-assets', 'building', 'Assets are addressed by their content, so people always get the current one.',
+ 'Hashed file names, never a hand-written ?v=; a shared sibling file is busted by its own hash.',
+ '2026-11-07', 'Menus shipped but browsers kept the old files.'),
+ R('cascade-in-order', 'building', 'Check a style in the order the page loads it.',
+ 'A preview that appends a rule last proves nothing about a rule that loads first; measure on the real page.',
+ '2026-10-25', 'A phone rule looked right in a preview and lost to a later file in production.'),
+]);
+
+function byArea() {
+ return AREAS.map((a) => ({ ...a, rules: RULES.filter((r) => r.area === a.id) })).filter((a) => a.rules.length);
+}
+function latest() {
+ return RULES.map((r) => r.decided).sort().pop() || null;
+}
+
+module.exports = { AREAS, RULES, byArea, latest };
@@ -7,17 +7,17 @@ const { layout, esc } = require('../render/layout');
@@ -7,17 +7,17 @@ const { layout, esc } = require('../render/layout');
const link = (name) => `/components/${name}`;
const link = (name) => `/components/${name}`;
const demo = {
const demo = {
- navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/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' }) })]) }),
+ navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Studio</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,
+ menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,
+ 'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),
dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),
dock: () => 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>`,
+ 'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,
'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),
sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),
- popover: () => kit.more({ label: 'More choices', items: [{ href: '/studio', label: 'Open Studio', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),
+ popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', 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>`,
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-row': () => kit.list([kit.row({ href: '/themes', title: 'OpenVibe.Design', sub: 'Make it yours', mark: kit.mark({ name: 'Design', square: true }), meta: 'Now' })]),
+ 'kit-card': () => kit.card({ href: '/themes', title: 'Make it yours', type: 'post', sub: 'Your theme everywhere', badge: 'DESIGN' }),
'kit-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' }) }),
'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' }),
'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 }),
'switch-row': () => kit.switchRow({ name: 'cr-contrast', label: 'More contrast', hint: 'Text and borders stand out more.', on: false }),
@@ -99,12 +99,12 @@ function home({ themes } = {}) {
@@ -99,12 +99,12 @@ function home({ themes } = {}) {
const glyphNames = (() => { try { return require('openvibe-shared/glyphs').NAMES.length; } catch { return 0; } })();
const glyphNames = (() => { try { return require('openvibe-shared/glyphs').NAMES.length; } catch { return 0; } })();
const componentCount = (() => { try { return Object.keys(require('./components').SAMPLES).length; } catch { return 0; } })();
const componentCount = (() => { try { return Object.keys(require('./components').SAMPLES).length; } catch { return 0; } })();
const SYSTEM = [
const SYSTEM = [
- { href: '/studio', name: 'Themes', count: all.length, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },
+ { href: '/themes', name: 'Themes', count: all.length, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },
{ href: '/icons', name: 'Icons', count: glyphNames, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },
{ href: '/icons', name: 'Icons', count: glyphNames, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },
{ href: '/components', name: 'Components', count: componentCount, unit: 'parts', text: 'Each drawn for the web, Jetpack Compose and SwiftUI from the same spec.', glyph: 'puzzle' },
{ href: '/components', name: 'Components', count: componentCount, unit: 'parts', text: 'Each drawn for the web, Jetpack Compose and SwiftUI from the same spec.', glyph: 'puzzle' },
- { href: '/patterns', name: 'Patterns', count: patterns.length, unit: 'rules', text: 'What production taught, each with its bug, a live demo and native code.', glyph: 'rules' },
- { href: '/motion', name: 'Motion', count: 3, unit: 'timings', text: 'Glint, glow, lift, press, ripple: one kit of effects and one set of curves.', glyph: 'motion' },
- { href: '/forms', name: 'Forms', count: 0, unit: '', text: 'Fields, choices and errors that read the same on every site.', glyph: 'forms' },
+ { href: '/rules', name: 'Rules', count: (() => { try { return require('../rules/register').RULES.length; } catch { return 0; } })(), unit: 'rules', text: 'Every style, design and organization decision, with the day and the reason.', glyph: 'rules' },
+ { href: '/patterns', name: 'Patterns', count: patterns.length, unit: 'patterns', text: 'What production taught, each with its bug, a live demo and native code.', glyph: 'rules' },
+ { href: '/motion', name: 'Motion', count: (() => { try { return require('./motion').EFFECTS.length; } catch { return 0; } })(), unit: 'effects', text: 'Glint, glow, lift, press, ripple: one kit of effects and one set of curves.', glyph: 'motion' },
{ href: '/tokens', name: 'Tokens', count: 7, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },
{ href: '/tokens', name: 'Tokens', count: 7, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },
{ href: '/native', name: 'Apps', count: 3, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },
{ href: '/native', name: 'Apps', count: 3, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },
];
];
@@ -119,7 +119,7 @@ function home({ themes } = {}) {
@@ -119,7 +119,7 @@ function home({ themes } = {}) {
apps: 'M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM10.5 18h3',
apps: 'M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM10.5 18h3',
};
};
const glyphSvg = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${GLYPH[k]}"/></svg>`;
const glyphSvg = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${GLYPH[k]}"/></svg>`;
- const system = `<section class="ds-system" aria-labelledby="ds-system-h"><div class="ds-head-row"><h2 id="ds-system-h">The whole system</h2><a class="ds-more" href="/kit">The kit${kit.icon('chevron')}</a></div>
+ const system = `<section class="ds-system" aria-labelledby="ds-system-h"><div class="ds-head-row"><h2 id="ds-system-h">The whole system</h2><a class="ds-more" href="/system">Everything${kit.icon('chevron')}</a></div>
<p class="ds-fine ds-system-lede">Everything an OpenVibe interface is made of, in one place: the same parts on every site, and in the apps.</p>
<p class="ds-fine ds-system-lede">Everything an OpenVibe interface is made of, in one place: the same parts on every site, and in the apps.</p>
<ul class="ds-system-grid">${SYSTEM.map((x) => `<li><a class="ds-sys" href="${esc(x.href)}"><span class="ds-sys-ic">${glyphSvg(x.glyph)}</span><span class="ds-sys-t"><b>${esc(x.name)}</b>${x.count ? `<em>${esc(String(x.count))} ${esc(x.unit)}</em>` : ''}</span><small>${esc(x.text)}</small></a></li>`).join('')}</ul></section>`;
<ul class="ds-system-grid">${SYSTEM.map((x) => `<li><a class="ds-sys" href="${esc(x.href)}"><span class="ds-sys-ic">${glyphSvg(x.glyph)}</span><span class="ds-sys-t"><b>${esc(x.name)}</b>${x.count ? `<em>${esc(String(x.count))} ${esc(x.unit)}</em>` : ''}</span><small>${esc(x.text)}</small></a></li>`).join('')}</ul></section>`;
const room = sc.room({
const room = sc.room({
@@ -127,8 +127,8 @@ function home({ themes } = {}) {
@@ -127,8 +127,8 @@ function home({ themes } = {}) {
hero: {
hero: {
title: 'Your look, everywhere',
title: 'Your look, everywhere',
lede: 'Pick or make a theme once and every OpenVibe site wears it. Take it into your own app as code for the web, Android and iOS, and design pages on it with AI.',
lede: 'Pick or make a theme once and every OpenVibe site wears it. Take it into your own app as code for the web, Android and iOS, and design pages on it with AI.',
- start: { href: '/studio', label: 'Change your theme' },
- chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/studio?theme=${encodeURIComponent(t.id)}` })),
+ start: { href: '/themes', label: 'Change your theme' },
+ chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/themes?theme=${encodeURIComponent(t.id)}` })),
motif: orb,
motif: orb,
},
},
proof: [
proof: [
@@ -137,7 +137,7 @@ function home({ themes } = {}) {
@@ -137,7 +137,7 @@ function home({ themes } = {}) {
{ value: 7, label: 'platforms to export to' },
{ value: 7, label: 'platforms to export to' },
].filter((p) => p.value),
].filter((p) => p.value),
scenes: [
scenes: [
- { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back. Comfort settings ride on top of any theme: more contrast, a readable font, colour-blind-safe colours, true black.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Open the studio' } },
+ { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back. Comfort settings ride on top of any theme: more contrast, a readable font, colour-blind-safe colours, true black.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/themes', label: 'Open the studio' } },
{ title: 'Make it with AI, on your theme', text: 'Describe what you need and three AI designers draft it at once. Every draft wears your theme, so it already looks like it belongs.', html: make, action: { href: '/create', label: 'Make something' } },
{ title: 'Make it with AI, on your theme', text: 'Describe what you need and three AI designers draft it at once. Every draft wears your theme, so it already looks like it belongs.', html: make, action: { href: '/create', label: 'Make something' } },
{ title: 'See which AI designs best', text: 'The Arena sends one prompt to three models, shows two drafts without their names, and ranks the designers by the votes, with what each draft cost and how long it took. Every prompt, page and vote is open data.', html: vs, action: { href: '/arena', label: 'Vote in the Arena' } },
{ title: 'See which AI designs best', text: 'The Arena sends one prompt to three models, shows two drafts without their names, and ranks the designers by the votes, with what each draft cost and how long it took. Every prompt, page and vote is open data.', html: vs, action: { href: '/arena', label: 'Vote in the Arena' } },
],
],
@@ -156,7 +156,7 @@ function home({ themes } = {}) {
@@ -156,7 +156,7 @@ function home({ themes } = {}) {
{ title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },
{ title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },
{ title: 'Your look', text: 'Light or dark, text size, motion, seasons and comfort settings, for every site', href: '/look', icon: 'ov:account' },
{ title: 'Your look', text: 'Light or dark, text size, motion, seasons and comfort settings, for every site', href: '/look', icon: 'ov:account' },
] },
] },
- html: system + (strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/studio">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : ''),
+ html: system + (strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/themes">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : ''),
faq: { items: [
faq: { items: [
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],
['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],
@@ -236,17 +236,17 @@ ${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<fo
@@ -236,17 +236,17 @@ ${kit.sheet({ id: 'ds-share-sheet', title: 'Share it in the gallery', body: `<fo
${signedIn ? `<details class="ds-history" id="ds-history"><summary>${kit.icon('clock')}<span>History</span></summary><ol id="ds-history-list"><li class="ds-history-empty"><span aria-hidden="true">◌</span><p>Your earlier looks appear here.</p></li></ol></details>` : ''}
${signedIn ? `<details class="ds-history" id="ds-history"><summary>${kit.icon('clock')}<span>History</span></summary><ol id="ds-history-list"><li class="ds-history-empty"><span aria-hidden="true">◌</span><p>Your earlier looks appear here.</p></li></ol></details>` : ''}
</aside></div></div>
</aside></div></div>
<script type="application/json" id="ds-themes">${data}</script>`;
<script type="application/json" id="ds-themes">${data}</script>`;
- return layout({ title: 'Theme studio', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/studio', body: `<div class="ds-wrap">${body}</div>`, active: '/studio', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });
+ return layout({ title: 'Theme studio', description: 'Pick an OpenVibe theme, edit any colour with contrast and colour-vision checks, see it on Live, Chat and a shop at once, and use it everywhere.', path: '/themes', body: `<div class="ds-wrap">${body}</div>`, active: '/themes', scripts: ['js/studio.js'], styles: ['css/studio-look.css'] });
}
}
function theme({ theme: t } = {}) {
function theme({ theme: t } = {}) {
const v = (t && t.variables) || {};
const v = (t && t.variables) || {};
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
-<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>
+<p class="ds-act">${kit.button({ href: `/themes?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p>${tryOn(t.slug || t.id)}</header>
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
- return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/studio' });
+ return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/themes' });
}
}
function developers() {
function developers() {
@@ -515,7 +515,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}
@@ -515,7 +515,7 @@ ${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}
}
}
function notFound() {
function notFound() {
- const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/studio">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;
+ const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/themes">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
}
}
@@ -43,7 +43,7 @@ function lookPage({ themes = [], me = {} } = {}) {
@@ -43,7 +43,7 @@ function lookPage({ themes = [], me = {} } = {}) {
const signedIn = !!me.signedIn;
const signedIn = !!me.signedIn;
const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2>
const current = `<section class="ovk-section lk-current" aria-labelledby="lk-cur-h"><h2 id="lk-cur-h">Your theme</h2>
<div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>
<div class="lk-theme"><span class="tk-dots" id="lk-dots" aria-hidden="true"></span><span><b id="lk-name">The OpenVibe default</b><small id="lk-mode"></small></span></div>
-<p class="lk-acts">${kit.button({ href: '/studio', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;
+<p class="lk-acts">${kit.button({ href: '/themes', label: 'Change it in the studio', attrs: { id: 'lk-studio' } })} ${kit.button({ href: '/tokens', label: 'As code', kind: 'quiet', attrs: { id: 'lk-code' } })}</p></section>`;
const settings = kit.section({
const settings = kit.section({
id: 'lk-settings',
id: 'lk-settings',
title: 'How every site looks to you',
title: 'How every site looks to you',
@@ -85,7 +85,7 @@ ${kit.sheet({ id: 'lk-site-sheet', title: 'Add a site', body: `<form class="lk-s
@@ -85,7 +85,7 @@ ${kit.sheet({ id: 'lk-site-sheet', title: 'Add a site', body: `<form class="lk-s
<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}
<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>
<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>
+${signedIn ? `<p class="lk-history-link"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/themes#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'], styles: ['css/studio-look.css'] });
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'] });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * /rules: how OpenVibe decides. The decision register (server/rules/register.js) as a page: every style, design and
+ * organization rule in one sentence, grouped by area, each with the day it was decided, why, what carries it out and
+ * what checks it. Whole without JavaScript; rules.js only adds the filter.
+ *
+ * rulesPage() the page
+ * rulesJson() the same register as data (/api/v1/rules)
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc, baseUrl } = require('../render/layout');
+const { AREAS, RULES, byArea, latest } = require('../rules/register');
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+const day = (iso) => { const [y, m, d] = String(iso || '').split('-').map(Number); return y ? `${d} ${MONTHS[m - 1]} ${y}` : ''; };
+const isExternal = (href) => /^https?:\/\//.test(href);
+
+function rule(r) {
+ const see = r.see.map((s) => `<a class="rl-chip" href="${esc(s.href)}"${isExternal(s.href) ? ' rel="noopener"' : ''}>${esc(s.label)}</a>`).join('');
+ const checked = r.checked.map((c) => `<span class="rl-chip rl-chip--check">${kit.icon('check')}${esc(c)}</span>`).join('');
+ return `<article class="rl-rule" id="${esc(r.id)}" data-rl-text="${esc(`${r.rule} ${r.detail} ${r.why}`.toLowerCase())}">
+ <header class="rl-rule-head"><h3><a href="#${esc(r.id)}">${esc(r.rule)}</a></h3><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time></header>
+ <p class="rl-detail">${esc(r.detail)}</p>
+ <p class="rl-why"><span>Why</span> ${esc(r.why)}</p>
+ ${see || checked ? `<p class="rl-links">${see ? `<span class="rl-label">See</span>${see}` : ''}${checked ? `<span class="rl-label">Checked by</span>${checked}` : ''}</p>` : ''}
+</article>`;
+}
+
+function rulesPage() {
+ const areas = byArea();
+ const newest = latest();
+ const recent = [...RULES].sort((a, b) => (a.decided < b.decided ? 1 : -1)).slice(0, 5);
+ const hero = `<header class="rl-hero">
+ <p class="rl-eye">Rules</p>
+ <h1 class="rl-h1">How OpenVibe decides.</h1>
+ <p class="rl-lede">Every style, design and organization decision behind OpenVibe, one sentence each, with the day it was made and what went wrong without it. <a href="/patterns">Patterns</a> show how a rule looks in a page; this says what the rule is and why.</p>
+ ${kit.stats([[RULES.length, 'rules'], [areas.length, 'areas'], [day(newest), 'last decided']], { label: 'The register in numbers' })}
+ <form class="rl-find" role="search" action="#" data-rl-find hidden><label for="rl-q" class="ovk-sr">Find a rule</label>${kit.icon('search')}<input id="rl-q" type="search" placeholder="Find a rule: tabs, spinner, price, phone…" autocomplete="off"><output data-rl-count></output></form>
+</header>`;
+ const index = `<nav class="rl-areas" aria-label="Areas"><ul>${areas.map((a) => `<li><a href="#area-${esc(a.id)}">${esc(a.title)}<span>${a.rules.length}</span></a></li>`).join('')}</ul></nav>`;
+ const latestBlock = `<section class="rl-sec rl-recent" aria-labelledby="rl-recent-h"><div class="rl-sec-head"><h2 id="rl-recent-h">Decided lately</h2><p>The newest rules first; each links to its place below.</p></div>
+ <ol class="rl-recent-list">${recent.map((r) => `<li><a href="#${esc(r.id)}"><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time><b>${esc(r.rule)}</b></a></li>`).join('')}</ol></section>`;
+ const sections = areas.map((a) => `<section class="rl-sec" id="area-${esc(a.id)}" aria-labelledby="area-${esc(a.id)}-h" data-rl-area>
+ <div class="rl-sec-head"><h2 id="area-${esc(a.id)}-h">${esc(a.title)}<span class="rl-n">${a.rules.length}</span></h2><p>${esc(a.lede)}</p></div>
+ <div class="rl-list">${a.rules.map(rule).join('')}</div>
+</section>`).join('');
+ const data = `<section class="rl-sec rl-data" aria-labelledby="rl-data-h"><div class="rl-sec-head"><h2 id="rl-data-h">Use the rules</h2><p>The register is data too: read it from code, hand it to an AI assistant before it designs for OpenVibe, or link one rule by its address.</p></div>
+ <p class="rl-links">${kit.button({ href: '/api/v1/rules', label: 'As JSON', kind: 'quiet', icon: 'code' })}${kit.button({ href: '/rules.md', label: 'As Markdown', kind: 'quiet', icon: 'file' })}${kit.button({ href: '/patterns', label: 'The patterns', kind: 'quiet', icon: 'forward' })}</p></section>`;
+ const jsonLd = [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'How OpenVibe decides', url: `${baseUrl()}/rules`, dateModified: newest, hasPart: RULES.map((r) => ({ '@type': 'CreativeWork', name: r.rule, url: `${baseUrl()}/rules#${r.id}`, dateCreated: r.decided })) }];
+ return layout({
+ title: 'Rules: how OpenVibe decides',
+ description: `${RULES.length} style, design and organization rules behind OpenVibe, each with the day it was decided, why, and what carries it out: honest pages, length without hiding, motion, navigation, phones, words, marks, data, agents and products.`,
+ path: '/rules',
+ active: '/system',
+ styles: ['css/rules.css'],
+ scripts: ['js/rules.js'],
+ jsonLd,
+ body: `<article class="rl">${hero}${index}${latestBlock}${sections}${data}</article>`,
+ });
+}
+
+function rulesJson() {
+ return { count: RULES.length, updated: latest(), areas: AREAS, rules: RULES.map((r) => ({ ...r, url: `${baseUrl()}/rules#${r.id}` })) };
+}
+
+module.exports = { rulesPage, rulesJson };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * /system: the design system in one map. Every reference page of openvibe.design, grouped the way a builder looks for
+ * it (foundations, components, guidance, platforms), each with what it holds counted from the source that holds it.
+ * The bar's System place opens it; More lists the same pages in the same order.
+ *
+ * systemPage({ themes }) themes: the theme count (null when the catalogue did not answer: then no count shows)
+ * GROUPS the map, for the nav and tests
+ */
+const { layout, esc } = require('../render/layout');
+
+const count = (fn) => { try { const n = fn(); return Number.isFinite(n) && n > 0 ? n : null; } catch { return null; } };
+
+const G = {
+ palette: 'M12 3a9 9 0 1 0 0 18c1 0 1.6-.8 1.6-1.6 0-.5-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1 0-.9.7-1.6 1.6-1.6H16a5 5 0 0 0 5-5c0-4.1-4-7.6-9-7.6ZM7.5 12.5h.01M9.5 8h.01M14.5 8h.01M17 11.5h.01',
+ code: 'M8.5 8 4.5 12l4 4M15.5 8l4 4-4 4M13.5 5.5l-3 13',
+ font: 'M5 19 10.5 5h1L17 19M7.4 14h7.2M19 19v-5.5',
+ shapes: 'M4 4h7v7H4zM17.5 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM14 20l3.5-6 3.5 6zM4 14h7v7H4z',
+ mark: 'M9 12a5 5 0 1 0 0 .01M13 7l3.5 9L20 7',
+ motion: 'M4 18c4 0 5-12 9-12s4 6 7 6M4 6h.01M20 18h.01',
+ puzzle: 'M9 4h4v2.5a1.5 1.5 0 0 0 3 0V4h4v5h-2.5a1.5 1.5 0 0 0 0 3H20v8h-5v-2.5a1.5 1.5 0 0 0-3 0V20H4v-5h2.5a1.5 1.5 0 0 0 0-3H4V4z',
+ kit: 'M4 7l8-4 8 4-8 4-8-4zM4 12l8 4 8-4M4 17l8 4 8-4',
+ forms: 'M4 5h16v5H4zM4 14h16v5H4zM7 7.5h5M7 16.5h8',
+ loading: 'M4 6h16M4 12h10M4 18h13',
+ progress: 'M4 12h16M4 12h9M16 8l4 4-4 4',
+ upload: 'M12 16V5M7.5 9.5 12 5l4.5 4.5M5 19h14',
+ rules: 'M6 4h12v16H6zM9 9h6M9 13h6M9 17h3',
+ patterns: 'M5 5h14M5 12h14M5 19h9M17 17l2 2 3-4',
+ prompt: 'M5 6h14v9H9l-4 4z',
+ apps: 'M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM10.5 18h3',
+ api: 'M4 12h4l2-5 4 10 2-5h4',
+};
+const glyph = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${G[k]}"/></svg>`;
+
+function groups({ themes = null } = {}) {
+ const n = {
+ icons: count(() => require('openvibe-shared/glyphs').NAMES.length),
+ formats: count(() => { const t = require('openvibe-shared/tokens').TARGETS; return Array.isArray(t) ? t.length : Object.keys(t).length; }),
+ effects: count(() => require('./motion').EFFECTS.length),
+ components: count(() => require('./components-list').GROUPS.reduce((s, g) => s + (Array.isArray(g[3]) ? g[3].length : 0), 0)),
+ shapes: count(() => require('./loading').SHAPES.length),
+ rules: count(() => require('../rules/register').RULES.length),
+ patterns: count(() => require('../patterns/catalog').patterns.length),
+ prompts: count(() => require('./prompts').list().length),
+ // The web, Android and iOS: the platforms the tokens and components are drawn for.
+ platforms: 3,
+ };
+ return [
+ { id: 'foundations', title: 'Foundations', lede: 'What every interface is made of: colour, type, icons, the mark and how things move.', items: [
+ { href: '/themes', name: 'Themes', n: themes, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },
+ { href: '/tokens', name: 'Tokens', n: n.formats, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },
+ { href: '/fonts', name: 'Fonts', text: 'Typefaces and pairings that read well on every screen.', glyph: 'font' },
+ { href: '/icons', name: 'Icons', n: n.icons, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },
+ { href: '/brand', name: 'The mark', text: 'The Pair: its forms, sizes, motion and the rules for using it.', glyph: 'mark' },
+ { href: '/motion', name: 'Motion', n: n.effects, unit: 'effects', text: 'One kit of effects and one set of curves, shared by every site.', glyph: 'motion' },
+ ] },
+ { id: 'components', title: 'Components', lede: 'The parts pages are built from, each the same on every site and specified for native apps.', items: [
+ { href: '/components', name: 'Components', n: n.components, unit: 'parts', text: 'Each defined once: anatomy, states and roles, for the web, Compose and SwiftUI.', glyph: 'puzzle' },
+ { href: '/kit', name: 'Kit', text: 'Every piece an OpenVibe page is made of, live, with the code to use it.', glyph: 'kit' },
+ { href: '/forms', name: 'Forms', text: 'Fields, choices and errors that read the same on every site.', glyph: 'forms' },
+ { href: '/loading', name: 'Loading', n: n.shapes, unit: 'shapes', text: 'Placeholders the size of what replaces them. Never a spinning ring.', glyph: 'loading' },
+ { href: '/progress', name: 'Progress', text: 'Every state of an upload or a job, in words and one bar.', glyph: 'progress' },
+ { href: '/uploader', name: 'Uploader', text: 'The shared file picker and queue: choose a file and keep going.', glyph: 'upload' },
+ ] },
+ { id: 'guidance', title: 'Guidance', lede: 'What we decided and what production taught us, so new work starts from it.', items: [
+ { href: '/rules', name: 'Rules', n: n.rules, unit: 'rules', text: 'Every style, design and organization decision, with the day and the reason.', glyph: 'rules' },
+ { href: '/patterns', name: 'Patterns', n: n.patterns, unit: 'patterns', text: 'Each with the bug that taught it, a live demo and native code.', glyph: 'patterns' },
+ { href: '/prompts', name: 'Prompts', n: n.prompts, unit: 'prompts', text: 'Every instruction this site sends an AI model, word for word.', glyph: 'prompt' },
+ ] },
+ { id: 'platforms', title: 'Platforms', lede: 'Taking the system off this site: into apps, code and AI assistants.', items: [
+ { href: '/native', name: 'Apps', n: n.platforms, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },
+ { href: '/developers', name: 'Developers', text: 'The API, MCP for AI assistants, and how to use any of it in your own app.', glyph: 'api' },
+ ] },
+ ];
+}
+
+// Columns that divide the cards (the even-grid pattern): six and three go three across, four and two go in twos.
+const colsFor = (k) => (k % 3 === 0 ? 3 : 2);
+
+function systemPage({ themes = null } = {}) {
+ const gs = groups({ themes });
+ const total = gs.reduce((s, g) => s + g.items.length, 0);
+ const card = (x) => `<li><a class="ds-sys" href="${esc(x.href)}"><span class="ds-sys-ic">${glyph(x.glyph)}</span><span class="ds-sys-t"><b>${esc(x.name)}</b>${x.n ? `<em>${esc(x.n.toLocaleString('en-US'))} ${esc(x.unit)}</em>` : ''}</span><small>${esc(x.text)}</small></a></li>`;
+ const body = `<article class="sy">
+<header class="sy-hero"><p class="sy-eye">The system</p><h1 class="sy-h1">Everything OpenVibe is made of.</h1>
+<p class="sy-lede">${total} references in four groups: the foundations, the components built on them, the rules and patterns that guide new work, and the ways to take it into your own app. The same parts are on every OpenVibe site.</p></header>
+${gs.map((g) => `<section class="sy-group" id="${esc(g.id)}" aria-labelledby="sy-${esc(g.id)}-h"><div class="sy-head"><h2 id="sy-${esc(g.id)}-h">${esc(g.title)}</h2><p>${esc(g.lede)}</p></div><ul class="ds-system-grid sy-grid" style="--sy-cols:${colsFor(g.items.length)}">${g.items.map(card).join('')}</ul></section>`).join('')}
+</article>`;
+ return layout({
+ title: 'The design system',
+ description: 'Everything OpenVibe is made of: themes, tokens, fonts, icons, the mark and motion; components, the kit and forms; the rules and patterns; and apps and developer tools.',
+ path: '/system',
+ active: '/system',
+ styles: ['css/rules.css'],
+ body,
+ });
+}
+
+module.exports = { systemPage, groups };
@@ -94,7 +94,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:
@@ -94,7 +94,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:
${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>
${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>
${filesRow(slug)}
${filesRow(slug)}
${kit.fold({ id: 'tk-all', title: 'Every colour token', meta: `${themeTokens.flatten(found.docs[0]).filter((t) => t.type === 'color').length} of them`, body: tokenTable(found) })}
${kit.fold({ id: 'tk-all', title: 'Every colour token', meta: `${themeTokens.flatten(found.docs[0]).filter((t) => t.type === 'color').length} of them`, body: tokenTable(found) })}
-${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/studio?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;
+${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/themes?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;
}
}
const body = `<header class="ds-page-h"><p class="ds-kicker">OpenVibe Tokens</p><h1>Your theme, as code</h1>
const body = `<header class="ds-page-h"><p class="ds-kicker">OpenVibe Tokens</p><h1>Your theme, as code</h1>
<p class="ds-lede">Take any OpenVibe theme into your own app, its light and dark halves together: the web, Android, iOS, Flutter or Figma. The colours are the ones the network paints.</p></header>
<p class="ds-lede">Take any OpenVibe theme into your own app, its light and dark halves together: the web, Android, iOS, Flutter or Figma. The colours are the ones the network paints.</p></header>
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * The pages and the plumbing: /, /studio, /t/:slug, /developers, the 404; public vs signed-in cache policy; the
+ * The pages and the plumbing: /, /themes, /t/:slug, /developers, the 404; public vs signed-in cache policy; the
* crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt); health and truthful readiness; the
* crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt); health and truthful readiness; the
* release manifest and content-hashed static assets.
* release manifest and content-hashed static assets.
*/
*/
@@ -27,14 +27,14 @@ const { assetVersion } = require('../server/assets');
@@ -27,14 +27,14 @@ const { assetVersion } = require('../server/assets');
assert.match(r.headers.get('vary'), /Cookie/i);
assert.match(r.headers.get('vary'), /Cookie/i);
});
});
- await check('/studio includes the studio script and stylesheet, and says who is signed in', async () => {
- const r = await t.get('/studio');
+ await check('/themes includes the studio script and stylesheet, and says who is signed in', async () => {
+ const r = await t.get('/themes');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/js\/studio\.js\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);
assert.match(r.text, /\/css\/design\.css\?v=[0-9a-f]{10}/);
assert.match(r.text, /Sign in to use a theme/);
assert.match(r.text, /Sign in to use a theme/);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
- const me = await t.get('/studio', { as: user });
+ const me = await t.get('/themes', { as: user });
assert.strictEqual(me.status, 200);
assert.strictEqual(me.status, 200);
assert.match(me.text, /kim/);
assert.match(me.text, /kim/);
assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'a signed-in page is never cached');
assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'a signed-in page is never cached');
@@ -64,7 +64,9 @@ const { assetVersion } = require('../server/assets');
@@ -64,7 +64,9 @@ const { assetVersion } = require('../server/assets');
assert.strictEqual(sitemap.status, 200);
assert.strictEqual(sitemap.status, 200);
assert.match(sitemap.headers.get('content-type'), /application\/xml/);
assert.match(sitemap.headers.get('content-type'), /application\/xml/);
assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/<\/loc>/);
assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/<\/loc>/);
- assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/studio<\/loc>/);
+ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/themes<\/loc>/);
+ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/rules<\/loc>/);
+ assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/system<\/loc>/);
assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/t\/aurora<\/loc>/);
assert.match(sitemap.text, /<loc>https:\/\/openvibe\.design\/t\/aurora<\/loc>/);
const llms = await t.get('/llms.txt');
const llms = await t.get('/llms.txt');
assert.strictEqual(llms.status, 200);
assert.strictEqual(llms.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+// The decision register (/rules), the system map (/system), the theme editor's move to /themes, and the bar.
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const { AREAS, RULES } = require('../server/rules/register');
+const { patterns } = require('../server/patterns/catalog');
+const { NAV } = require('../server/render/layout');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('every rule is whole: a unique id, a known area, a real date, a sentence and a reason', async () => {
+ const ids = new Set();
+ const areas = new Set(AREAS.map((a) => a.id));
+ const today = new Date().toISOString().slice(0, 10);
+ for (const r of RULES) {
+ assert.ok(/^[a-z0-9-]+$/.test(r.id), `${r.id} id`);
+ assert.ok(!ids.has(r.id), `${r.id} is unique`);
+ ids.add(r.id);
+ assert.ok(areas.has(r.area), `${r.id} area ${r.area}`);
+ assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(r.decided) && r.decided <= today, `${r.id} decided ${r.decided}`);
+ assert.ok(/[.!?]$/.test(r.rule) && r.rule.length <= 120, `${r.id}: one sentence`);
+ assert.ok(r.detail && r.why, `${r.id} detail and why`);
+ }
+ for (const a of AREAS) assert.ok(RULES.some((r) => r.area === a.id), `${a.id} has rules`);
+ });
+
+ await check('what a rule points at exists: patterns by slug, pages by route, tests by file', async () => {
+ const slugs = new Set(patterns.map((p) => p.slug));
+ for (const r of RULES) {
+ for (const s of r.see) {
+ const m = s.href.match(/^\/patterns\/([a-z0-9-]+)$/);
+ if (m) assert.ok(slugs.has(m[1]), `${r.id} → pattern ${m[1]}`);
+ else if (s.href.startsWith('/')) assert.equal((await t.get(s.href)).status, 200, `${r.id} → ${s.href}`);
+ }
+ for (const c of r.checked) {
+ const file = c.match(/^test\/[\w.-]+\.js$/);
+ if (file) assert.ok(fs.existsSync(path.join(__dirname, '..', c)), `${r.id} checked by ${c}`);
+ }
+ }
+ });
+
+ await check('/rules shows every rule under its area, with its date and an anchor', async () => {
+ const res = await t.get('/rules');
+ assert.equal(res.status, 200);
+ for (const r of RULES) assert.ok(res.text.includes(`id="${r.id}"`), r.id);
+ for (const a of AREAS) assert.ok(res.text.includes(`id="area-${a.id}"`), a.id);
+ assert.ok(res.text.includes('How OpenVibe decides.'));
+ assert.ok(res.text.includes(`${RULES.length}`), 'the count');
+ assert.ok(!/spin/i.test(res.text.replace(/no-spin|spinning|spinner/gi, '')), 'no spinner on the rules page');
+ });
+
+ await check('the register as Markdown and as JSON', async () => {
+ const md = await t.get('/rules.md');
+ assert.equal(md.status, 200);
+ assert.match(md.headers.get('content-type'), /markdown/);
+ for (const r of RULES) assert.ok(md.text.includes(r.rule), r.id);
+ const js = await t.get('/api/v1/rules');
+ assert.equal(js.status, 200);
+ const body = js.json();
+ assert.equal(body.count, RULES.length);
+ assert.equal(body.rules.length, RULES.length);
+ assert.ok(body.rules.every((r) => r.url.endsWith(`/rules#${r.id}`)));
+ assert.equal(js.headers.get('access-control-allow-origin'), '*');
+ });
+
+ await check('/system maps every reference page, and a count is shown only where it is counted', async () => {
+ const res = await t.get('/system');
+ assert.equal(res.status, 200);
+ for (const href of ['/themes', '/tokens', '/fonts', '/icons', '/brand', '/motion', '/components', '/kit', '/forms', '/loading', '/progress', '/uploader', '/rules', '/patterns', '/prompts', '/native', '/developers']) {
+ assert.ok(res.text.includes(`href="${href}"`), href);
+ }
+ assert.ok(res.text.includes(`${RULES.length} rules`), 'the rules count');
+ assert.ok(res.text.includes(`${patterns.length} patterns`), 'the patterns count');
+ assert.ok(!/<em>0 /.test(res.text), 'no zero counts');
+ });
+
+ await check('the theme editor is /themes, and /studio answers 301 with its query', async () => {
+ assert.equal((await t.get('/themes')).status, 200);
+ const old = await t.get('/studio?theme=forest');
+ assert.equal(old.status, 301);
+ assert.equal(old.headers.get('location'), '/themes?theme=forest');
+ assert.equal((await t.get('/studio')).headers.get('location'), '/themes');
+ });
+
+ await check('the pages that moved point at /themes, and the rules page brings its own files', async () => {
+ for (const asset of ['/js/rules.js', '/css/rules.css', '/js/look.js', '/js/studio.js']) assert.equal((await t.get(asset)).status, 200, asset);
+ assert.ok((await t.get('/look')).text.includes('href="/themes"'), 'Your look sends people to /themes');
+ assert.ok(!(await t.get('/components/registry')).text.includes('href="/studio'), 'the registry has no /studio link');
+ assert.ok((await t.get('/patterns/tabs-not-folds')).text.includes('never split content'), 'tabs are for settings, never content');
+ });
+
+ await check('the bar holds six places, the rest under More, and no two share an icon', async () => {
+ const bar = NAV.filter((l) => !l.more);
+ assert.equal(bar.length, 6, bar.map((l) => l.label).join(', '));
+ assert.deepEqual(bar.map((l) => l.href), ['/', '/create', '/themes', '/look', '/arena', '/system']);
+ const icons = NAV.map((l) => l.icon);
+ assert.equal(new Set(icons).size, icons.length, 'one icon per place');
+ assert.ok(NAV.every((l) => l.label.length <= 22), 'labels stay short');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -27,7 +27,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
@@ -27,7 +27,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
'javascript:alert(1)', null, '', 12]) {
'javascript:alert(1)', null, '', 12]) {
assert.strictEqual(sanitizeNext(v, config), '/', JSON.stringify(v) + ' goes home');
assert.strictEqual(sanitizeNext(v, config), '/', JSON.stringify(v) + ' goes home');
}
}
- assert.strictEqual(sanitizeNext('/studio?tab=contrast', config), '/studio?tab=contrast');
+ assert.strictEqual(sanitizeNext('/themes?tab=contrast', config), '/themes?tab=contrast');
// The SDK keeps a same-site address whole (it is this site's own origin, never another one).
// The SDK keeps a same-site address whole (it is this site's own origin, never another one).
assert.strictEqual(sanitizeNext('https://openvibe.design/a/b?x=1#c', config), 'https://openvibe.design/a/b?x=1#c');
assert.strictEqual(sanitizeNext('https://openvibe.design/a/b?x=1#c', config), 'https://openvibe.design/a/b?x=1#c');
for (const v of ['https://evil.com/a', 'https://openvibe.design.evil.com/a', 'https://[email protected]/', 'http://openvibe.design/a']) {
for (const v of ['https://evil.com/a', 'https://openvibe.design.evil.com/a', 'https://[email protected]/', 'http://openvibe.design/a']) {
@@ -38,7 +38,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
@@ -38,7 +38,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
const user = t.network.addUser('kim');
const user = t.network.addUser('kim');
await check('login redirects to Network with state, client id design and an S256 challenge', async () => {
await check('login redirects to Network with state, client id design and an S256 challenge', async () => {
- const r = await t.get('/auth/login?next=/studio');
+ const r = await t.get('/auth/login?next=/themes');
assert.strictEqual(r.status, 302);
assert.strictEqual(r.status, 302);
const loc = new URL(r.headers.get('location'));
const loc = new URL(r.headers.get('location'));
assert.strictEqual(loc.origin + loc.pathname, t.network.url + '/oauth/authorize');
assert.strictEqual(loc.origin + loc.pathname, t.network.url + '/oauth/authorize');
@@ -53,7 +53,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
@@ -53,7 +53,7 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
assert.ok(state, 'the flow cookie is set');
assert.ok(state, 'the flow cookie is set');
assert.match(cookies(r).find((c) => c.includes('ov_oauth_state=')), /Path=\/auth.*HttpOnly/i);
assert.match(cookies(r).find((c) => c.includes('ov_oauth_state=')), /Path=\/auth.*HttpOnly/i);
assert.ok(cookieNamed(r, 'ov_oauth_verifier'), 'the verifier is kept in its own cookie');
assert.ok(cookieNamed(r, 'ov_oauth_verifier'), 'the verifier is kept in its own cookie');
- assert.strictEqual(decodeURIComponent(cookieNamed(r, 'ov_oauth_next').split('=')[1]), '/studio');
+ assert.strictEqual(decodeURIComponent(cookieNamed(r, 'ov_oauth_next').split('=')[1]), '/themes');
});
});
await check('silent sign-in adds prompt=none and next= only keeps this site', async () => {
await check('silent sign-in adds prompt=none and next= only keeps this site', async () => {
@@ -66,13 +66,13 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
@@ -66,13 +66,13 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
});
});
await check('the callback exchanges the code and sets httpOnly session cookies', async () => {
await check('the callback exchanges the code and sets httpOnly session cookies', async () => {
- const login = await t.get('/auth/login?next=/studio');
+ const login = await t.get('/auth/login?next=/themes');
const loc = new URL(login.headers.get('location'));
const loc = new URL(login.headers.get('location'));
const flowCookie = cookieJar(login);
const flowCookie = cookieJar(login);
const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
const r = await t.get('/auth/callback?code=' + code + '&state=' + loc.searchParams.get('state'), { cookie: flowCookie });
const r = await t.get('/auth/callback?code=' + code + '&state=' + loc.searchParams.get('state'), { cookie: flowCookie });
assert.strictEqual(r.status, 302, r.text);
assert.strictEqual(r.status, 302, r.text);
- assert.strictEqual(r.headers.get('location'), '/studio');
+ assert.strictEqual(r.headers.get('location'), '/themes');
const all = cookies(r).join('\n');
const all = cookies(r).join('\n');
assert.match(all, /ov_token=[^;]+;.*HttpOnly/i);
assert.match(all, /ov_token=[^;]+;.*HttpOnly/i);
assert.match(all, /ov_refresh=[^;]+;.*HttpOnly/i);
assert.match(all, /ov_refresh=[^;]+;.*HttpOnly/i);
@@ -146,9 +146,9 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
@@ -146,9 +146,9 @@ const cookieJar = (r) => cookies(r).map((c) => c.split(';')[0]).join('; ');
});
});
await check('sign-out clears the cookies and returns to next', async () => {
await check('sign-out clears the cookies and returns to next', async () => {
- const r = await t.get('/auth/logout?next=%2Fstudio', { headers: { 'sec-fetch-site': 'same-origin' } });
+ const r = await t.get('/auth/logout?next=%2Fthemes', { headers: { 'sec-fetch-site': 'same-origin' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- assert.strictEqual(r.headers.get('location'), '/studio');
+ assert.strictEqual(r.headers.get('location'), '/themes');
assert.match(cookies(r).join('\n'), /design_at=;/);
assert.match(cookies(r).join('\n'), /design_at=;/);
});
});
@@ -79,17 +79,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -79,17 +79,17 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('the studio and a theme page link to the theme as code and to trying it on the real sites', async () => {
await check('the studio and a theme page link to the theme as code and to trying it on the real sites', async () => {
- assert.match((await t.get('/studio')).text, /id="ds-code" href="\/tokens"/);
+ assert.match((await t.get('/themes')).text, /id="ds-code" href="\/tokens"/);
assert.match((await t.get('/t/vibe')).text, /href="\/tokens\?theme=vibe"/);
assert.match((await t.get('/t/vibe')).text, /href="\/tokens\?theme=vibe"/);
assert.match((await t.get('/developers')).text, /GET \/api\/v1\/tokens\/:theme\?format=/);
assert.match((await t.get('/developers')).text, /GET \/api\/v1\/tokens\/:theme\?format=/);
// Try it on the real sites (openvibe-shared 4.41.0 ?ov-try=): the theme page links each site with the theme's name.
// Try it on the real sites (openvibe-shared 4.41.0 ?ov-try=): the theme page links each site with the theme's name.
const page = (await t.get('/t/vibe')).text;
const page = (await t.get('/t/vibe')).text;
assert.match(page, /href="https:\/\/openvibe\.live\/\?ov-try=vibe"/);
assert.match(page, /href="https:\/\/openvibe\.live\/\?ov-try=vibe"/);
assert.match(page, /href="https:\/\/openvibe\.chat\/\?ov-try=vibe"/);
assert.match(page, /href="https:\/\/openvibe\.chat\/\?ov-try=vibe"/);
- const studioPage = (await t.get('/studio')).text;
+ const studioPage = (await t.get('/themes')).text;
assert.match(studioPage, /id="ds-code" href="\/tokens" data-tokens-src="\/shared\/tokens\.js\?v=[0-9a-f]+"/, 'the studio can compile edits in the page');
assert.match(studioPage, /id="ds-code" href="\/tokens" data-tokens-src="\/shared\/tokens\.js\?v=[0-9a-f]+"/, 'the studio can compile edits in the page');
assert.match(studioPage, /<dialog class="ovk-sheet" id="ds-code-sheet"[\s\S]*<option value="compose">Android \(Jetpack Compose\)<\/option>/);
assert.match(studioPage, /<dialog class="ovk-sheet" id="ds-code-sheet"[\s\S]*<option value="compose">Android \(Jetpack Compose\)<\/option>/);
- assert.match((await t.get('/studio')).text, /<div id="ds-try" hidden>[\s\S]*data-try="https:\/\/openvibe\.live\/"/, 'the studio points them at the picked theme');
+ assert.match((await t.get('/themes')).text, /<div id="ds-try" hidden>[\s\S]*data-try="https:\/\/openvibe\.live\/"/, 'the studio points them at the picked theme');
});
});
await check('/look: the theme worn and the comfort settings, wired to the theme loader', async () => {
await check('/look: the theme worn and the comfort settings, wired to the theme loader', async () => {