openvibers/openvibe.design Commits
Design: "Your look, everywhere" — a new front door, and /tokens: any theme as code for the web, Android and iOS (#15)
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.39.0++- openvibe-shared: v4.40.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.39.0",++ "shared": "openvibe-shared v4.40.0","packages": ["openvibe-contracts v0.236.0","openvibe-sdk v0.70.0",-- "openvibe-shared v4.39.0"++ "openvibe-shared v4.40.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.39.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.0","pg": "^8.23.1"},"devDependencies": {@@@ -1114,9 +1114,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.39.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.39.0",-- "integrity": "sha512-xmEqLmhC+njwb2icgzOX4HibrgpXjHI7m4cI9I7sdYseKWH0xEFpaqlhxAsdKm2ZEbzaKkVo+F5rYn5HUPdwZA==",++ "version": "4.40.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.0",++ "integrity": "sha512-5eFN1Es2d5wvR9Uo8PjZUANIBU/hFcBs6SSa8t5K9vmFW7yTBeaRMWzWMfWOLnDv/kR1kwaD+w3QP4SKaoos+Q==","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.39.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.0","pg": "^8.23.1"},"engines": {@@@ -351,3 +351,61 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.dv-element { left: 12px; right: 12px; bottom: calc(var(--ovnav-appbar-h, 56px) + 76px); width: auto; max-height: 56vh; }}@media (prefers-reduced-motion: reduce) { .dv-pick { transition: none; } }++++/* ── /tokens: a theme as code (views/tokens.js) ── */++.tk { max-width: 980px; }++.tk-pick { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 18px 0 12px; }++.tk-pick-l { display: flex; align-items: center; gap: 8px; font-weight: 700; }++.tk-pick select { min-height: 44px; max-width: 64vw; padding: 0 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }++.tk-both { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; color: var(--ovk-muted); }++.tk-both input { width: 18px; height: 18px; accent-color: var(--room-accent, var(--ovk-accent)); }++.tk-pick .ovk-btn { min-height: 44px; }++.tk-halves { list-style: none; display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; padding: 0; }++.tk-half { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.tk-half b { display: block; font-size: .95rem; }++.tk-half small { color: var(--ovk-muted); font-size: .8rem; }++.tk-dots { display: inline-flex; }++.tk-dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovk-text) 18%, transparent); }++.tk-dots .tk-dot + .tk-dot { margin-left: -5px; }++.tk-out { margin-top: 14px; }++.tk-out-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; }++.tk-out-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; }++.tk-how { margin: 8px 0 12px; color: var(--ovk-muted); line-height: 1.55; }++.tk-how code, .tk .ovk-fold-body code { padding: 1px 5px; border-radius: 6px; background: var(--ovk-panel-2); color: var(--ovk-text); font-size: .86em; overflow-wrap: anywhere; }++.tk .ovk-snip pre { max-height: min(56vh, 520px); }++.tk .ovk-fold { margin-top: 12px; }++.tk-table { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }++.tk-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px 14px; padding: 8px 0; border-bottom: 1px solid var(--ovk-line); }++.tk-row > code { font-weight: 700; overflow-wrap: anywhere; }++.tk-vals { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.tk-vals code { margin-right: 8px; color: var(--ovk-muted); font-size: .85rem; }++.tk-row small { color: var(--ovk-muted); }++@media (min-width: 760px) { .tk-row { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) minmax(0, 1fr); align-items: center; } }++/* Exported code keeps its lines: it scrolls sideways inside its box instead of wrapping. */++.tk .ovk-snip pre { white-space: pre; overflow-x: auto; }++/* The home's Create scene: the three steps, numbered. */++.ds-flow { list-style: none; counter-reset: flow; margin: 0; padding: 0; display: grid; gap: 10px; }++.ds-flow li { counter-increment: flow; display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 2px 12px; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.ds-flow li::before { content: counter(flow); grid-row: span 2; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 18%, transparent); color: var(--room-strong, var(--ovk-accent)); font-weight: 800; }++.ds-flow b { font-size: 1rem; }++.ds-flow span { color: var(--ovk-muted); font-size: .92rem; line-height: 1.45; }++/* The home's Arena scene: two drafts, names hidden. */++.ds-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; }++.ds-vs-card { margin: 0; display: grid; gap: 8px; padding: 12px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.ds-vs-bar { height: 10px; width: 60%; border-radius: 6px; background: var(--ovk-panel-2); }++.ds-vs-hero { height: 54px; border-radius: 10px; background: linear-gradient(135deg, color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 45%, transparent), var(--ovk-panel-2)); }++.ds-vs-card + .ds-vs-or + .ds-vs-card .ds-vs-hero { background: linear-gradient(135deg, var(--ovk-panel-2), color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 30%, transparent)); }++.ds-vs-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++.ds-vs-row i { height: 26px; border-radius: 8px; background: var(--ovk-panel-2); }++.ds-vs-card figcaption { font-weight: 800; text-align: center; }++.ds-vs-or { color: var(--ovk-muted); font-weight: 800; text-transform: uppercase; font-size: .78rem; letter-spacing: .08em; }++.ds-vs-note { margin: 10px 0 0; text-align: center; }++/* Phones: the home's themes two to a row (the mode word gives way to the dots), the developer links 44 px tall. */++@media (max-width: 599px) {++ .ds-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }++ .ds-strip .ds-swatch { grid-template-columns: auto minmax(0, 1fr); }++ .ds-strip .ds-swatch-mode { display: none; }++ .ds-strip .ds-swatch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++}++.ds .sc-room-devlinks a { display: inline-flex; align-items: center; min-height: 44px; text-decoration: none; }++.ds .sc-room-devlinks a:hover { text-decoration: underline; }@@@ -106,6 +106,8 @@base = t; edited = null;$$('.ds-swatch[data-theme-id]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-theme-id') === id)));paint();++ const code = $('#ds-code');++ if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;if (!quiet) { try { history.replaceState(null, '', `/studio?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }say('');}@@@ -12,6 +12,10 @@* GET /api/me/theme/history Network GET /api/themes/me/history* 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/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** Writes need the request's Origin to be this site's own origin (CSRF), otherwise 403 design.cross_site. A Network* 4xx answer is passed through with its status and body; a Network failure is 502. Everything under /api/me is@@@ -21,6 +25,7 @@ const { http } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { ACCESS_COOKIE } = require('../auth/sso');const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');++const themeTokens = require('../tokens');/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */function passThrough(res, r) {@@@ -79,6 +84,26 @@ function createApi(ctx) {}});++ // ── OpenVibe Tokens: any public theme as code for every platform (public, cacheable) ──++ r.get('/v1/tokens', (req, res) => {++ res.set('Cache-Control', 'public, max-age=3600');++ return res.json({ targets: themeTokens.TARGETS.map(({ id, label, ext, lang }) => ({ id, label, ext, lang })), docs: 'https://openvibe.design/tokens' });++ });++ r.get('/v1/tokens/:idOrSlug', async (req, res) => {++ const format = String(req.query.format || 'json');++ const target = themeTokens.TARGETS.find((t) => t.id === format);++ if (!target) return http.sendProblem(res, 400, 'tokens.format', { detail: `format is one of ${themeTokens.TARGETS.map((t) => t.id).join(', ')}.`, ctx: req.ov });++ let list;++ try { list = await catalog.list(); } catch (err) { return failure(res, req, err); }++ const found = themeTokens.resolve(list.themes, req.params.idOrSlug, { both: req.query.both !== '0' });++ if (!found) return http.sendProblem(res, 404, 'theme.not_found', { detail: 'No public theme by that name.', ctx: req.ov });++ const body = themeTokens.compile(found.docs.length > 1 ? found.docs : found.docs[0], target.id);++ res.set('Cache-Control', 'public, max-age=600');++ res.type(`${target.type}; charset=utf-8`);++ if (req.query.download === '1') res.set('Content-Disposition', `attachment; filename="${themeTokens.fileName(found.docs[0], target.id)}"`);++ return res.send(body);++ });++// 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 : {};@@@ -29,6 +29,7 @@ function publicPages(themes = []) {const static_ = [{ 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: '/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.' },];const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({@@@ -68,6 +69,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'Start here', links: [{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },++ { title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },] },@@@ -16,6 +16,8 @@*/const express = require('express');const views = require('../views');++const tokensView = require('../views/tokens');++const themeTokens = require('../tokens');const designView = require('../views/design');const { asyncRouter } = require('./router');const { send } = require('../render/layout');@@@ -163,6 +165,18 @@ function createPageRoutes(ctx) {return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);});++ // ── Tokens: any public theme as code (views/tokens.js) ──++ r.get('/tokens', async (req, res) => {++ const list = await safe(() => catalog.list());++ const themes = list ? list.themes : [];++ const want = String(req.query.theme || 'vibe');++ const flag = [].concat(req.query.both || []);++ const both = flag.length ? flag.includes('1') : true;++ const found = themeTokens.resolve(themes, want, { both }) || (req.query.theme ? null : themeTokens.resolve(themes, (themes[0] || {}).slug, { both }));++ const format = String(req.query.format || 'css');++ return page(res, found || !req.query.theme ? 200 : 404, tokensView.tokensPage({ themes, found, format, both }), req.viewer);++ });++// ── Arena ───────────────────────────────────────────────r.get('/arena', async (req, res) => {const me = meOf(req.viewer);@@@ -26,6 +26,7 @@ const NAV = [{ label: 'Create', href: '/create', icon: 'fa-palette' },{ label: 'Studio', href: '/studio', icon: 'fa-palette' },{ label: 'Arena', href: '/arena', icon: 'fa-crown' },++ { label: 'Tokens', href: '/tokens', icon: 'fa-code' },{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },];nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++/**++ * Any theme in the catalog as OpenVibe Tokens (openvibe-shared/tokens): the semantic design-token document, and its++ * code for the web, Android, iOS, Flutter and Figma. The catalog (OpenVibe.Network) carries a theme's own colours;++ * deriveTokens adds the readable text, status and on-accent colours every site derives, so the export is exactly the++ * theme the network paints. A theme with a light or dark half (builtin-themes PAIRS) exports both by default.++ *++ * resolve(list, idOrSlug, { both }) → { theme, other, docs: [dark, light?] } | null++ * TARGETS → openvibe-shared/tokens' targets, with the response type of each++ */++const tokens = require('openvibe-shared/tokens');++const { deriveTokens, PAIRS } = require('openvibe-shared/builtin-themes');++++const TYPES = { json: 'application/json', figma: 'application/json' };++const TARGETS = tokens.TARGETS.map((t) => ({ ...t, type: TYPES[t.id] || 'text/plain' }));++++const modeOf = (doc) => ((doc.$extensions && doc.$extensions['network.openvibe']) || {}).mode;++++function resolve(list, idOrSlug, { both = true } = {}) {++ const themes = Array.isArray(list) ? list : [];++ const key = String(idOrSlug || '').toLowerCase();++ const find = (k) => themes.find((t) => t && (String(t.slug).toLowerCase() === k || String(t.id).toLowerCase() === k));++ const theme = find(key);++ if (!theme) return null;++ const pairSlug = ((PAIRS || []).find((p) => p.includes(theme.slug) || p.includes(theme.id)) || []).find((s) => s !== theme.slug && s !== theme.id);++ const other = both && pairSlug ? find(pairSlug) || null : null;++ const full = (t) => ({ ...t, variables: deriveTokens({ ...(t.variables || {}) }, t.mode === 'light' ? 'light' : 'dark') });++ const docs = [tokens.fromTheme(full(theme))].concat(other ? [tokens.fromTheme(full(other))] : []);++ docs.sort((a, b) => (modeOf(a) === 'dark' ? 0 : 1) - (modeOf(b) === 'dark' ? 0 : 1));++ return { theme, other, docs };++}++++module.exports = { resolve, TARGETS, compile: tokens.compile, fileName: tokens.fileName, flatten: tokens.flatten, parseColor: tokens.parseColor, ident: tokens.ident, modeOf };@@@ -64,39 +64,64 @@ const CVD = `<svg width="0" height="0" style="position:absolute" aria-hidden="tr<filter id="ds-cvd-deutan"><feColorMatrix type="matrix" values="0.367 0.861 -0.228 0 0 0.280 0.673 0.047 0 0 -0.012 0.043 0.969 0 0 0 0 0 1 0"/></filter><filter id="ds-cvd-tritan"><feColorMatrix type="matrix" values="1.256 -0.077 -0.179 0 0 -0.078 0.931 0.148 0 0 0.005 0.691 0.304 0 0 0 0 0 1 0"/></filter></defs></svg>`;++/**++ * The front door (the overhaul, 2026-10-29: "Your look, everywhere"). Three things Design is for, in the order people++ * meet them: wear a theme across every OpenVibe site (the studio), take it into your own app as code (OpenVibe Tokens:++ * the web, Android, iOS), and make pages with AI on it (Create, ranked honestly by the Arena).++ */function home({ themes } = {}) {const all = listOf(themes);const picks = ['vibe', 'violet-hour', 'forest', 'neon-tokyo', 'sunset', 'cloud', 'daylight', 'meadow'].map((id) => all.find((t) => t.id === id)).filter(Boolean);const shown = picks.concat(all.filter((t) => !picks.includes(t))).slice(0, 8);const strip = shown.map((t) => `<li>${swatch(t)}</li>`).join('');const orb = `<div class="ds-orb" aria-hidden="true"><span class="ds-orb-ring"></span><span class="ds-orb-core"></span>${(picks.length ? picks : all).slice(0, 6).map((t, i) => `<i style="--i:${i};background:${esc(col(t.variables, '--accent', '#888'))}"></i>`).join('')}</div>`;++ const make = `<ol class="ds-flow" aria-label="How Create works"><li><b>Describe it</b><span>A page, an app screen or a component, in a sentence.</span></li><li><b>Three models draft it</b><span>At once, on your theme, at phone and desktop sizes.</span></li><li><b>Make it yours</b><span>Pick one, click any part to change it or ask for a change, and keep every version.</span></li></ol>`;++ const draft = (side) => `<figure class="ds-vs-card"><span class="ds-vs-bar"></span><span class="ds-vs-hero"></span><span class="ds-vs-row"><i></i><i></i><i></i></span><figcaption>${side}</figcaption></figure>`;++ const vs = `<div class="ds-vs" role="img" aria-label="Two drafts side by side, their models hidden">${draft('Left')}<span class="ds-vs-or">or</span>${draft('Right')}</div><p class="ds-fine ds-vs-note">The models' names stay hidden until you vote.</p>`;const body = sc.room({room: 'design',hero: {-- title: ROOM.line,-- lede: 'Pick a theme and watch Live, Chat and a shop wear it side by side. Change a colour, see every contrast check pass or fail, and use it on every OpenVibe site at once. Any earlier look is one tap back.',-- start: { href: '/studio', label: 'Open the studio' },++ title: 'Your look, everywhere',++ lede: 'Pick or make a theme once and every OpenVibe site wears it. Take it into your own app as code for the web, Android and iOS, and design pages on it with AI.',++ start: { href: '/studio', label: 'Change your theme' },chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/studio?theme=${encodeURIComponent(t.id)}` })),motif: orb,},-- proof: [{ value: all.length, label: all.length === 1 ? 'theme to try' : 'themes to try' }, { value: all.filter((t) => t.mode === 'light').length, label: 'with a light side' }].filter((p) => p.value),-- html: strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to try</h2><a class="ds-more" href="/studio">All of them in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : '',++ proof: [++ { value: all.length, label: all.length === 1 ? 'theme to wear' : 'themes to wear' },++ { value: all.filter((t) => t.mode === 'light').length, label: 'with a light side' },++ { value: 7, label: 'platforms to export to' },++ ].filter((p) => p.value),scenes: [-- { title: 'One theme, three sites, side by side', text: 'Every theme is tried on a Live channel, a Chat room and a shop\'s grid at once, so you see the buttons, the chat and the prices before you choose.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Try one' } },-- { title: 'Checks that must pass', text: 'Text on every surface, buttons, links and muted text are measured against WCAG as you edit, and the previews can be seen as people with colour-vision differences see them.', html: `<ul class="ds-checks is-demo"><li class="is-pass"><b>Text on background</b><span>14.2:1</span></li><li class="is-pass"><b>Button text</b><span>7.1:1</span></li><li class="is-fail"><b>Muted text on surface</b><span>3.1:1 · needs 4.5</span></li></ul>`, action: { href: '/studio', label: 'Edit a theme' } },++ { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Open the studio' } },++ { title: 'Make it with AI, on your theme', text: 'Describe what you need and three AI designers draft it at once. Every draft wears your theme, so it already looks like it belongs.', html: make, action: { href: '/create', label: 'Make something' } },++ { title: 'See which AI designs best', text: 'The Arena sends one prompt to three models, shows two drafts without their names, and ranks the designers by the votes, with what each draft cost and how long it took. Every prompt, page and vote is open data.', html: vs, action: { href: '/arena', label: 'Vote in the Arena' } },],++ dev: {++ title: 'Your theme, in your app',++ 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: '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' }],++ },explore: { title: 'More on Design', items: [-- { title: 'The studio', text: 'Pick, edit, check and use a theme', href: '/studio', icon: 'ov:theme' },-- { title: 'Your themes on Network', text: 'Display, motion and text size', href: 'https://openvibe.network/my', icon: 'ov:account' },-- { title: 'The API', text: 'The theme catalog as open JSON', href: '/developers', icon: 'ov:code' },++ { title: 'Tokens', text: 'Any theme as CSS, Compose, SwiftUI, Flutter or Figma', href: '/tokens', icon: 'ov:code' },++ { title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },++ { title: 'Your settings', text: 'Text size, motion and display on Network', href: 'https://openvibe.network/my', icon: 'ov:account' },] },++ html: strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/studio">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : '',faq: { items: [['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],++ ['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the studio\'s history brings any of the last fifty back.'],['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],++ ['What is coming next?', 'Publishing a design straight to OpenVibe.Website and OpenVibe.Space, brand kits that dress a creator\'s channel, overlays and shop in one go, and the kit\'s components as native Android and iOS views.'],] },});-- return layout({ title: null, path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/' });++ return layout({ title: null, description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.', path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/' });}function studio({ themes, me = {} } = {}) {@@@ -119,7 +144,8 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u<div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div><h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul><div class="ds-actions"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${signedIn ? 'Use this theme' : 'Sign in to use it'}</button>--<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button></div>++<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button>++<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" title="This theme for the web, Android, iOS, Flutter and Figma">As code</a></div><p class="ds-status" id="ds-status" role="status"></p><details class="ds-submit"><summary>Share it in the gallery</summary><form id="ds-submit-form"><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label><label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label>@@@ -134,7 +160,7 @@ function theme({ theme: t } = {}) {const v = (t && t.variables) || {};const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}--<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/api/themes/${encodeURIComponent(t.slug || t.id)}`, label: 'As JSON', kind: 'quiet' })}</p></header>++<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p></header>${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/studio' });@@@ -149,6 +175,8 @@ function developers() {['POST /api/v1/designs/:id/edit', 'Chat edit the working frame with {instruction}; one paid AI run and a small per-design budget.'],['POST /api/v1/designs/:id/click', 'Apply {path, text?, color?, spacing?, align?} to one element; validated paths and theme tokens only.'],['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/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).'],@@@ -156,7 +184,7 @@ function developers() {['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],];const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>--<section aria-labelledby="ds-ep-h"><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>++<section aria-labelledby="ds-ep-h" id="tokens"><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section><p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });}nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * /tokens: a theme as code. Pick any public theme (its other half comes along), choose a platform, copy or download++ * the file. Server-rendered and whole without JavaScript: the theme picker is a GET form, the platforms are tab links++ * (?format=), and kit-web.js adds the copy button. The code is openvibe-shared/tokens' output (server/tokens.js), the++ * same token document a native OpenVibe app reads.++ *++ * tokensPage({ themes, found, format, both })++ */++const kit = require('openvibe-shared/kit');++const { layout, esc } = require('../render/layout');++const themeTokens = require('../tokens');++++const SHORT = { css: 'CSS', tailwind: 'Tailwind', compose: 'Android', swiftui: 'iOS', flutter: 'Flutter', figma: 'Figma', json: 'JSON' };++++/** How to use one target's file, in a sentence or two. name: the theme's identifier (VibeTokens…). */++function howTo(id, name, pair) {++ const n = name;++ const lower = n[0].toLowerCase() + n.slice(1);++ 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 '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>.`;++ case 'figma': return `Import it with a variables-import plugin: one collection${pair ? ', a mode for each half' : ''}, named the way the tokens are.`;++ default: return 'The W3C Design Tokens (DTCG) document itself, for Style Dictionary, Tokens Studio or your own build.';++ }++}++++const cssColor = (v) => { const c = themeTokens.parseColor(v); return c ? (c.a < 1 ? `rgba(${c.r},${c.g},${c.b},${c.a})` : `#${[c.r, c.g, c.b].map((x) => x.toString(16).padStart(2, '0')).join('')}`) : null; };++const dot = (v) => { const c = cssColor(v); return c ? `<i class="tk-dot" style="background:${esc(c)}"></i>` : ''; };++++function picker(themes, current, both, format) {++ const opt = (t) => `<option value="${esc(t.slug || t.id)}"${t === current ? ' selected' : ''}>${esc(t.name)}</option>`;++ const dark = themes.filter((t) => t.mode !== 'light');++ const light = themes.filter((t) => t.mode === 'light');++ return `<form class="tk-pick" method="get" action="/tokens"><label class="tk-pick-l"><span>Theme</span><select name="theme">${dark.length ? `<optgroup label="Dark">${dark.map(opt).join('')}</optgroup>` : ''}${light.length ? `<optgroup label="Light">${light.map(opt).join('')}</optgroup>` : ''}</select></label>++<input type="hidden" name="format" value="${esc(format)}"><label class="tk-both"><input type="checkbox" name="both" value="1"${both ? ' checked' : ''}><span>With its other half</span></label><input type="hidden" name="both" value="0">++${kit.button({ type: 'submit', label: 'Show', kind: 'quiet', small: true })}</form>`;++}++++function themeCard(found) {++ const half = (doc) => {++ const m = (doc.$extensions && doc.$extensions['network.openvibe']) || {};++ const pick = (p) => p.split('.').reduce((o, k) => (o ? o[k] : null), doc);++ const dots = ['color.bg.base', 'color.bg.raised', 'color.text.primary', 'color.accent.strong', 'color.status.ok.base'].map((p) => dot(pick(p) && pick(p).$value)).join('');++ return `<li class="tk-half"><span class="tk-dots" aria-hidden="true">${dots}</span><span><b>${esc(m.name)}</b><small>${m.mode === 'light' ? 'Light' : 'Dark'}</small></span></li>`;++ };++ return `<ul class="tk-halves">${found.docs.map(half).join('')}</ul>`;++}++++function tokenTable(found) {++ const [a, b] = found.docs;++ const get = (doc, path) => path.reduce((o, k) => (o ? o[k] : null), doc);++ const rows = themeTokens.flatten(a).filter((t) => t.type === 'color').map((t) => {++ const other = b ? get(b, t.path) : null;++ return `<li class="tk-row"><code>${esc(t.path.join('.'))}</code><span class="tk-vals">${dot(t.value)}<code>${esc(String(t.value))}</code>${other ? `${dot(other.$value)}<code>${esc(String(other.$value))}</code>` : ''}</span><small>${esc(t.description)}</small></li>`;++ }).join('');++ return `<ul class="tk-table" aria-label="Colour tokens">${rows}</ul>++<p class="ds-fine">Every theme also carries the kit's scales: <code>radius.*</code>, <code>space.*</code> (4 to 32), <code>size.tap</code> (44), <code>type.size.*</code>, <code>type.weight.*</code> and <code>motion.*</code>.</p>`;++}++++function tokensPage({ themes = [], found = null, format = 'css', both = true } = {}) {++ const list = (themes || []).filter((t) => t && (t.slug || t.id));++ const target = themeTokens.TARGETS.find((t) => t.id === format) || themeTokens.TARGETS[0];++ let main;++ if (!found) {++ main = list.length++ ? kit.empty({ icon: 'palette', title: 'No theme by that name', text: 'Pick one from the list above.' })++ : kit.empty({ icon: 'palette', title: 'The theme catalog is not answering', text: 'OpenVibe.Network did not answer. Try again in a minute.' });++ } else {++ const slug = found.theme.slug || found.theme.id;++ const pair = found.docs.length > 1;++ const name = themeTokens.ident(found.theme.name);++ const code = themeTokens.compile(pair ? found.docs : found.docs[0], target.id);++ const base = `/tokens?theme=${encodeURIComponent(slug)}${both ? '' : '&both=0'}`;++ const dl = `/api/v1/tokens/${encodeURIComponent(slug)}?format=${encodeURIComponent(target.id)}${pair ? '' : '&both=0'}&download=1`;++ main = `${themeCard(found)}++${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items: themeTokens.TARGETS.map((t) => ({ key: t.id, label: SHORT[t.id] || t.label })) })}++<section class="tk-out" aria-labelledby="tk-out-h"><div class="tk-out-head"><h2 id="tk-out-h">${esc(target.label)}</h2>${kit.button({ href: dl, label: `Download ${themeTokens.fileName(found.docs[0], target.id)}`, kind: 'quiet', small: true, icon: 'download' })}</div>++<p class="tk-how">${howTo(target.id, name, pair)}</p>++${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>++${kit.fold({ id: 'tk-all', title: 'Every colour token', meta: `${themeTokens.flatten(found.docs[0]).filter((t) => t.type === 'color').length} of them`, body: tokenTable(found) })}++${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/studio?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;++ }++ const body = `<header class="ds-page-h"><p class="ds-kicker">OpenVibe Tokens</p><h1>Your theme, as code</h1>++<p class="ds-lede">Take any OpenVibe theme into your own app, its light and dark halves together: the web, Android, iOS, Flutter or Figma. The colours are the ones the network paints.</p></header>++${list.length ? picker(list, found ? found.theme : null, both, target.id) : ''}++${main}`;++ const title = found ? `${found.theme.name} as code: CSS, Compose, SwiftUI, Flutter, Figma` : 'Your theme, as code';++ return layout({++ title,++ description: found ? `The ${found.theme.name} theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark.` : 'Every OpenVibe theme as design tokens for the web, Android, iOS, Flutter and Figma.',++ path: found ? `/tokens?theme=${encodeURIComponent(found.theme.slug || found.theme.id)}` : '/tokens',++ canonical: undefined,++ body: `<div class="ds-wrap tk">${body}</div>`,++ active: '/tokens',++ });++}++++module.exports = { tokensPage, howTo };@@@ -15,7 +15,8 @@ const { assetVersion } = require('../server/assets');await check('the home page lists the catalogue, escaped, and is publicly cacheable', async () => {const r = await t.get('/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /Make it yours/);++ assert.match(r.text, /Your look, everywhere/);++ assert.match(r.text, /href="\/tokens"/, 'the nav and the home lead to the tokens');assert.match(r.text, /<a class="ds-swatch" href="\/t\/aurora">[\s\S]*?<span class="ds-swatch-name">Aurora<\/span>/);assert.match(r.text, /<main id="main"/);assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++/**++ * /tokens and /api/v1/tokens: any public theme as code (openvibe-shared/tokens), its other half along by default,++ * every platform as a tab, a download that names its file, and nothing from the catalog reaching the code unchecked.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const themes = [++ { id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark', description: 'The default.', variables: { '--bg-primary': '#0a0f1c', '--bg-card': '#131c2e', '--text-primary': '#e6edf7', '--accent': '#3b82f6', '--border': '#1f2d47' } },++ { id: 'cloud', name: 'Vibe Light', slug: 'cloud', mode: 'light', description: 'Its light half.', variables: { '--bg-primary': '#f5f7fb', '--bg-card': '#ffffff', '--text-primary': '#0f172a', '--accent': '#2563eb', '--border': '#d8dee9' } },++ { id: 'evil', name: 'Evil */ import os //', slug: 'evil', mode: 'dark', description: 'x\n*/ fun pwn() {}', variables: { '--bg-primary': '#000; } body { background: url(https://evil.example) }', '--accent': '#ff0000', '--text-primary': '#ffffff' } },++ ];++ const t = await boot({ network: { themes } });++++ await check('/tokens shows the default theme with its other half, the CSS tab open, and is publicly cacheable', async () => {++ const r = await t.get('/tokens');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Your theme, as code/);++ assert.match(r.text, /<b>Vibe<\/b><small>Dark<\/small>[\s\S]*<b>Vibe Light<\/b><small>Light<\/small>/, 'both halves');++ assert.match(r.text, /aria-current="page"[^>]*>(<[^>]+>)*<span>CSS<\/span>|<a href="[^"]*format=css" aria-current="page">/);++ assert.match(r.text, /--ov-color-bg-base: #0a0f1c;/);++ assert.match(r.text, /:root\[data-theme-mode="light"\]/);++ assert.match(r.text, /href="\/api\/v1\/tokens\/vibe\?format=css&download=1"/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');++ });++++ await check('every platform is a tab, and Android is a Compose colour scheme for both halves', async () => {++ const r = await t.get('/tokens?theme=vibe&format=compose');++ for (const label of ['CSS', 'Tailwind', 'Android', 'iOS', 'Flutter', 'Figma', 'JSON']) assert.match(r.text, new RegExp(`<span>${label}</span>`), label);++ assert.match(r.text, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);++ assert.match(r.text, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);++ assert.match(r.text, /isSystemInDarkTheme\(\)/, 'it says how to use it');++ const one = await t.get('/tokens?theme=vibe&format=swiftui&both=0');++ assert.match(one.text, /public enum VibeTokens \{/);++ assert.doesNotMatch(one.text, /<b>Vibe Light<\/b>/, 'both=0 leaves the other half out');++ const checked = await t.get('/tokens?theme=vibe&format=css&both=1&both=0');++ assert.match(checked.text, /<b>Vibe Light<\/b>/, 'the form sends both=1&both=0 when the box is ticked');++ });++++ await check('an unknown theme is a 404 page; an unknown format falls back to CSS', async () => {++ const r = await t.get('/tokens?theme=nope');++ assert.strictEqual(r.status, 404);++ assert.match(r.text, /No theme by that name/);++ const f = await t.get('/tokens?theme=vibe&format=cobol');++ assert.strictEqual(f.status, 200);++ assert.match(f.text, /--ov-color-bg-base/);++ });++++ await check('the API: targets, each format with its type, a download with a safe file name', async () => {++ const list = (await t.get('/api/v1/tokens')).json();++ assert.deepStrictEqual(list.targets.map((x) => x.id), ['css', 'tailwind', 'compose', 'swiftui', 'flutter', 'figma', 'json']);++ const css = await t.get('/api/v1/tokens/vibe?format=css');++ assert.match(css.headers.get('content-type'), /^text\/plain; charset=utf-8/);++ assert.match(css.text, /--bg-primary: var\(--ov-color-bg-base\);/);++ const json = (await t.get('/api/v1/tokens/cloud?format=json&both=0')).json();++ assert.strictEqual(json.color.bg.base.$value, '#f5f7fb');++ const fig = (await t.get('/api/v1/tokens/vibe?format=figma')).json();++ assert.deepStrictEqual(fig.modes, ['dark', 'light']);++ const dl = await t.get('/api/v1/tokens/vibe?format=compose&download=1');++ assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="VibeTheme.kt"');++ assert.strictEqual(dl.headers.get('cache-control'), 'public, max-age=600');++ assert.strictEqual((await t.get('/api/v1/tokens/vibe?format=cobol')).status, 400);++ assert.strictEqual((await t.get('/api/v1/tokens/nope?format=css')).status, 404);++ });++++ await check('a catalog theme cannot put code into the export', async () => {++ for (const format of ['css', 'compose', 'swiftui', 'flutter']) {++ const r = await t.get(`/api/v1/tokens/evil?format=${format}`);++ assert.strictEqual(r.status, 200);++ assert.doesNotMatch(r.text, /evil\.example/, `${format}: the bad value is dropped`);++ const code = r.text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');++ assert.doesNotMatch(code, /import os|fun pwn/, `${format}: the name stays inside comments`);++ }++ const page = await t.get('/tokens?theme=evil&format=compose');++ assert.doesNotMatch(page.text, /evil\.example/, 'the dropped value is not on the page either');++ assert.match(page.text, /object EvilImportOsTokens/);++ });++++ await check('the studio and a theme page link to the theme as code', async () => {++ assert.match((await t.get('/studio')).text, /id="ds-code" href="\/tokens"/);++ assert.match((await t.get('/t/vibe')).text, /href="\/tokens\?theme=vibe"/);++ assert.match((await t.get('/developers')).text, /GET \/api\/v1\/tokens\/:theme\?format=/);++ });++++ 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.39.0
+- openvibe-shared: v4.40.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.39.0",
+ "shared": "openvibe-shared v4.40.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.39.0"
+ "openvibe-shared v4.40.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.39.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.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.39.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.39.0",
- "integrity": "sha512-xmEqLmhC+njwb2icgzOX4HibrgpXjHI7m4cI9I7sdYseKWH0xEFpaqlhxAsdKm2ZEbzaKkVo+F5rYn5HUPdwZA==",
+ "version": "4.40.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.0",
+ "integrity": "sha512-5eFN1Es2d5wvR9Uo8PjZUANIBU/hFcBs6SSa8t5K9vmFW7yTBeaRMWzWMfWOLnDv/kR1kwaD+w3QP4SKaoos+Q==",
"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.39.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.40.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
@@ -351,3 +351,61 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -351,3 +351,61 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-element { left: 12px; right: 12px; bottom: calc(var(--ovnav-appbar-h, 56px) + 76px); width: auto; max-height: 56vh; }
.dv-element { left: 12px; right: 12px; bottom: calc(var(--ovnav-appbar-h, 56px) + 76px); width: auto; max-height: 56vh; }
}
}
@media (prefers-reduced-motion: reduce) { .dv-pick { transition: none; } }
@media (prefers-reduced-motion: reduce) { .dv-pick { transition: none; } }
+
+/* ── /tokens: a theme as code (views/tokens.js) ── */
+.tk { max-width: 980px; }
+.tk-pick { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 18px 0 12px; }
+.tk-pick-l { display: flex; align-items: center; gap: 8px; font-weight: 700; }
+.tk-pick select { min-height: 44px; max-width: 64vw; padding: 0 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }
+.tk-both { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; color: var(--ovk-muted); }
+.tk-both input { width: 18px; height: 18px; accent-color: var(--room-accent, var(--ovk-accent)); }
+.tk-pick .ovk-btn { min-height: 44px; }
+.tk-halves { list-style: none; display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; padding: 0; }
+.tk-half { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.tk-half b { display: block; font-size: .95rem; }
+.tk-half small { color: var(--ovk-muted); font-size: .8rem; }
+.tk-dots { display: inline-flex; }
+.tk-dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovk-text) 18%, transparent); }
+.tk-dots .tk-dot + .tk-dot { margin-left: -5px; }
+.tk-out { margin-top: 14px; }
+.tk-out-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; }
+.tk-out-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; }
+.tk-how { margin: 8px 0 12px; color: var(--ovk-muted); line-height: 1.55; }
+.tk-how code, .tk .ovk-fold-body code { padding: 1px 5px; border-radius: 6px; background: var(--ovk-panel-2); color: var(--ovk-text); font-size: .86em; overflow-wrap: anywhere; }
+.tk .ovk-snip pre { max-height: min(56vh, 520px); }
+.tk .ovk-fold { margin-top: 12px; }
+.tk-table { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
+.tk-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px 14px; padding: 8px 0; border-bottom: 1px solid var(--ovk-line); }
+.tk-row > code { font-weight: 700; overflow-wrap: anywhere; }
+.tk-vals { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.tk-vals code { margin-right: 8px; color: var(--ovk-muted); font-size: .85rem; }
+.tk-row small { color: var(--ovk-muted); }
+@media (min-width: 760px) { .tk-row { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) minmax(0, 1fr); align-items: center; } }
+/* Exported code keeps its lines: it scrolls sideways inside its box instead of wrapping. */
+.tk .ovk-snip pre { white-space: pre; overflow-x: auto; }
+/* The home's Create scene: the three steps, numbered. */
+.ds-flow { list-style: none; counter-reset: flow; margin: 0; padding: 0; display: grid; gap: 10px; }
+.ds-flow li { counter-increment: flow; display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 2px 12px; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.ds-flow li::before { content: counter(flow); grid-row: span 2; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 18%, transparent); color: var(--room-strong, var(--ovk-accent)); font-weight: 800; }
+.ds-flow b { font-size: 1rem; }
+.ds-flow span { color: var(--ovk-muted); font-size: .92rem; line-height: 1.45; }
+/* The home's Arena scene: two drafts, names hidden. */
+.ds-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; }
+.ds-vs-card { margin: 0; display: grid; gap: 8px; padding: 12px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.ds-vs-bar { height: 10px; width: 60%; border-radius: 6px; background: var(--ovk-panel-2); }
+.ds-vs-hero { height: 54px; border-radius: 10px; background: linear-gradient(135deg, color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 45%, transparent), var(--ovk-panel-2)); }
+.ds-vs-card + .ds-vs-or + .ds-vs-card .ds-vs-hero { background: linear-gradient(135deg, var(--ovk-panel-2), color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 30%, transparent)); }
+.ds-vs-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+.ds-vs-row i { height: 26px; border-radius: 8px; background: var(--ovk-panel-2); }
+.ds-vs-card figcaption { font-weight: 800; text-align: center; }
+.ds-vs-or { color: var(--ovk-muted); font-weight: 800; text-transform: uppercase; font-size: .78rem; letter-spacing: .08em; }
+.ds-vs-note { margin: 10px 0 0; text-align: center; }
+/* Phones: the home's themes two to a row (the mode word gives way to the dots), the developer links 44 px tall. */
+@media (max-width: 599px) {
+ .ds-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+ .ds-strip .ds-swatch { grid-template-columns: auto minmax(0, 1fr); }
+ .ds-strip .ds-swatch-mode { display: none; }
+ .ds-strip .ds-swatch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+}
+.ds .sc-room-devlinks a { display: inline-flex; align-items: center; min-height: 44px; text-decoration: none; }
+.ds .sc-room-devlinks a:hover { text-decoration: underline; }
@@ -106,6 +106,8 @@
@@ -106,6 +106,8 @@
base = t; edited = null;
base = t; edited = null;
$$('.ds-swatch[data-theme-id]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-theme-id') === id)));
$$('.ds-swatch[data-theme-id]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-theme-id') === id)));
paint();
paint();
+ const code = $('#ds-code');
+ if (code) code.href = `/tokens?theme=${encodeURIComponent(id)}`;
if (!quiet) { try { history.replaceState(null, '', `/studio?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }
if (!quiet) { try { history.replaceState(null, '', `/studio?theme=${encodeURIComponent(id)}`); } catch (e) { /* */ } }
say('');
say('');
}
}
@@ -12,6 +12,10 @@
@@ -12,6 +12,10 @@
* GET /api/me/theme/history Network GET /api/themes/me/history
* GET /api/me/theme/history Network GET /api/themes/me/history
* 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/: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
*
*
* Writes need the request's Origin to be this site's own origin (CSRF), otherwise 403 design.cross_site. A Network
* Writes need the request's Origin to be this site's own origin (CSRF), otherwise 403 design.cross_site. A Network
* 4xx answer is passed through with its status and body; a Network failure is 502. Everything under /api/me is
* 4xx answer is passed through with its status and body; a Network failure is 502. Everything under /api/me is
@@ -21,6 +25,7 @@ const { http } = require('openvibe-contracts');
@@ -21,6 +25,7 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
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');
/** 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) {
@@ -79,6 +84,26 @@ function createApi(ctx) {
@@ -79,6 +84,26 @@ function createApi(ctx) {
}
}
});
});
+ // ── OpenVibe Tokens: any public theme as code for every platform (public, cacheable) ──
+ r.get('/v1/tokens', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=3600');
+ return res.json({ targets: themeTokens.TARGETS.map(({ id, label, ext, lang }) => ({ id, label, ext, lang })), docs: 'https://openvibe.design/tokens' });
+ });
+ r.get('/v1/tokens/:idOrSlug', async (req, res) => {
+ const format = String(req.query.format || 'json');
+ const target = themeTokens.TARGETS.find((t) => t.id === format);
+ if (!target) return http.sendProblem(res, 400, 'tokens.format', { detail: `format is one of ${themeTokens.TARGETS.map((t) => t.id).join(', ')}.`, ctx: req.ov });
+ let list;
+ try { list = await catalog.list(); } catch (err) { return failure(res, req, err); }
+ const found = themeTokens.resolve(list.themes, req.params.idOrSlug, { both: req.query.both !== '0' });
+ if (!found) return http.sendProblem(res, 404, 'theme.not_found', { detail: 'No public theme by that name.', ctx: req.ov });
+ const body = themeTokens.compile(found.docs.length > 1 ? found.docs : found.docs[0], target.id);
+ res.set('Cache-Control', 'public, max-age=600');
+ res.type(`${target.type}; charset=utf-8`);
+ if (req.query.download === '1') res.set('Content-Disposition', `attachment; filename="${themeTokens.fileName(found.docs[0], target.id)}"`);
+ return res.send(body);
+ });
+
// 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 : {};
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
const static_ = [
const static_ = [
{ 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: '/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.' },
];
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@ -68,6 +69,7 @@ function createDiscoveryRoutes(ctx) {
@@ -68,6 +69,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
+ { title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
] },
@@ -16,6 +16,8 @@
@@ -16,6 +16,8 @@
*/
*/
const express = require('express');
const express = require('express');
const views = require('../views');
const views = require('../views');
+const tokensView = require('../views/tokens');
+const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
@@ -163,6 +165,18 @@ function createPageRoutes(ctx) {
@@ -163,6 +165,18 @@ function createPageRoutes(ctx) {
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
});
});
+ // ── Tokens: any public theme as code (views/tokens.js) ──
+ r.get('/tokens', async (req, res) => {
+ const list = await safe(() => catalog.list());
+ const themes = list ? list.themes : [];
+ const want = String(req.query.theme || 'vibe');
+ const flag = [].concat(req.query.both || []);
+ const both = flag.length ? flag.includes('1') : true;
+ const found = themeTokens.resolve(themes, want, { both }) || (req.query.theme ? null : themeTokens.resolve(themes, (themes[0] || {}).slug, { both }));
+ const format = String(req.query.format || 'css');
+ return page(res, found || !req.query.theme ? 200 : 404, tokensView.tokensPage({ themes, found, format, both }), req.viewer);
+ });
+
// ── Arena ───────────────────────────────────────────────
// ── Arena ───────────────────────────────────────────────
r.get('/arena', async (req, res) => {
r.get('/arena', async (req, res) => {
const me = meOf(req.viewer);
const me = meOf(req.viewer);
@@ -26,6 +26,7 @@ const NAV = [
@@ -26,6 +26,7 @@ const NAV = [
{ 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: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
+ { label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+/**
+ * Any theme in the catalog as OpenVibe Tokens (openvibe-shared/tokens): the semantic design-token document, and its
+ * code for the web, Android, iOS, Flutter and Figma. The catalog (OpenVibe.Network) carries a theme's own colours;
+ * deriveTokens adds the readable text, status and on-accent colours every site derives, so the export is exactly the
+ * theme the network paints. A theme with a light or dark half (builtin-themes PAIRS) exports both by default.
+ *
+ * resolve(list, idOrSlug, { both }) → { theme, other, docs: [dark, light?] } | null
+ * TARGETS → openvibe-shared/tokens' targets, with the response type of each
+ */
+const tokens = require('openvibe-shared/tokens');
+const { deriveTokens, PAIRS } = require('openvibe-shared/builtin-themes');
+
+const TYPES = { json: 'application/json', figma: 'application/json' };
+const TARGETS = tokens.TARGETS.map((t) => ({ ...t, type: TYPES[t.id] || 'text/plain' }));
+
+const modeOf = (doc) => ((doc.$extensions && doc.$extensions['network.openvibe']) || {}).mode;
+
+function resolve(list, idOrSlug, { both = true } = {}) {
+ const themes = Array.isArray(list) ? list : [];
+ const key = String(idOrSlug || '').toLowerCase();
+ const find = (k) => themes.find((t) => t && (String(t.slug).toLowerCase() === k || String(t.id).toLowerCase() === k));
+ const theme = find(key);
+ if (!theme) return null;
+ const pairSlug = ((PAIRS || []).find((p) => p.includes(theme.slug) || p.includes(theme.id)) || []).find((s) => s !== theme.slug && s !== theme.id);
+ const other = both && pairSlug ? find(pairSlug) || null : null;
+ const full = (t) => ({ ...t, variables: deriveTokens({ ...(t.variables || {}) }, t.mode === 'light' ? 'light' : 'dark') });
+ const docs = [tokens.fromTheme(full(theme))].concat(other ? [tokens.fromTheme(full(other))] : []);
+ docs.sort((a, b) => (modeOf(a) === 'dark' ? 0 : 1) - (modeOf(b) === 'dark' ? 0 : 1));
+ return { theme, other, docs };
+}
+
+module.exports = { resolve, TARGETS, compile: tokens.compile, fileName: tokens.fileName, flatten: tokens.flatten, parseColor: tokens.parseColor, ident: tokens.ident, modeOf };
@@ -64,39 +64,64 @@ const CVD = `<svg width="0" height="0" style="position:absolute" aria-hidden="tr
@@ -64,39 +64,64 @@ const CVD = `<svg width="0" height="0" style="position:absolute" aria-hidden="tr
<filter id="ds-cvd-deutan"><feColorMatrix type="matrix" values="0.367 0.861 -0.228 0 0 0.280 0.673 0.047 0 0 -0.012 0.043 0.969 0 0 0 0 0 1 0"/></filter>
<filter id="ds-cvd-deutan"><feColorMatrix type="matrix" values="0.367 0.861 -0.228 0 0 0.280 0.673 0.047 0 0 -0.012 0.043 0.969 0 0 0 0 0 1 0"/></filter>
<filter id="ds-cvd-tritan"><feColorMatrix type="matrix" values="1.256 -0.077 -0.179 0 0 -0.078 0.931 0.148 0 0 0.005 0.691 0.304 0 0 0 0 0 1 0"/></filter></defs></svg>`;
<filter id="ds-cvd-tritan"><feColorMatrix type="matrix" values="1.256 -0.077 -0.179 0 0 -0.078 0.931 0.148 0 0 0.005 0.691 0.304 0 0 0 0 0 1 0"/></filter></defs></svg>`;
+/**
+ * The front door (the overhaul, 2026-10-29: "Your look, everywhere"). Three things Design is for, in the order people
+ * meet them: wear a theme across every OpenVibe site (the studio), take it into your own app as code (OpenVibe Tokens:
+ * the web, Android, iOS), and make pages with AI on it (Create, ranked honestly by the Arena).
+ */
function home({ themes } = {}) {
function home({ themes } = {}) {
const all = listOf(themes);
const all = listOf(themes);
const picks = ['vibe', 'violet-hour', 'forest', 'neon-tokyo', 'sunset', 'cloud', 'daylight', 'meadow'].map((id) => all.find((t) => t.id === id)).filter(Boolean);
const picks = ['vibe', 'violet-hour', 'forest', 'neon-tokyo', 'sunset', 'cloud', 'daylight', 'meadow'].map((id) => all.find((t) => t.id === id)).filter(Boolean);
const shown = picks.concat(all.filter((t) => !picks.includes(t))).slice(0, 8);
const shown = picks.concat(all.filter((t) => !picks.includes(t))).slice(0, 8);
const strip = shown.map((t) => `<li>${swatch(t)}</li>`).join('');
const strip = shown.map((t) => `<li>${swatch(t)}</li>`).join('');
const orb = `<div class="ds-orb" aria-hidden="true"><span class="ds-orb-ring"></span><span class="ds-orb-core"></span>${(picks.length ? picks : all).slice(0, 6).map((t, i) => `<i style="--i:${i};background:${esc(col(t.variables, '--accent', '#888'))}"></i>`).join('')}</div>`;
const orb = `<div class="ds-orb" aria-hidden="true"><span class="ds-orb-ring"></span><span class="ds-orb-core"></span>${(picks.length ? picks : all).slice(0, 6).map((t, i) => `<i style="--i:${i};background:${esc(col(t.variables, '--accent', '#888'))}"></i>`).join('')}</div>`;
+ const make = `<ol class="ds-flow" aria-label="How Create works"><li><b>Describe it</b><span>A page, an app screen or a component, in a sentence.</span></li><li><b>Three models draft it</b><span>At once, on your theme, at phone and desktop sizes.</span></li><li><b>Make it yours</b><span>Pick one, click any part to change it or ask for a change, and keep every version.</span></li></ol>`;
+ const draft = (side) => `<figure class="ds-vs-card"><span class="ds-vs-bar"></span><span class="ds-vs-hero"></span><span class="ds-vs-row"><i></i><i></i><i></i></span><figcaption>${side}</figcaption></figure>`;
+ const vs = `<div class="ds-vs" role="img" aria-label="Two drafts side by side, their models hidden">${draft('Left')}<span class="ds-vs-or">or</span>${draft('Right')}</div><p class="ds-fine ds-vs-note">The models' names stay hidden until you vote.</p>`;
const body = sc.room({
const body = sc.room({
room: 'design',
room: 'design',
hero: {
hero: {
- title: ROOM.line,
- lede: 'Pick a theme and watch Live, Chat and a shop wear it side by side. Change a colour, see every contrast check pass or fail, and use it on every OpenVibe site at once. Any earlier look is one tap back.',
- start: { href: '/studio', label: 'Open the studio' },
+ title: 'Your look, everywhere',
+ lede: 'Pick or make a theme once and every OpenVibe site wears it. Take it into your own app as code for the web, Android and iOS, and design pages on it with AI.',
+ start: { href: '/studio', label: 'Change your theme' },
chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/studio?theme=${encodeURIComponent(t.id)}` })),
chips: picks.slice(0, 5).map((t) => ({ label: t.name, href: `/studio?theme=${encodeURIComponent(t.id)}` })),
motif: orb,
motif: orb,
},
},
- proof: [{ value: all.length, label: all.length === 1 ? 'theme to try' : 'themes to try' }, { value: all.filter((t) => t.mode === 'light').length, label: 'with a light side' }].filter((p) => p.value),
- html: strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to try</h2><a class="ds-more" href="/studio">All of them in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : '',
+ proof: [
+ { value: all.length, label: all.length === 1 ? 'theme to wear' : 'themes to wear' },
+ { value: all.filter((t) => t.mode === 'light').length, label: 'with a light side' },
+ { value: 7, label: 'platforms to export to' },
+ ].filter((p) => p.value),
scenes: [
scenes: [
- { title: 'One theme, three sites, side by side', text: 'Every theme is tried on a Live channel, a Chat room and a shop\'s grid at once, so you see the buttons, the chat and the prices before you choose.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Try one' } },
- { title: 'Checks that must pass', text: 'Text on every surface, buttons, links and muted text are measured against WCAG as you edit, and the previews can be seen as people with colour-vision differences see them.', html: `<ul class="ds-checks is-demo"><li class="is-pass"><b>Text on background</b><span>14.2:1</span></li><li class="is-pass"><b>Button text</b><span>7.1:1</span></li><li class="is-fail"><b>Muted text on surface</b><span>3.1:1 · needs 4.5</span></li></ul>`, action: { href: '/studio', label: 'Edit a theme' } },
+ { title: 'One theme, every site', text: 'Try a theme on a Live channel, a Chat room and a shop side by side. Every contrast check must pass, and colour-blind previews show it as everyone sees it. Use it, and the whole network wears it from its next page; any earlier look is one tap back.', html: `<div class="ds-scene-pv">${previews()}</div>`, action: { href: '/studio', label: 'Open the studio' } },
+ { title: 'Make it with AI, on your theme', text: 'Describe what you need and three AI designers draft it at once. Every draft wears your theme, so it already looks like it belongs.', html: make, action: { href: '/create', label: 'Make something' } },
+ { title: 'See which AI designs best', text: 'The Arena sends one prompt to three models, shows two drafts without their names, and ranks the designers by the votes, with what each draft cost and how long it took. Every prompt, page and vote is open data.', html: vs, action: { href: '/arena', label: 'Vote in the Arena' } },
],
],
+ dev: {
+ title: 'Your theme, in your app',
+ 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: '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' }],
+ },
explore: { title: 'More on Design', items: [
explore: { title: 'More on Design', items: [
- { title: 'The studio', text: 'Pick, edit, check and use a theme', href: '/studio', icon: 'ov:theme' },
- { title: 'Your themes on Network', text: 'Display, motion and text size', href: 'https://openvibe.network/my', icon: 'ov:account' },
- { title: 'The API', text: 'The theme catalog as open JSON', href: '/developers', icon: 'ov:code' },
+ { title: 'Tokens', text: 'Any theme as CSS, Compose, SwiftUI, Flutter or Figma', href: '/tokens', icon: 'ov:code' },
+ { title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },
+ { title: 'Your settings', text: 'Text size, motion and display on Network', href: 'https://openvibe.network/my', icon: 'ov:account' },
] },
] },
+ html: strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/studio">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : '',
faq: { items: [
faq: { items: [
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
['Where does my theme apply?', 'Everywhere you are signed in to OpenVibe: the theme lives on your account, and every site picks it up on its next page.'],
+ ['Can I use it in my own app?', 'Yes. Tokens gives any theme as CSS variables, a Tailwind config, a Jetpack Compose colour scheme, SwiftUI colours, a Flutter colour scheme, Figma variables or W3C design-token JSON, with its light and dark halves together. The API is open.'],
['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the studio\'s history brings any of the last fifty back.'],
['Can I undo a change?', 'Yes. Every change keeps the look it replaced; the studio\'s history brings any of the last fifty back.'],
['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],
['Can others use my theme?', 'Submit it to the gallery. A person reviews each submission before anyone else sees it.'],
+ ['What is coming next?', 'Publishing a design straight to OpenVibe.Website and OpenVibe.Space, brand kits that dress a creator\'s channel, overlays and shop in one go, and the kit\'s components as native Android and iOS views.'],
] },
] },
});
});
- return layout({ title: null, path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
+ return layout({ title: null, description: 'Your look, everywhere: one theme across every OpenVibe site, the same theme as code for the web, Android and iOS, and AI design on it, ranked honestly by the Design Arena.', path: '/', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
}
}
function studio({ themes, me = {} } = {}) {
function studio({ themes, me = {} } = {}) {
@@ -119,7 +144,8 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u
@@ -119,7 +144,8 @@ ${signedIn ? `<p class="ds-sign ds-fine">Signed in as <b>${esc((me.user && (me.u
<div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div>
<div class="ds-fields">${CORE.map(([k, l]) => `<label class="ds-field"><span>${esc(l)}</span><input type="color" data-var="${k}" aria-label="${esc(l)}"><input type="text" data-hex="${k}" inputmode="text" maxlength="7" spellcheck="false" aria-label="${esc(l)}, hex"></label>`).join('')}</div>
<h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul>
<h3>Checks</h3><ul class="ds-checks" id="ds-checks" aria-live="polite"><li class="ds-fine">Pick a theme to check it.</li></ul>
<div class="ds-actions"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${signedIn ? 'Use this theme' : 'Sign in to use it'}</button>
<div class="ds-actions"><button type="button" class="ovk-btn ovk-btn--primary" id="ds-use" disabled>${signedIn ? 'Use this theme' : 'Sign in to use it'}</button>
-<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button></div>
+<button type="button" class="ovk-btn ovk-btn--quiet" id="ds-reset" disabled>Undo my edits</button>
+<a class="ovk-btn ovk-btn--quiet" id="ds-code" href="/tokens" title="This theme for the web, Android, iOS, Flutter and Figma">As code</a></div>
<p class="ds-status" id="ds-status" role="status"></p>
<p class="ds-status" id="ds-status" role="status"></p>
<details class="ds-submit"><summary>Share it in the gallery</summary><form id="ds-submit-form"><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label>
<details class="ds-submit"><summary>Share it in the gallery</summary><form id="ds-submit-form"><label>Name <input type="text" id="ds-name" maxlength="60" minlength="2" required placeholder="Cozy autumn"></label>
<label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label>
<label>One line about it <input type="text" id="ds-desc" maxlength="300" placeholder="Warm browns and a pumpkin accent"></label>
@@ -134,7 +160,7 @@ function theme({ theme: t } = {}) {
@@ -134,7 +160,7 @@ function theme({ theme: t } = {}) {
const v = (t && t.variables) || {};
const v = (t && t.variables) || {};
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const rows = CORE.map(([k, l]) => (HEX.test(String(v[k] || '')) ? `<div><dt>${esc(l)}</dt><dd><i style="background:${esc(v[k])}"></i><code>${esc(v[k])}</code></dd></div>` : '')).join('');
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
const body = `<header class="ds-page-h"><p class="ds-kicker">${t.mode === 'light' ? 'Light theme' : 'Dark theme'}</p><h1>${esc(t.name)}</h1>${t.description ? `<p class="ds-lede">${esc(t.description)}</p>` : ''}
-<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/api/themes/${encodeURIComponent(t.slug || t.id)}`, label: 'As JSON', kind: 'quiet' })}</p></header>
+<p class="ds-act">${kit.button({ href: `/studio?theme=${encodeURIComponent(t.id)}`, label: 'Open in the studio' })} ${kit.button({ href: `/tokens?theme=${encodeURIComponent(t.slug || t.id)}`, label: 'As code', kind: 'quiet' })}</p></header>
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
<section aria-labelledby="ds-pv-h"><h2 id="ds-pv-h">How it looks</h2><div class="ds-previews ds-previews--static" style="${esc(varsStyle(v))}">${previews()}</div></section>`;
return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/studio' });
return layout({ title: `${t.name}: an OpenVibe theme`, description: t.description || `${t.name}, a ${t.mode === 'light' ? 'light' : 'dark'} OpenVibe theme: its colours and how Live, Chat and a shop look in it.`, path: `/t/${encodeURIComponent(t.slug || t.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/studio' });
@@ -149,6 +175,8 @@ function developers() {
@@ -149,6 +175,8 @@ function developers() {
['POST /api/v1/designs/:id/edit', 'Chat edit the working frame with {instruction}; one paid AI run and a small per-design budget.'],
['POST /api/v1/designs/:id/edit', 'Chat edit the working frame with {instruction}; one paid AI run and a small per-design budget.'],
['POST /api/v1/designs/:id/click', 'Apply {path, text?, color?, spacing?, align?} to one element; validated paths and theme tokens only.'],
['POST /api/v1/designs/:id/click', 'Apply {path, text?, color?, spacing?, align?} to one element; validated paths and theme tokens only.'],
['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', 'The export targets.'],
['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).'],
@@ -156,7 +184,7 @@ function developers() {
@@ -156,7 +184,7 @@ function developers() {
['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],
['GET /api/me/theme/history', 'Your earlier looks, newest first; POST /api/me/theme/history/:id/restore brings one back.'],
];
];
const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>
const body = `<header class="ds-page-h"><p class="ds-kicker">For developers</p><h1>The Design API</h1><p class="ds-lede">The theme catalog is open JSON. A theme is a set of CSS custom properties every OpenVibe site reads; Network keeps which one each person uses.</p></header>
-<section aria-labelledby="ds-ep-h"><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
+<section aria-labelledby="ds-ep-h" id="tokens"><h2 id="ds-ep-h">Endpoints</h2><dl class="ds-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
<p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;
<p class="ds-fine">Themes also export and import as <code>openvibe-theme@1</code> files through <a href="https://openvibe.network/api/themes">Network's theme API</a>.</p>`;
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * /tokens: a theme as code. Pick any public theme (its other half comes along), choose a platform, copy or download
+ * the file. Server-rendered and whole without JavaScript: the theme picker is a GET form, the platforms are tab links
+ * (?format=), and kit-web.js adds the copy button. The code is openvibe-shared/tokens' output (server/tokens.js), the
+ * same token document a native OpenVibe app reads.
+ *
+ * tokensPage({ themes, found, format, both })
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc } = require('../render/layout');
+const themeTokens = require('../tokens');
+
+const SHORT = { css: 'CSS', tailwind: 'Tailwind', compose: 'Android', swiftui: 'iOS', flutter: 'Flutter', figma: 'Figma', json: 'JSON' };
+
+/** How to use one target's file, in a sentence or two. name: the theme's identifier (VibeTokens…). */
+function howTo(id, name, pair) {
+ const n = name;
+ const lower = n[0].toLowerCase() + n.slice(1);
+ 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 '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>.`;
+ case 'figma': return `Import it with a variables-import plugin: one collection${pair ? ', a mode for each half' : ''}, named the way the tokens are.`;
+ default: return 'The W3C Design Tokens (DTCG) document itself, for Style Dictionary, Tokens Studio or your own build.';
+ }
+}
+
+const cssColor = (v) => { const c = themeTokens.parseColor(v); return c ? (c.a < 1 ? `rgba(${c.r},${c.g},${c.b},${c.a})` : `#${[c.r, c.g, c.b].map((x) => x.toString(16).padStart(2, '0')).join('')}`) : null; };
+const dot = (v) => { const c = cssColor(v); return c ? `<i class="tk-dot" style="background:${esc(c)}"></i>` : ''; };
+
+function picker(themes, current, both, format) {
+ const opt = (t) => `<option value="${esc(t.slug || t.id)}"${t === current ? ' selected' : ''}>${esc(t.name)}</option>`;
+ const dark = themes.filter((t) => t.mode !== 'light');
+ const light = themes.filter((t) => t.mode === 'light');
+ return `<form class="tk-pick" method="get" action="/tokens"><label class="tk-pick-l"><span>Theme</span><select name="theme">${dark.length ? `<optgroup label="Dark">${dark.map(opt).join('')}</optgroup>` : ''}${light.length ? `<optgroup label="Light">${light.map(opt).join('')}</optgroup>` : ''}</select></label>
+<input type="hidden" name="format" value="${esc(format)}"><label class="tk-both"><input type="checkbox" name="both" value="1"${both ? ' checked' : ''}><span>With its other half</span></label><input type="hidden" name="both" value="0">
+${kit.button({ type: 'submit', label: 'Show', kind: 'quiet', small: true })}</form>`;
+}
+
+function themeCard(found) {
+ const half = (doc) => {
+ const m = (doc.$extensions && doc.$extensions['network.openvibe']) || {};
+ const pick = (p) => p.split('.').reduce((o, k) => (o ? o[k] : null), doc);
+ const dots = ['color.bg.base', 'color.bg.raised', 'color.text.primary', 'color.accent.strong', 'color.status.ok.base'].map((p) => dot(pick(p) && pick(p).$value)).join('');
+ return `<li class="tk-half"><span class="tk-dots" aria-hidden="true">${dots}</span><span><b>${esc(m.name)}</b><small>${m.mode === 'light' ? 'Light' : 'Dark'}</small></span></li>`;
+ };
+ return `<ul class="tk-halves">${found.docs.map(half).join('')}</ul>`;
+}
+
+function tokenTable(found) {
+ const [a, b] = found.docs;
+ const get = (doc, path) => path.reduce((o, k) => (o ? o[k] : null), doc);
+ const rows = themeTokens.flatten(a).filter((t) => t.type === 'color').map((t) => {
+ const other = b ? get(b, t.path) : null;
+ return `<li class="tk-row"><code>${esc(t.path.join('.'))}</code><span class="tk-vals">${dot(t.value)}<code>${esc(String(t.value))}</code>${other ? `${dot(other.$value)}<code>${esc(String(other.$value))}</code>` : ''}</span><small>${esc(t.description)}</small></li>`;
+ }).join('');
+ return `<ul class="tk-table" aria-label="Colour tokens">${rows}</ul>
+<p class="ds-fine">Every theme also carries the kit's scales: <code>radius.*</code>, <code>space.*</code> (4 to 32), <code>size.tap</code> (44), <code>type.size.*</code>, <code>type.weight.*</code> and <code>motion.*</code>.</p>`;
+}
+
+function tokensPage({ themes = [], found = null, format = 'css', both = true } = {}) {
+ const list = (themes || []).filter((t) => t && (t.slug || t.id));
+ const target = themeTokens.TARGETS.find((t) => t.id === format) || themeTokens.TARGETS[0];
+ let main;
+ if (!found) {
+ main = list.length
+ ? kit.empty({ icon: 'palette', title: 'No theme by that name', text: 'Pick one from the list above.' })
+ : kit.empty({ icon: 'palette', title: 'The theme catalog is not answering', text: 'OpenVibe.Network did not answer. Try again in a minute.' });
+ } else {
+ const slug = found.theme.slug || found.theme.id;
+ const pair = found.docs.length > 1;
+ const name = themeTokens.ident(found.theme.name);
+ const code = themeTokens.compile(pair ? found.docs : found.docs[0], target.id);
+ const base = `/tokens?theme=${encodeURIComponent(slug)}${both ? '' : '&both=0'}`;
+ const dl = `/api/v1/tokens/${encodeURIComponent(slug)}?format=${encodeURIComponent(target.id)}${pair ? '' : '&both=0'}&download=1`;
+ main = `${themeCard(found)}
+${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items: themeTokens.TARGETS.map((t) => ({ key: t.id, label: SHORT[t.id] || t.label })) })}
+<section class="tk-out" aria-labelledby="tk-out-h"><div class="tk-out-head"><h2 id="tk-out-h">${esc(target.label)}</h2>${kit.button({ href: dl, label: `Download ${themeTokens.fileName(found.docs[0], target.id)}`, kind: 'quiet', small: true, icon: 'download' })}</div>
+<p class="tk-how">${howTo(target.id, name, pair)}</p>
+${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>
+${kit.fold({ id: 'tk-all', title: 'Every colour token', meta: `${themeTokens.flatten(found.docs[0]).filter((t) => t.type === 'color').length} of them`, body: tokenTable(found) })}
+${kit.fold({ id: 'tk-why', title: 'Why tokens, and what is next', body: `<p>A theme on OpenVibe is one set of design tokens: semantic names such as <code>color.accent.strong</code> and <code>space.lg</code>, kept apart from any one platform. The web reads them as CSS variables today. An Android or iOS app reads the same values in Compose or SwiftUI, so a native app wears your theme without being a web page in a frame.</p><p>The format is the W3C Design Tokens one, so the same file works in Style Dictionary, Tokens Studio and Figma. Next: the kit's components described the same way (anatomy, states, roles), so a page made on OpenVibe.Design can be drawn natively too.</p><p>${kit.button({ href: `/studio?theme=${encodeURIComponent(found.theme.id || slug)}`, label: 'Change this theme in the studio', kind: 'quiet', small: true })} ${kit.button({ href: '/developers#tokens', label: 'The tokens API', kind: 'quiet', small: true })}</p>` })}`;
+ }
+ const body = `<header class="ds-page-h"><p class="ds-kicker">OpenVibe Tokens</p><h1>Your theme, as code</h1>
+<p class="ds-lede">Take any OpenVibe theme into your own app, its light and dark halves together: the web, Android, iOS, Flutter or Figma. The colours are the ones the network paints.</p></header>
+${list.length ? picker(list, found ? found.theme : null, both, target.id) : ''}
+${main}`;
+ const title = found ? `${found.theme.name} as code: CSS, Compose, SwiftUI, Flutter, Figma` : 'Your theme, as code';
+ return layout({
+ title,
+ description: found ? `The ${found.theme.name} theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark.` : 'Every OpenVibe theme as design tokens for the web, Android, iOS, Flutter and Figma.',
+ path: found ? `/tokens?theme=${encodeURIComponent(found.theme.slug || found.theme.id)}` : '/tokens',
+ canonical: undefined,
+ body: `<div class="ds-wrap tk">${body}</div>`,
+ active: '/tokens',
+ });
+}
+
+module.exports = { tokensPage, howTo };
@@ -15,7 +15,8 @@ const { assetVersion } = require('../server/assets');
@@ -15,7 +15,8 @@ const { assetVersion } = require('../server/assets');
await check('the home page lists the catalogue, escaped, and is publicly cacheable', async () => {
await check('the home page lists the catalogue, escaped, and is publicly cacheable', async () => {
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /Make it yours/);
+ assert.match(r.text, /Your look, everywhere/);
+ assert.match(r.text, /href="\/tokens"/, 'the nav and the home lead to the tokens');
assert.match(r.text, /<a class="ds-swatch" href="\/t\/aurora">[\s\S]*?<span class="ds-swatch-name">Aurora<\/span>/);
assert.match(r.text, /<a class="ds-swatch" href="\/t\/aurora">[\s\S]*?<span class="ds-swatch-name">Aurora<\/span>/);
assert.match(r.text, /<main id="main"/);
assert.match(r.text, /<main id="main"/);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+/**
+ * /tokens and /api/v1/tokens: any public theme as code (openvibe-shared/tokens), its other half along by default,
+ * every platform as a tab, a download that names its file, and nothing from the catalog reaching the code unchecked.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const themes = [
+ { id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark', description: 'The default.', variables: { '--bg-primary': '#0a0f1c', '--bg-card': '#131c2e', '--text-primary': '#e6edf7', '--accent': '#3b82f6', '--border': '#1f2d47' } },
+ { id: 'cloud', name: 'Vibe Light', slug: 'cloud', mode: 'light', description: 'Its light half.', variables: { '--bg-primary': '#f5f7fb', '--bg-card': '#ffffff', '--text-primary': '#0f172a', '--accent': '#2563eb', '--border': '#d8dee9' } },
+ { id: 'evil', name: 'Evil */ import os //', slug: 'evil', mode: 'dark', description: 'x\n*/ fun pwn() {}', variables: { '--bg-primary': '#000; } body { background: url(https://evil.example) }', '--accent': '#ff0000', '--text-primary': '#ffffff' } },
+ ];
+ const t = await boot({ network: { themes } });
+
+ await check('/tokens shows the default theme with its other half, the CSS tab open, and is publicly cacheable', async () => {
+ const r = await t.get('/tokens');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Your theme, as code/);
+ assert.match(r.text, /<b>Vibe<\/b><small>Dark<\/small>[\s\S]*<b>Vibe Light<\/b><small>Light<\/small>/, 'both halves');
+ assert.match(r.text, /aria-current="page"[^>]*>(<[^>]+>)*<span>CSS<\/span>|<a href="[^"]*format=css" aria-current="page">/);
+ assert.match(r.text, /--ov-color-bg-base: #0a0f1c;/);
+ assert.match(r.text, /:root\[data-theme-mode="light"\]/);
+ assert.match(r.text, /href="\/api\/v1\/tokens\/vibe\?format=css&download=1"/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
+ });
+
+ await check('every platform is a tab, and Android is a Compose colour scheme for both halves', async () => {
+ const r = await t.get('/tokens?theme=vibe&format=compose');
+ for (const label of ['CSS', 'Tailwind', 'Android', 'iOS', 'Flutter', 'Figma', 'JSON']) assert.match(r.text, new RegExp(`<span>${label}</span>`), label);
+ assert.match(r.text, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);
+ assert.match(r.text, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);
+ assert.match(r.text, /isSystemInDarkTheme\(\)/, 'it says how to use it');
+ const one = await t.get('/tokens?theme=vibe&format=swiftui&both=0');
+ assert.match(one.text, /public enum VibeTokens \{/);
+ assert.doesNotMatch(one.text, /<b>Vibe Light<\/b>/, 'both=0 leaves the other half out');
+ const checked = await t.get('/tokens?theme=vibe&format=css&both=1&both=0');
+ assert.match(checked.text, /<b>Vibe Light<\/b>/, 'the form sends both=1&both=0 when the box is ticked');
+ });
+
+ await check('an unknown theme is a 404 page; an unknown format falls back to CSS', async () => {
+ const r = await t.get('/tokens?theme=nope');
+ assert.strictEqual(r.status, 404);
+ assert.match(r.text, /No theme by that name/);
+ const f = await t.get('/tokens?theme=vibe&format=cobol');
+ assert.strictEqual(f.status, 200);
+ assert.match(f.text, /--ov-color-bg-base/);
+ });
+
+ await check('the API: targets, each format with its type, a download with a safe file name', async () => {
+ const list = (await t.get('/api/v1/tokens')).json();
+ assert.deepStrictEqual(list.targets.map((x) => x.id), ['css', 'tailwind', 'compose', 'swiftui', 'flutter', 'figma', 'json']);
+ const css = await t.get('/api/v1/tokens/vibe?format=css');
+ assert.match(css.headers.get('content-type'), /^text\/plain; charset=utf-8/);
+ assert.match(css.text, /--bg-primary: var\(--ov-color-bg-base\);/);
+ const json = (await t.get('/api/v1/tokens/cloud?format=json&both=0')).json();
+ assert.strictEqual(json.color.bg.base.$value, '#f5f7fb');
+ const fig = (await t.get('/api/v1/tokens/vibe?format=figma')).json();
+ assert.deepStrictEqual(fig.modes, ['dark', 'light']);
+ const dl = await t.get('/api/v1/tokens/vibe?format=compose&download=1');
+ assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="VibeTheme.kt"');
+ assert.strictEqual(dl.headers.get('cache-control'), 'public, max-age=600');
+ assert.strictEqual((await t.get('/api/v1/tokens/vibe?format=cobol')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/tokens/nope?format=css')).status, 404);
+ });
+
+ await check('a catalog theme cannot put code into the export', async () => {
+ for (const format of ['css', 'compose', 'swiftui', 'flutter']) {
+ const r = await t.get(`/api/v1/tokens/evil?format=${format}`);
+ assert.strictEqual(r.status, 200);
+ assert.doesNotMatch(r.text, /evil\.example/, `${format}: the bad value is dropped`);
+ const code = r.text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
+ assert.doesNotMatch(code, /import os|fun pwn/, `${format}: the name stays inside comments`);
+ }
+ const page = await t.get('/tokens?theme=evil&format=compose');
+ assert.doesNotMatch(page.text, /evil\.example/, 'the dropped value is not on the page either');
+ assert.match(page.text, /object EvilImportOsTokens/);
+ });
+
+ await check('the studio and a theme page link to the theme as code', async () => {
+ assert.match((await t.get('/studio')).text, /id="ds-code" href="\/tokens"/);
+ assert.match((await t.get('/t/vibe')).text, /href="\/tokens\?theme=vibe"/);
+ assert.match((await t.get('/developers')).text, /GET \/api\/v1\/tokens\/:theme\?format=/);
+ });
+
+ await t.close();
+ done();
+})();