openvibers/openvibe.design Commits

Design: four doors, the bar and a breadcrumb on every page (#259)

OP OpenVibers committed 2 days ago · 9d1f090901 · 17 files, +433 −48

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 471868d..53be438 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; }
ddiff --git a/server/agent-twins.js b/server/agent-twins.js
iindex 8398918..2fa5185 100644
---- a/server/agent-twins.js
++++ b/server/agent-twins.js
@@@ -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() {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a496a1f..92b61a3 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c655f67..08b41bb 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ecdf20e..2198184 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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',
ddiff --git a/server/site/doors.js b/server/site/doors.js
nnew file mode 100644
iindex 0000000..b251fbd
---- /dev/null
++++ b/server/site/doors.js
@@@ -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 };
ddiff --git a/server/views/doors.js b/server/views/doors.js
nnew file mode 100644
iindex 0000000..f4ec107
---- /dev/null
++++ b/server/views/doors.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 5d5541e..c844463 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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' });
ddiff --git a/server/views/rules.js b/server/views/rules.js
iindex 6a90972..b1d0e52 100644
---- a/server/views/rules.js
++++ b/server/views/rules.js
@@@ -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>`,
});
}
ddiff --git a/test/brand.test.js b/test/brand.test.js
iindex f5c8339..f27394e 100644
---- a/test/brand.test.js
++++ b/test/brand.test.js
@@@ -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();
ddiff --git a/test/doors.test.js b/test/doors.test.js
nnew file mode 100644
iindex 0000000..94afb04
---- /dev/null
++++ b/test/doors.test.js
@@@ -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(/&nbsp;/g, ' ')
++ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
++ .replace(/&#39;|&apos;|&rsquo;|&lsquo;|’|‘/g, "'")
++ .replace(/&ldquo;|&rdquo;|“|”/g, '"').replace(/&mdash;/g, '—').replace(/&ndash;/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; });
ddiff --git a/test/forms-page.test.js b/test/forms-page.test.js
iindex 3f85476..5b831b1 100644
---- a/test/forms-page.test.js
++++ b/test/forms-page.test.js
@@@ -26,8 +26,8 @@ const { ATTRS } = require('../server/views/forms');
assert.match(html, /\/js\/forms-page\.js/);
assert.match(html, /data-ov-forms=&quot;off&quot;|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'));
});
ddiff --git a/test/icons.test.js b/test/icons.test.js
iindex 160376b..94d4bd0 100644
---- a/test/icons.test.js
++++ b/test/icons.test.js
@@@ -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 () => {
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();
ddiff --git a/test/loading-page.test.js b/test/loading-page.test.js
iindex f71d63e..a887918 100644
---- a/test/loading-page.test.js
++++ b/test/loading-page.test.js
@@@ -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'));
});
ddiff --git a/test/native-page.test.js b/test/native-page.test.js
iindex 6e4e2b3..2971ae6 100644
---- a/test/native-page.test.js
++++ b/test/native-page.test.js
@@@ -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);
});
ddiff --git a/test/prompts.test.js b/test/prompts.test.js
iindex 9b81102..7944f4c 100644
---- a/test/prompts.test.js
++++ b/test/prompts.test.js
@@@ -22,7 +22,8 @@ const unescape = (s) => s.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace
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(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');
ddiff --git a/test/rules.test.js b/test/rules.test.js
iindex c99254b..2043c79 100644
---- a/test/rules.test.js
++++ b/test/rules.test.js
@@@ -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 {