openvibers/openvibe.design Commits

Store, import and serve the component registry as a plain list (#222)

OP OpenVibers committed 4 days ago · b58cef049f · 12 files, +842 −17

* Add the component registry storage, import and plain list

* Registry design: the components grouped by purpose, cards with counts, an entry page like the interface pages

Opus organizes the registry (the owner, 11-09): nine groups (actions, marks, layout, content, choosing, surfaces, data, tools, Home; a new part lands in Other), a chip row to jump between them, one card per component with its prop and state counts and module. An entry: its group as the kicker, Build it (the builder call), props and anatomy beside behaviour and access, states and tokens as chips, Android and iOS notes, the stored markup as code (never rendered).

Changes

ddiff --git a/migrations/0020_component_registry.sql b/migrations/0020_component_registry.sql
nnew file mode 100644
iindex 0000000..0438d1c
---- /dev/null
++++ b/migrations/0020_component_registry.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- The component registry: one design.component@1 document per Shared kit component, its default example's HTML,
++-- and where in openvibe-shared it came from (server/components/import.js). A new table; a release before this
++-- one ignores it and keeps serving the hand-written definitions it shipped.
++CREATE TABLE IF NOT EXISTS design_components (
++ name text COLLATE "C" PRIMARY KEY,
++ source text COLLATE "C" NOT NULL,
++ definition jsonb NOT NULL,
++ example_markup text NOT NULL DEFAULT '',
++ imported_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS design_components_source_idx ON design_components (source);
ddiff --git a/public/css/component-registry.css b/public/css/component-registry.css
iindex f8b1e60..61b7447 100644
---- a/public/css/component-registry.css
++++ b/public/css/component-registry.css
@@@ -62,3 +62,32 @@
.cr-motion-notice div { display: flex; flex-wrap: wrap; gap: 8px; }
.cr-motion-notice button { min-height: 44px; }
@media (max-width: 720px) { .cr-layout { grid-template-columns: minmax(0, 1fr); } .cr-props div, .cr-facts div { grid-template-columns: minmax(0, 1fr); gap: 2px; } }
++
++/* The registry list and an entry page (Opus, 11-09): a chip row to jump between groups, each group's line under its
++ name, cards with their counts and module; an entry's states and tokens as chips beside its behaviour. */
++.crl-jump { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin: 18px 0 6px; padding-bottom: 4px; }
++.crl-jump::-webkit-scrollbar { display: none; }
++.crl-jump a { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-text); text-decoration: none; font-size: .88rem; font-weight: 600; }
++.crl-jump a:hover { border-color: var(--ovk-accent, var(--accent)); }
++.crl-jump a span { color: var(--ovk-muted); font-variant-numeric: tabular-nums; font-weight: 500; }
++.crl-group { scroll-margin-top: calc(var(--ovnav-h, 56px) + 12px); }
++.crl-group-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 10px; }
++.crl-group-h h2 { margin: 0; }
++.crl-group-h p { margin: 0; color: var(--ovk-muted); font-size: .9rem; }
++.crl-counts { color: var(--ovk-muted); font-size: .78rem; }
++.crl-module { padding: 2px 7px; border-radius: 6px; background: var(--ovk-panel-2); color: var(--ovk-muted); font-size: .72rem; }
++.crl-where { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 8px; }
++.crl-where code { padding: 3px 8px; border-radius: 6px; background: var(--ovk-panel-2); font-size: .8rem; }
++.crl .ds-kicker a { color: inherit; text-decoration: none; }
++.crl .ds-kicker a:hover { text-decoration: underline; }
++.crl-sec h2 { margin: 18px 0 10px; font-size: 1.05rem; }
++.crl-sec h2:first-child { margin-top: 0; }
++.crl-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
++.crl-chips li { padding: 4px 10px; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font-size: .82rem; }
++.crl-chips--code li { border-radius: 6px; padding: 3px 8px; }
++.crl-chips--code code { font-size: .78rem; }
++.crl-none { color: var(--ovk-muted); margin: 0; }
++.crl-role { color: var(--ovk-muted); font-size: .85rem; margin: 0 0 6px; }
++.crl-ex-t { margin: 12px 0 6px; font-weight: 700; }
++.crl-card .cr-card-link span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
++.crl-build { margin: 4px 0 22px; }
ddiff --git a/scripts/import-components.js b/scripts/import-components.js
nnew file mode 100755
iindex 0000000..d06f794
---- /dev/null
++++ b/scripts/import-components.js
@@@ -0,0 +1,29 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Import the installed openvibe-shared kit into the design_components registry (server/components/import.js).
++ *
++ * node scripts/import-components.js add components this database has not seen; keep edits
++ * node scripts/import-components.js --force replace every entry with the current Shared definitions
++ *
++ * Run it after a shared release; the app also runs the additive import at boot, so a fresh database is never empty.
++ * The database is the service's own (DATABASE_URL, or the development PGlite directory).
++ */
++const { load } = require('../server/config');
++const { openStore } = require('../server/db');
++const { createComponentRegistry } = require('../server/components/store');
++
++(async () => {
++ const store = await openStore(load());
++ try {
++ const registry = createComponentRegistry({ db: store.db });
++ const result = await registry.importEntries({ force: process.argv.includes('--force') });
++ console.log(`component registry: ${result.imported} of ${result.total} imported`);
++ } finally {
++ await store.close();
++ }
++})().catch((err) => {
++ console.error('component registry import failed:', (err && err.stack) || err);
++ process.exit(1);
++});
ddiff --git a/server/app.js b/server/app.js
iindex 2ac745c..0a49ba6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -40,6 +40,7 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
++const { createComponentRegistry } = require('./components/store');
const { createSharing, describeRef } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
const { createTokenFileRoutes } = require('./http/tokens');
@@@ -68,6 +69,10 @@ async function createApp(opts = {}) {
const now = opts.now || (() => Date.now());
const s = opts.store || await openStore(config, { now, log });
++ // The public component registry: one design.component@1 row per Shared kit component. The import is additive so a
++ // definition a person edited is kept; a shared release refreshes with scripts/import-components.js --force.
++ const registry = opts.registry || createComponentRegistry({ db: s.db, log });
++ try { await registry.importEntries(); } catch (err) { log.warn('[OpenVibe.Design] component registry import failed:', err && err.message); }
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, log, now });
// D11 owner settings (design.publishing): created before the app mounts /internal/config. A failure leaves
@@@ -110,7 +115,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker,
++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry,
random: opts.random || Math.random,
};
ddiff --git a/server/components/import.js b/server/components/import.js
nnew file mode 100644
iindex 0000000..66b733c
---- /dev/null
++++ b/server/components/import.js
@@@ -0,0 +1,408 @@
++'use strict';
++
++/**
++ * Import the Shared kit into the public component registry.
++ *
++ * A registry entry is a design.component@1 document (Contracts) plus the HTML its default example draws. The definition
++ * is not written by hand: the props are read from the builder's own destructured parameters, the states from the
++ * component contract where openvibe-shared/components has one and otherwise from the attributes and state classes the
++ * builder writes, and the example is the /kit part's sample rendered against the installed kit. Every entry is checked
++ * against design.component@1 before it is stored.
++ *
++ * buildEntries() → [{ name, source, definition, markup }]
++ *
++ * The editor's own kit parts (server/kit/parts.js) are the source of truth for which builders exist: the /kit page
++ * fails when a new builder has no part, so the registry cannot silently miss one. The menu's Home grid and its Edit
++ * button are browser components in openvibe-shared/ov-home.js; their options and states are read from that file too.
++ */
++const fs = require('fs');
++const contracts = require('openvibe-contracts');
++const kit = require('openvibe-shared/kit');
++const components = require('openvibe-shared/components');
++const { PARTS, render } = require('../kit/parts');
++
++const MAX = { title: 120, summary: 500, part: 80, role: 300, a11y: 300 };
++const GENERIC_REDUCED = 'Remove movement; keep the state change.';
++const GENERIC_TARGET = 'At least 44 × 44 px on touch';
++const GENERIC_FOCUS = 'Visible keyboard focus; follow reading order';
++
++const HOME_MODULE = 'openvibe-shared/ov-home';
++const KIT_MODULE = 'openvibe-shared/kit';
++
++/** A contract-sized string: one line, no control characters, never empty. */
++function text(value, max) {
++ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
++ return (s || 'Component').slice(0, max);
++}
++
++/** A type name for a destructured default: what a reader of the builder sees. */
++function typeOf(value) {
++ const s = String(value == null ? '' : value).trim();
++ if (!s || s === 'null' || s === 'undefined') return 'any';
++ if (/^(true|false)$/.test(s)) return 'boolean';
++ if (/^-?\d+(?:\.\d+)?$/.test(s)) return 'number';
++ if (/^['"`]/.test(s)) return 'string';
++ if (/^\[/.test(s)) return 'list';
++ if (/^\{/.test(s)) return 'object';
++ if (/^[A-Za-z_$][\w$]*$/.test(s)) return 'any';
++ return 'any';
++}
++
++/** The parameter list of a builder, without its wrapping parentheses (strings and nesting respected). */
++function paramsOf(fn) {
++ const src = String(fn);
++ const start = src.indexOf('(');
++ if (start < 0) return '';
++ let depth = 0;
++ let quote = '';
++ for (let i = start; i < src.length; i += 1) {
++ const c = src[i];
++ if (quote) {
++ if (c === '\\') i += 1;
++ else if (c === quote) quote = '';
++ continue;
++ }
++ if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
++ if (c === '(') depth += 1;
++ else if (c === ')') {
++ depth -= 1;
++ if (depth === 0) return src.slice(start + 1, i);
++ }
++ }
++ return '';
++}
++
++/** Split on a delimiter at the top level of a parameter list (braces, brackets, parentheses and strings respected). */
++function splitTop(input, delimiter = ',') {
++ const out = [];
++ let depth = 0;
++ let quote = '';
++ let last = 0;
++ for (let i = 0; i < input.length; i += 1) {
++ const c = input[i];
++ if (quote) {
++ if (c === '\\') i += 1;
++ else if (c === quote) quote = '';
++ continue;
++ }
++ if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
++ if (c === '{' || c === '[' || c === '(') depth += 1;
++ else if (c === '}' || c === ']' || c === ')') depth -= 1;
++ else if (c === delimiter && depth === 0) { out.push(input.slice(last, i)); last = i + 1; }
++ }
++ out.push(input.slice(last));
++ return out;
++}
++
++/** The top-level `=` of a parameter: its default expression, or '' when it has none. */
++function splitDefault(param) {
++ let depth = 0;
++ let quote = '';
++ for (let i = 0; i < param.length; i += 1) {
++ const c = param[i];
++ if (quote) {
++ if (c === '\\') i += 1;
++ else if (c === quote) quote = '';
++ continue;
++ }
++ if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
++ if (c === '{' || c === '[' || c === '(') depth += 1;
++ else if (c === '}' || c === ']' || c === ')') depth -= 1;
++ else if (c === '=' && depth === 0) return [param.slice(0, i), param.slice(i + 1)];
++ }
++ return [param, ''];
++}
++
++/** The props a builder reads: its destructured keys (and positional arguments), typed from their defaults. */
++function propsOf(fn, rest = null) {
++ const props = rest ? { ...propsOf(rest) } : {};
++ for (const raw of splitTop(paramsOf(fn))) {
++ const param = raw.trim();
++ if (!param) continue;
++ if (param.startsWith('...')) continue;
++ if (param.startsWith('{')) {
++ const close = param.lastIndexOf('}');
++ if (close < 0) continue;
++ for (const rawKey of splitTop(param.slice(1, close))) {
++ const item = rawKey.trim();
++ if (!item || item.startsWith('...')) continue;
++ const [head, value] = splitDefault(item);
++ const colon = head.indexOf(':');
++ const key = (colon >= 0 ? head.slice(0, colon) : head).trim();
++ if (!/^[a-z][a-zA-Z0-9]{0,39}$/.test(key)) continue;
++ if (Object.keys(props).length >= 60) break;
++ props[key] = value.trim() ? `${typeOf(value)}?` : typeOf('');
++ }
++ continue;
++ }
++ const [head, value] = splitDefault(param);
++ const key = head.trim();
++ if (!/^[a-z][a-zA-Z0-9]{0,39}$/.test(key)) continue;
++ if (Object.keys(props).length >= 60) break;
++ props[key] = value.trim() ? `${typeOf(value)}?` : 'any';
++ }
++ return Object.freeze(props);
++}
++
++/** States the builder's markup and attributes expose (when openvibe-shared/components has no contract for it). */
++function statesOf(part, source, markup) {
++ const spec = components.get(part.id);
++ if (spec && Array.isArray(spec.states) && spec.states.length) return [...new Set(spec.states)].slice(0, 30);
++ const out = new Set(['default']);
++ const add = (state) => {
++ const s = String(state || '').trim();
++ if (/^[a-z][a-z0-9 +-]{0,39}$/i.test(s) && out.size < 30) out.add(s);
++ };
++ // The builder's own source, not its sample: a sheet's sample contains a chips control whose states are not
++ // the sheet's. The element and the attributes are what the builder writes.
++ if (/<dialog|showModal|aria-haspopup="dialog"/i.test(source)) { add('closed'); add('open'); }
++ if (/<details|aria-expanded/i.test(source)) { add('closed'); add('open'); }
++ if (/type="radio"/i.test(source)) { add('selected'); add('focused'); }
++ if (/type="checkbox"|aria-pressed/i.test(source)) { add('on'); add('off'); }
++ if (/disabled/i.test(source)) add('disabled');
++ if (/aria-current/i.test(source)) add('current');
++ if (/unread/i.test(source)) add('unread');
++ if (/aria-busy|busy/i.test(source)) add('busy');
++ if (/<progress|aria-valuenow/i.test(source)) { add('uploading'); add('complete'); }
++ if (/is-live/i.test(source)) add('live');
++ if (/is-edit/i.test(source)) add('editing');
++ if (/focus|tabindex/i.test(source)) add('focused');
++ if (/ovk-empty|empty-title/i.test(source)) add('empty');
++ return [...out].slice(0, 30);
++}
++
++/** The anatomy openvibe-shared/components gives, or the builder and what it draws. */
++function anatomyOf(part, spec) {
++ if (spec && Array.isArray(spec.anatomy) && spec.anatomy.length) {
++ return spec.anatomy.slice(0, 32).map((a) => ({
++ part: text(a.part, MAX.part),
++ role: text([a.note, ...Object.entries(a.tokens || {}).map(([k, v]) => `${k} ${v}`)].filter(Boolean).join('; ') || a.part, MAX.role),
++ }));
++ }
++ return [{ part: text(part.fn, MAX.part), role: text(part.line || part.name, MAX.role) }];
++}
++
++/** Token paths the component contract names, or the one role a plain builder always wears. */
++function tokensOf(part, spec) {
++ if (spec) {
++ const named = components.tokensOf(spec);
++ if (named.length) return [...new Set(named)].slice(0, 50);
++ }
++ const byGroup = {
++ actions: ['color.accent.strong', 'size.tap'],
++ content: ['color.bg.raised', 'color.text.primary'],
++ layout: ['color.text.primary', 'size.tap'],
++ forms: ['color.bg.raised', 'size.tap'],
++ data: ['color.accent.base', 'color.text.primary'],
++ tools: ['color.bg.raised', 'color.text.primary'],
++ feedback: ['color.text.muted', 'color.status.bad.base'],
++ };
++ return (byGroup[part.group] || ['color.text.primary']).slice(0, 50);
++}
++
++function behavioursOf(part, source, spec) {
++ const both = `${source}\n${part.code || ''}`;
++ const layout = spec && spec.layout ? `${spec.layout.phone || ''} ${spec.layout.computer || ''}` : '';
++ return {
++ keyboardAware: /input|textarea|select|contenteditable|dialog/i.test(both) || /focus|keyboard/i.test(layout),
++ outsideTapCloses: /dialog|data-ovk-close|scrim|outside/i.test(both),
++ scrollLock: /dialog|sidebar|drawer|scrim/i.test(both),
++ reducedMotion: part.id.includes('motion') || part.id === 'chart'
++ ? 'Durations become zero; the state change stays.'
++ : GENERIC_REDUCED,
++ };
++}
++
++function accessibilityOf(part, spec) {
++ const a = spec && spec.a11y ? spec.a11y : {};
++ return {
++ name: text(a.name || part.name, MAX.a11y),
++ target: text(a.minTarget || GENERIC_TARGET, MAX.a11y),
++ focus: text(a.keys || GENERIC_FOCUS, MAX.a11y),
++ };
++}
++
++/** The Material 3 container a Compose reference view uses, read from its own code. */
++function material3Of(spec, part) {
++ if (spec && spec.native && spec.native.compose) {
++ const m = /(ModalBottomSheet|NavigationBar|NavigationDrawer|TopAppBar|Scaffold|SingleChoiceSegmentedButtonRow|ScrollableTabRow|LazyVerticalGrid|LazyColumn|Column|Row|ListItem|OutlinedTextField|TextField|Card|Button|OutlinedButton|Surface|Switch|Tab)\b/.exec(spec.native.compose);
++ if (m) return m[1];
++ }
++ return `Material 3 ${part.name}`;
++}
++
++const NATIVE = {
++ 'page-head': {
++ material3: 'TopAppBar',
++ android: 'Use a TopAppBar with a navigationIcon for back, the page title, and actions; the icon and lede sit below it.',
++ ios: 'Use .navigationTitle with a leading back ToolbarItem, trailing actions and .toolbarTitleDisplayMode.',
++ },
++ head: {
++ material3: 'TopAppBar',
++ android: 'Use a TopAppBar for the title and actions; put the tabs in a ScrollableTabRow below it.',
++ ios: 'Use .navigationTitle, .toolbar for actions, and a Picker or a horizontal ScrollView for the tabs.',
++ },
++ sheet: {
++ material3: 'ModalBottomSheet',
++ android: 'Use ModalBottomSheet with sheetState; a close action and the scrim or Escape dismiss it, then return focus to the trigger.',
++ ios: 'Use .sheet with a NavigationStack, a title and a Done toolbar item; respect accessibilityReduceMotion.',
++ },
++ 'switch-row': {
++ material3: 'ListItem',
++ android: 'Use a ListItem with a trailing Material 3 Switch and supporting text.',
++ ios: 'Use Toggle with a supporting Text label; the whole row is one accessible control.',
++ },
++ segmented: {
++ material3: 'SingleChoiceSegmentedButtonRow',
++ android: 'Use SingleChoiceSegmentedButtonRow with SegmentedButton items; the selected one is the current value.',
++ ios: 'Use Picker with .segmented style; announce the selected value.',
++ },
++ seg: {
++ material3: 'SingleChoiceSegmentedButtonRow',
++ android: 'Use SingleChoiceSegmentedButtonRow with SegmentedButton items, or a radio row when space is tight.',
++ ios: 'Use Picker with .segmented style.',
++ },
++ dock: {
++ material3: 'Scaffold',
++ android: 'Use Scaffold.bottomBar with imePadding; the field grows and the send action stays in reach.',
++ ios: 'Use safeAreaInset(edge: .bottom) with keyboard avoidance and a send Button.',
++ },
++ find: {
++ material3: 'OutlinedTextField',
++ android: 'Use an OutlinedTextField with a search leading icon and a trailing submit Button.',
++ ios: 'Use .searchable or a TextField with a submit Button.',
++ },
++ 'home-grid': {
++ material3: 'LazyVerticalGrid',
++ android: 'Use a LazyVerticalGrid of app tiles with long-press drag (ItemTouchHelper or a drag-and-drop grid); folders are tiles with four previews.',
++ ios: 'Use a LazyVGrid of app tiles with .onDrag/.onDrop and a jiggle-free Edit mode; folders open a sheet.',
++ },
++ 'home-edit': {
++ material3: 'IconButton',
++ android: 'Use a TextButton or IconButton in the grid header that toggles edit mode; Done replaces Edit while arranging.',
++ ios: 'Use a Button in the header that toggles Edit; Done replaces it and focus returns to it.',
++ },
++};
++
++function platformsOf(part, spec, sourceModule) {
++ const native = NATIVE[part.id] || {};
++ const compose = spec && spec.native ? spec.native.compose : '';
++ const swift = spec && spec.native ? spec.native.swiftui : '';
++ return {
++ web: { module: sourceModule, markup: String(part.code || `kit.${part.fn}(…)`).trim().slice(0, 1000), demo: `/components/${part.id}` },
++ android: {
++ toolkit: 'Jetpack Compose',
++ material3: text(native.material3 || material3Of(spec, part), 120),
++ specification: compose || text(native.android || `Use ${part.name} in Jetpack Compose with the shared tokens.`, 500),
++ },
++ ios: {
++ toolkit: 'SwiftUI',
++ specification: swift || text(native.ios || `Use ${part.name} in SwiftUI with the shared tokens.`, 500),
++ },
++ };
++}
++
++/** One /kit part as a registry entry. */
++function buildPart(part) {
++ const fn = kit[part.fn];
++ if (typeof fn !== 'function') throw new Error(`component registry: kit.${part.fn} is missing for ${part.id}`);
++ const source = String(fn);
++ const markup = render(part);
++ if (!markup) throw new Error(`component registry: ${part.id} draws no example (${part.error || 'empty'})`);
++ const spec = components.get(part.id);
++ const definition = {
++ schema: 'design.component@1',
++ name: part.id,
++ title: text(part.name, MAX.title),
++ summary: text(part.line || part.name, MAX.summary),
++ anatomy: anatomyOf(part, spec),
++ // action() forwards its button props (...btn) to kit.button(); merge the builder it forwards to.
++ props: part.fn === 'action' ? propsOf(fn, kit.button) : propsOf(fn),
++ states: statesOf(part, source, markup),
++ tokens: tokensOf(part, spec),
++ behaviors: behavioursOf(part, source, spec),
++ accessibility: accessibilityOf(part, spec),
++ platforms: platformsOf(part, spec, KIT_MODULE),
++ examples: [{ title: 'Default', props: { code: part.code } }],
++ };
++ return { name: part.id, source: `${KIT_MODULE}#${part.fn}`, definition, markup };
++}
++
++/** The menu's Home grid: read from the browser component's option object and state classes. */
++function buildHomeGrid() {
++ const source = fs.readFileSync(require.resolve(HOME_MODULE), 'utf8');
++ const dom = new Set(['addEventListener', 'append', 'close', 'setAttribute', 'href']);
++ const options = [...new Set([...source.matchAll(/\bo\.([a-zA-Z][a-zA-Z0-9]*)/g)].map((m) => m[1]))]
++ .filter((key) => !dom.has(key));
++ const props = {};
++ for (const key of options) {
++ if (Object.keys(props).length >= 60) break;
++ if (!/^[a-z][a-zA-Z0-9]*$/.test(key)) continue;
++ props[key] = key === 'token' ? 'string?' : /^(apps|yours|tools|groups)$/.test(key) ? 'list?' : /^(user)$/.test(key) ? 'object?' : /^(look|feedLive|feedMissing|onPosted|pulseReady)$/.test(key) ? 'function?' : 'any';
++ }
++ const markup = `<div class="ovh"><div class="ovh-sec"><span>Your Home</span><button type="button" aria-label="Edit Home">Edit</button></div><ul class="ovh-grid" aria-label="Your home"><li><a class="ovh-app" href="https://openvibe.live"><span class="ovh-ic"></span><span class="ovh-name">Live</span></a></li><li><a class="ovh-app" href="https://openvibe.chat"><span class="ovh-ic"></span><span class="ovh-name">Chat</span></a></li></ul></div>`;
++ const part = {
++ id: 'home-grid', fn: 'home', name: 'Home grid', group: 'layout',
++ line: 'The menu\'s Home screen: your apps in a grid you arrange by long-press or Edit, with folders and the app drawer.',
++ code: 'OpenVibeHome.home(el, o)',
++ };
++ const definition = {
++ schema: 'design.component@1',
++ name: part.id,
++ title: part.name,
++ summary: text(part.line, MAX.summary),
++ anatomy: [
++ { part: 'grid', role: 'The apps and folders, four or five to a row on a phone and a wrapped grid on a computer.' },
++ { part: 'app', role: 'One app tile: its icon and name, a link; long-press starts Edit and drags it.' },
++ { part: 'folder', role: 'A tile of up to four app previews that opens the folder.' },
++ ],
++ props,
++ states: ['default', 'editing', 'dragging', 'folder-open', 'signed-in', 'signed-out'],
++ tokens: ['color.bg.raised', 'color.text.secondary', 'size.tap', 'hsl(--ovh-h)'],
++ behaviors: { keyboardAware: false, outsideTapCloses: false, scrollLock: false, reducedMotion: 'Movement is removed; a tile still moves with the keyboard and Edit still works.' },
++ accessibility: { name: 'Your home grid', target: 'At least 44 × 44 px per tile', focus: 'Arrow keys move between tiles in Edit; Delete takes a tile off Home; Enter opens the app.' },
++ platforms: platformsOf(part, null, HOME_MODULE),
++ examples: [{ title: 'Default', props: { apps: [{ id: 'live', name: 'Live', url: 'https://openvibe.live' }], edit: false } }],
++ };
++ return { name: part.id, source: `${HOME_MODULE}#home`, definition, markup };
++}
++
++/** The menu's Edit button: toggles arranging Home, and says Done while it is on. */
++function buildHomeEdit() {
++ const part = {
++ id: 'home-edit', fn: 'setEdit', name: 'Home Edit button', group: 'actions',
++ line: 'The menu\'s Edit button: turns arranging Home on, and says Done while dragging and removing apps is on.',
++ code: '<button type="button" aria-label="Edit Home">Edit</button>',
++ };
++ const markup = '<button type="button" class="ovh-sec" aria-label="Edit Home" title="Edit Home: move apps, remove them, add folders">Edit</button>';
++ const definition = {
++ schema: 'design.component@1',
++ name: part.id,
++ title: part.name,
++ summary: text(part.line, MAX.summary),
++ anatomy: [{ part: 'button', role: 'A text button in the Home header; its label is Edit at rest and Done while arranging.' }],
++ props: { edit: 'boolean', label: 'string?' },
++ states: ['default', 'editing', 'focused'],
++ tokens: ['color.accent.strong', 'size.tap'],
++ behaviors: { keyboardAware: true, outsideTapCloses: false, scrollLock: false, reducedMotion: 'The label changes; no movement is needed.' },
++ accessibility: { name: 'Edit Home, or Done arranging Home', target: 'At least 44 × 44 px', focus: 'Tab reaches it first in the Home header; Enter or Space toggles edit mode.' },
++ platforms: platformsOf(part, null, HOME_MODULE),
++ examples: [{ title: 'Default', props: { edit: false, label: 'Edit Home' } }],
++ };
++ return { name: part.id, source: `${HOME_MODULE}#home`, definition, markup };
++}
++
++/** Every entry: the /kit builders, then the two Home parts that are not kit builders. */
++function buildEntries() {
++ const entries = PARTS.map(buildPart);
++ entries.push(buildHomeGrid(), buildHomeEdit());
++ for (const entry of entries) {
++ const check = contracts.validate('design.component', entry.definition);
++ if (!check.valid) {
++ throw new Error(`component registry: ${entry.name} is not design.component@1: ${check.errors.slice(0, 3).map((e) => `${e.path} ${e.message}`).join('; ')}`);
++ }
++ }
++ return entries;
++}
++
++module.exports = { buildEntries, propsOf, statesOf };
ddiff --git a/server/components/store.js b/server/components/store.js
nnew file mode 100644
iindex 0000000..c2f47af
---- /dev/null
++++ b/server/components/store.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++/**
++ * The component registry store: design_components (migration 0020), one row per Shared kit component.
++ *
++ * The importer (server/components/import.js) builds the design.component@1 document and the default example's HTML
++ * from the installed openvibe-shared; this module only persists and reads them. An import never overwrites a row that
++ * already exists, so a person who edits a definition (a clearer summary, an organizing decision) keeps it across
++ * releases; `importEntries({ force: true })` is the explicit refresh, and the CLI in scripts/import-components.js runs
++ * it after a shared release.
++ *
++ * list() → entries ordered by name
++ * get(name) → entry or null
++ * importEntries({ force }) → { imported, total }
++ * count() → rows in the table
++ */
++const { buildEntries } = require('./import');
++
++function toEntry(row) {
++ if (!row) return null;
++ const definition = typeof row.definition === 'string' ? JSON.parse(row.definition) : row.definition;
++ const markup = row.example_markup || '';
++ const examples = Array.isArray(definition.examples) ? definition.examples.map((example, i) => (
++ i === 0 && markup ? { ...example, markup } : example
++ )) : [];
++ return {
++ name: row.name,
++ source: row.source,
++ definition,
++ markup,
++ examples,
++ importedAt: row.imported_at || null,
++ };
++}
++
++function createComponentRegistry({ db, log = console } = {}) {
++ if (!db) throw new Error('createComponentRegistry: a database is required');
++
++ async function list() {
++ const rows = await db.many('SELECT name, source, definition, example_markup, imported_at FROM design_components ORDER BY name');
++ return rows.map(toEntry);
++ }
++
++ async function get(name) {
++ const row = await db.maybe('SELECT name, source, definition, example_markup, imported_at FROM design_components WHERE name = $1', [String(name || '')]);
++ return toEntry(row);
++ }
++
++ async function count() {
++ const row = await db.maybe('SELECT count(*)::int AS n FROM design_components');
++ return row ? Number(row.n) : 0;
++ }
++
++ async function importEntries({ force = false } = {}) {
++ const entries = buildEntries();
++ let imported = 0;
++ await db.tx(async () => {
++ for (const entry of entries) {
++ const sql = force
++ ? 'INSERT INTO design_components (name, source, definition, example_markup, imported_at) VALUES ($1, $2, $3::jsonb, $4, now()) ON CONFLICT (name) DO UPDATE SET source = EXCLUDED.source, definition = EXCLUDED.definition, example_markup = EXCLUDED.example_markup, imported_at = now() RETURNING name'
++ : 'INSERT INTO design_components (name, source, definition, example_markup, imported_at) VALUES ($1, $2, $3::jsonb, $4, now()) ON CONFLICT (name) DO NOTHING RETURNING name';
++ const inserted = await db.maybe(sql, [entry.name, entry.source, JSON.stringify(entry.definition), entry.markup]);
++ if (inserted) imported += 1;
++ }
++ });
++ if (imported && log && typeof log.info === 'function') {
++ log.info(`[OpenVibe.Design] component registry: imported ${imported} of ${entries.length}`);
++ }
++ return { imported, total: entries.length };
++ }
++
++ return { list, get, count, importEntries };
++}
++
++module.exports = { createComponentRegistry, toEntry };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 1616382..7de01ef 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -15,7 +15,8 @@
* GET /api/me/theme/overrides Network GET /api/themes/me/overrides (a theme for one site)
* PUT|DELETE /api/me/theme/overrides/:service Network's PUT|DELETE /api/themes/me/overrides/:service
* GET /api/v1/tokens the export targets and the token names (OpenVibe Tokens)
-- * GET /api/v1/components the components' contract (openvibe-shared/components)
++ * GET /api/v1/components design.component@1 entries from the component registry
++ * GET /api/v1/components/:name one entry, with its default example's markup
* POST /api/v1/components/validate { doc } → { ok, errors }: a kit document checked against it
* GET /api/v1/tokens/:idOrSlug?format=&both=&download= a theme as code: css, tailwind, compose, swiftui, flutter,
* figma or json (DTCG); both=0 leaves out its other half; download=1
@@@ -31,7 +32,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const themeTokens = require('../tokens');
const components = require('openvibe-shared/components');
--const registry = require('../components/registry');
++const legacyRegistry = require('../components/registry');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
@@@ -57,7 +58,7 @@ function forward(res, req, up) {
}
function createApi(ctx) {
-- const { config, catalog, log } = ctx;
++ const { config, catalog, log, registry } = ctx;
const r = asyncRouter();
// A cookie write needs a same-origin signal; a Bearer write is not cookie-authenticated and is left alone.
const requireOrigin = requireSameOrigin(config, { detail: 'A change to a theme must come from openvibe.design itself.' });
@@@ -110,13 +111,30 @@ function createApi(ctx) {
return res.send(body);
});
-- // ── Components: the contract as JSON, and a kit document checked against it (public; nothing is stored) ──
-- r.get('/v1/components', (req, res) => {
++ // ── Components: the registry as JSON, and a kit document checked against it (public; read-only) ──
++ r.get('/v1/components', async (req, res) => {
++ const entries = await registry.list();
res.set('Cache-Control', 'public, max-age=3600');
-- return res.json({ schema: 'design.component@1', version: 1, components: registry.COMPONENTS, kitComponents: components.SPECS, limits: components.LIMITS, preamble: components.PREAMBLE, docs: 'https://openvibe.design/components/registry', llms: 'https://openvibe.design/llms.txt' });
++ return res.json({
++ schema: 'design.component@1',
++ version: 1,
++ components: entries.map((entry) => entry.definition),
++ examples: Object.fromEntries(entries.map((entry) => [entry.name, entry.examples])),
++ kitComponents: components.SPECS,
++ limits: components.LIMITS,
++ preamble: components.PREAMBLE,
++ docs: 'https://openvibe.design/components',
++ llms: 'https://openvibe.design/llms.txt',
++ });
});
-- r.get('/v1/components/:name', (req, res) => {
-- const component = registry.get(req.params.name);
++ r.get('/v1/components/:name', async (req, res) => {
++ const entry = await registry.get(req.params.name);
++ if (entry) {
++ res.set('Cache-Control', 'public, max-age=3600');
++ return res.json({ schema: entry.definition.schema, component: entry.definition, examples: entry.examples });
++ }
++ // The designed interface pages shipped before the registry had storage; their definitions stay readable.
++ const component = legacyRegistry.get(req.params.name);
if (!component) return http.sendProblem(res, 404, 'design.component_not_found', { detail: 'No component by that name.', ctx: req.ov });
res.set('Cache-Control', 'public, max-age=3600');
return res.json({ schema: component.schema, component, examples: component.examples });
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 344beb2..2bb29fe 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -33,7 +33,7 @@ function publicPages(themes = []) {
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
-- { path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
++ { path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The 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/registry', changefreq: 'weekly', priority: 0.8, title: 'Component registry', text: 'Every shared OpenVibe interface as one design.component@1 definition: navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion, for web, Android and iOS.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 9c52906..1ec9436 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -23,6 +23,7 @@ const views = require('../views');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
const componentsView = require('../views/components');
++const componentsListView = require('../views/components-list');
const kitView = require('../views/kit');
const themeTokens = require('../tokens');
const designView = require('../views/design');
@@@ -55,7 +56,7 @@ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewe
const tokenOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null);
function createPageRoutes(ctx) {
-- const { config, catalog, arena, log } = ctx;
++ const { config, catalog, arena, log, registry } = ctx;
const r = asyncRouter();
const safe = (fn) => Promise.resolve().then(fn).catch((err) => {
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
@@@ -249,11 +250,31 @@ function createPageRoutes(ctx) {
const html = kitView.kitPart(String(req.params.id || ''));
return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
});
-- r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
++ // /components is the registry as the plain list the owner asked for; the earlier designed kit-document page stays
++ // on its deep links (?c=<id>&p=web|android|ios), so a link from /kit or an old page still lands on its component.
++ r.get('/components', async (req, res) => {
++ if (req.query.c || req.query.p) {
++ return page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer);
++ }
++ try {
++ return page(res, 200, componentsListView.componentsListPage(await registry.list()), req.viewer);
++ } catch (err) {
++ log.error('[OpenVibe.Design] component registry list failed:', (err && err.message) || err);
++ return page(res, 500, views.notFound(), req.viewer);
++ }
++ });
r.get('/components/registry', (req, res) => page(res, 200, require('../views/component-registry').registryPage(), req.viewer));
-- r.get('/components/:name', (req, res) => {
-- const html = require('../views/component-registry').componentPage(req.params.name);
-- return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
++ r.get('/components/:name', async (req, res) => {
++ const designed = require('../views/component-registry').componentPage(req.params.name);
++ if (designed) return page(res, 200, designed, req.viewer);
++ try {
++ const entry = await registry.get(req.params.name);
++ return entry ? page(res, 200, componentsListView.componentEntryPage(entry), req.viewer)
++ : page(res, 404, views.notFound(), req.viewer);
++ } catch (err) {
++ log.error('[OpenVibe.Design] component registry entry failed:', (err && err.message) || err);
++ return page(res, 500, views.notFound(), req.viewer);
++ }
});
// ── Tokens: any public theme as code (views/tokens.js) ──
ddiff --git a/server/views/components-list.js b/server/views/components-list.js
nnew file mode 100644
iindex 0000000..3e40464
---- /dev/null
++++ b/server/views/components-list.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++/**
++ * The component registry pages, organized and designed by Opus (11-09; the owner: "design and style and organizing
++ * remain with Opus"). /components groups every imported design.component@1 entry by what it is for, with a chip row to
++ * jump between groups and one card per component (its summary, how many props and states, where it lives).
++ * /components/<name> is one entry: what it is, its anatomy and props beside its behaviour and accessibility, its
++ * states and tokens as chips, Android and iOS notes, and the example markup (as code: stored markup is never rendered).
++ * Every value is data from design_components, escaped.
++ */
++const kit = require('openvibe-shared/kit');
++const { layout, esc } = require('../render/layout');
++
++/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */
++const GROUPS = [
++ ['actions', 'Actions', 'Buttons and the things a tap does.', ['button', 'icon-button', 'action', 'more', 'share', 'ask-agent']],
++ ['marks', 'Marks and status', 'Small signs that say what something is or how it is.', ['badge', 'dot', 'mark', 'labels', 'notice']],
++ ['layout', 'Page and layout', 'The frame of a page: heads, sections, sidebars, rails and tabs.', ['page', 'page-head', 'head', 'section', 'fold', 'sidebar', 'rail', 'tabs', 'steps']],
++ ['content', 'Lists and content', 'Rows, cards and what shows while there is nothing yet.', ['list', 'row', 'card', 'empty', 'snippet', 'skeleton']],
++ ['choosing', 'Choosing and settings', 'Switches, ranges, selects, segmented controls, chips and saving.', ['switch-row', 'range-row', 'select-row', 'seg', 'segmented', 'chips', 'find', 'save-bar', 'drop']],
++ ['surfaces', 'Surfaces', 'What opens over the page.', ['sheet', 'dock']],
++ ['data', 'Numbers and data', 'Stats, tables, charts and receipts.', ['stats', 'num', 'table', 'chart', 'rank', 'meter', 'receipt']],
++ ['tools', 'Tools', 'The parts of an OpenVibe tool page.', ['tool', 'tool-head', 'tool-input', 'answer', 'family', 'tries']],
++ ['home', 'Home', 'The menu\'s Home screen.', ['home-grid', 'home-edit']],
++];
++const GROUP_OF = new Map(GROUPS.flatMap(([id, , , names]) => names.map((n) => [n, id])));
++const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
++const chips = (items, cls = '') => (items.length ? `<ul class="crl-chips${cls}">${items.map((x) => `<li>${x}</li>`).join('')}</ul>` : '<p class="crl-none">None.</p>');
++
++function groupsOf(entries) {
++ const by = new Map(GROUPS.map(([id]) => [id, []]));
++ const other = [];
++ for (const e of entries) (by.get(GROUP_OF.get(e.name)) || other).push(e);
++ const out = GROUPS.map(([id, title, note]) => ({ id, title, note, entries: by.get(id) })).filter((g) => g.entries.length);
++ if (other.length) out.push({ id: 'other', title: 'Other', note: 'New parts not yet placed in a group.', entries: other });
++ return out;
++}
++
++function card(entry) {
++ const c = entry.definition || {};
++ const nProps = Object.keys(c.props || {}).length, nStates = (c.states || []).length;
++ const module = c.platforms && c.platforms.web && c.platforms.web.module;
++ return `<li class="cr-card crl-card"><a class="cr-card-link" href="/components/${encodeURIComponent(entry.name)}"><b>${esc(c.title || entry.name)}</b><span>${esc(c.summary || '')}</span></a>
++<div class="cr-card-meta"><span class="crl-counts">${plural(nProps, 'prop')} · ${plural(nStates, 'state')}</span>${module ? `<code class="crl-module">${esc(String(module).replace(/^openvibe-shared\//, ''))}</code>` : ''}</div></li>`;
++}
++
++/** /components: the registry, grouped by purpose. */
++function componentsListPage(entries) {
++ const groups = groupsOf(entries);
++ const nav = groups.length > 1 ? `<nav class="crl-jump" aria-label="Groups">${groups.map((g) => `<a href="#crl-${g.id}">${esc(g.title)}<span>${g.entries.length}</span></a>`).join('')}</nav>` : '';
++ const body = `<div class="ds-wrap tk cr crl">
++<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>Every part of OpenVibe, defined once</h1>
++<p class="ds-lede">${plural(entries.length, 'component')} from the shared kit, each with its props, states, tokens, behaviour, accessibility and how it is built on the web, Android and iOS.</p>
++<p class="cr-links"><a href="/components/registry">The interface pages</a> · <a href="/api/v1/components">All of it as JSON</a> · <a href="/kit">The kit</a></p></header>
++${nav}
++${groups.length ? groups.map((g) => `<section class="cr-group crl-group" id="crl-${g.id}" aria-labelledby="crl-${g.id}-h"><header class="crl-group-h"><h2 id="crl-${g.id}-h">${esc(g.title)}</h2><p>${esc(g.note)}</p></header><ul class="cr-cards">${g.entries.map(card).join('')}</ul></section>`).join('')
++ : kit.empty({ icon: 'grid', title: 'No components imported yet', text: 'The registry fills from the installed openvibe-shared when the service starts.' })}
++</div>`;
++ return layout({
++ title: 'Component registry: every part of OpenVibe',
++ description: 'Every Shared kit component, grouped by purpose: props, states, tokens, behaviour, accessibility, web markup and Android and iOS notes.',
++ path: '/components',
++ active: '/components',
++ styles: ['css/component-registry.css'],
++ body,
++ });
++}
++
++function facts(c) {
++ const b = c.behaviors || {}, a = c.accessibility || {};
++ const rows = [
++ ['Keyboard', b.keyboardAware ? 'Keeps the focused control in view' : 'Standard focus order'],
++ ['Outside tap', b.outsideTapCloses ? 'Closes it' : 'Leaves it open'],
++ ['Scroll', b.scrollLock ? 'Locks the page while open' : 'The page still scrolls'],
++ ['Reduced motion', b.reducedMotion || 'No motion to reduce'],
++ ['Name', a.name || ''], ['Target', a.target || ''], ['Focus', a.focus || ''],
++ ].filter(([, v]) => v);
++ return `<dl class="cr-facts">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;
++}
++
++function examples(entry) {
++ const list = entry.examples && entry.examples.length ? entry.examples : ((entry.definition && entry.definition.examples) || []);
++ const withMarkup = list.filter((x) => x && x.markup);
++ if (!withMarkup.length) return '';
++ return `<section class="crl-sec" aria-labelledby="crl-ex-h"><h2 id="crl-ex-h">Markup</h2>${withMarkup.map((x, i) => `${x.title ? `<p class="crl-ex-t">${esc(x.title)}</p>` : ''}${kit.snippet({ code: String(x.markup), id: `crl-ex-${i}` })}`).join('')}</section>`;
++}
++
++/** /components/<name>: one entry, laid out like the designed interface pages. */
++function componentEntryPage(entry) {
++ const c = entry.definition || {};
++ const group = GROUPS.find(([id]) => id === GROUP_OF.get(entry.name));
++ const p = c.platforms || {}, web = p.web || {}, android = p.android || {}, ios = p.ios || {};
++ const anatomy = (c.anatomy || []).map((part) => `<li><b>${esc(part.part)}</b><span>${esc(part.role)}</span></li>`).join('');
++ const props = Object.entries(c.props || {});
++ const body = `<div class="ds-wrap tk cr crl">
++<header class="ds-page-h"><p class="ds-kicker"><a href="/components${group ? `#crl-${group[0]}` : ''}">${esc(group ? group[1] : 'Component registry')}</a></p><h1>${esc(c.title || entry.name)}</h1><p class="ds-lede">${esc(c.summary || '')}</p>
++<p class="crl-where">${web.module ? `<code>${esc(web.module)}</code>` : ''}<a href="/api/v1/components/${encodeURIComponent(entry.name)}">JSON</a></p></header>
++${web.markup ? `<section class="crl-sec crl-build" aria-labelledby="crl-build-h"><h2 id="crl-build-h">Build it</h2>${kit.snippet({ code: String(web.markup), id: 'crl-build' })}</section>` : ''}
++<div class="cr-layout">
++<section class="crl-sec" aria-labelledby="crl-props-h"><h2 id="crl-props-h">Props</h2>${props.length ? `<dl class="cr-props">${props.map(([k, t]) => `<div><dt><code>${esc(k)}</code></dt><dd>${esc(t)}</dd></div>`).join('')}</dl>` : '<p class="crl-none">No props.</p>'}
++${anatomy ? `<h2>Anatomy</h2><ul class="cr-anatomy">${anatomy}</ul>` : ''}</section>
++<section class="crl-sec" aria-labelledby="crl-beh-h"><h2 id="crl-beh-h">Behaviour and access</h2>${facts(c)}
++<h2>States</h2>${chips((c.states || []).map((s) => esc(s)))}
++<h2>Tokens</h2>${chips((c.tokens || []).map((t) => `<code>${esc(t)}</code>`), ' crl-chips--code')}</section>
++</div>
++<div class="cr-layout">
++<section class="cr-native"><h3>Android${android.toolkit ? ` · ${esc(android.toolkit)}` : ''}</h3>${android.material3 ? `<p class="crl-role">${esc(android.material3)}</p>` : ''}<p>${esc(android.specification || 'Not specified yet.')}</p></section>
++<section class="cr-native"><h3>iOS${ios.toolkit ? ` · ${esc(ios.toolkit)}` : ''}</h3><p>${esc(ios.specification || 'Not specified yet.')}</p></section>
++</div>
++${examples(entry)}
++</div>`;
++ return layout({
++ title: `${c.title || entry.name}: OpenVibe component`,
++ description: c.summary || 'One design.component@1 entry.',
++ path: `/components/${entry.name}`,
++ active: '/components',
++ styles: ['css/component-registry.css'],
++ body,
++ });
++}
++
++module.exports = { componentsListPage, componentEntryPage, GROUPS };
ddiff --git a/test/component-registry.test.js b/test/component-registry.test.js
nnew file mode 100644
iindex 0000000..ee5421e
---- /dev/null
++++ b/test/component-registry.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * The component registry's plumbing: the design_components storage, the import from the installed Shared kit, the read
++ * API, and the plain /components list. Design is Opus's; these pages carry data only, with no cards or categories.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the registry is stored: one valid design.component@1 entry per Shared kit part, with example markup', async () => {
++ const rows = await t.ctx.registry.list();
++ const names = new Set(rows.map((row) => row.name));
++ assert.ok(rows.length >= 50, `${rows.length} entries imported`);
++ for (const name of ['button', 'card', 'page-head', 'head', 'tabs', 'sheet', 'seg', 'segmented', 'empty', 'switch-row', 'home-grid', 'home-edit']) {
++ assert.ok(names.has(name), `${name} is in the registry`);
++ }
++ for (const row of rows) {
++ const checkDoc = contracts.validate('design.component', row.definition);
++ assert.ok(checkDoc.valid, `${row.name}: ${JSON.stringify(checkDoc.errors).slice(0, 300)}`);
++ assert.ok(row.markup.length > 20, `${row.name} stores its default example's markup`);
++ assert.ok(row.definition.states.length && row.definition.tokens.length, `${row.name} states and tokens`);
++ assert.ok(row.definition.platforms.web.markup && row.definition.platforms.android.specification && row.definition.platforms.ios.specification, `${row.name} names all three platforms`);
++ }
++ });
++
++ await check('props and states are read from the Shared kit, not hand-written', async () => {
++ const head = await t.ctx.registry.get('page-head');
++ assert.ok(Object.keys(head.definition.props).includes('title'));
++ assert.ok(Object.keys(head.definition.props).includes('back'));
++ assert.deepStrictEqual(head.definition.states, ['default']);
++
++ const sheet = await t.ctx.registry.get('sheet');
++ assert.ok(Object.keys(sheet.definition.props).includes('id'));
++ assert.ok(sheet.definition.states.includes('closed') && sheet.definition.states.includes('open'));
++
++ const segmented = await t.ctx.registry.get('segmented');
++ assert.ok(Object.keys(segmented.definition.props).includes('items'));
++ assert.ok(segmented.definition.states.includes('selected'));
++
++ const home = await t.ctx.registry.get('home-grid');
++ assert.ok(Object.keys(home.definition.props).includes('apps'));
++ assert.ok(home.definition.states.includes('editing'));
++ const edit = await t.ctx.registry.get('home-edit');
++ assert.ok(edit.definition.states.includes('editing'));
++ });
++
++ await check('the API reads the stored registry and returns the example markup', async () => {
++ const all = (await t.get('/api/v1/components')).json();
++ assert.strictEqual(all.schema, 'design.component@1');
++ assert.strictEqual(all.components.length, (await t.ctx.registry.list()).length);
++ assert.match(all.examples['page-head'][0].markup, /ovk-page-head/, 'the list carries each example markup too');
++ assert.strictEqual((await t.get('/api/v1/components')).headers.get('cache-control'), 'public, max-age=3600');
++
++ const one = (await t.get('/api/v1/components/page-head')).json();
++ assert.strictEqual(one.component.name, 'page-head');
++ assert.strictEqual(one.examples[0].title, 'Default');
++ assert.match(one.examples[0].markup, /ovk-page-head/);
++ assert.ok(one.component.platforms.android.material3);
++ assert.strictEqual((await t.get('/api/v1/components/no-such-component')).status, 404);
++
++ // A designed interface page from before storage keeps its definition readable.
++ const legacy = (await t.get('/api/v1/components/thread-composer')).json();
++ assert.strictEqual(legacy.component.name, 'thread-composer');
++ });
++
++ await check('/components groups every entry by purpose (Opus organizes the registry), each one a card linking its page', async () => {
++ const page = await t.get('/components');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<h1>Every part of OpenVibe, defined once<\/h1>/);
++ assert.match(page.text, /component-registry\.css/);
++ assert.match(page.text, /id="crl-layout"/, 'the layout group');
++ assert.match(page.text, /id="crl-home"/, 'the Home group');
++ assert.match(page.text, /href="\/components\/page-head"><b>Page head<\/b>/);
++ assert.match(page.text, /href="\/components\/home-grid"><b>Home grid<\/b>/);
++ assert.match(page.text, /class="crl-jump"/, 'a chip row jumps between groups');
++ assert.doesNotMatch(page.text, /&lt;header class=&quot;ovk-page-head&quot;/, 'markup lives on the entry page, not the list');
++
++ const one = await t.get('/components/page-head');
++ assert.strictEqual(one.status, 200);
++ assert.match(one.text, /<h1>Page head<\/h1>/);
++ assert.match(one.text, /kit\.pageHead/);
++
++ // The earlier kit-document and designed interface pages keep their own routes.
++ assert.match((await t.get('/components?c=card')).text, /One design system/);
++ assert.match((await t.get('/components/sheet')).text, /data-ov-component="sheet"/);
++ });
++
++ await check('an import adds new entries and leaves an edited entry alone unless forced', async () => {
++ await t.ctx.s.db.exec("UPDATE design_components SET definition = jsonb_set(definition, '{title}', '\"Edited\"') WHERE name = 'button'");
++ const first = await t.ctx.registry.importEntries();
++ assert.strictEqual(first.imported, 0, 'every entry exists');
++ assert.strictEqual((await t.ctx.registry.get('button')).definition.title, 'Edited', 'the edit survives an additive import');
++ const forced = await t.ctx.registry.importEntries({ force: true });
++ assert.strictEqual(forced.total, first.total);
++ assert.strictEqual((await t.ctx.registry.get('button')).definition.title, 'Button', 'the forced import refreshes it');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 9cdb7f0..b18d507 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -129,11 +129,15 @@ const { boot, check, done } = require('./helpers/boot');
await check('the components API: the contract as JSON, and kit documents checked against it', async () => {
const spec = (await t.get('/api/v1/components')).json();
assert.strictEqual(spec.schema, 'design.component@1');
-- assert.ok(spec.components.find((c) => c.name === 'thread-composer').platforms.android.specification.includes('OutlinedTextField'));
++ assert.ok(spec.components.find((c) => c.name === 'page-head').platforms.android.specification.includes('TopAppBar'));
assert.ok(spec.kitComponents.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
const one = (await t.get('/api/v1/components/sheet')).json();
assert.strictEqual(one.component.name, 'sheet');
assert.strictEqual(one.component.behaviors.scrollLock, true);
++ assert.match(one.examples[0].markup, /ovk-sheet/);
++ // The hand-written interface pages still read their own definitions through the same endpoint.
++ const legacy = (await t.get('/api/v1/components/thread-composer')).json();
++ assert.ok(legacy.component.platforms.android.specification.includes('OutlinedTextField'));
assert.strictEqual((await t.get('/api/v1/components/no-such-part')).status, 404);
const page = await t.get('/components/sheet');
assert.strictEqual(page.status, 200);
@@@ -148,7 +152,7 @@ const { boot, check, done } = require('./helpers/boot');
const registry = await t.get('/components/registry');
assert.strictEqual(registry.status, 200);
assert.match(registry.text, /One vocabulary, three platforms/);
-- assert.strictEqual((registry.text.match(/class="cr-card-link"/g) || []).length, spec.components.length);
++ assert.strictEqual((registry.text.match(/class="cr-card-link"/g) || []).length, require('../server/components/registry').COMPONENTS.length);
assert.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/components\/registry/);
assert.strictEqual((await t.get('/sitemap.xml')).text.includes('/components/registry'), true);
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });