openvibers/openvibe.design Commits
Design: four doors, the bar and a breadcrumb on every page (#259)
Changes
@@@ -14,6 +14,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.ds-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }.ds-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }++/* Where a page sits, above its h1: plain text and one link, the same on every page (the four doors, 2026-11-10). */++.ds-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 10px; font-size: .86rem; color: var(--text-muted, #7386a3); }++.ds-crumbs a { color: var(--text-secondary, #96a7c2); text-decoration: none; font-weight: 700; }++.ds-crumbs a:hover { color: var(--room-strong); }.ds-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }.ds-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; color: var(--room-strong); }.ds-more .ovk-i { width: 1em; height: 1em; }@@@ -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|rules|system|themes|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;++const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|make|motion|rules|see|system|themes|tokens|use|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() {@@@ -4,8 +4,8 @@* 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 /, /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.++ * The sitemap leads with the four doors (/see, /make, /rules, /use), then /, /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');const path = require('path');@@@ -29,10 +29,14 @@ function siteUpdated() {/** Every public page, in the order a crawler should read them. */function publicPages(themes = []) {const static_ = [++ // The four doors lead (the owner, 2026-11-10): the bar, and the first thing a crawler reads.++ { path: '/see', changefreq: 'weekly', priority: 1.0, title: 'See: how OpenVibe looks and moves', text: 'Look, tokens, the mark, components, the kit, forms, patterns, motion, loading, progress and native apps: how OpenVibe looks and moves.' },++ { path: '/make', changefreq: 'weekly', priority: 0.9, title: 'Make: what you make with it', text: 'Create, themes, icons, fonts, the uploader, prompts and the Arena: what you make with OpenVibe\'s design system.' },++ { path: '/rules', changefreq: 'weekly', priority: 0.9, 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: '/use', changefreq: 'weekly', priority: 0.8, title: 'Use: building with it', text: 'The SDK, the API, the files and the versions, and how to install and call them in your own app.' },{ 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: '/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,6 +104,13 @@ function createDiscoveryRoutes(ctx) {markdown: true,base: config.baseUrl,sections: [++ // The four doors come first: the bar, and the map every page says it sits in.++ { title: 'The four doors', links: [++ { title: 'See', url: abs('/see'), note: 'how OpenVibe looks and moves: look, tokens, the mark, components, the kit, forms, patterns, motion, loading, progress and native apps' },++ { title: 'Make', url: abs('/make'), note: 'what you make with it: create, themes, icons, fonts, uploader, prompts and the Arena' },++ { title: 'Rules', url: abs('/rules'), note: 'the register of every style, design and organization decision, then the system map at /system' },++ { title: 'Use', url: abs('/use'), note: 'building with it: the SDK, the API, the files and the versions' },++ ] },{ title: 'Start here', links: [{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },{ title: 'Themes', url: abs('/themes'), note: 'try, edit, use, submit and roll back a theme' },@@@ -2,6 +2,7 @@/*** The pages:++ * /see, /make, /use the doors: their pages as cards in order, each from its own lede (views/doors.js)* / the theme catalogue's front door* /themes the theme editor and gallery (/studio answers 301)* /rules the decision register (its Markdown twin /rules.md; data at /api/v1/rules)@@@ -71,6 +72,11 @@ function createPageRoutes(ctx) {return subject ? await arena.counts(subject).catch(() => null) : null;};++ // ── The four doors: the site's whole bar, and where every page says it sits (server/views/doors.js) ──++ r.get('/see', (req, res) => page(res, 200, require('../views/doors').doorPage('see'), req.viewer));++ r.get('/make', (req, res) => page(res, 200, require('../views/doors').doorPage('make'), req.viewer));++ r.get('/use', (req, res) => page(res, 200, require('../views/doors').doorPage('use'), req.viewer));++// ── Production patterns: one data-backed page and generated native spec per pattern ──// ── The OpenVibe mark, and its files ──r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));@@@ -13,39 +13,18 @@ const shell = require('openvibe-shared/shell');const ovServe = require('openvibe-shared/serve');const appIcon = require('openvibe-shared/app-icon');const kit = require('openvibe-shared/kit');++const seo = require('openvibe-shared/seo');const rooms = require('openvibe-shared/rooms');const { asset, assetVersion } = require('../assets');++const { DOORS, breadcrumbOf, doorHrefOf } = require('../site/doors');const NAME = 'OpenVibe.Design';const NETWORK = 'https://openvibe.network';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-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: '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-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-gauge-high', more: true },-- { label: 'Uploader', href: '/uploader', icon: 'fa-upload', 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 },--];++// Four doors, in this order, are the site's whole bar (the owner, 2026-11-10). The brand is the way home.++const NAV = DOORS.map((door) => ({ label: door.name, href: door.href, icon: { eye: 'fa-eye', spark: 'fa-wand-magic-sparkles', scale: 'fa-scale-balanced', terminal: 'fa-terminal' }[door.icon] }));let RELEASE = 'dev';let BASE = 'https://openvibe.design';@@@ -58,11 +37,25 @@ const canonical = (p) => `${BASE}${p || '/'}`;function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {const pathNow = path || active || '/';canonical = canonical || (path ? `${BASE}${path}` : undefined);++ // The bar marks the door a page sits under, not the page itself (there are four places now).++ const barActive = doorHrefOf(pathNow) || active;++ // Where this page sits, above its own h1. A page that already carries its own local breadcrumb keeps it.++ const found = breadcrumbOf(pathNow);++ const crumb = found && !/aria-label="Breadcrumb"/.test(String(body || '')) ? found : null;++ if (crumb) {++ const html = `<nav class="ds-crumbs" aria-label="Breadcrumb"><a href="${esc(crumb.door.href)}">${esc(crumb.door.name)}</a><span aria-hidden="true">›</span><span aria-current="page">${esc(crumb.name)}</span></nav>`;++ const at = String(body || '').indexOf('<h1');++ body = at === -1 ? `${html}${body || ''}` : `${String(body).slice(0, at)}${html}${String(body).slice(at)}`;++ jsonLd = [...(jsonLd || []), seo.jsonLd.breadcrumbs([++ { name: crumb.door.name, url: `${BASE}${crumb.door.href}` },++ { name: crumb.name, url: `${BASE}${pathNow}` },++ ])];++ }const navbar = {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: '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' }],++ links: NAV.map((l) => ({ ...l, active: l.href === barActive || (l.href !== '/' && String(barActive).startsWith(`${l.href}/`)) })),++ appBar: NAV.map(({ label, href, icon }) => ({ label, href, icon })),history: { type: 'document', title: title || NAME, image_url: image || null },silentLogin: `${BASE}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * The four doors are the whole bar (the owner, 2026-11-10: "There's so many … pages … it needs to be a more cohesive,++ * interconnected product that's more straightforward to use and … consolidated"). This is the one map of where every++ * page sits: the doors in order, their pages in order, each page's icon and the one line it shows on its door.++ *++ * The line is an excerpt of the page's own lede (its h1 where a page has no lede), so a card never invents copy.++ * test/doors.test.js holds every line against the page it came from: when a page's lede changes, that test fails++ * instead of the card drifting.++ *++ * The doors are the bar. A door's own page is at its href (/see, /make, /use); /rules is the register itself, with++ * /system as its second page. Old addresses keep working: `breadcrumbOf` also places the pages that were not in the++ * map (a theme page under Themes, a component page under Components) under the door they belong to.++ */++++/** A page's one line: its lede's first clause, or its h1 when it has no lede. */++const PAGES = {++ look: { name: 'Look', href: '/look', icon: 'eye', line: 'One theme and one set of comfort settings for every OpenVibe site' },++ tokens: { name: 'Tokens', href: '/tokens', icon: 'code', line: 'Take any OpenVibe theme into your own app, its light and dark halves together' },++ brand: { name: 'Brand', href: '/brand', icon: 'star', line: 'The O is the network, open to everyone; the V is the vibe in it' },++ components: { name: 'Components', href: '/components', icon: 'puzzle', line: 'from the shared kit, each with its props, states, tokens, behaviour, accessibility and how it is built on the web, Android and iOS' },++ kit: { name: 'Kit', href: '/kit', icon: 'cubes', line: 'each one function call that returns plain HTML: escaped, phone first, on the site’s own theme' },++ forms: { name: 'Forms', href: '/forms', icon: 'list', line: 'Every select is OpenVibe’s own: a menu on a computer, a sheet from the bottom on a phone' },++ patterns: { name: 'Patterns', href: '/patterns', icon: 'table', line: 'Each pattern pairs a live demo with its rule, the bug that taught it, shared tokens, and generated native examples' },++ motion: { name: 'Motion', href: '/motion', icon: 'wind', line: 'The mark draws its ring, drops its V in on a spring and catches the light once' },++ loading: { name: 'Loading', href: '/loading', icon: 'hourglass', line: 'How OpenVibe waits: the page arrives drawn, a list shows its own shape until it fills in' },++ progress: { name: 'Progress', href: '/progress', icon: 'gauge', line: 'One ov-progress follows a file from selection through transfer and processing' },++ native: { name: 'Native', href: '/native', icon: 'phone', line: 'An OpenVibe app is never a web page in a frame' },++ create: { name: 'Create', href: '/create', icon: 'spark', line: 'What will you design?' },++ themes: { name: 'Themes', href: '/themes', icon: 'palette', line: 'Pick a theme, change any colour, and see it on three sites at once' },++ icons: { name: 'Icons', href: '/icons', icon: 'shapes', line: 'icons in the mark’s own line: a 24-unit grid, strokes two units wide with round ends, in the colour of the text around them' },++ fonts: { name: 'Fonts', href: '/fonts', icon: 'book', line: 'Open licence typefaces with their source and licence beside them' },++ uploader: { name: 'Uploader', href: '/uploader', icon: 'upload', line: 'The picker opens the system chooser on a phone and accepts a drop on a computer' },++ prompts: { name: 'Prompts', href: '/prompts', icon: 'quote', line: 'Every instruction openvibe.design sends to an AI model, word for word' },++ arena: { name: 'Arena', href: '/arena', icon: 'crown', line: 'Two designs from the same brief, the models hidden' },++ rules: { name: 'The register', href: '/rules', icon: 'scale', line: 'Every style, design and organization decision behind OpenVibe, one sentence each' },++ system: { name: 'System', href: '/system', icon: 'layers', line: 'Everything OpenVibe is made of' },++ developers: { name: 'Developers', href: '/developers', icon: 'terminal', line: 'The theme catalog is open JSON' },++};++++const DOORS = [++ {++ id: 'see', name: 'See', href: '/see', icon: 'eye', line: 'How OpenVibe looks and moves.',++ pages: ['look', 'tokens', 'brand', 'components', 'kit', 'forms', 'patterns', 'motion', 'loading', 'progress', 'native'],++ },++ {++ id: 'make', name: 'Make', href: '/make', icon: 'spark', line: 'What you make with it.',++ pages: ['create', 'themes', 'icons', 'fonts', 'uploader', 'prompts', 'arena'],++ },++ {++ id: 'rules', name: 'Rules', href: '/rules', icon: 'scale', line: 'The register, and the system map of everything OpenVibe is made of.',++ pages: ['rules', 'system'],++ },++ {++ id: 'use', name: 'Use', href: '/use', icon: 'terminal', line: 'Building with it.',++ pages: ['developers'],++ },++];++++const doorById = (id) => DOORS.find((d) => d.id === id) || null;++const pageOf = (id) => PAGES[id] || null;++++/** Every page in the map, with its door, in door order. */++function pagesInDoors() {++ return DOORS.flatMap((door) => door.pages.map((id) => ({ ...PAGES[id], door })));++}++++/** The four doors' own addresses: the bar's places, and the only pages that are a door rather than under one. */++const DOOR_ROOTS = new Set(DOORS.map((d) => d.href));++++/**++ * Pages that are not in the map, under the page they extend. Their old addresses are untouched; this only says which++ * door their breadcrumb leads back to.++ */++const EXTRA = [++ [/^\/brand\/explore$/, 'see', 'Brand'],++ [/^\/components\/registry$/, 'see', 'Components'],++ [/^\/components\/[^/]+$/, 'see', 'Components'],++ [/^\/kit\/(?:layout|[^/]+)$/, 'see', 'Kit'],++ [/^\/patterns\/(?!mark\/studies$)[^/]+$/, 'see', 'Patterns'],++ [/^\/icons\/(?:font-awesome-alternative|migrate|kit)$/, 'make', 'Icons'],++ [/^\/icons\/[^/.]+$/, 'make', 'Icons'],++ [/^\/fonts\/make$/, 'make', 'Fonts'],++ [/^\/fonts\/[^/]+$/, 'make', 'Fonts'],++ [/^\/vectors\/make$/, 'make', 'Create'],++ [/^\/arena\/(?:new|r\/[^/]+)$/, 'make', 'Arena'],++ [/^\/t\/[^/]+$/, 'make', 'Themes'],++ [/^\/d\/[^/]+$/, 'make', 'Create'],++];++++/**++ * Where a path sits: { door, name } for a breadcrumb, or null for the home, the 404 and machine endpoints. A door's++ * own page has no crumb (it is the door).++ */++function breadcrumbOf(path) {++ const p = String(path || '').split('?')[0].replace(/\/+$/, '') || '/';++ // /rules is the register itself: the door's first page, so it says where it sits like every other map page.++ if (DOOR_ROOTS.has(p) && p !== '/rules') return null;++ const page = pagesInDoors().find((x) => x.href === p);++ if (page) return { door: page.door, name: page.name };++ for (const [re, doorId, name] of EXTRA) {++ if (re.test(p)) return { door: doorById(doorId), name };++ }++ return null;++}++++/** The bar's active place for a path: the door it sits under. */++function doorHrefOf(path) {++ const crumb = breadcrumbOf(path);++ if (crumb) return crumb.door.href;++ const p = String(path || '').split('?')[0].replace(/\/+$/, '') || '/';++ return DOOR_ROOTS.has(p) ? p : null;++}++++/** "11 pages", "1 page". */++const countOf = (n) => `${n} ${n === 1 ? 'page' : 'pages'}`;++++/** A card's line starts a sentence even when its excerpt starts mid-sentence ("from the shared kit…"). */++const sentence = (line) => String(line || '').charAt(0).toUpperCase() + String(line || '').slice(1);++++module.exports = { PAGES, DOORS, EXTRA, doorById, pageOf, pagesInDoors, breadcrumbOf, doorHrefOf, countOf, sentence, DOOR_ROOTS };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * The four doors' pages (/see, /make, /use) and the four cards that lead the home. Plain kit markup the site already++ * draws: the .ds-system-grid / .ds-sys card is the one the home's "whole system" grid and /system use, and each++ * page's icon is the site's own glyph (openvibe-shared/glyphs). No new look.++ *++ * doorPage(id) one door: its name, one line, its pages as cards in their order, and the count++ * homeDoors() the same cards for the doors themselves, at the top of the home++ */++const kit = require('openvibe-shared/kit');++const glyphs = require('openvibe-shared/glyphs');++const { layout, esc } = require('../render/layout');++const { DOORS, doorById, pageOf, countOf, sentence } = require('../site/doors');++++const icon = (name) => glyphs.svg(name, { size: 22 });++++/** A page's card: its own glyph, its name, and one line from its own lede. */++function pageCard(page) {++ return `<li><a class="ds-sys" href="${esc(page.href)}"><span class="ds-sys-ic">${icon(page.icon)}</span><span class="ds-sys-t"><b>${esc(page.name)}</b></span><small>${esc(sentence(page.line))}</small></a></li>`;++}++++function doorPage(id) {++ const door = doorById(id);++ if (!door) throw new Error(`No door: ${id}`);++ const pages = door.pages.map(pageOf).filter(Boolean);++ const body = `<div class="ds-wrap">++<header class="ds-page-h"><p class="ds-kicker">OpenVibe.Design</p><h1>${esc(door.name)}</h1>++<p class="ds-lede">${esc(door.line)}</p><p class="ds-fine" data-door-count>${esc(countOf(pages.length))}</p></header>++<ul class="ds-system-grid" data-door-pages>${pages.map(pageCard).join('')}</ul>++</div>`;++ return layout({++ title: door.name,++ description: door.line,++ path: door.href,++ active: door.href,++ body,++ });++}++++/** The four doors at the top of the home: the same cards, one line each, linking to the doors. */++function homeDoors() {++ return `<section class="ds-system" aria-labelledby="ds-doors-h"><div class="ds-head-row"><h2 id="ds-doors-h">Four doors</h2><a class="ds-more" href="/system">The system map${kit.icon('chevron')}</a></div>++<p class="ds-fine ds-system-lede">Everything on OpenVibe.Design, in four places.</p>++<ul class="ds-system-grid" data-home-doors>${DOORS.map((door) => `<li><a class="ds-sys" href="${esc(door.href)}"><span class="ds-sys-ic">${icon(door.icon)}</span><span class="ds-sys-t"><b>${esc(door.name)}</b></span><small>${esc(door.line)}</small></a></li>`).join('')}</ul></section>`;++}++++module.exports = { doorPage, homeDoors, pageCard };@@@ -14,6 +14,7 @@const layoutLib = require('../render/layout');const kit = require('openvibe-shared/kit');const seo = require('openvibe-shared/seo');++const { homeDoors } = require('./doors');// The short home (the owner's rule, 2026-10-30): pick blocks and folds.const { pickBlock, foldAround } = require('../render/short');const sc = require('openvibe-shared/showcase');@@@ -189,7 +190,7 @@ function home({ themes } = {}) {body = foldAround(body, '>Questions</h2>', 'faq', '<h2 id="ds-faq-h">Questions</h2>');return layout({title: null, description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.',-- path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/',++ path: '/', body: `<div class="ds-wrap">${homeDoors()}${body}</div>`, active: '/',jsonLd: [seo.jsonLd.website({ name: 'OpenVibe.Design', url: 'https://openvibe.design/', description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.' }), seo.jsonLd.organization()],// The rule that shows the picked scene of the pick block: one per scene, written for this page.});@@@ -248,6 +249,29 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}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' });}++// The SDK and the versions this site installs, read from its own package.json so the page never states a stale pin.++const DESIGN_PACKAGE = require('../../package.json');++function pinOf(name) {++ const m = String((DESIGN_PACKAGE.dependencies || {})[name] || '').match(/refs\/tags\/v([^/]+)$/);++ return m ? m[1] : null;++}++function sdkSection() {++ const sdk = pinOf('openvibe-sdk');++ const versions = [['openvibe-contracts', pinOf('openvibe-contracts')], ['openvibe-sdk', sdk], ['openvibe-shared', pinOf('openvibe-shared')], ['openvibe-publishing', pinOf('openvibe-publishing')]]++ .filter(([, v]) => v);++ const pin = sdk ? `"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${sdk}"` : '';++ return `<section aria-labelledby="ds-sdk-h" id="sdk"><h2 id="ds-sdk-h">The SDK, the files and the versions</h2>++<p>openvibe-sdk is the supported client for this platform: the server client, the browser client, their ES modules and their TypeScript declarations. It ships from the OpenVibers/OpenVibe.SDK repository by tag, so a service pins a version rather than floating on a branch.</p>++${pin ? `<p class="ds-fine">Add the pin to <code>package.json</code>, then install:</p>${kit.snippet({ code: `{ "dependencies": {\n ${pin}\n} }`, label: 'Copy the pin' })}` : ''}++<dl class="ds-eps">++<div><dt><code>index.js</code></dt><dd>The server client: auth, accounts, the async PostgreSQL data layer, events, media, AI runs and tools.</dd></div>++<div><dt><code>browser.js</code></dt><dd>The browser client, with the same subpath modules where the browser has them.</dd></div>++<div><dt><code>esm/*.mjs</code>, <code>types/*.d.ts</code></dt><dd>The ES module entry points and the TypeScript declarations, one pair per subpath export.</dd></div>++${versions.map(([name, v]) => `<div><dt><code>${esc(name)}</code></dt><dd>v${esc(v)}, pinned by this site.</dd></div>`).join('')}++</dl>++<p class="ds-fine">The deployed release and its file hashes are at <a href="/release.json">/release.json</a>; the whole HTTP API is described by <a href="/openapi.json">/openapi.json</a>; an AI assistant can call it through the MCP server at <code>POST /mcp</code>.</p></section>`;++}++function developers() {const rows = [['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],@@@ -269,6 +293,7 @@ function developers() {['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],];const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>++${sdkSection()}<section aria-labelledby="ds-ep-h" id="tokens"><span id="mcp"></span><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section><p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });@@@ -37,6 +37,8 @@ function rulesPage() {${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>`;++ // The Rules door's two pages: this register, then the System map (the owner, 2026-11-10).++ const door = `<p class="rl-links rl-door"><span class="rl-label">Rules</span><span class="rl-chip rl-chip--check" aria-current="page">The register</span><a class="rl-chip" href="/system">The system map</a></p>`;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>`;@@@ -55,7 +57,7 @@ function rulesPage() {styles: ['css/rules.css'],scripts: ['js/rules.js'],jsonLd,-- body: `<article class="rl">${hero}${index}${latestBlock}${sections}${data}</article>`,++ body: `<article class="rl">${hero}${door}${index}${latestBlock}${sections}${data}</article>`,});}@@@ -53,7 +53,8 @@ const { FILES, STATES } = require('../server/views/brand');const pattern = await t.get('/patterns/mark-system');assert.ok(pattern.text.includes('href="/brand"'), 'the mark pattern links it');const page = await t.get('/brand');-- assert.ok(/"href":"\/brand"[^}]*"active":true/.test(page.text), 'the navbar marks it');++ assert.ok(/"href":"\/see"[^}]*"active":true/.test(page.text), 'the bar marks the See door');++ assert.match(page.text, /<a href="\/see">See<\/a>[\s\S]*?aria-current="page">Brand</, 'and the breadcrumb says where it sits');});} finally {await t.close();nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * The four doors (the owner, 2026-11-10): the bar is four links; /see, /make and /use list exactly their pages in++ * order, each card with its page's own glyph and a line from its own lede; every page in the map says "Door › Page"++ * above its h1; the home leads with the doors; the sitemap and llms.txt list the doors first.++ */++const assert = require('assert');++const glyphs = require('openvibe-shared/glyphs');++const { boot, check, done } = require('./helpers/boot');++const { NAV } = require('../server/render/layout');++const { DOORS, PAGES, pagesInDoors, countOf } = require('../server/site/doors');++const doorsView = require('../server/views/doors');++++/** HTML to the words a reader sees: tags and scripts out, entities in, whitespace and case folded. */++function words(html) {++ return String(html)++ .replace(/<script[\s\S]*?<\/script>/gi, ' ')++ .replace(/<style[\s\S]*?<\/style>/gi, ' ')++ .replace(/<[^>]+>/g, ' ')++ .replace(/ /g, ' ')++ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')++ .replace(/'|'|’|‘|’|‘/g, "'")++ .replace(/“|”|“|”/g, '"').replace(/—/g, '—').replace(/–/g, '–')++ .replace(/\s+/g, ' ').trim().toLowerCase();++}++++const cardsIn = (html, marker) => {++ const start = html.indexOf(marker);++ if (start === -1) return [];++ const end = html.indexOf('</ul>', start);++ return [...html.slice(start, end).matchAll(/<a class="ds-sys" href="([^"]+)"[^>]*>[\s\S]*?<b>([^<]+)<\/b><\/span><small>([^<]*)<\/small>/g)]++ .map((m) => ({ href: m[1], name: m[2], line: m[3] }));++};++++(async () => {++ const t = await boot();++ // One fetch per page across the checks: the app renders the whole catalogue on some of these.++ const seen = new Map();++ const get = (p) => { if (!seen.has(p)) seen.set(p, t.get(p)); return seen.get(p); };++ try {++ await check('a card is plain kit markup: a link, its glyph, its name and its line', async () => {++ const card = doorsView.pageCard(PAGES.look);++ assert.match(card, /^<li><a class="ds-sys" href="\/look">/);++ assert.match(card, /<b>Look<\/b>/);++ assert.ok(card.includes('One theme and one set of comfort settings'));++ });++++ await check('the bar is the four doors, in order, and nothing under More', async () => {++ assert.deepStrictEqual(NAV.map((l) => l.href), ['/see', '/make', '/rules', '/use']);++ assert.deepStrictEqual(NAV.map((l) => l.label), ['See', 'Make', 'Rules', 'Use']);++ assert.ok(NAV.every((l) => !l.more), 'no More menu');++ const home = await get('/');++ const json = home.text.match(/window\.__OV_PAGE = (\{[\s\S]*?\});/);++ assert.ok(json, 'the page carries its navbar config');++ const page = JSON.parse(json[1]);++ assert.deepStrictEqual(page.navbar.links.map((l) => l.href), ['/see', '/make', '/rules', '/use'], 'four links in the bar');++ assert.deepStrictEqual(page.navbar.appBar.map((l) => l.href), ['/see', '/make', '/rules', '/use'], 'the phone bar too');++ });++++ await check('each door page has its h1, its one line, its count and exactly its pages as cards in order', async () => {++ for (const door of DOORS) {++ if (door.id === 'rules') continue; // the register is its own page, with /system as its second (below)++ const r = await get(door.href);++ assert.strictEqual(r.status, 200, door.href);++ assert.match(r.text, new RegExp(`<h1>${door.name}</h1>`), `${door.href} h1`);++ assert.ok(r.text.includes(door.line), `${door.href} says what is behind it`);++ assert.match(r.text, new RegExp(`data-door-count>${countOf(door.pages.length)}<`), `${door.href} counts its pages`);++ const cards = cardsIn(r.text, 'data-door-pages');++ assert.deepStrictEqual(cards.map((c) => c.href), door.pages.map((id) => PAGES[id].href), `${door.href} page order`);++ assert.strictEqual(cards.length, door.pages.length, `${door.href} lists exactly its pages`);++ }++ });++++ await check('each card uses its page\'s own glyph and a line from that page\'s own lede', async () => {++ for (const page of pagesInDoors()) {++ // The line is held against the page it came from: when the page's copy changes, this fails instead of++ // the card drifting. (Create has no lede; its h1 is the line.)++ const source = await get(page.href);++ assert.strictEqual(source.status, 200, page.href);++ assert.ok(words(source.text).includes(words(page.line)), `${page.href} still says "${page.line}"`);++ }++ for (const door of DOORS.filter((d) => d.id !== 'rules')) {++ const r = await get(door.href);++ for (const id of door.pages) {++ assert.ok(r.text.includes(glyphs.svg(PAGES[id].icon, { size: 22 })), `${door.href}: ${PAGES[id].name}'s own glyph`);++ }++ }++ });++++ await check('every page in the map says where it sits, above its h1, and the door links to its page', async () => {++ for (const page of pagesInDoors()) {++ const r = await get(page.href);++ const at = r.text.indexOf('<nav class="ds-crumbs"');++ const h1 = r.text.indexOf('<h1');++ assert.ok(at !== -1 && at < h1, `${page.href}: breadcrumb above the h1`);++ const crumb = r.text.slice(at, r.text.indexOf('</nav>', at));++ assert.ok(crumb.includes(`<a href="${page.door.href}">${page.door.name}</a>`), `${page.href}: the door links to ${page.door.href}`);++ assert.ok(crumb.includes(`aria-current="page">${page.name}<`), `${page.href}: names ${page.name}`);++ }++ });++++ await check('Rules is the register, with the system map as its second page', async () => {++ const r = await get('/rules');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /aria-current="page">The register</, 'the register is the door\'s first page');++ assert.match(r.text, /class="rl-chip" href="\/system">The system map</, 'the system map is its second page');++ const sys = await get('/system');++ assert.match(sys.text, /<a href="\/rules">Rules<\/a>[\s\S]*?aria-current="page">System</, 'System sits under Rules');++ });++++ await check('the home leads with the four doors and keeps everything below them', async () => {++ const home = await get('/');++ const cards = cardsIn(home.text, 'data-home-doors');++ assert.deepStrictEqual(cards.map((c) => c.href), DOORS.map((d) => d.href), 'the four doors, in order');++ assert.ok(home.text.indexOf('data-home-doors') < home.text.indexOf('sc-room'), 'above what the home showed before');++ assert.ok(home.text.includes('Your look, everywhere'), 'the hero is still there');++ assert.ok(home.text.includes('ds-system-grid'), 'the whole system is still there');++ });++++ await check('the Use door covers the SDK, its files and the versions this site pins', async () => {++ const r = await get('/developers');++ assert.match(r.text, /id="sdk">[\s\S]*?The SDK, the files and the versions/, 'the SDK section');++ for (const name of ['openvibe-sdk', 'openvibe-contracts', 'openvibe-shared']) {++ assert.ok(r.text.includes(`${name}</code></dt><dd>v`), `${name}'s pinned version`);++ }++ assert.match(r.text, /codeload\.github\.com\/OpenVibers\/OpenVibe\.SDK\/tar\.gz\/refs\/tags\/v[0-9]/, 'the SDK pin');++ assert.match(r.text, /href="\/release\.json"/, 'the files and versions this release serves');++ });++++ await check('a page that was not in the map sits under the page it extends', async () => {++ const expected = [++ ['/t/aurora', 'Make', '/make', 'Themes'],++ ['/components/registry', 'See', '/see', 'Components'],++ ['/patterns/tabs-not-folds', 'See', '/see', 'Patterns'],++ ['/fonts/make', 'Make', '/make', 'Fonts'],++ ['/vectors/make', 'Make', '/make', 'Create'],++ ['/arena/new', 'Make', '/make', 'Arena'],++ ];++ for (const [path, door, href, page] of expected) {++ const r = await get(path);++ assert.strictEqual(r.status, 200, path);++ const at = r.text.indexOf('<nav class="ds-crumbs"');++ assert.ok(at !== -1 && at < r.text.indexOf('<h1'), `${path}: a breadcrumb above the h1`);++ const crumb = r.text.slice(at, r.text.indexOf('</nav>', at));++ assert.ok(crumb.includes(`<a href="${href}">${door}</a>`) && crumb.includes(`aria-current="page">${page}<`), `${path}: ${door} › ${page}`);++ }++ });++++ await check('the sitemap and llms.txt list the doors first', async () => {++ const map = await t.get('/sitemap.xml');++ const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.deepStrictEqual(locs.slice(0, 4), ['https://openvibe.design/see', 'https://openvibe.design/make', 'https://openvibe.design/rules', 'https://openvibe.design/use']);++ const llms = await t.get('/llms.txt');++ const text = llms.text;++ assert.ok(text.indexOf('## The four doors') !== -1, 'the doors are a section');++ assert.ok(text.indexOf('## The four doors') < text.indexOf('## Start here'), 'the doors come first');++ for (const door of DOORS) assert.ok(text.includes(`https://openvibe.design${door.href}`), `llms lists ${door.href}`);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((error) => { console.error(error); process.exitCode = 1; });@@@ -26,8 +26,8 @@ const { ATTRS } = require('../server/views/forms');assert.match(html, /\/js\/forms-page\.js/);assert.match(html, /data-ov-forms="off"|data-ov-forms="off"/, 'how a site keeps the browser\'s own');});-- await check('found: the menu, the sitemap and llms.txt', async () => {-- assert.ok((await t.get('/')).text.includes('/forms'), 'the menu');++ await check('found: the See door, the sitemap and llms.txt', async () => {++ assert.ok((await t.get('/see')).text.includes('/forms'), 'the See door');assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/forms'));assert.ok((await t.get('/llms.txt')).text.includes('/forms'));});@@@ -253,7 +253,9 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headerawait check('it is in the navbar, the sitemap and llms.txt', async () => {assert.ok((await t.get('/sitemap.xml')).text.includes('/icons</loc>'), 'sitemap');assert.ok((await t.get('/llms.txt')).text.includes('/icons'), 'llms.txt');-- assert.ok(/"href":"\/icons"[^}]*"active":true/.test((await t.get('/icons')).text), 'the navbar marks it');++ const page = await t.get('/icons');++ assert.ok(/"href":"\/make"[^}]*"active":true/.test(page.text), 'the bar marks the Make door');++ assert.match(page.text, /<a href="\/make">Make<\/a>[\s\S]*?aria-current="page">Icons</, 'and the breadcrumb says where it sits');});} finally {await t.close();@@@ -32,8 +32,8 @@ const { SHAPES } = require('../server/views/loading');assert.match(js, /window\.OpenVibeLoading\.mount\(screen/);assert.match(js, /nav\.busy\(true\)[\s\S]*nav\.busy\(false\)/);});-- await check('found: the menu, the sitemap and llms.txt', async () => {-- assert.ok((await t.get('/')).text.includes('/loading'), 'the menu');++ await check('found: the See door, the sitemap and llms.txt', async () => {++ assert.ok((await t.get('/see')).text.includes('/loading'), 'the See door');assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/loading'));assert.ok((await t.get('/llms.txt')).text.includes('/loading'));});@@@ -1,7 +1,7 @@'use strict';// /native (11-07): the road from the web to Android and iOS, without a frame. The page renders whole without// JavaScript, links each shared layer, says every size class, every platform difference and every road step, is in--// the sitemap, and is reachable from the bar's More.++// the sitemap, and sits under the See door (its breadcrumb leads back).const assert = require('node:assert/strict');const { boot, check, done } = require('./helpers/boot');const { LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST } = require('../server/views/native');@@@ -22,10 +22,12 @@ const { NAV } = require('../server/render/layout');assert.ok(r.text.includes('/css/native.css'), 'its stylesheet');assert.ok(!/<script[^>]*native/.test(r.text), 'no page script: whole without JavaScript');});-- await check('it is in the sitemap and under More', async () => {++ await check('it is in the sitemap and under the See door', async () => {const map = await t.get('/sitemap.xml');assert.match(map.text, /\/native</);-- assert.ok(NAV.some((l) => l.href === '/native' && l.more), 'More lists it');++ const page = await t.get('/native');++ assert.match(page.text, /<a href="\/see">See<\/a>[\s\S]*?aria-current="page">Native</, 'its breadcrumb is See › Native');++ assert.ok(NAV.some((l) => l.href === '/see'), 'See is a door in the bar');const css = await t.get('/css/native.css');assert.equal(css.status, 200);});@@@ -22,7 +22,8 @@ const unescape = (s) => s.replace(/'/g, "'").replace(/"/g, '"').replaceassert.ok(html.includes(themePrompt('{your prompt}', ["{the theme's colour variables}"])), 'the theme prompt');assert.ok(html.includes(editPrompt('{your instruction}', '{the current page}', "{the theme's colour variables}")), 'the edit prompt');assert.ok(html.includes(SYSTEM.slice(0, 200)), 'the icon prompt');-- assert.match(r.text, /href="\/prompts"/, 'linked from the navigation');++ assert.match(r.text, /<a href="\/make">Make<\/a>[\s\S]*?aria-current="page">Prompts</, 'it sits under the Make door');++ assert.match((await t.get('/make')).text, /href="\/prompts"/, 'the Make door lists it');});await check('the same list as JSON, cacheable', async () => {const r = await t.get('/api/v1/prompts');@@@ -7,6 +7,7 @@ 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');++const rulesView = require('../server/views/rules');(async () => {const t = await boot();@@@ -92,12 +93,18 @@ const { NAV } = require('../server/render/layout');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']);++ await check('the register names the Rules door and its second page', async () => {++ const html = rulesView.rulesPage();++ assert.match(html, /class="rl-chip rl-chip--check" aria-current="page">The register</);++ assert.match(html, /class="rl-chip" href="\/system">The system map</);++ });++++ await check('the bar is the four doors, in order, and no two share an icon', async () => {++ assert.equal(NAV.length, 4, NAV.map((l) => l.label).join(', '));++ assert.deepEqual(NAV.map((l) => l.href), ['/see', '/make', '/rules', '/use']);++ assert.ok(NAV.every((l) => !l.more), 'nothing under More: the four doors are the whole bar');const icons = NAV.map((l) => l.icon);-- assert.equal(new Set(icons).size, icons.length, 'one icon per place');++ assert.equal(new Set(icons).size, icons.length, 'one icon per door');assert.ok(NAV.every((l) => l.label.length <= 22), 'labels stay short');});} finally {
@@ -14,6 +14,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -14,6 +14,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.ds-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
.ds-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
.ds-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }
.ds-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }
+/* Where a page sits, above its h1: plain text and one link, the same on every page (the four doors, 2026-11-10). */
+.ds-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 10px; font-size: .86rem; color: var(--text-muted, #7386a3); }
+.ds-crumbs a { color: var(--text-secondary, #96a7c2); text-decoration: none; font-weight: 700; }
+.ds-crumbs a:hover { color: var(--room-strong); }
.ds-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ds-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ds-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; color: var(--room-strong); }
.ds-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; color: var(--room-strong); }
.ds-more .ovk-i { width: 1em; height: 1em; }
.ds-more .ovk-i { width: 1em; height: 1em; }
@@ -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|rules|system|themes|tokens|vectors\/make|t\/[A-Za-z0-9_-]+)?$/;
+const TWIN_PATH_RE = /^\/(?:arena|brand|components|developers|fonts(?:\/make)?|forms|kit|loading|look|make|motion|rules|see|system|themes|tokens|use|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() {
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
* 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 /, /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.
+ * The sitemap leads with the four doors (/see, /make, /rules, /use), then /, /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');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -29,10 +29,14 @@ function siteUpdated() {
@@ -29,10 +29,14 @@ function siteUpdated() {
/** Every public page, in the order a crawler should read them. */
/** Every public page, in the order a crawler should read them. */
function publicPages(themes = []) {
function publicPages(themes = []) {
const static_ = [
const static_ = [
+ // The four doors lead (the owner, 2026-11-10): the bar, and the first thing a crawler reads.
+ { path: '/see', changefreq: 'weekly', priority: 1.0, title: 'See: how OpenVibe looks and moves', text: 'Look, tokens, the mark, components, the kit, forms, patterns, motion, loading, progress and native apps: how OpenVibe looks and moves.' },
+ { path: '/make', changefreq: 'weekly', priority: 0.9, title: 'Make: what you make with it', text: 'Create, themes, icons, fonts, the uploader, prompts and the Arena: what you make with OpenVibe\'s design system.' },
+ { path: '/rules', changefreq: 'weekly', priority: 0.9, 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: '/use', changefreq: 'weekly', priority: 0.8, title: 'Use: building with it', text: 'The SDK, the API, the files and the versions, and how to install and call them in your own app.' },
{ 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: '/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: '/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: '/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,6 +104,13 @@ function createDiscoveryRoutes(ctx) {
@@ -100,6 +104,13 @@ function createDiscoveryRoutes(ctx) {
markdown: true,
markdown: true,
base: config.baseUrl,
base: config.baseUrl,
sections: [
sections: [
+ // The four doors come first: the bar, and the map every page says it sits in.
+ { title: 'The four doors', links: [
+ { title: 'See', url: abs('/see'), note: 'how OpenVibe looks and moves: look, tokens, the mark, components, the kit, forms, patterns, motion, loading, progress and native apps' },
+ { title: 'Make', url: abs('/make'), note: 'what you make with it: create, themes, icons, fonts, uploader, prompts and the Arena' },
+ { title: 'Rules', url: abs('/rules'), note: 'the register of every style, design and organization decision, then the system map at /system' },
+ { title: 'Use', url: abs('/use'), note: 'building with it: the SDK, the API, the files and the versions' },
+ ] },
{ 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: 'Themes', url: abs('/themes'), 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' },
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
/**
/**
* The pages:
* The pages:
+ * /see, /make, /use the doors: their pages as cards in order, each from its own lede (views/doors.js)
* / the theme catalogue's front door
* / the theme catalogue's front door
* /themes the theme editor and gallery (/studio answers 301)
* /themes the theme editor and gallery (/studio answers 301)
* /rules the decision register (its Markdown twin /rules.md; data at /api/v1/rules)
* /rules the decision register (its Markdown twin /rules.md; data at /api/v1/rules)
@@ -71,6 +72,11 @@ function createPageRoutes(ctx) {
@@ -71,6 +72,11 @@ function createPageRoutes(ctx) {
return subject ? await arena.counts(subject).catch(() => null) : null;
return subject ? await arena.counts(subject).catch(() => null) : null;
};
};
+ // ── The four doors: the site's whole bar, and where every page says it sits (server/views/doors.js) ──
+ r.get('/see', (req, res) => page(res, 200, require('../views/doors').doorPage('see'), req.viewer));
+ r.get('/make', (req, res) => page(res, 200, require('../views/doors').doorPage('make'), req.viewer));
+ r.get('/use', (req, res) => page(res, 200, require('../views/doors').doorPage('use'), req.viewer));
+
// ── Production patterns: one data-backed page and generated native spec per pattern ──
// ── Production patterns: one data-backed page and generated native spec per pattern ──
// ── The OpenVibe mark, and its files ──
// ── The OpenVibe mark, and its files ──
r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));
r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));
@@ -13,39 +13,18 @@ const shell = require('openvibe-shared/shell');
@@ -13,39 +13,18 @@ const shell = require('openvibe-shared/shell');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const seo = require('openvibe-shared/seo');
const rooms = require('openvibe-shared/rooms');
const rooms = require('openvibe-shared/rooms');
const { asset, assetVersion } = require('../assets');
const { asset, assetVersion } = require('../assets');
+const { DOORS, breadcrumbOf, doorHrefOf } = require('../site/doors');
const NAME = 'OpenVibe.Design';
const NAME = 'OpenVibe.Design';
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
const ROOM = rooms.get('design');
const ROOM = rooms.get('design');
const esc = kit.esc;
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-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: '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-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-gauge-high', more: true },
- { label: 'Uploader', href: '/uploader', icon: 'fa-upload', 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 },
-];
+// Four doors, in this order, are the site's whole bar (the owner, 2026-11-10). The brand is the way home.
+const NAV = DOORS.map((door) => ({ label: door.name, href: door.href, icon: { eye: 'fa-eye', spark: 'fa-wand-magic-sparkles', scale: 'fa-scale-balanced', terminal: 'fa-terminal' }[door.icon] }));
let RELEASE = 'dev';
let RELEASE = 'dev';
let BASE = 'https://openvibe.design';
let BASE = 'https://openvibe.design';
@@ -58,11 +37,25 @@ const canonical = (p) => `${BASE}${p || '/'}`;
@@ -58,11 +37,25 @@ const canonical = (p) => `${BASE}${p || '/'}`;
function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
const pathNow = path || active || '/';
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
canonical = canonical || (path ? `${BASE}${path}` : undefined);
+ // The bar marks the door a page sits under, not the page itself (there are four places now).
+ const barActive = doorHrefOf(pathNow) || active;
+ // Where this page sits, above its own h1. A page that already carries its own local breadcrumb keeps it.
+ const found = breadcrumbOf(pathNow);
+ const crumb = found && !/aria-label="Breadcrumb"/.test(String(body || '')) ? found : null;
+ if (crumb) {
+ const html = `<nav class="ds-crumbs" aria-label="Breadcrumb"><a href="${esc(crumb.door.href)}">${esc(crumb.door.name)}</a><span aria-hidden="true">›</span><span aria-current="page">${esc(crumb.name)}</span></nav>`;
+ const at = String(body || '').indexOf('<h1');
+ body = at === -1 ? `${html}${body || ''}` : `${String(body).slice(0, at)}${html}${String(body).slice(at)}`;
+ jsonLd = [...(jsonLd || []), seo.jsonLd.breadcrumbs([
+ { name: crumb.door.name, url: `${BASE}${crumb.door.href}` },
+ { name: crumb.name, url: `${BASE}${pathNow}` },
+ ])];
+ }
const navbar = {
const navbar = {
service: 'design',
service: 'design',
apiBase: NETWORK,
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: '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' }],
+ links: NAV.map((l) => ({ ...l, active: l.href === barActive || (l.href !== '/' && String(barActive).startsWith(`${l.href}/`)) })),
+ appBar: NAV.map(({ label, href, icon }) => ({ label, href, icon })),
history: { type: 'document', title: title || NAME, image_url: image || null },
history: { type: 'document', title: title || NAME, image_url: image || null },
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,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * The four doors are the whole bar (the owner, 2026-11-10: "There's so many … pages … it needs to be a more cohesive,
+ * interconnected product that's more straightforward to use and … consolidated"). This is the one map of where every
+ * page sits: the doors in order, their pages in order, each page's icon and the one line it shows on its door.
+ *
+ * The line is an excerpt of the page's own lede (its h1 where a page has no lede), so a card never invents copy.
+ * test/doors.test.js holds every line against the page it came from: when a page's lede changes, that test fails
+ * instead of the card drifting.
+ *
+ * The doors are the bar. A door's own page is at its href (/see, /make, /use); /rules is the register itself, with
+ * /system as its second page. Old addresses keep working: `breadcrumbOf` also places the pages that were not in the
+ * map (a theme page under Themes, a component page under Components) under the door they belong to.
+ */
+
+/** A page's one line: its lede's first clause, or its h1 when it has no lede. */
+const PAGES = {
+ look: { name: 'Look', href: '/look', icon: 'eye', line: 'One theme and one set of comfort settings for every OpenVibe site' },
+ tokens: { name: 'Tokens', href: '/tokens', icon: 'code', line: 'Take any OpenVibe theme into your own app, its light and dark halves together' },
+ brand: { name: 'Brand', href: '/brand', icon: 'star', line: 'The O is the network, open to everyone; the V is the vibe in it' },
+ components: { name: 'Components', href: '/components', icon: 'puzzle', line: 'from the shared kit, each with its props, states, tokens, behaviour, accessibility and how it is built on the web, Android and iOS' },
+ kit: { name: 'Kit', href: '/kit', icon: 'cubes', line: 'each one function call that returns plain HTML: escaped, phone first, on the site’s own theme' },
+ forms: { name: 'Forms', href: '/forms', icon: 'list', line: 'Every select is OpenVibe’s own: a menu on a computer, a sheet from the bottom on a phone' },
+ patterns: { name: 'Patterns', href: '/patterns', icon: 'table', line: 'Each pattern pairs a live demo with its rule, the bug that taught it, shared tokens, and generated native examples' },
+ motion: { name: 'Motion', href: '/motion', icon: 'wind', line: 'The mark draws its ring, drops its V in on a spring and catches the light once' },
+ loading: { name: 'Loading', href: '/loading', icon: 'hourglass', line: 'How OpenVibe waits: the page arrives drawn, a list shows its own shape until it fills in' },
+ progress: { name: 'Progress', href: '/progress', icon: 'gauge', line: 'One ov-progress follows a file from selection through transfer and processing' },
+ native: { name: 'Native', href: '/native', icon: 'phone', line: 'An OpenVibe app is never a web page in a frame' },
+ create: { name: 'Create', href: '/create', icon: 'spark', line: 'What will you design?' },
+ themes: { name: 'Themes', href: '/themes', icon: 'palette', line: 'Pick a theme, change any colour, and see it on three sites at once' },
+ icons: { name: 'Icons', href: '/icons', icon: 'shapes', line: 'icons in the mark’s own line: a 24-unit grid, strokes two units wide with round ends, in the colour of the text around them' },
+ fonts: { name: 'Fonts', href: '/fonts', icon: 'book', line: 'Open licence typefaces with their source and licence beside them' },
+ uploader: { name: 'Uploader', href: '/uploader', icon: 'upload', line: 'The picker opens the system chooser on a phone and accepts a drop on a computer' },
+ prompts: { name: 'Prompts', href: '/prompts', icon: 'quote', line: 'Every instruction openvibe.design sends to an AI model, word for word' },
+ arena: { name: 'Arena', href: '/arena', icon: 'crown', line: 'Two designs from the same brief, the models hidden' },
+ rules: { name: 'The register', href: '/rules', icon: 'scale', line: 'Every style, design and organization decision behind OpenVibe, one sentence each' },
+ system: { name: 'System', href: '/system', icon: 'layers', line: 'Everything OpenVibe is made of' },
+ developers: { name: 'Developers', href: '/developers', icon: 'terminal', line: 'The theme catalog is open JSON' },
+};
+
+const DOORS = [
+ {
+ id: 'see', name: 'See', href: '/see', icon: 'eye', line: 'How OpenVibe looks and moves.',
+ pages: ['look', 'tokens', 'brand', 'components', 'kit', 'forms', 'patterns', 'motion', 'loading', 'progress', 'native'],
+ },
+ {
+ id: 'make', name: 'Make', href: '/make', icon: 'spark', line: 'What you make with it.',
+ pages: ['create', 'themes', 'icons', 'fonts', 'uploader', 'prompts', 'arena'],
+ },
+ {
+ id: 'rules', name: 'Rules', href: '/rules', icon: 'scale', line: 'The register, and the system map of everything OpenVibe is made of.',
+ pages: ['rules', 'system'],
+ },
+ {
+ id: 'use', name: 'Use', href: '/use', icon: 'terminal', line: 'Building with it.',
+ pages: ['developers'],
+ },
+];
+
+const doorById = (id) => DOORS.find((d) => d.id === id) || null;
+const pageOf = (id) => PAGES[id] || null;
+
+/** Every page in the map, with its door, in door order. */
+function pagesInDoors() {
+ return DOORS.flatMap((door) => door.pages.map((id) => ({ ...PAGES[id], door })));
+}
+
+/** The four doors' own addresses: the bar's places, and the only pages that are a door rather than under one. */
+const DOOR_ROOTS = new Set(DOORS.map((d) => d.href));
+
+/**
+ * Pages that are not in the map, under the page they extend. Their old addresses are untouched; this only says which
+ * door their breadcrumb leads back to.
+ */
+const EXTRA = [
+ [/^\/brand\/explore$/, 'see', 'Brand'],
+ [/^\/components\/registry$/, 'see', 'Components'],
+ [/^\/components\/[^/]+$/, 'see', 'Components'],
+ [/^\/kit\/(?:layout|[^/]+)$/, 'see', 'Kit'],
+ [/^\/patterns\/(?!mark\/studies$)[^/]+$/, 'see', 'Patterns'],
+ [/^\/icons\/(?:font-awesome-alternative|migrate|kit)$/, 'make', 'Icons'],
+ [/^\/icons\/[^/.]+$/, 'make', 'Icons'],
+ [/^\/fonts\/make$/, 'make', 'Fonts'],
+ [/^\/fonts\/[^/]+$/, 'make', 'Fonts'],
+ [/^\/vectors\/make$/, 'make', 'Create'],
+ [/^\/arena\/(?:new|r\/[^/]+)$/, 'make', 'Arena'],
+ [/^\/t\/[^/]+$/, 'make', 'Themes'],
+ [/^\/d\/[^/]+$/, 'make', 'Create'],
+];
+
+/**
+ * Where a path sits: { door, name } for a breadcrumb, or null for the home, the 404 and machine endpoints. A door's
+ * own page has no crumb (it is the door).
+ */
+function breadcrumbOf(path) {
+ const p = String(path || '').split('?')[0].replace(/\/+$/, '') || '/';
+ // /rules is the register itself: the door's first page, so it says where it sits like every other map page.
+ if (DOOR_ROOTS.has(p) && p !== '/rules') return null;
+ const page = pagesInDoors().find((x) => x.href === p);
+ if (page) return { door: page.door, name: page.name };
+ for (const [re, doorId, name] of EXTRA) {
+ if (re.test(p)) return { door: doorById(doorId), name };
+ }
+ return null;
+}
+
+/** The bar's active place for a path: the door it sits under. */
+function doorHrefOf(path) {
+ const crumb = breadcrumbOf(path);
+ if (crumb) return crumb.door.href;
+ const p = String(path || '').split('?')[0].replace(/\/+$/, '') || '/';
+ return DOOR_ROOTS.has(p) ? p : null;
+}
+
+/** "11 pages", "1 page". */
+const countOf = (n) => `${n} ${n === 1 ? 'page' : 'pages'}`;
+
+/** A card's line starts a sentence even when its excerpt starts mid-sentence ("from the shared kit…"). */
+const sentence = (line) => String(line || '').charAt(0).toUpperCase() + String(line || '').slice(1);
+
+module.exports = { PAGES, DOORS, EXTRA, doorById, pageOf, pagesInDoors, breadcrumbOf, doorHrefOf, countOf, sentence, DOOR_ROOTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * The four doors' pages (/see, /make, /use) and the four cards that lead the home. Plain kit markup the site already
+ * draws: the .ds-system-grid / .ds-sys card is the one the home's "whole system" grid and /system use, and each
+ * page's icon is the site's own glyph (openvibe-shared/glyphs). No new look.
+ *
+ * doorPage(id) one door: its name, one line, its pages as cards in their order, and the count
+ * homeDoors() the same cards for the doors themselves, at the top of the home
+ */
+const kit = require('openvibe-shared/kit');
+const glyphs = require('openvibe-shared/glyphs');
+const { layout, esc } = require('../render/layout');
+const { DOORS, doorById, pageOf, countOf, sentence } = require('../site/doors');
+
+const icon = (name) => glyphs.svg(name, { size: 22 });
+
+/** A page's card: its own glyph, its name, and one line from its own lede. */
+function pageCard(page) {
+ return `<li><a class="ds-sys" href="${esc(page.href)}"><span class="ds-sys-ic">${icon(page.icon)}</span><span class="ds-sys-t"><b>${esc(page.name)}</b></span><small>${esc(sentence(page.line))}</small></a></li>`;
+}
+
+function doorPage(id) {
+ const door = doorById(id);
+ if (!door) throw new Error(`No door: ${id}`);
+ const pages = door.pages.map(pageOf).filter(Boolean);
+ const body = `<div class="ds-wrap">
+<header class="ds-page-h"><p class="ds-kicker">OpenVibe.Design</p><h1>${esc(door.name)}</h1>
+<p class="ds-lede">${esc(door.line)}</p><p class="ds-fine" data-door-count>${esc(countOf(pages.length))}</p></header>
+<ul class="ds-system-grid" data-door-pages>${pages.map(pageCard).join('')}</ul>
+</div>`;
+ return layout({
+ title: door.name,
+ description: door.line,
+ path: door.href,
+ active: door.href,
+ body,
+ });
+}
+
+/** The four doors at the top of the home: the same cards, one line each, linking to the doors. */
+function homeDoors() {
+ return `<section class="ds-system" aria-labelledby="ds-doors-h"><div class="ds-head-row"><h2 id="ds-doors-h">Four doors</h2><a class="ds-more" href="/system">The system map${kit.icon('chevron')}</a></div>
+<p class="ds-fine ds-system-lede">Everything on OpenVibe.Design, in four places.</p>
+<ul class="ds-system-grid" data-home-doors>${DOORS.map((door) => `<li><a class="ds-sys" href="${esc(door.href)}"><span class="ds-sys-ic">${icon(door.icon)}</span><span class="ds-sys-t"><b>${esc(door.name)}</b></span><small>${esc(door.line)}</small></a></li>`).join('')}</ul></section>`;
+}
+
+module.exports = { doorPage, homeDoors, pageCard };
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
const layoutLib = require('../render/layout');
const layoutLib = require('../render/layout');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const { homeDoors } = require('./doors');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
const { pickBlock, foldAround } = require('../render/short');
const { pickBlock, foldAround } = require('../render/short');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
@@ -189,7 +190,7 @@ function home({ themes } = {}) {
@@ -189,7 +190,7 @@ function home({ themes } = {}) {
body = foldAround(body, '>Questions</h2>', 'faq', '<h2 id="ds-faq-h">Questions</h2>');
body = foldAround(body, '>Questions</h2>', 'faq', '<h2 id="ds-faq-h">Questions</h2>');
return layout({
return layout({
title: null, description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.',
title: null, description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.',
- path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/',
+ path: '/', body: `<div class="ds-wrap">${homeDoors()}${body}</div>`, active: '/',
jsonLd: [seo.jsonLd.website({ name: 'OpenVibe.Design', url: 'https://openvibe.design/', description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.' }), seo.jsonLd.organization()],
jsonLd: [seo.jsonLd.website({ name: 'OpenVibe.Design', url: 'https://openvibe.design/', description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.' }), seo.jsonLd.organization()],
// The rule that shows the picked scene of the pick block: one per scene, written for this page.
// The rule that shows the picked scene of the pick block: one per scene, written for this page.
});
});
@@ -248,6 +249,29 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
@@ -248,6 +249,29 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
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' });
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' });
}
}
+// The SDK and the versions this site installs, read from its own package.json so the page never states a stale pin.
+const DESIGN_PACKAGE = require('../../package.json');
+function pinOf(name) {
+ const m = String((DESIGN_PACKAGE.dependencies || {})[name] || '').match(/refs\/tags\/v([^/]+)$/);
+ return m ? m[1] : null;
+}
+function sdkSection() {
+ const sdk = pinOf('openvibe-sdk');
+ const versions = [['openvibe-contracts', pinOf('openvibe-contracts')], ['openvibe-sdk', sdk], ['openvibe-shared', pinOf('openvibe-shared')], ['openvibe-publishing', pinOf('openvibe-publishing')]]
+ .filter(([, v]) => v);
+ const pin = sdk ? `"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${sdk}"` : '';
+ return `<section aria-labelledby="ds-sdk-h" id="sdk"><h2 id="ds-sdk-h">The SDK, the files and the versions</h2>
+<p>openvibe-sdk is the supported client for this platform: the server client, the browser client, their ES modules and their TypeScript declarations. It ships from the OpenVibers/OpenVibe.SDK repository by tag, so a service pins a version rather than floating on a branch.</p>
+${pin ? `<p class="ds-fine">Add the pin to <code>package.json</code>, then install:</p>${kit.snippet({ code: `{ "dependencies": {\n ${pin}\n} }`, label: 'Copy the pin' })}` : ''}
+<dl class="ds-eps">
+<div><dt><code>index.js</code></dt><dd>The server client: auth, accounts, the async PostgreSQL data layer, events, media, AI runs and tools.</dd></div>
+<div><dt><code>browser.js</code></dt><dd>The browser client, with the same subpath modules where the browser has them.</dd></div>
+<div><dt><code>esm/*.mjs</code>, <code>types/*.d.ts</code></dt><dd>The ES module entry points and the TypeScript declarations, one pair per subpath export.</dd></div>
+${versions.map(([name, v]) => `<div><dt><code>${esc(name)}</code></dt><dd>v${esc(v)}, pinned by this site.</dd></div>`).join('')}
+</dl>
+<p class="ds-fine">The deployed release and its file hashes are at <a href="/release.json">/release.json</a>; the whole HTTP API is described by <a href="/openapi.json">/openapi.json</a>; an AI assistant can call it through the MCP server at <code>POST /mcp</code>.</p></section>`;
+}
+
function developers() {
function developers() {
const rows = [
const rows = [
['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],
['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],
@@ -269,6 +293,7 @@ function developers() {
@@ -269,6 +293,7 @@ function developers() {
['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],
['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],
];
];
const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>
const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>
+${sdkSection()}
<section aria-labelledby="ds-ep-h" id="tokens"><span id="mcp"></span><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
<section aria-labelledby="ds-ep-h" id="tokens"><span id="mcp"></span><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
<p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;
<p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
@@ -37,6 +37,8 @@ function rulesPage() {
@@ -37,6 +37,8 @@ function rulesPage() {
${kit.stats([[RULES.length, 'rules'], [areas.length, 'areas'], [day(newest), 'last decided']], { label: 'The register in numbers' })}
${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>
<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>`;
</header>`;
+ // The Rules door's two pages: this register, then the System map (the owner, 2026-11-10).
+ const door = `<p class="rl-links rl-door"><span class="rl-label">Rules</span><span class="rl-chip rl-chip--check" aria-current="page">The register</span><a class="rl-chip" href="/system">The system map</a></p>`;
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 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>
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>`;
<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>`;
@@ -55,7 +57,7 @@ function rulesPage() {
@@ -55,7 +57,7 @@ function rulesPage() {
styles: ['css/rules.css'],
styles: ['css/rules.css'],
scripts: ['js/rules.js'],
scripts: ['js/rules.js'],
jsonLd,
jsonLd,
- body: `<article class="rl">${hero}${index}${latestBlock}${sections}${data}</article>`,
+ body: `<article class="rl">${hero}${door}${index}${latestBlock}${sections}${data}</article>`,
});
});
}
}
@@ -53,7 +53,8 @@ const { FILES, STATES } = require('../server/views/brand');
@@ -53,7 +53,8 @@ const { FILES, STATES } = require('../server/views/brand');
const pattern = await t.get('/patterns/mark-system');
const pattern = await t.get('/patterns/mark-system');
assert.ok(pattern.text.includes('href="/brand"'), 'the mark pattern links it');
assert.ok(pattern.text.includes('href="/brand"'), 'the mark pattern links it');
const page = await t.get('/brand');
const page = await t.get('/brand');
- assert.ok(/"href":"\/brand"[^}]*"active":true/.test(page.text), 'the navbar marks it');
+ assert.ok(/"href":"\/see"[^}]*"active":true/.test(page.text), 'the bar marks the See door');
+ assert.match(page.text, /<a href="\/see">See<\/a>[\s\S]*?aria-current="page">Brand</, 'and the breadcrumb says where it sits');
});
});
} finally {
} finally {
await t.close();
await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * The four doors (the owner, 2026-11-10): the bar is four links; /see, /make and /use list exactly their pages in
+ * order, each card with its page's own glyph and a line from its own lede; every page in the map says "Door › Page"
+ * above its h1; the home leads with the doors; the sitemap and llms.txt list the doors first.
+ */
+const assert = require('assert');
+const glyphs = require('openvibe-shared/glyphs');
+const { boot, check, done } = require('./helpers/boot');
+const { NAV } = require('../server/render/layout');
+const { DOORS, PAGES, pagesInDoors, countOf } = require('../server/site/doors');
+const doorsView = require('../server/views/doors');
+
+/** HTML to the words a reader sees: tags and scripts out, entities in, whitespace and case folded. */
+function words(html) {
+ return String(html)
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
+ .replace(/'|'|’|‘|’|‘/g, "'")
+ .replace(/“|”|“|”/g, '"').replace(/—/g, '—').replace(/–/g, '–')
+ .replace(/\s+/g, ' ').trim().toLowerCase();
+}
+
+const cardsIn = (html, marker) => {
+ const start = html.indexOf(marker);
+ if (start === -1) return [];
+ const end = html.indexOf('</ul>', start);
+ return [...html.slice(start, end).matchAll(/<a class="ds-sys" href="([^"]+)"[^>]*>[\s\S]*?<b>([^<]+)<\/b><\/span><small>([^<]*)<\/small>/g)]
+ .map((m) => ({ href: m[1], name: m[2], line: m[3] }));
+};
+
+(async () => {
+ const t = await boot();
+ // One fetch per page across the checks: the app renders the whole catalogue on some of these.
+ const seen = new Map();
+ const get = (p) => { if (!seen.has(p)) seen.set(p, t.get(p)); return seen.get(p); };
+ try {
+ await check('a card is plain kit markup: a link, its glyph, its name and its line', async () => {
+ const card = doorsView.pageCard(PAGES.look);
+ assert.match(card, /^<li><a class="ds-sys" href="\/look">/);
+ assert.match(card, /<b>Look<\/b>/);
+ assert.ok(card.includes('One theme and one set of comfort settings'));
+ });
+
+ await check('the bar is the four doors, in order, and nothing under More', async () => {
+ assert.deepStrictEqual(NAV.map((l) => l.href), ['/see', '/make', '/rules', '/use']);
+ assert.deepStrictEqual(NAV.map((l) => l.label), ['See', 'Make', 'Rules', 'Use']);
+ assert.ok(NAV.every((l) => !l.more), 'no More menu');
+ const home = await get('/');
+ const json = home.text.match(/window\.__OV_PAGE = (\{[\s\S]*?\});/);
+ assert.ok(json, 'the page carries its navbar config');
+ const page = JSON.parse(json[1]);
+ assert.deepStrictEqual(page.navbar.links.map((l) => l.href), ['/see', '/make', '/rules', '/use'], 'four links in the bar');
+ assert.deepStrictEqual(page.navbar.appBar.map((l) => l.href), ['/see', '/make', '/rules', '/use'], 'the phone bar too');
+ });
+
+ await check('each door page has its h1, its one line, its count and exactly its pages as cards in order', async () => {
+ for (const door of DOORS) {
+ if (door.id === 'rules') continue; // the register is its own page, with /system as its second (below)
+ const r = await get(door.href);
+ assert.strictEqual(r.status, 200, door.href);
+ assert.match(r.text, new RegExp(`<h1>${door.name}</h1>`), `${door.href} h1`);
+ assert.ok(r.text.includes(door.line), `${door.href} says what is behind it`);
+ assert.match(r.text, new RegExp(`data-door-count>${countOf(door.pages.length)}<`), `${door.href} counts its pages`);
+ const cards = cardsIn(r.text, 'data-door-pages');
+ assert.deepStrictEqual(cards.map((c) => c.href), door.pages.map((id) => PAGES[id].href), `${door.href} page order`);
+ assert.strictEqual(cards.length, door.pages.length, `${door.href} lists exactly its pages`);
+ }
+ });
+
+ await check('each card uses its page\'s own glyph and a line from that page\'s own lede', async () => {
+ for (const page of pagesInDoors()) {
+ // The line is held against the page it came from: when the page's copy changes, this fails instead of
+ // the card drifting. (Create has no lede; its h1 is the line.)
+ const source = await get(page.href);
+ assert.strictEqual(source.status, 200, page.href);
+ assert.ok(words(source.text).includes(words(page.line)), `${page.href} still says "${page.line}"`);
+ }
+ for (const door of DOORS.filter((d) => d.id !== 'rules')) {
+ const r = await get(door.href);
+ for (const id of door.pages) {
+ assert.ok(r.text.includes(glyphs.svg(PAGES[id].icon, { size: 22 })), `${door.href}: ${PAGES[id].name}'s own glyph`);
+ }
+ }
+ });
+
+ await check('every page in the map says where it sits, above its h1, and the door links to its page', async () => {
+ for (const page of pagesInDoors()) {
+ const r = await get(page.href);
+ const at = r.text.indexOf('<nav class="ds-crumbs"');
+ const h1 = r.text.indexOf('<h1');
+ assert.ok(at !== -1 && at < h1, `${page.href}: breadcrumb above the h1`);
+ const crumb = r.text.slice(at, r.text.indexOf('</nav>', at));
+ assert.ok(crumb.includes(`<a href="${page.door.href}">${page.door.name}</a>`), `${page.href}: the door links to ${page.door.href}`);
+ assert.ok(crumb.includes(`aria-current="page">${page.name}<`), `${page.href}: names ${page.name}`);
+ }
+ });
+
+ await check('Rules is the register, with the system map as its second page', async () => {
+ const r = await get('/rules');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /aria-current="page">The register</, 'the register is the door\'s first page');
+ assert.match(r.text, /class="rl-chip" href="\/system">The system map</, 'the system map is its second page');
+ const sys = await get('/system');
+ assert.match(sys.text, /<a href="\/rules">Rules<\/a>[\s\S]*?aria-current="page">System</, 'System sits under Rules');
+ });
+
+ await check('the home leads with the four doors and keeps everything below them', async () => {
+ const home = await get('/');
+ const cards = cardsIn(home.text, 'data-home-doors');
+ assert.deepStrictEqual(cards.map((c) => c.href), DOORS.map((d) => d.href), 'the four doors, in order');
+ assert.ok(home.text.indexOf('data-home-doors') < home.text.indexOf('sc-room'), 'above what the home showed before');
+ assert.ok(home.text.includes('Your look, everywhere'), 'the hero is still there');
+ assert.ok(home.text.includes('ds-system-grid'), 'the whole system is still there');
+ });
+
+ await check('the Use door covers the SDK, its files and the versions this site pins', async () => {
+ const r = await get('/developers');
+ assert.match(r.text, /id="sdk">[\s\S]*?The SDK, the files and the versions/, 'the SDK section');
+ for (const name of ['openvibe-sdk', 'openvibe-contracts', 'openvibe-shared']) {
+ assert.ok(r.text.includes(`${name}</code></dt><dd>v`), `${name}'s pinned version`);
+ }
+ assert.match(r.text, /codeload\.github\.com\/OpenVibers\/OpenVibe\.SDK\/tar\.gz\/refs\/tags\/v[0-9]/, 'the SDK pin');
+ assert.match(r.text, /href="\/release\.json"/, 'the files and versions this release serves');
+ });
+
+ await check('a page that was not in the map sits under the page it extends', async () => {
+ const expected = [
+ ['/t/aurora', 'Make', '/make', 'Themes'],
+ ['/components/registry', 'See', '/see', 'Components'],
+ ['/patterns/tabs-not-folds', 'See', '/see', 'Patterns'],
+ ['/fonts/make', 'Make', '/make', 'Fonts'],
+ ['/vectors/make', 'Make', '/make', 'Create'],
+ ['/arena/new', 'Make', '/make', 'Arena'],
+ ];
+ for (const [path, door, href, page] of expected) {
+ const r = await get(path);
+ assert.strictEqual(r.status, 200, path);
+ const at = r.text.indexOf('<nav class="ds-crumbs"');
+ assert.ok(at !== -1 && at < r.text.indexOf('<h1'), `${path}: a breadcrumb above the h1`);
+ const crumb = r.text.slice(at, r.text.indexOf('</nav>', at));
+ assert.ok(crumb.includes(`<a href="${href}">${door}</a>`) && crumb.includes(`aria-current="page">${page}<`), `${path}: ${door} › ${page}`);
+ }
+ });
+
+ await check('the sitemap and llms.txt list the doors first', async () => {
+ const map = await t.get('/sitemap.xml');
+ const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.deepStrictEqual(locs.slice(0, 4), ['https://openvibe.design/see', 'https://openvibe.design/make', 'https://openvibe.design/rules', 'https://openvibe.design/use']);
+ const llms = await t.get('/llms.txt');
+ const text = llms.text;
+ assert.ok(text.indexOf('## The four doors') !== -1, 'the doors are a section');
+ assert.ok(text.indexOf('## The four doors') < text.indexOf('## Start here'), 'the doors come first');
+ for (const door of DOORS) assert.ok(text.includes(`https://openvibe.design${door.href}`), `llms lists ${door.href}`);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -26,8 +26,8 @@ const { ATTRS } = require('../server/views/forms');
@@ -26,8 +26,8 @@ const { ATTRS } = require('../server/views/forms');
assert.match(html, /\/js\/forms-page\.js/);
assert.match(html, /\/js\/forms-page\.js/);
assert.match(html, /data-ov-forms="off"|data-ov-forms="off"/, 'how a site keeps the browser\'s own');
assert.match(html, /data-ov-forms="off"|data-ov-forms="off"/, 'how a site keeps the browser\'s own');
});
});
- await check('found: the menu, the sitemap and llms.txt', async () => {
- assert.ok((await t.get('/')).text.includes('/forms'), 'the menu');
+ await check('found: the See door, the sitemap and llms.txt', async () => {
+ assert.ok((await t.get('/see')).text.includes('/forms'), 'the See door');
assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/forms'));
assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/forms'));
assert.ok((await t.get('/llms.txt')).text.includes('/forms'));
assert.ok((await t.get('/llms.txt')).text.includes('/forms'));
});
});
@@ -253,7 +253,9 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -253,7 +253,9 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
await check('it is in the navbar, the sitemap and llms.txt', async () => {
await check('it is in the navbar, the sitemap and llms.txt', async () => {
assert.ok((await t.get('/sitemap.xml')).text.includes('/icons</loc>'), 'sitemap');
assert.ok((await t.get('/sitemap.xml')).text.includes('/icons</loc>'), 'sitemap');
assert.ok((await t.get('/llms.txt')).text.includes('/icons'), 'llms.txt');
assert.ok((await t.get('/llms.txt')).text.includes('/icons'), 'llms.txt');
- assert.ok(/"href":"\/icons"[^}]*"active":true/.test((await t.get('/icons')).text), 'the navbar marks it');
+ const page = await t.get('/icons');
+ assert.ok(/"href":"\/make"[^}]*"active":true/.test(page.text), 'the bar marks the Make door');
+ assert.match(page.text, /<a href="\/make">Make<\/a>[\s\S]*?aria-current="page">Icons</, 'and the breadcrumb says where it sits');
});
});
} finally {
} finally {
await t.close();
await t.close();
@@ -32,8 +32,8 @@ const { SHAPES } = require('../server/views/loading');
@@ -32,8 +32,8 @@ const { SHAPES } = require('../server/views/loading');
assert.match(js, /window\.OpenVibeLoading\.mount\(screen/);
assert.match(js, /window\.OpenVibeLoading\.mount\(screen/);
assert.match(js, /nav\.busy\(true\)[\s\S]*nav\.busy\(false\)/);
assert.match(js, /nav\.busy\(true\)[\s\S]*nav\.busy\(false\)/);
});
});
- await check('found: the menu, the sitemap and llms.txt', async () => {
- assert.ok((await t.get('/')).text.includes('/loading'), 'the menu');
+ await check('found: the See door, the sitemap and llms.txt', async () => {
+ assert.ok((await t.get('/see')).text.includes('/loading'), 'the See door');
assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/loading'));
assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/loading'));
assert.ok((await t.get('/llms.txt')).text.includes('/loading'));
assert.ok((await t.get('/llms.txt')).text.includes('/loading'));
});
});
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// /native (11-07): the road from the web to Android and iOS, without a frame. The page renders whole without
// /native (11-07): the road from the web to Android and iOS, without a frame. The page renders whole without
// JavaScript, links each shared layer, says every size class, every platform difference and every road step, is in
// JavaScript, links each shared layer, says every size class, every platform difference and every road step, is in
-// the sitemap, and is reachable from the bar's More.
+// the sitemap, and sits under the See door (its breadcrumb leads back).
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST } = require('../server/views/native');
const { LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST } = require('../server/views/native');
@@ -22,10 +22,12 @@ const { NAV } = require('../server/render/layout');
@@ -22,10 +22,12 @@ const { NAV } = require('../server/render/layout');
assert.ok(r.text.includes('/css/native.css'), 'its stylesheet');
assert.ok(r.text.includes('/css/native.css'), 'its stylesheet');
assert.ok(!/<script[^>]*native/.test(r.text), 'no page script: whole without JavaScript');
assert.ok(!/<script[^>]*native/.test(r.text), 'no page script: whole without JavaScript');
});
});
- await check('it is in the sitemap and under More', async () => {
+ await check('it is in the sitemap and under the See door', async () => {
const map = await t.get('/sitemap.xml');
const map = await t.get('/sitemap.xml');
assert.match(map.text, /\/native</);
assert.match(map.text, /\/native</);
- assert.ok(NAV.some((l) => l.href === '/native' && l.more), 'More lists it');
+ const page = await t.get('/native');
+ assert.match(page.text, /<a href="\/see">See<\/a>[\s\S]*?aria-current="page">Native</, 'its breadcrumb is See › Native');
+ assert.ok(NAV.some((l) => l.href === '/see'), 'See is a door in the bar');
const css = await t.get('/css/native.css');
const css = await t.get('/css/native.css');
assert.equal(css.status, 200);
assert.equal(css.status, 200);
});
});
@@ -22,7 +22,8 @@ const unescape = (s) => s.replace(/'/g, "'").replace(/"/g, '"').replace
@@ -22,7 +22,8 @@ const unescape = (s) => s.replace(/'/g, "'").replace(/"/g, '"').replace
assert.ok(html.includes(themePrompt('{your prompt}', ["{the theme's colour variables}"])), 'the theme prompt');
assert.ok(html.includes(themePrompt('{your prompt}', ["{the theme's colour variables}"])), 'the theme prompt');
assert.ok(html.includes(editPrompt('{your instruction}', '{the current page}', "{the theme's colour variables}")), 'the edit prompt');
assert.ok(html.includes(editPrompt('{your instruction}', '{the current page}', "{the theme's colour variables}")), 'the edit prompt');
assert.ok(html.includes(SYSTEM.slice(0, 200)), 'the icon prompt');
assert.ok(html.includes(SYSTEM.slice(0, 200)), 'the icon prompt');
- assert.match(r.text, /href="\/prompts"/, 'linked from the navigation');
+ assert.match(r.text, /<a href="\/make">Make<\/a>[\s\S]*?aria-current="page">Prompts</, 'it sits under the Make door');
+ assert.match((await t.get('/make')).text, /href="\/prompts"/, 'the Make door lists it');
});
});
await check('the same list as JSON, cacheable', async () => {
await check('the same list as JSON, cacheable', async () => {
const r = await t.get('/api/v1/prompts');
const r = await t.get('/api/v1/prompts');
@@ -7,6 +7,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -7,6 +7,7 @@ const { boot, check, done } = require('./helpers/boot');
const { AREAS, RULES } = require('../server/rules/register');
const { AREAS, RULES } = require('../server/rules/register');
const { patterns } = require('../server/patterns/catalog');
const { patterns } = require('../server/patterns/catalog');
const { NAV } = require('../server/render/layout');
const { NAV } = require('../server/render/layout');
+const rulesView = require('../server/views/rules');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
@@ -92,12 +93,18 @@ const { NAV } = require('../server/render/layout');
@@ -92,12 +93,18 @@ const { NAV } = require('../server/render/layout');
assert.ok((await t.get('/patterns/tabs-not-folds')).text.includes('never split content'), 'tabs are for settings, never content');
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']);
+ await check('the register names the Rules door and its second page', async () => {
+ const html = rulesView.rulesPage();
+ assert.match(html, /class="rl-chip rl-chip--check" aria-current="page">The register</);
+ assert.match(html, /class="rl-chip" href="\/system">The system map</);
+ });
+
+ await check('the bar is the four doors, in order, and no two share an icon', async () => {
+ assert.equal(NAV.length, 4, NAV.map((l) => l.label).join(', '));
+ assert.deepEqual(NAV.map((l) => l.href), ['/see', '/make', '/rules', '/use']);
+ assert.ok(NAV.every((l) => !l.more), 'nothing under More: the four doors are the whole bar');
const icons = NAV.map((l) => l.icon);
const icons = NAV.map((l) => l.icon);
- assert.equal(new Set(icons).size, icons.length, 'one icon per place');
+ assert.equal(new Set(icons).size, icons.length, 'one icon per door');
assert.ok(NAV.every((l) => l.label.length <= 22), 'labels stay short');
assert.ok(NAV.every((l) => l.label.length <= 22), 'labels stay short');
});
});
} finally {
} finally {