openvibers/openvibe.design Commits

Design: /components — the design system's components for web, Android and iOS; openvibe-shared v4.42.0 (#18)

OP OpenVibers committed 15 days ago · 20cefa4932 · 13 files, +191 −14

One component at a time: what it is for, the real thing drawn by the kit from a kit document, its parts and the tokens each wears, its props, states, accessibility and layout, and its code for the web (the kit document), Android (Compose) and iOS (SwiftUI). GET /api/v1/components (the contract) and POST /api/v1/components/validate (a kit document checked). The copy names OvScale for the scales; .ds h3 no longer uppercases kit card titles; nav icons the navbar draws.

Changes

ddiff --git a/README.md b/README.md
iindex ae0243d..1a7c786 100644
---- a/README.md
++++ b/README.md
@@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
- openvibe-contracts: v0.236.0
- openvibe-sdk: v0.70.0
--- openvibe-shared: v4.41.0
++- openvibe-shared: v4.42.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3b00d87..b6755bf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,11 +10,11 @@
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.236.0",
"sdk": "openvibe-sdk v0.70.0",
-- "shared": "openvibe-shared v4.41.0",
++ "shared": "openvibe-shared v4.42.0",
"packages": [
"openvibe-contracts v0.236.0",
"openvibe-sdk v0.70.0",
-- "openvibe-shared v4.41.0"
++ "openvibe-shared v4.42.0"
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 1672a85..55a2e4e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.41.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -1114,9 +1114,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.41.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.41.0",
-- "integrity": "sha512-WMd2s+SQI2lOa0o4NDSM+jMmgBdkGIXiEfGM31EX+ASN/pWZMdsuXeRGKGsd9KJKgq5I9PT8Sf8sM25u0vyyNA==",
++ "version": "4.42.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
++ "integrity": "sha512-Nrbbi3zZjD492EsicO49UWv7sJRj368BDvFdO3DhQBaUJFYSuVAiAAbjuPz5pDT0h7EyTTz71mSkY59hSQbFwA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex bf78c61..a5635c2 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.41.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
"pg": "^8.23.1"
},
"engines": {
ddiff --git a/public/css/design.css b/public/css/design.css
iindex f72ef07..d6a8a2f 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -9,7 +9,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.ds-wrap { max-width: 1320px; margin: 0 auto; padding: 0 16px 64px; }
.ds a:not([class]) { color: var(--room-strong); }
.ds h2 { margin: 0 0 10px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
--.ds h3 { margin: 18px 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.ds h3:not(.ovk-card-title) { margin: 18px 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
:where(.ds) .ovk-i { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ds-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
@@@ -434,3 +434,30 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
}
.lk .ovk-seg label span { display: flex; align-items: center; justify-content: center; min-height: 40px; }
@media (pointer: coarse) { .lk .ovk-seg label span { min-height: 44px; } }
++
++/* ── /components (views/components.js) ── */
++.cp { margin-top: 10px; }
++.cp-head h2 { margin: 6px 0 4px; font-size: 1.4rem; font-weight: 800; }
++.cp-head .ds-lede { margin: 0 0 12px; }
++.cp-stage { padding: 18px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-bg); overflow: hidden; }
++.cp-stage > .ovk-section, .cp-stage > .ovk-fold, .cp-stage > .ovk-list { margin: 0; }
++.cp-stage > .ovk-card { max-width: 320px; }
++.cp-stage .ovk-section .ovk-btn { margin: 4px 6px 10px 14px; }
++.cp-stage .ovk-section .ovk-notice { margin: 6px 14px 10px; }
++.cp-grid { display: grid; gap: 18px; margin-top: 18px; }
++.cp-grid > div > h3, .cp > h3 { margin: 0 0 8px; font-size: 1rem; font-weight: 800; }
++.cp > h3 { margin-top: 20px; }
++.cp-parts { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.cp-parts li { padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
++.cp-opt { color: var(--ovk-muted); font-size: .8rem; }
++.cp-tokens { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 6px; }
++.cp-tok { display: inline-flex; align-items: center; gap: 6px; font-size: .84rem; }
++.cp-tok small { color: var(--ovk-muted); }
++.cp-tok code { font-size: .8rem; }
++.cp-note { display: block; margin-top: 4px; color: var(--ovk-muted); font-size: .84rem; }
++.cp-facts { margin: 0; display: grid; gap: 6px; }
++.cp-facts > div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--ovk-line); }
++.cp-facts dt { color: var(--ovk-muted); font-weight: 700; font-size: .86rem; }
++.cp-facts dd { margin: 0; overflow-wrap: anywhere; }
++.cp-facts code { font-size: .84rem; }
++@media (min-width: 900px) { .cp-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
ddiff --git a/server/http/api.js b/server/http/api.js
iindex cbce08b..7abe587 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -13,6 +13,8 @@
* POST /api/me/theme/history/:id/restore Network's restore (id a positive integer)
* POST /api/themes/submit Network POST /api/themes
* GET /api/v1/tokens the export targets and the token names (OpenVibe Tokens)
++ * GET /api/v1/components the components' contract (openvibe-shared/components)
++ * 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
* names a file
@@@ -26,6 +28,7 @@ const { asyncRouter } = require('./router');
const { ACCESS_COOKIE } = require('../auth/sso');
const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const themeTokens = require('../tokens');
++const components = require('openvibe-shared/components');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
@@@ -104,6 +107,18 @@ 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) => {
++ res.set('Cache-Control', 'public, max-age=3600');
++ return res.json({ version: components.VERSION, limits: components.LIMITS, preamble: components.PREAMBLE, components: components.SPECS, docs: 'https://openvibe.design/components' });
++ });
++ r.post('/v1/components/validate', (req, res) => {
++ const doc = req.body && req.body.doc;
++ const out = components.validate(doc);
++ res.set('Cache-Control', 'no-store');
++ return res.status(out.ok ? 200 : 422).json(out);
++ });
++
// A submission is a write: same-origin, signed in, and the same body Network expects.
r.post('/themes/submit', requireOrigin, requireToken, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 44e12c2..95681e2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -30,6 +30,7 @@ function publicPages(themes = []) {
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/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: '/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: '/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: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 62c0b1e..46cc9bd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,6 +18,7 @@ const express = require('express');
const views = require('../views');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
++const componentsView = require('../views/components');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const { asyncRouter } = require('./router');
@@@ -172,6 +173,9 @@ function createPageRoutes(ctx) {
return page(res, 200, lookView.lookPage({ themes: list ? list.themes : [], me: meOf(req.viewer) }), req.viewer);
});
++ // ── Components: the design system's components for web, Android and iOS (views/components.js) ──
++ r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
++
// ── Tokens: any public theme as code (views/tokens.js) ──
r.get('/tokens', async (req, res) => {
const list = await safe(() => catalog.list());
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 7858dcc..85b3db6 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -25,9 +25,10 @@ const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
-- { label: 'Your look', href: '/look', icon: 'fa-sliders' },
++ { label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
++ { label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
];
@@@ -46,7 +47,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Your look', href: '/look', icon: 'fa-sliders' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/server/views/components.js b/server/views/components.js
nnew file mode 100644
iindex 0000000..9b8f03a
---- /dev/null
++++ b/server/views/components.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * /components: the OpenVibe design system's components, drawn natively everywhere (openvibe-shared/components 4.42.0).
++ * One component at a time (?c=card): what it is for, the real thing (a sample kit document rendered through kit.js),
++ * its parts and the tokens each wears (swatches in the default theme), props, states, accessibility, how it behaves on a
++ * phone and a computer, and its code for the web (the kit document and kit.js), Android (Compose) and iOS (SwiftUI)
++ * (?p=web|android|ios). Server-rendered and whole without JavaScript; kit-web.js adds the copy buttons.
++ *
++ * componentsPage({ id, platform, themes })
++ */
++const kit = require('openvibe-shared/kit');
++const components = require('openvibe-shared/components');
++const tokens = require('openvibe-shared/tokens');
++const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
++const { layout, esc } = require('../render/layout');
++
++// A sample kit document for each component: what the page shows rendered, and what the Web tab prints.
++const card = (n, title, extra = {}) => ({ type: 'card', props: { href: `/components?c=card#${n}`, title, type: 'stream', ...extra } });
++const SAMPLES = {
++ button: { type: 'section', props: { title: 'Buttons' }, children: [{ type: 'button', props: { label: 'Go live', href: '/components?c=button', icon: 'live' } }, { type: 'button', props: { label: 'Not now', kind: 'quiet' } }, { type: 'button', props: { label: 'Delete the room', kind: 'danger', small: true } }] },
++ card: card(1, 'Seattle at night, chat picks the route', { live: true, who: 'goosely', count: [1204, 'watching'], sub: 'IRL · Capitol Hill' }),
++ rail: { type: 'rail', props: { id: 'sample-rail', title: 'Live now', more: { href: '/components?c=rail', label: 'See all' } }, children: [card(1, 'Seattle at night', { live: true, who: 'goosely' }), card(2, 'Surf race on Utopia', { who: 'kiwi', corner: '3 laps' }), card(3, 'Building a robot arm', { who: 'maru', badge: 'NEW' })] },
++ row: { type: 'list', props: {}, children: [{ type: 'row', props: { href: '/components?c=row', title: 'night-owls', sub: 'ana: anyone up for the surf race?', meta: '17:29', mark: 'Night owls', unread: 2 } }] },
++ list: { type: 'list', props: { title: 'Your rooms', more: { href: '/components?c=list', label: 'All' } }, children: [{ type: 'row', props: { href: '/components?c=list', title: 'night-owls', sub: '38 here', mark: 'Night owls' } }, { type: 'row', props: { href: '/components?c=list', title: 'surf-crew', sub: 'jules: in five', mark: 'Surf crew', meta: '17:12' } }, { type: 'row', props: { href: '/components?c=list', title: 'builders', sub: 'a new world went up', mark: 'Builders', meta: 'Tue' } }] },
++ section: { type: 'section', props: { title: 'Notifications', note: 'Saved to your account' }, children: [{ type: 'notice', props: { kind: 'info', text: 'You will hear when someone you follow goes live.' } }] },
++ fold: { type: 'fold', props: { title: 'Put this room on your site', meta: 'two ways' }, children: [{ type: 'notice', props: { kind: 'info', text: 'Copy the embed code, or link to the room.' } }] },
++ tabs: { type: 'tabs', props: { base: '/components?c=tabs', param: 'tab', active: 'live', items: [{ key: 'live', label: 'Live', count: 12 }, { key: 'clips', label: 'Clips' }, { key: 'about', label: 'About' }] } },
++ stats: { type: 'stats', props: { items: [[1204, 'watching now'], [48, 'people chatting'], [12, 'streams live', '/components?c=stats']] } },
++ notice: { type: 'section', props: { title: 'Notices' }, children: [{ type: 'notice', props: { kind: 'ok', text: 'Saved. Every OpenVibe site follows.' } }, { type: 'notice', props: { kind: 'error', text: 'That name is taken. Try another.' } }] },
++ empty: { type: 'empty', props: { title: 'No public rooms yet', text: 'Start the first one: public, or private to the people you invite.', icon: 'chat', action: { href: '/components?c=empty', label: 'Start a room' } } },
++};
++
++const vibe = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === 'vibe'));
++const tokenValue = (path) => path.split('.').reduce((o, k) => (o ? o[k] : null), vibe);
++const swatch = (path) => {
++ const t = tokenValue(path);
++ if (!t || t.$type !== 'color') return '';
++ const c = tokens.parseColor(t.$value);
++ return c ? `<i class="tk-dot" style="background:${esc(t.$value)}"></i>` : '';
++};
++
++function anatomy(spec) {
++ return `<ul class="cp-parts">${spec.anatomy.map((p) => `<li><b>${esc(p.part)}</b>${p.optional ? ' <span class="cp-opt">optional</span>' : ''}`
++ + `${Object.keys(p.tokens || {}).length ? `<span class="cp-tokens">${Object.entries(p.tokens).map(([k, v]) => `<span class="cp-tok">${swatch(v)}<small>${esc(k)}</small><code>${esc(v)}</code></span>`).join('')}</span>` : ''}`
++ + `${p.note ? `<small class="cp-note">${esc(p.note)}</small>` : ''}</li>`).join('')}</ul>`;
++}
++function facts(spec) {
++ const props = Object.entries(spec.props).map(([k, p]) => `<code>${esc(k)}</code>${p.required ? '*' : ''}`).join(' ');
++ const rows = [
++ ['Role', esc(spec.role)],
++ ['Props', `${props} <small class="cp-note">* required</small>`],
++ ['States', esc((spec.states || []).join(' · '))],
++ ['Name', esc(spec.a11y.name)],
++ ['Keys', esc(spec.a11y.keys)],
++ spec.a11y.minTarget ? ['Target', esc(spec.a11y.minTarget)] : null,
++ ['Phone', esc(spec.layout.phone || '')],
++ ['Computer', esc(spec.layout.computer || spec.layout.tablet || '')],
++ ].filter(Boolean);
++ const variants = spec.variants ? Object.entries(spec.variants).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${Object.entries(v).map(([n, d]) => `<code>${esc(n)}</code> ${esc(d)}`).join('<br>')}</dd></div>`).join('') : '';
++ return `<dl class="cp-facts">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${v}</dd></div>`).join('')}${variants}</dl>`;
++}
++
++const PLATFORMS = [{ key: 'web', label: 'Web' }, { key: 'android', label: 'Android' }, { key: 'ios', label: 'iOS' }];
++
++function componentsPage({ id = '', platform = 'web' } = {}) {
++ const specs = components.SPECS;
++ const spec = components.get(id) || specs[0];
++ const sample = SAMPLES[spec.id];
++ const rendered = sample && components.validate(sample).ok ? components.render(sample, kit) : '';
++ const p = PLATFORMS.some((x) => x.key === platform) ? platform : 'web';
++ const code = p === 'android' ? `${components.PREAMBLE.compose}\n\n${spec.native.compose}`
++ : p === 'ios' ? `${components.PREAMBLE.swiftui}\n\n${spec.native.swiftui}`
++ : `// A kit document (JSON). The web renders it with openvibe-shared/components:\n// components.render(doc, kit) → ${spec.web}(…)\n${JSON.stringify(sample, null, 2)}`;
++ const how = p === 'android' ? 'Jetpack Compose, on the theme file from Tokens (Android): Material 3 roles for colour, OvScale for sizes.'
++ : p === 'ios' ? 'SwiftUI, on the theme file from Tokens (iOS): OvTokens (the theme you wear) for colour, OvScale for sizes.'
++ : `The same component as data: a server sends this, the web renders it with ${spec.web}, and a native app with its own view.`;
++ const base = `/components?c=${encodeURIComponent(spec.id)}`;
++ const body = `<header class="ds-page-h"><p class="ds-kicker">Components</p><h1>One design system, drawn natively everywhere</h1>
++<p class="ds-lede">Every OpenVibe component is described by what it is: its parts, the tokens they wear, its states, and how it reads to a screen reader. The web draws it with the kit; an Android or iOS app draws the same component with its own views, so an app is never a web page in a frame.</p></header>
++${kit.tabs({ base: '/components', param: 'c', active: spec.id, label: 'Components', items: specs.map((s) => ({ key: s.id, label: s.name })) })}
++<section class="cp" aria-labelledby="cp-h"><div class="cp-head"><h2 id="cp-h">${esc(spec.name)}</h2><p class="ds-lede">${esc(spec.summary)}</p></div>
++<div class="cp-stage" aria-label="${esc(spec.name)}, drawn by the kit">${rendered}</div>
++<div class="cp-grid"><div><h3>Parts and their tokens</h3>${anatomy(spec)}</div><div><h3>How it behaves</h3>${facts(spec)}</div></div>
++<h3 id="cp-code">Code</h3>
++${kit.tabs({ base: `${base}`, param: 'p', active: p, label: 'Platform', items: PLATFORMS.map((x) => ({ ...x, href: `${base}&p=${x.key}#cp-code` })) })}
++<p class="tk-how">${esc(how)}</p>
++${kit.snippet({ code, id: 'cp-code-snip', label: 'Copy the code' })}</section>
++${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}`;
++ return layout({
++ title: `${spec.name}: an OpenVibe component for web, Android and iOS`,
++ description: `${spec.summary} Its parts, tokens, states and accessibility, with code for the web, Jetpack Compose and SwiftUI.`,
++ path: `/components?c=${encodeURIComponent(spec.id)}`,
++ body: `<div class="ds-wrap tk">${body}</div>`,
++ active: '/components',
++ });
++}
++
++module.exports = { componentsPage, SAMPLES };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex a517120..11d9242 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -111,7 +111,7 @@ function home({ themes } = {}) {
lede: 'Every theme is a set of W3C design tokens. The same values reach the web as CSS variables, Android as a Compose colour scheme and iOS as SwiftUI colours, so a native app wears your theme without being a web page in a frame.',
samples: [
{ label: 'Android', code: 'MaterialTheme(\n colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme\n else VibeLightColorScheme,\n) { App() }' },
-- { label: 'iOS', code: 'Text("Live now")\n .foregroundStyle(VibeDarkTokens.colorTextPrimary)\n .padding(VibeDarkTokens.spaceLg)\n .background(VibeDarkTokens.colorBgRaised,\n in: .rect(cornerRadius: VibeDarkTokens.radiusMd))' },
++ { label: 'iOS', code: 'Text("Live now")\n .foregroundStyle(VibeDarkTokens.colorTextPrimary)\n .padding(OvScale.spaceLg)\n .background(VibeDarkTokens.colorBgRaised,\n in: .rect(cornerRadius: OvScale.radiusMd))' },
{ label: 'Web', code: 'curl "https://openvibe.design/api/v1/tokens/vibe?format=css" > vibe.css\n\n.card { background: var(--ov-color-bg-raised);\n border-radius: var(--ov-radius-md); }' },
],
links: [{ label: 'Every platform', href: '/tokens' }, { label: 'The API', href: '/developers' }],
@@@ -187,6 +187,8 @@ function developers() {
['GET /api/v1/designs/:id/versions', 'Every saved version with its model/provider provenance; POST /undo and /redo move the pointer.'],
['GET /api/v1/tokens/:theme?format=', 'A theme as code (OpenVibe Tokens): css, tailwind, compose (Android), swiftui (iOS), flutter, figma or json (W3C DTCG). Its other half comes along unless both=0; download=1 names a file.'],
['GET /api/v1/tokens', 'The export targets.'],
++ ['GET /api/v1/components', 'The components\' contract: anatomy and tokens, props, states, a11y, layout, and Compose and SwiftUI reference views.'],
++ ['POST /api/v1/components/validate', 'Check a kit document ({ doc }): known components and props, typed values, children, size, links. Answers { ok, errors }.'],
['GET /api/themes', 'Every public OpenVibe theme with its colours (Network\'s catalog).'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/me/theme', 'Your active theme (signed in).'],
ddiff --git a/server/views/tokens.js b/server/views/tokens.js
iindex dafef6b..d06e62b 100644
---- a/server/views/tokens.js
++++ b/server/views/tokens.js
@@@ -20,8 +20,8 @@ function howTo(id, name, pair) {
switch (id) {
case 'css': return `Paste it into your stylesheet: <code>--ov-color-bg-base</code> and the rest, with the network's own names (<code>--bg-primary</code>…) pointing at them.${pair ? ' The light half switches on <code>data-theme-mode="light"</code>.' : ''}`;
case 'tailwind': return 'Save it as <code>tailwind.config.js</code> beside the CSS file. Classes like <code>bg-bg-raised</code> and <code>text-accent-base</code> read the variables, so light and dark need no rebuild.';
-- case 'compose': return `Add it to your app module and wrap your UI in <code>MaterialTheme(colorScheme = ${pair ? `if (isSystemInDarkTheme()) ${n}DarkColorScheme else ${n}LightColorScheme` : `${n}${'DarkColorScheme'}`}) { … }</code>. Every token is on <code>${n}${pair ? 'Dark' : ''}Tokens</code> too: spacing, radii, type and motion.`;
-- case 'swiftui': return `Add it to your target and use <code>${n}${pair ? 'Dark' : ''}Tokens.colorAccentBase</code>, <code>.radiusMd</code>, <code>.spaceLg</code>… in your views; <code>colorScheme</code> says which half each one is.`;
++ case 'compose': return `Add it to your app module and wrap your UI in <code>MaterialTheme(colorScheme = ${pair ? `if (isSystemInDarkTheme()) ${n}DarkColorScheme else ${n}LightColorScheme` : `${n}${'DarkColorScheme'}`}) { … }</code>. Every colour is on <code>${n}${pair ? 'Dark' : ''}Tokens</code> too, and the spacing, radii, type and motion are on <code>OvScale</code>, the same in every theme.`;
++ case 'swiftui': return `Add it to your target and use <code>${n}${pair ? 'Dark' : ''}Tokens.colorAccentBase</code> and the rest for colours, and <code>OvScale.radiusMd</code>, <code>OvScale.spaceLg</code>… for sizes; <code>colorScheme</code> says which half each one is.`;
case 'flutter': return pair
? `<code>MaterialApp(theme: ThemeData(colorScheme: ${lower}LightScheme), darkTheme: ThemeData(colorScheme: ${lower}DarkScheme))</code>. The raw values are on <code>${n}DarkTokens</code> and <code>${n}LightTokens</code>.`
: `<code>MaterialApp(theme: ThemeData(colorScheme: ${lower}DarkScheme))</code>. The raw values are on <code>${n}Tokens</code>.`;
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 34f8237..7e3ec53 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -101,6 +101,35 @@ const { boot, check, done } = require('./helpers/boot');
assert.match((await t.get('/')).text, /href="\/look"/, 'the nav and the home lead to it');
});
++ await check('/components: each component with its parts, tokens, behaviour and code for web, Android and iOS', async () => {
++ const r = await t.get('/components?c=card');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /One design system, drawn natively everywhere/);
++ for (const name of ['Button', 'Card', 'Rail', 'Row', 'List', 'Section', 'Fold', 'Tabs', 'Numbers', 'Notice', 'Empty state']) assert.match(r.text, new RegExp(`<span>${name}</span>`), name);
++ assert.match(r.text, /<article class="ovk-card is-live ovk-card--stream"/, 'the real card, drawn by the kit');
++ assert.match(r.text, /<code>color\.text\.primary<\/code>/, 'its parts name their tokens');
++ assert.match(r.text, /&quot;type&quot;: &quot;card&quot;/, 'the Web tab shows the kit document');
++ const android = await t.get('/components?c=card&p=android');
++ assert.match(android.text, /fun OvCard\(/);
++ assert.match(android.text, /enum class OvKind/);
++ const ios = await t.get('/components?c=rail&p=ios');
++ assert.match(ios.text, /struct OvRail&lt;Content: View&gt;: View/);
++ assert.match(ios.text, /typealias OvTokens = VibeDarkTokens/);
++ assert.match((await t.get('/components?c=nope')).text, /<h2 id="cp-h">Button<\/h2>/, 'an unknown one shows the first');
++ });
++
++ 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.components.length, 11);
++ assert.ok(spec.components.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
++ 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' } }] } } });
++ assert.strictEqual(good.status, 200);
++ assert.deepStrictEqual(good.json(), { ok: true, errors: [] });
++ const bad = await t.get('/api/v1/components/validate', { json: { doc: { type: 'card', props: { href: 'javascript:alert(1)', title: 'x' } } } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.json().errors.join(' '), /card\.props\.href: expected url/);
++ });
++
await t.close();
done();
})();