openvibers/openvibe.design Commits
Design: /components — the design system's components for web, Android and iOS; openvibe-shared v4.42.0 (#18)
Changes
@@@ -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 -->@@@ -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": [@@@ -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",@@@ -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": {@@@ -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); } }@@@ -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 : {};@@@ -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.' },];@@@ -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());@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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).'],@@@ -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>.`;@@@ -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, /"type": "card"/, '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<Content: View>: 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();})();
@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.236.0
- openvibe-contracts: v0.236.0
- openvibe-sdk: v0.70.0
- openvibe-sdk: v0.70.0
-- openvibe-shared: v4.41.0
+- openvibe-shared: v4.42.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,11 +10,11 @@
@@ -10,11 +10,11 @@
"domain": "openvibe.design",
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.236.0",
"contracts": "openvibe-contracts v0.236.0",
"sdk": "openvibe-sdk v0.70.0",
"sdk": "openvibe-sdk v0.70.0",
- "shared": "openvibe-shared v4.41.0",
+ "shared": "openvibe-shared v4.42.0",
"packages": [
"packages": [
"openvibe-contracts v0.236.0",
"openvibe-contracts v0.236.0",
"openvibe-sdk v0.70.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.",
"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": [
"works": [
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.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-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"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -1114,9 +1114,9 @@
@@ -1114,9 +1114,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.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-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"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
@@ -9,7 +9,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -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-wrap { max-width: 1320px; margin: 0 auto; padding: 0 16px 64px; }
.ds a:not([class]) { color: var(--room-strong); }
.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 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; }
: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-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
.ds-lede { margin: 8px 0 0; max-width: 720px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
@@ -434,3 +434,30 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -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; }
.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; } }
@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); } }
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* POST /api/me/theme/history/:id/restore Network's restore (id a positive integer)
* POST /api/me/theme/history/:id/restore Network's restore (id a positive integer)
* POST /api/themes/submit Network POST /api/themes
* POST /api/themes/submit Network POST /api/themes
* 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)
+ * 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
* names a file
* names a file
@@ -26,6 +28,7 @@ const { asyncRouter } = require('./router');
@@ -26,6 +28,7 @@ const { asyncRouter } = require('./router');
const { ACCESS_COOKIE } = require('../auth/sso');
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');
/** 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) {
@@ -104,6 +107,18 @@ function createApi(ctx) {
@@ -104,6 +107,18 @@ 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) => {
+ 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.
// A submission is a write: same-origin, signed in, and the same body Network expects.
r.post('/themes/submit', requireOrigin, requireToken, async (req, res) => {
r.post('/themes/submit', requireOrigin, requireToken, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
@@ -30,6 +30,7 @@ function publicPages(themes = []) {
@@ -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: '/', 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: '/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: '/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: '/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.' },
{ 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.' },
];
];
@@ -18,6 +18,7 @@ const express = require('express');
@@ -18,6 +18,7 @@ const express = require('express');
const views = require('../views');
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 themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -172,6 +173,9 @@ function createPageRoutes(ctx) {
@@ -172,6 +173,9 @@ function createPageRoutes(ctx) {
return page(res, 200, lookView.lookPage({ themes: list ? list.themes : [], me: meOf(req.viewer) }), req.viewer);
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) ──
// ── Tokens: any public theme as code (views/tokens.js) ──
r.get('/tokens', async (req, res) => {
r.get('/tokens', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
@@ -25,9 +25,10 @@ const NAV = [
@@ -25,9 +25,10 @@ const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', 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: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ 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 },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
];
];
@@ -46,7 +47,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -46,7 +47,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
service: 'design',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
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 },
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
@@ -111,7 +111,7 @@ function home({ themes } = {}) {
@@ -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.',
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: [
samples: [
{ label: 'Android', code: 'MaterialTheme(\n colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme\n else VibeLightColorScheme,\n) { App() }' },
{ 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); }' },
{ 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' }],
links: [{ label: 'Every platform', href: '/tokens' }, { label: 'The API', href: '/developers' }],
@@ -187,6 +187,8 @@ function 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/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/: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/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', 'Every public OpenVibe theme with its colours (Network\'s catalog).'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/me/theme', 'Your active theme (signed in).'],
['GET /api/me/theme', 'Your active theme (signed in).'],
@@ -20,8 +20,8 @@ function howTo(id, name, pair) {
@@ -20,8 +20,8 @@ function howTo(id, name, pair) {
switch (id) {
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 '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 '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
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}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>.`;
: `<code>MaterialApp(theme: ThemeData(colorScheme: ${lower}DarkScheme))</code>. The raw values are on <code>${n}Tokens</code>.`;
@@ -101,6 +101,35 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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');
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, /"type": "card"/, '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<Content: View>: 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();
await t.close();
done();
done();
})();
})();