openvibers/openvibe.design Commits
Store, import and serve the component registry as a plain list (#222)
Changes
nnew file mode 100644@@@ -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);@@@ -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; }nnew file mode 100755@@@ -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);++});@@@ -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,};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 });@@@ -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.' },@@@ -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.vieweconst 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) ──nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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, /<header class="ovk-page-head"/, '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); });@@@ -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' } }] } } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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);
@@ -62,3 +62,32 @@
@@ -62,3 +62,32 @@
.cr-motion-notice div { display: flex; flex-wrap: wrap; gap: 8px; }
.cr-motion-notice div { display: flex; flex-wrap: wrap; gap: 8px; }
.cr-motion-notice button { min-height: 44px; }
.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; } }
@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; }
new file mode 100755
new file mode 100755
@@ -0,0 +1,29 @@
@@ -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);
+});
@@ -40,6 +40,7 @@ const { createMcpRoutes } = require('./http/mcp');
@@ -40,6 +40,7 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
+const { createComponentRegistry } = require('./components/store');
const { createSharing, describeRef } = require('./sharing');
const { createSharing, describeRef } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createTokenFileRoutes } = require('./http/tokens');
const { createTokenFileRoutes } = require('./http/tokens');
@@ -68,6 +69,10 @@ async function createApp(opts = {}) {
@@ -68,6 +69,10 @@ async function createApp(opts = {}) {
const now = opts.now || (() => Date.now());
const now = opts.now || (() => Date.now());
const s = opts.store || await openStore(config, { now, log });
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 keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, log, now });
const sso = createSso({ config, keys, fetchImpl, log, now });
// D11 owner settings (design.publishing): created before the app mounts /internal/config. A failure leaves
// D11 owner settings (design.publishing): created before the app mounts /internal/config. A failure leaves
@@ -110,7 +115,7 @@ async function createApp(opts = {}) {
@@ -110,7 +115,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
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,
random: opts.random || Math.random,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,408 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
* GET /api/me/theme/overrides Network GET /api/themes/me/overrides (a theme for one site)
* 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
* 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/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
* 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,
* 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
* figma or json (DTCG); both=0 leaves out its other half; download=1
@@ -31,7 +32,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');
@@ -31,7 +32,7 @@ const { ACCESS_COOKIE } = require('../auth/sso');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const components = require('openvibe-shared/components');
const components = require('openvibe-shared/components');
-const registry = require('../components/registry');
+const legacyRegistry = require('../components/registry');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
function passThrough(res, r) {
@@ -57,7 +58,7 @@ function forward(res, req, up) {
@@ -57,7 +58,7 @@ function forward(res, req, up) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, catalog, log } = ctx;
+ const { config, catalog, log, registry } = ctx;
const r = asyncRouter();
const r = asyncRouter();
// A cookie write needs a same-origin signal; a Bearer write is not cookie-authenticated and is left alone.
// 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.' });
const requireOrigin = requireSameOrigin(config, { detail: 'A change to a theme must come from openvibe.design itself.' });
@@ -110,13 +111,30 @@ function createApi(ctx) {
@@ -110,13 +111,30 @@ function createApi(ctx) {
return res.send(body);
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');
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 });
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');
res.set('Cache-Control', 'public, max-age=3600');
return res.json({ schema: component.schema, component, examples: component.examples });
return res.json({ schema: component.schema, component, examples: component.examples });
@@ -33,7 +33,7 @@ function publicPages(themes = []) {
@@ -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: '/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: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
{ path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
- { path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
+ { path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The 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: '/components/registry', changefreq: 'weekly', priority: 0.8, title: 'Component registry', text: 'Every shared OpenVibe interface as one design.component@1 definition: navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion, for web, Android and iOS.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
@@ -23,6 +23,7 @@ const views = require('../views');
@@ -23,6 +23,7 @@ const views = require('../views');
const tokensView = require('../views/tokens');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
const lookView = require('../views/look');
const componentsView = require('../views/components');
const componentsView = require('../views/components');
+const componentsListView = require('../views/components-list');
const kitView = require('../views/kit');
const kitView = require('../views/kit');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
@@ -55,7 +56,7 @@ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewe
@@ -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);
const tokenOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null);
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, catalog, arena, log } = ctx;
+ const { config, catalog, arena, log, registry } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const safe = (fn) => Promise.resolve().then(fn).catch((err) => {
const safe = (fn) => Promise.resolve().then(fn).catch((err) => {
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
@@ -249,11 +250,31 @@ function createPageRoutes(ctx) {
@@ -249,11 +250,31 @@ function createPageRoutes(ctx) {
const html = kitView.kitPart(String(req.params.id || ''));
const html = kitView.kitPart(String(req.params.id || ''));
return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
});
});
- r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
+ // /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/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) ──
// ── Tokens: any public theme as code (views/tokens.js) ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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, /<header class="ovk-page-head"/, '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); });
@@ -129,11 +129,15 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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 () => {
await check('the components API: the contract as JSON, and kit documents checked against it', async () => {
const spec = (await t.get('/api/v1/components')).json();
const spec = (await t.get('/api/v1/components')).json();
assert.strictEqual(spec.schema, 'design.component@1');
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'));
assert.ok(spec.kitComponents.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
const one = (await t.get('/api/v1/components/sheet')).json();
const one = (await t.get('/api/v1/components/sheet')).json();
assert.strictEqual(one.component.name, 'sheet');
assert.strictEqual(one.component.name, 'sheet');
assert.strictEqual(one.component.behaviors.scrollLock, true);
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);
assert.strictEqual((await t.get('/api/v1/components/no-such-part')).status, 404);
const page = await t.get('/components/sheet');
const page = await t.get('/components/sheet');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
@@ -148,7 +152,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -148,7 +152,7 @@ const { boot, check, done } = require('./helpers/boot');
const registry = await t.get('/components/registry');
const registry = await t.get('/components/registry');
assert.strictEqual(registry.status, 200);
assert.strictEqual(registry.status, 200);
assert.match(registry.text, /One vocabulary, three platforms/);
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.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/components\/registry/);
assert.strictEqual((await t.get('/sitemap.xml')).text.includes('/components/registry'), true);
assert.strictEqual((await t.get('/sitemap.xml')).text.includes('/components/registry'), true);
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });