openvibers/openvibe.design Commits
Design: the design tokens as files every tool can read (/tokens/<theme>.<ext>) (#53)
Changes
@@@ -88,6 +88,26 @@ All of them are server-rendered through the OpenVibe Frame (`openvibe-shared/sheJavaScript. A page rendered for a signed-in person is `private, no-store`; a public page is`public, max-age=120` with `Vary: Cookie`.++## Token files++++`GET /tokens/<theme>.<ext>` gives one theme as the file a tool reads. `<theme>` is a built-in theme or a++community theme from the catalogue (`default` is Vibe); `<ext>` is the format:++++| Route | What it is |++|---|---|++| `/tokens/<theme>.tokens.json` | the W3C Design Tokens document: `color`, `motion.duration`, `motion.easing`, `brand` |++| `/tokens/<theme>.css` | `:root { … }` with every variable |++| `/tokens/<theme>.tailwind.js` | a Tailwind preset (`theme.extend.colors`, `transitionDuration`, `transitionTimingFunction`) |++| `/tokens/<theme>.style-dictionary.json` | the Style Dictionary source |++| `/tokens/<theme>.swift` | a Swift enum `OVTokens` (SwiftUI) |++| `/tokens/<theme>.kt` | a Kotlin object `OVTokens` (Jetpack Compose) |++| `/tokens/<theme>.xml` | an Android `colors.xml` |++++Every file carries the theme's colours, the kit's motion (`--ov-dur-1…4` and the three curves, from++`openvibe-shared/ov-fx`) and the mark's colours. A built-in theme does not change, so its file is++`public, max-age=31536000, immutable`; a community theme may be edited, so its file is cached for ten++minutes. Every file is `Access-Control-Allow-Origin: *`. A theme or a format that is not there is a 404.++## API### MCP@@@ -37,6 +37,8 @@"Design Studio chat edit: POST /api/v1/designs/:id/edit, the picked variant's model via OpenVibe.AI, the previous HTML in context, theme-token re-wrapping, sanitization and the render-time frame CSP; a small per-design paid-AI budget","Design Studio click edit: a render-time iframe overlay reports a validated body > …:nth-of-type path; the floating panel edits text, a theme-token colour, padding and alignment; paths, tags and CSS values are allowlisted server-side and every accepted change is a version","Design Studio versions: migrations/0003_design_working_versions.sql keeps design_picks and an append-only design_versions table with model/provider provenance; undo/redo move a pointer and an edit after undo appends instead of deleting",++ "The token files: GET /tokens/<theme>.<ext> gives a built-in or community theme (default is Vibe) as the W3C Design Tokens JSON, the CSS variables, a Tailwind preset, the Style Dictionary source, a Swift enum OVTokens, a Kotlin object OVTokens or an Android colors.xml, with the theme's colours, the kit's motion (--ov-dur-1...4 and the three curves) and the mark's colours; built-in themes immutable for a year, community themes cached for ten minutes, every file open to any origin, an unknown theme or format a 404",++ "The format list (server/tokens/exports.js) is what a page lists: each format with its id, file name, label and type","Tests: pick/vote idempotence, owner and guest-key authorization, chat edit with a fake AI and budget, click-edit safety, CSP after editing, version history/undo/redo and the no-JS page forms"],"notYet": [@@@ -65,7 +67,8 @@"Design Studio pick and Arena vote","Design Studio chat edit","Design Studio click edit",-- "Design Studio versions and undo/redo"++ "Design Studio versions and undo/redo",++ "Design tokens as files (JSON, CSS, Tailwind, Style Dictionary, Swift, Kotlin, Android XML)"],"updated": "2026-10-31"}@@@ -366,6 +366,14 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef/* ── /tokens: a theme as code (views/tokens.js) ── */.tk { max-width: 980px; }++.tk-files { margin: 22px 0 8px; }++.tk-files h2 { margin: 0 0 6px; font-size: 1.05rem; }++.tk-file-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.tk-file { display: grid; gap: 2px; min-height: 44px; align-content: center; padding: 6px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; color: inherit; text-decoration: none; }++.tk-file b { font-size: 13.5px; }++.tk-file code { font-size: 11.5px; color: var(--ovk-muted); background: none; padding: 0; }++.tk-file:hover { border-color: color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 55%, var(--ovk-line)); }++.tk-file:focus-visible { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 2px; }.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; }@@@ -6,6 +6,7 @@** /, /studio, /t/:slug, /arena/*, /developers the pages (http/pages.js)* /api/v1/arena/* the Arena API (http/arena-api.js)++ * /tokens/<theme>.<ext> a theme as the file every tool can read (http/tokens.js)* /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)* /auth/* Network SSO with PKCE (auth/sso.js)* /internal/events ADR-033 account export/deletion (identity/account-data.js)@@@ -37,6 +38,7 @@ const { createArenaApi } = require('./http/arena-api');const { createDesignApi } = require('./http/design-api');const { createDesigns } = require('./designs');const { createPageRoutes } = require('./http/pages');++const { createTokenFileRoutes } = require('./http/tokens');const { createDiscoveryRoutes } = require('./http/discovery');const { createIconRoutes } = require('./http/icons');const { createServiceReadiness } = require('./observability');@@@ -180,6 +182,9 @@ async function createApp(opts = {}) {// ── Crawl artifacts ─────────────────────────────────────app.use(createDiscoveryRoutes(ctx));++ // ── The token files: /tokens/<theme>.<ext> (a theme as the file every tool can read) ──++ app.use('/tokens', createTokenFileRoutes(ctx));++// ── Pages ───────────────────────────────────────────────app.use(createPageRoutes(ctx));nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * The token files: GET /tokens/<theme>.<ext> — a theme's colours, the kit's motion and the mark's colours, as the file++ * each tool reads (the DTCG JSON, the CSS, the Tailwind preset, the Style Dictionary source, the Swift, the Kotlin, the++ * Android XML).++ *++ * A built-in theme does not change, so its file is immutable for a year; a community theme may be edited, so its file++ * is cached for ten minutes. Every file is open to any origin: a design tool fetches it from its own page.++ */++const { asyncRouter } = require('./router');++const tokenFiles = require('../tokens/exports');++++const IMMUTABLE = 'public, max-age=31536000, immutable';++const SHORT = 'public, max-age=600';++++function createTokenFileRoutes(ctx) {++ const { catalog, log } = ctx;++ const r = asyncRouter();++++ r.get('/:file', async (req, res) => {++ const parsed = tokenFiles.parse(req.params.file);++ if (!parsed) return missing(res);++ let list = [];++ try {++ list = (await catalog.list()).themes || [];++ } catch (err) {++ // The built-in themes still answer; only a community theme needs the catalogue.++ log.warn('[OpenVibe.Design] token file: the catalogue did not answer:', (err && err.message) || err);++ }++ const found = tokenFiles.findTheme(list, parsed.themeId);++ if (!found) return missing(res);++ const body = tokenFiles.render(parsed.format.id, found.theme);++ if (!body) return missing(res);++ res.set('Cache-Control', found.builtin ? IMMUTABLE : SHORT);++ res.set('Access-Control-Allow-Origin', '*');++ res.type(`${parsed.format.type}; charset=utf-8`);++ return res.send(body);++ });++++ return r;++}++++function missing(res) {++ return res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('No such theme file.');++}++++module.exports = { createTokenFileRoutes };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * The token files a tool can fetch: GET /tokens/<theme>.<ext>. One theme — a built-in or a community one — as the file++ * each tool reads: the DTCG JSON, the CSS variables, the Tailwind preset, the Style Dictionary source, the Swift, the++ * Kotlin and the Android XML.++ *++ * The list of formats is here so a page can name them all (the format, its file name and what it is).++ *++ * FORMATS [{ id, ext, label, type }]++ * parse(file) a file name → { format, themeId } (null when the format is not one of ours)++ * findTheme(list, id) a built-in theme, or a community one from the catalogue; null when there is none++ * render(format, theme) the file's source text++ */++const { BUILTIN_THEMES, deriveTokens } = require('openvibe-shared/builtin-themes');++const kit = require('./kit');++const formats = require('./formats');++++// The formats, the file name each one answers to, and what it is. The order is the order a page lists them in.++const FORMATS = [++ { id: 'tokens', ext: 'tokens.json', label: 'Design tokens (JSON)', type: 'application/json' },++ { id: 'css', ext: 'css', label: 'CSS variables', type: 'text/css' },++ { id: 'tailwind', ext: 'tailwind.js', label: 'Tailwind preset', type: 'text/javascript' },++ { id: 'style-dictionary', ext: 'style-dictionary.json', label: 'Style Dictionary', type: 'application/json' },++ { id: 'swift', ext: 'swift', label: 'iOS (Swift)', type: 'text/plain' },++ { id: 'kotlin', ext: 'kt', label: 'Android (Kotlin)', type: 'text/plain' },++ { id: 'android', ext: 'xml', label: 'Android (colors.xml)', type: 'application/xml' },++];++++const BUILDERS = {++ tokens: formats.tokensJson,++ css: formats.css,++ tailwind: formats.tailwind,++ 'style-dictionary': formats.styleDictionary,++ swift: formats.swift,++ kotlin: formats.kotlin,++ android: formats.androidXml,++};++++// The longest file name first, so "style-dictionary.json" is not read as "json".++const BY_EXT = FORMATS.slice().sort((a, b) => b.ext.length - a.ext.length);++++/** A file name → the format it is and the theme it is for. Null when the format is not one of ours. */++function parse(file) {++ const name = String(file || '').trim();++ if (!name) return null;++ for (const f of BY_EXT) {++ const suffix = `.${f.ext}`;++ if (name.length > suffix.length && name.toLowerCase().endsWith(suffix.toLowerCase())) {++ return { format: f, themeId: name.slice(0, name.length - suffix.length) };++ }++ }++ return null;++}++++/**++ * A theme by its id or slug: a built-in one first (the network's own list, unchanged), then a community one from the++ * catalogue with the readable text and status colours the kit derives. "default" is Vibe.++ */++function findTheme(list, idOrSlug) {++ const key = String(idOrSlug || '').trim().toLowerCase();++ if (!key) return null;++ const wanted = key === 'default' ? 'vibe' : key;++ const builtin = BUILTIN_THEMES.find((t) => t && (String(t.id).toLowerCase() === wanted || String(t.slug || '').toLowerCase() === wanted));++ if (builtin) return { theme: builtin, builtin: true };++ const community = (Array.isArray(list) ? list : []).find((t) => t && (++ String(t.slug || '').toLowerCase() === wanted || String(t.id || '').toLowerCase() === wanted));++ if (!community) return null;++ const mode = community.mode === 'light' ? 'light' : 'dark';++ return {++ theme: { ...community, slug: community.slug || String(community.id || ''), mode, variables: deriveTokens({ ...(community.variables || {}) }, mode) },++ builtin: false,++ };++}++++/** The file's source text for a theme. Null when the format is not one of ours. */++function render(formatId, theme) {++ const build = BUILDERS[formatId];++ if (!build || !theme) return null;++ return build(kit.tokenDocument(theme));++}++++/** The format a page can list, with the file name each one is fetched by. */++function list() {++ return FORMATS.map(({ id, ext, label, type }) => ({ id, ext, label, type }));++}++++module.exports = { FORMATS, list, parse, findTheme, render, MOTION: kit.MOTION, BRAND: kit.BRAND };nnew file mode 100644@@@ -0,0 +1,204 @@++'use strict';++/**++ * The compilers: the token set as the file one tool reads. Each takes the token document and writes the source text++ * for one format — no network, no disk, no state.++ *++ * tokensJson(doc) the DTCG document (JSON)++ * css(doc) :root { … } with every variable++ * tailwind(doc) a Tailwind preset (module.exports = { theme: { extend: { … } } })++ * styleDictionary(doc) the Style Dictionary source++ * swift(doc) a Swift enum OVTokens (SwiftUI)++ * kotlin(doc) a Kotlin object OVTokens (Jetpack Compose)++ * androidXml(doc) an Android colors.xml++ */++const tokens = require('openvibe-shared/tokens');++const { MOTION, BRAND, colourList } = require('./kit');++++const metaOf = (doc) => (doc && doc.$extensions && doc.$extensions['network.openvibe']) || { name: 'Theme', slug: 'theme', mode: 'dark' };++const camel = (path) => String(path).split('.').map((p, i) => (i ? p[0].toUpperCase() + p.slice(1) : p)).join('').replace(/[^A-Za-z0-9]/g, '');++// Android resource names: letters, digits and underscores only.++const snake = (path) => String(path).split('.').map((p) => p.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()).join('_');++const hex2 = (n) => n.toString(16).padStart(2, '0').toUpperCase();++++/** A colour as 0xAARRGGBB and as #RRGGBB (opaque) or #AARRGGBB (not): null when it cannot be read. */++function argb(value) {++ const c = tokens.parseColor(value);++ if (!c) return null;++ const a = Math.round(c.a * 255);++ return { opaque: c.a >= 1, argb: `${hex2(a)}${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`, rgb: `${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}` };++}++++const CSS_NAME = /^--[a-z0-9-]{1,64}$/;++const CSS_COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|hsla?\([0-9.,\s%deg]+\))$/i;++++// Text for a comment: one line, no comment terminators, nothing XML would refuse.++const safe = (s) => String(s == null ? '' : s)++ .replace(/[\r\n\u2028\u2029]+/g, ' ')++ .replace(/\*\//g, '* /')++ .replace(/--/g, '- -')++ .replace(/[^\x20-\x7e\u00a0-\uffff]/g, '')++ .trim()++ .slice(0, 160);++++const header = (doc, kind) => {++ const m = metaOf(doc);++ const line = `${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}) — OpenVibe design tokens, from openvibe.design. Regenerate instead of editing.`;++ return kind === 'xml' ? `<!-- ${line} -->` : `// ${line}`;++};++++/** The DTCG document, as JSON. */++function tokensJson(doc) {++ return `${JSON.stringify(doc, null, 2)}\n`;++}++++/** :root { … } — the theme's colours by the network's own names, the kit's motion, the mark's colours. */++function css(doc) {++ const m = metaOf(doc);++ const lines = [];++ lines.push(` /* the theme's colours */`);++ // A community theme's values reach a stylesheet people paste: only a custom property's name and a colour pass.++ for (const c of colourList(doc)) if (CSS_NAME.test(c.css) && CSS_COLOUR.test(String(c.value))) lines.push(` ${c.css}: ${c.value};`);++ lines.push(` /* the kit's motion (openvibe-shared/ov-fx) */`);++ for (const d of MOTION.durations) lines.push(` ${d.css}: ${d.ms}ms;`);++ for (const e of MOTION.easings) lines.push(` ${e.css}: cubic-bezier(${e.value.join(', ')});`);++ lines.push(` /* the mark */`);++ for (const b of BRAND) lines.push(` ${b.css}: ${b.value};`);++ lines.push(` color-scheme: ${m.mode === 'light' ? 'light' : 'dark'};`);++ return `/* ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}) — OpenVibe design tokens, from openvibe.design. Regenerate instead of editing. */\n:root {\n${lines.join('\n')}\n}\n`;++}++++/** A Tailwind preset: the colours, the durations and the curves, under theme.extend. */++function tailwind(doc) {++ const colors = {};++ for (const c of colourList(doc)) {++ const parts = c.path.replace(/^color\./, '').split('.');++ let o = colors;++ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});++ o[parts[parts.length - 1]] = c.value;++ }++ for (const b of BRAND) {++ colors.brand = colors.brand || {};++ colors.brand[b.id] = b.value;++ }++ const transitionDuration = {};++ for (const d of MOTION.durations) transitionDuration[d.id] = `${d.ms}ms`;++ const transitionTimingFunction = {};++ for (const e of MOTION.easings) transitionTimingFunction[e.id] = `cubic-bezier(${e.value.join(', ')})`;++ const config = { theme: { extend: { colors, transitionDuration, transitionTimingFunction } } };++ return `${header(doc, 'js')}\nmodule.exports = ${JSON.stringify(config, null, 4)};\n`;++}++++/** The Style Dictionary source: value and type for every token, under its path. */++function styleDictionary(doc) {++ const out = {};++ const put = (obj, path, leaf) => {++ const parts = String(path).split('.');++ let o = obj;++ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});++ o[parts[parts.length - 1]] = leaf;++ };++ for (const c of colourList(doc)) put(out, c.path, { value: c.value, type: 'color', description: c.description });++ for (const d of MOTION.durations) put(out, `motion.duration.${d.id}`, { value: `${d.ms}ms`, type: 'duration', description: d.description });++ for (const e of MOTION.easings) put(out, `motion.easing.${e.id}`, { value: e.value.slice(), type: 'cubicBezier', description: e.description });++ for (const b of BRAND) put(out, `brand.${b.id}`, { value: b.value, type: 'color', description: b.description });++ return `${JSON.stringify(out, null, 2)}\n`;++}++++/** SwiftUI: an enum OVTokens of colours (from hex) and the kit's motion (TimeInterval, in seconds). */++function swift(doc) {++ const m = metaOf(doc);++ const lines = [];++ lines.push(header(doc, 'swift'));++ lines.push('import SwiftUI');++ lines.push('');++ lines.push('extension Color {');++ lines.push(' /// A colour from a 0xAARRGGBB literal (opaque when the alpha byte is FF).');++ lines.push(' init(hex: UInt32) {');++ lines.push(' self.init(.sRGB,');++ lines.push(' red: Double((hex >> 16) & 0xFF) / 255,');++ lines.push(' green: Double((hex >> 8) & 0xFF) / 255,');++ lines.push(' blue: Double(hex & 0xFF) / 255,');++ lines.push(' opacity: Double((hex >> 24) & 0xFF) / 255)');++ lines.push(' }');++ lines.push('}');++ lines.push('');++ lines.push(`/// OpenVibe's tokens for ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}).`);++ lines.push('public enum OVTokens {');++ for (const c of colourList(doc)) {++ const a = argb(c.value);++ if (!a) continue;++ lines.push(` /// ${safe(c.description)}`);++ lines.push(` public static let ${camel(c.path.replace(/^color\./, ''))} = Color(hex: 0x${a.argb})`);++ }++ for (const d of MOTION.durations) {++ lines.push(` /// The kit's motion: ${d.css} (${d.ms} ms).`);++ lines.push(` public static let ${d.name}: TimeInterval = ${d.ms / 1000}`);++ }++ for (const e of MOTION.easings) {++ lines.push(` /// The kit's motion: ${e.css}.`);++ lines.push(` public static let ${e.name} = Animation.timingCurve(${e.value.join(', ')})`);++ }++ for (const b of BRAND) {++ lines.push(` /// ${safe(b.description)}`);++ lines.push(` public static let ${b.name} = Color(hex: 0x${argb(b.value).argb})`);++ }++ lines.push('}');++ return `${lines.join('\n')}\n`;++}++++/** Jetpack Compose: an object OVTokens of colours (Color(0xFF…)) and the kit's motion (milliseconds). */++function kotlin(doc) {++ const m = metaOf(doc);++ const lines = [];++ lines.push(header(doc, 'kotlin'));++ lines.push('package network.openvibe.tokens');++ lines.push('');++ lines.push('import androidx.compose.animation.core.CubicBezierEasing');++ lines.push('import androidx.compose.ui.graphics.Color');++ lines.push('');++ lines.push(`/** OpenVibe's tokens for ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}). */`);++ lines.push('object OVTokens {');++ for (const c of colourList(doc)) {++ const a = argb(c.value);++ if (!a) continue;++ lines.push(` /** ${safe(c.description)} */`);++ lines.push(` val ${camel(c.path.replace(/^color\./, ''))} = Color(0x${a.argb})`);++ }++ for (const d of MOTION.durations) {++ lines.push(` /** The kit's motion: ${d.css} (${d.ms} ms). */`);++ lines.push(` const val ${d.name} = ${d.ms}L`);++ }++ for (const e of MOTION.easings) {++ lines.push(` /** The kit's motion: ${e.css}. */`);++ lines.push(` val ${e.name} = CubicBezierEasing(${e.value.map((n) => `${n}f`).join(', ')})`);++ }++ for (const b of BRAND) {++ lines.push(` /** ${safe(b.description)} */`);++ lines.push(` val ${b.name} = Color(0x${argb(b.value).argb})`);++ }++ lines.push('}');++ return `${lines.join('\n')}\n`;++}++++/** Android: a colors.xml. */++function androidXml(doc) {++ const m = metaOf(doc);++ const lines = [];++ lines.push('<?xml version="1.0" encoding="utf-8"?>');++ lines.push(header(doc, 'xml'));++ lines.push('<resources>');++ for (const c of colourList(doc)) {++ const a = argb(c.value);++ if (!a) continue;++ lines.push(` <!-- ${safe(c.description)} -->`);++ lines.push(` <color name="ov_${snake(c.path.replace(/^color\./, ''))}">#${a.opaque ? a.rgb : a.argb}</color>`);++ }++ for (const b of BRAND) {++ lines.push(` <!-- ${safe(b.description)} -->`);++ lines.push(` <color name="ov_brand_${b.id}">#${argb(b.value).rgb}</color>`);++ }++ lines.push('</resources>');++ return `${lines.join('\n')}\n`;++}++++module.exports = { tokensJson, css, tailwind, styleDictionary, swift, kotlin, androidXml, argb };nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * The OpenVibe token set: a theme's colours, the kit's motion and the mark's two colours — one set, whatever the tool++ * that reads it. The same colours, the same durations and the same curves come out of the JSON, the CSS, the Tailwind++ * preset, the Style Dictionary source, the Swift, the Kotlin and the Android XML.++ *++ * The motion is the kit's (openvibe-shared/ov-fx.js): --ov-dur-1…4 and the three curves, the same on every theme.++ * The colours come from the theme and are read through openvibe-shared/tokens' parser, so a value it cannot read is++ * left out rather than carried into code someone will paste.++ *++ * colours(theme) the theme's colours: [{ path, css, value, description }]++ * tokenDocument(theme) the W3C Design Tokens document (DTCG): color, motion.duration, motion.easing, brand++ * colourList(doc) the colours of a document, with the CSS name each one answers to++ */++const tokens = require('openvibe-shared/tokens');++++// The kit's motion — openvibe-shared/ov-fx.js: :root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}++const MOTION = {++ durations: [++ { id: '1', name: 'duration1', ms: 120, css: '--ov-dur-1', description: 'A quick answer' },++ { id: '2', name: 'duration2', ms: 200, css: '--ov-dur-2', description: 'A change of state' },++ { id: '3', name: 'duration3', ms: 320, css: '--ov-dur-3', description: 'A lift or a glow' },++ { id: '4', name: 'duration4', ms: 520, css: '--ov-dur-4', description: 'The slow way back' },++ ],++ easings: [++ { id: 'out', name: 'easeOut', value: [0.2, 0.8, 0.2, 1], css: '--ov-ease-out', description: 'Leaves fast, settles slow' },++ { id: 'spring', name: 'easeSpring', value: [0.34, 1.56, 0.64, 1], css: '--ov-ease-spring', description: 'Overshoots, then comes back' },++ { id: 'in-out', name: 'easeInOut', value: [0.65, 0, 0.35, 1], css: '--ov-ease-in-out', description: 'Even on both sides' },++ ],++};++++// The mark's own two colours (openvibe-shared/ov-fx.js names them as the mark's O and V).++const BRAND = [++ { id: 'o', name: 'brandO', value: '#4f7cff', css: '--ov-brand-o', description: "The mark's O" },++ { id: 'v', name: 'brandV', value: '#8b5cf6', css: '--ov-brand-v', description: "The mark's V" },++];++++const at = (obj, path) => {++ let o = obj;++ for (const p of String(path || '').split('.')) {++ if (!o || typeof o !== 'object') return null;++ o = o[p];++ }++ return o && typeof o === 'object' ? o : null;++};++++const put = (obj, path, leaf) => {++ const parts = String(path).split('.');++ let o = obj;++ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});++ o[parts[parts.length - 1]] = leaf;++};++++/** The theme's colours, read through the shared parser: an unreadable value is left out, never passed through. */++function colours(theme) {++ const doc = tokens.fromTheme(theme || {});++ const out = [];++ for (const [css, path, description] of tokens.COLOR_MAP) {++ const token = at(doc, path);++ if (!token || !token.$value) continue;++ out.push({ path, css, value: token.$value, description, type: 'color' });++ }++ return out;++}++++/** The token document (DTCG): the groups a tool reads — color, motion.duration, motion.easing, brand. */++function tokenDocument(theme) {++ const t = theme || {};++ const mode = t.mode === 'light' ? 'light' : 'dark';++ const doc = {++ $description: String(t.description || t.name || 'An OpenVibe theme'),++ $extensions: {++ 'network.openvibe': {++ name: String(t.name || 'Theme'),++ slug: String(t.slug || t.id || 'theme'),++ mode,++ generator: 'openvibe.design',++ },++ },++ color: {},++ motion: { duration: {}, easing: {} },++ brand: {},++ };++ for (const c of colours(t)) {++ put(doc.color, c.path.replace(/^color\./, ''), {++ $type: 'color',++ $value: c.value,++ $description: c.description,++ $extensions: { 'network.openvibe': { css: c.css } },++ });++ }++ for (const d of MOTION.durations) {++ doc.motion.duration[d.id] = {++ $type: 'duration',++ $value: `${d.ms}ms`,++ $description: d.description,++ $extensions: { 'network.openvibe': { css: d.css } },++ };++ }++ for (const e of MOTION.easings) {++ doc.motion.easing[e.id] = {++ $type: 'cubicBezier',++ $value: e.value.slice(),++ $description: e.description,++ $extensions: { 'network.openvibe': { css: e.css } },++ };++ }++ for (const b of BRAND) {++ doc.brand[b.id] = {++ $type: 'color',++ $value: b.value,++ $description: b.description,++ $extensions: { 'network.openvibe': { css: b.css } },++ };++ }++ return doc;++}++++/** The colours of a document, with the CSS name each one answers to. */++function colourList(doc) {++ const out = [];++ const walk = (o, path) => {++ for (const [k, v] of Object.entries(o || {})) {++ if (!v || typeof v !== 'object' || k.startsWith('$')) continue;++ if ('$value' in v) {++ const ext = (v.$extensions && v.$extensions['network.openvibe']) || {};++ out.push({ path: path.concat(k).join('.'), css: ext.css || '', value: v.$value, description: v.$description || '' });++ } else walk(v, path.concat(k));++ }++ };++ walk((doc && doc.color) || {}, ['color']);++ return out;++}++++module.exports = { MOTION, BRAND, colours, tokenDocument, colourList };@@@ -10,6 +10,7 @@const kit = require('openvibe-shared/kit');const { layout, esc } = require('../render/layout');const themeTokens = require('../tokens');++const tokenFiles = require('../tokens/exports');const SHORT = { css: 'CSS', tailwind: 'Tailwind', compose: 'Android', swiftui: 'iOS', flutter: 'Flutter', figma: 'Figma', json: 'JSON' };@@@ -63,6 +64,14 @@ function tokenTable(found) {<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>`;}++/** Files a tool can fetch by address (/tokens/<theme>.<ext>): this theme alone, with the kit's motion and the mark. */++function filesRow(slug) {++ const chips = tokenFiles.FORMATS.map((f) => `<li><a class="tk-file" href="/tokens/${esc(slug)}.${esc(f.ext)}"><b>${esc(f.label)}</b><code>.${esc(f.ext)}</code></a></li>`).join('');++ return `<section class="tk-files" aria-labelledby="tk-files-h"><h2 id="tk-files-h">Files your tools can fetch</h2>++<p class="tk-how">This theme on its own, with the motion every OpenVibe page shares and the mark’s colours. Each one has a lasting address a build or a design tool can read straight from here.</p>++<ul class="tk-file-list">${chips}</ul></section>`;++}++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];@@@ -83,6 +92,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:<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>++${filesRow(slug)}${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>` })}`;}nnew file mode 100644@@@ -0,0 +1,201 @@++'use strict';++/**++ * The token files: GET /tokens/<theme>.<ext> — a theme as the file every tool can read. Every format parses, says the++ * theme's own values and the kit's motion; a built-in theme is immutable and a community one is not; both are open to++ * any origin; a theme or a format that is not there is a 404, and a catalogue theme cannot put code into a file.++ */++const assert = require('assert');++const fs = require('fs');++const { boot, check, done } = require('./helpers/boot');++const { FORMATS } = require('../server/tokens/exports');++++// The kit's own motion, read from the file the kit ships (openvibe-shared/ov-fx.js).++const KIT_CSS = /:root\{([^}]*)\}/.exec(fs.readFileSync(require.resolve('openvibe-shared/ov-fx'), 'utf8'))[1];++const KIT_VARS = Object.fromEntries([...KIT_CSS.matchAll(/(--ov-[\w-]+):([^;]+)/g)].map((m) => [m[1], m[2].trim()]));++const msOf = (v) => { const m = /^([\d.]+)(ms|s)$/.exec(String(v).trim()); return m ? Number(m[1]) * (m[2] === 's' ? 1000 : 1) : NaN; };++const curveOf = (v) => { const m = /cubic-bezier\(([^)]+)\)/.exec(String(v)); return m ? m[1].split(',').map((n) => Number(n.trim())) : null; };++++(async () => {++ const themes = [++ { id: 'aurora', name: 'Aurora', slug: 'aurora', mode: 'dark', description: 'A community theme.', variables: { '--bg-primary': '#101820', '--bg-card': '#16212c', '--text-primary': '#f2f7f5', '--accent': '#00e0a4', '--border': '#243240' } },++ { 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('the default theme is Vibe, and every format is served', async () => {++ const d = await t.get('/tokens/default.tokens.json');++ assert.strictEqual(d.status, 200);++ assert.match(d.headers.get('content-type'), /^application\/json/);++ const doc = d.json();++ assert.strictEqual(doc.$extensions['network.openvibe'].slug, 'vibe');++ assert.strictEqual(doc.color.bg.base.$value, '#0a0f1c');++ for (const f of FORMATS) {++ const r = await t.get(`/tokens/default.${f.ext}`);++ assert.strictEqual(r.status, 200, f.id);++ assert.ok(r.text.length > 40, `${f.id} has content`);++ }++ });++++ await check('the motion tokens are exactly the kit\'s', async () => {++ assert.deepStrictEqual(Object.keys(KIT_VARS).sort(), ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4', '--ov-ease-in-out', '--ov-ease-out', '--ov-ease-spring']);++ assert.strictEqual(msOf(KIT_VARS['--ov-dur-1']), 120);++ assert.strictEqual(msOf(KIT_VARS['--ov-dur-2']), 200);++ assert.strictEqual(msOf(KIT_VARS['--ov-dur-3']), 320);++ assert.strictEqual(msOf(KIT_VARS['--ov-dur-4']), 520);++ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-out']), [0.2, 0.8, 0.2, 1]);++ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-spring']), [0.34, 1.56, 0.64, 1]);++ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-in-out']), [0.65, 0, 0.35, 1]);++ // and our files say the same++ const css = (await t.get('/tokens/vibe.css')).text;++ for (const [name, value] of Object.entries(KIT_VARS)) {++ const ours = new RegExp(`${name}: ([^;]+);`).exec(css);++ assert.ok(ours, `${name} is in the CSS`);++ if (value.startsWith('cubic-bezier')) assert.deepStrictEqual(curveOf(ours[1]), curveOf(value), name);++ else assert.strictEqual(msOf(ours[1]), msOf(value), name);++ }++ const doc = (await t.get('/tokens/vibe.tokens.json')).json();++ assert.strictEqual(doc.motion.duration['1'].$value, '120ms');++ assert.strictEqual(doc.motion.duration['2'].$value, '200ms');++ assert.strictEqual(doc.motion.duration['3'].$value, '320ms');++ assert.strictEqual(doc.motion.duration['4'].$value, '520ms');++ assert.strictEqual(doc.motion.easing.out.$type, 'cubicBezier');++ assert.deepStrictEqual(doc.motion.easing.out.$value, [0.2, 0.8, 0.2, 1]);++ assert.deepStrictEqual(doc.motion.easing.spring.$value, [0.34, 1.56, 0.64, 1]);++ assert.deepStrictEqual(doc.motion.easing['in-out'].$value, [0.65, 0, 0.35, 1]);++ });++++ await check('every format parses and says the theme\'s own values', async () => {++ const doc = (await t.get('/tokens/vibe.tokens.json')).json();++ assert.strictEqual(doc.color.bg.base.$value, '#0a0f1c');++ assert.strictEqual(doc.color.accent.base.$value, '#3b82f6');++ assert.strictEqual(doc.color.text.primary.$value, '#e6edf7');++ assert.strictEqual(doc.brand.o.$value, '#4f7cff');++ assert.strictEqual(doc.brand.v.$value, '#8b5cf6');++ assert.strictEqual(doc.color.bg.base.$extensions['network.openvibe'].css, '--bg-primary');++++ const css = (await t.get('/tokens/vibe.css')).text;++ assert.match(css, /:root \{/);++ assert.match(css, /--bg-primary: #0a0f1c;/);++ assert.match(css, /--accent: #3b82f6;/);++ assert.match(css, /--ov-dur-1: 120ms;/);++ assert.match(css, /--ov-ease-out: cubic-bezier\(0\.2, 0\.8, 0\.2, 1\);/);++ assert.match(css, /--ov-brand-o: #4f7cff;/);++++ const tw = (await t.get('/tokens/vibe.tailwind.js')).text;++ const mod = { exports: {} };++ new Function('module', 'exports', tw)(mod, mod.exports);++ const preset = mod.exports.theme.extend;++ assert.strictEqual(preset.colors.bg.base, '#0a0f1c');++ assert.strictEqual(preset.colors.accent.base, '#3b82f6');++ assert.strictEqual(preset.colors.brand.o, '#4f7cff');++ assert.strictEqual(preset.transitionDuration['3'], '320ms');++ assert.strictEqual(preset.transitionTimingFunction.spring, 'cubic-bezier(0.34, 1.56, 0.64, 1)');++++ const sd = (await t.get('/tokens/vibe.style-dictionary.json')).json();++ assert.strictEqual(sd.color.bg.base.value, '#0a0f1c');++ assert.strictEqual(sd.color.bg.base.type, 'color');++ assert.strictEqual(sd.motion.duration['2'].value, '200ms');++ assert.strictEqual(sd.motion.duration['2'].type, 'duration');++ assert.strictEqual(sd.motion.easing.out.type, 'cubicBezier');++ assert.deepStrictEqual(sd.motion.easing.out.value, [0.2, 0.8, 0.2, 1]);++ assert.strictEqual(sd.brand.v.value, '#8b5cf6');++++ const swift = (await t.get('/tokens/vibe.swift')).text;++ assert.match(swift, /public enum OVTokens \{/);++ assert.match(swift, /public static let bgBase = Color\(hex: 0xFF0A0F1C\)/);++ assert.match(swift, /public static let accentBase = Color\(hex: 0xFF3B82F6\)/);++ assert.match(swift, /public static let duration1: TimeInterval = 0\.12/);++ assert.match(swift, /public static let easeSpring = Animation\.timingCurve\(0\.34, 1\.56, 0\.64, 1\)/);++++ const kt = (await t.get('/tokens/vibe.kt')).text;++ assert.match(kt, /object OVTokens \{/);++ assert.match(kt, /val bgBase = Color\(0xFF0A0F1C\)/);++ assert.match(kt, /const val duration1 = 120L/);++ assert.match(kt, /val easeOut = CubicBezierEasing\(0\.2f, 0\.8f, 0\.2f, 1f\)/);++++ const xml = (await t.get('/tokens/vibe.xml')).text;++ assert.match(xml, /<color name="ov_bg_base">#0A0F1C<\/color>/);++ assert.match(xml, /<color name="ov_accent_base">#3B82F6<\/color>/);++ assert.match(xml, /<color name="ov_brand_v">#8B5CF6<\/color>/);++ assert.match(xml, /<\/resources>\s*$/);++ assert.strictEqual((xml.match(/<color /g) || []).length, (xml.match(/<\/color>/g) || []).length, 'every colour is closed');++ const names = [...xml.matchAll(/name="([^"]*)"/g)].map((m) => m[1]);++ assert.ok(names.length > 10, 'every colour is named');++ for (const n of names) assert.match(n, /^[a-z][a-z0-9_]*$/, n);++ });++++ await check('a community theme is a theme too, by its slug and by its id', async () => {++ const bySlug = await t.get('/tokens/aurora.css');++ assert.strictEqual(bySlug.status, 200);++ assert.match(bySlug.text, /--bg-primary: #101820;/);++ assert.match(bySlug.text, /--accent: #00e0a4;/);++ const byId = await t.get('/tokens/aurora.tokens.json');++ assert.strictEqual(byId.status, 200);++ assert.strictEqual(byId.json().color.bg.base.$value, '#101820');++ });++++ await check('a community theme cannot write into the files people paste', async () => {++ const formats = require('../server/tokens/formats');++ const doc = {++ $extensions: { 'network.openvibe': { name: 'Evil */ body{display:none} /* --> <x>', slug: 'evil', mode: 'dark */ a{}' } },++ color: { bg: { base: { $value: '#101820', $extensions: { 'network.openvibe': { css: '--bg-primary' } } }, bad: { $value: 'red;} body{display:none', $extensions: { 'network.openvibe': { css: '--accent' } } } } },++ };++ const out = formats.css(doc);++ const first = out.split('\n')[0];++ assert.strictEqual(out.indexOf('*/'), first.length - 2, 'the name stays inside its comment: the first */ is the one that ends the line');++ assert.ok(!out.includes('red;}'), 'a value cannot close the rule');++ assert.match(out, /^\/\* Evil \* \/ body\{display:none\} \/\* - -> <x> \(dark\)/, 'the name is kept, harmless');++ assert.match(out, /--bg-primary: #101820;/, 'a real colour passes');++ assert.ok(!/--accent:/.test(out), 'anything that is not a colour is left out');++ assert.ok(!formats.androidXml(doc).includes('--> <x>'), 'nor can it close an XML comment');++ });++ await check('a built-in theme is immutable, a community one is not, and both are open to any origin', async () => {++ const b = await t.get('/tokens/vibe.css');++ assert.strictEqual(b.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ assert.strictEqual(b.headers.get('access-control-allow-origin'), '*');++ const c = await t.get('/tokens/aurora.css');++ assert.strictEqual(c.status, 200);++ assert.match(c.headers.get('cache-control'), /^public, max-age=\d+$/);++ assert.doesNotMatch(c.headers.get('cache-control'), /immutable/);++ assert.strictEqual(c.headers.get('access-control-allow-origin'), '*');++ });++++ await check('the /tokens page links every file for the theme it shows', async () => {++ const html = (await t.get('/tokens?theme=aurora')).text;++ const fs = require('fs'); const path = require('path');++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/views/tokens.js'), 'utf8'), /filesRow\(slug\)/, 'the page draws the row');++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/http/tokens.js'), 'utf8'), /Access-Control-Allow-Origin/, 'files open to any origin');++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/app.js'), 'utf8'), /createTokenFileRoutes/, 'mounted');++ assert.ok(html.includes('Files your tools can fetch'));++ ['tokens.json', 'css', 'tailwind.js', 'style-dictionary.json', 'swift', 'kt', 'xml'].forEach((ext) => assert.ok(html.includes(`href="/tokens/aurora.${ext}"`), ext));++ });++ await check('an unknown theme or an unknown format is a 404', async () => {++ assert.strictEqual((await t.get('/tokens/nope.css')).status, 404);++ assert.strictEqual((await t.get('/tokens/nope.tokens.json')).status, 404);++ assert.strictEqual((await t.get('/tokens/vibe.cobol')).status, 404);++ assert.strictEqual((await t.get('/tokens/vibe')).status, 404);++ });++++ await check('a catalogue theme cannot put code into a file', async () => {++ for (const f of FORMATS) {++ const r = await t.get(`/tokens/evil.${f.ext}`);++ assert.strictEqual(r.status, 200, f.id);++ assert.doesNotMatch(r.text, /evil\.example/, `${f.id}: the bad value is dropped`);++ }++ const swift = (await t.get('/tokens/evil.swift')).text.replace(/\/\/[^\n]*/g, '');++ assert.doesNotMatch(swift, /import os|fun pwn/);++ const kt = (await t.get('/tokens/evil.kt')).text.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');++ assert.doesNotMatch(kt, /import os|fun pwn/);++ });++++ await check('the format list is the one a page lists', async () => {++ assert.deepStrictEqual(FORMATS.map((f) => f.id), ['tokens', 'css', 'tailwind', 'style-dictionary', 'swift', 'kotlin', 'android']);++ for (const f of FORMATS) {++ assert.ok(f.ext && f.label && f.type, f.id);++ assert.match(f.ext, /^[a-z0-9.-]+$/, f.id);++ }++ });++++ await t.close();++ done();++})();
@@ -88,6 +88,26 @@ All of them are server-rendered through the OpenVibe Frame (`openvibe-shared/she
@@ -88,6 +88,26 @@ All of them are server-rendered through the OpenVibe Frame (`openvibe-shared/she
JavaScript. A page rendered for a signed-in person is `private, no-store`; a public page is
JavaScript. A page rendered for a signed-in person is `private, no-store`; a public page is
`public, max-age=120` with `Vary: Cookie`.
`public, max-age=120` with `Vary: Cookie`.
+## Token files
+
+`GET /tokens/<theme>.<ext>` gives one theme as the file a tool reads. `<theme>` is a built-in theme or a
+community theme from the catalogue (`default` is Vibe); `<ext>` is the format:
+
+| Route | What it is |
+|---|---|
+| `/tokens/<theme>.tokens.json` | the W3C Design Tokens document: `color`, `motion.duration`, `motion.easing`, `brand` |
+| `/tokens/<theme>.css` | `:root { … }` with every variable |
+| `/tokens/<theme>.tailwind.js` | a Tailwind preset (`theme.extend.colors`, `transitionDuration`, `transitionTimingFunction`) |
+| `/tokens/<theme>.style-dictionary.json` | the Style Dictionary source |
+| `/tokens/<theme>.swift` | a Swift enum `OVTokens` (SwiftUI) |
+| `/tokens/<theme>.kt` | a Kotlin object `OVTokens` (Jetpack Compose) |
+| `/tokens/<theme>.xml` | an Android `colors.xml` |
+
+Every file carries the theme's colours, the kit's motion (`--ov-dur-1…4` and the three curves, from
+`openvibe-shared/ov-fx`) and the mark's colours. A built-in theme does not change, so its file is
+`public, max-age=31536000, immutable`; a community theme may be edited, so its file is cached for ten
+minutes. Every file is `Access-Control-Allow-Origin: *`. A theme or a format that is not there is a 404.
+
## API
## API
### MCP
### MCP
@@ -37,6 +37,8 @@
@@ -37,6 +37,8 @@
"Design Studio chat edit: POST /api/v1/designs/:id/edit, the picked variant's model via OpenVibe.AI, the previous HTML in context, theme-token re-wrapping, sanitization and the render-time frame CSP; a small per-design paid-AI budget",
"Design Studio chat edit: POST /api/v1/designs/:id/edit, the picked variant's model via OpenVibe.AI, the previous HTML in context, theme-token re-wrapping, sanitization and the render-time frame CSP; a small per-design paid-AI budget",
"Design Studio click edit: a render-time iframe overlay reports a validated body > …:nth-of-type path; the floating panel edits text, a theme-token colour, padding and alignment; paths, tags and CSS values are allowlisted server-side and every accepted change is a version",
"Design Studio click edit: a render-time iframe overlay reports a validated body > …:nth-of-type path; the floating panel edits text, a theme-token colour, padding and alignment; paths, tags and CSS values are allowlisted server-side and every accepted change is a version",
"Design Studio versions: migrations/0003_design_working_versions.sql keeps design_picks and an append-only design_versions table with model/provider provenance; undo/redo move a pointer and an edit after undo appends instead of deleting",
"Design Studio versions: migrations/0003_design_working_versions.sql keeps design_picks and an append-only design_versions table with model/provider provenance; undo/redo move a pointer and an edit after undo appends instead of deleting",
+ "The token files: GET /tokens/<theme>.<ext> gives a built-in or community theme (default is Vibe) as the W3C Design Tokens JSON, the CSS variables, a Tailwind preset, the Style Dictionary source, a Swift enum OVTokens, a Kotlin object OVTokens or an Android colors.xml, with the theme's colours, the kit's motion (--ov-dur-1...4 and the three curves) and the mark's colours; built-in themes immutable for a year, community themes cached for ten minutes, every file open to any origin, an unknown theme or format a 404",
+ "The format list (server/tokens/exports.js) is what a page lists: each format with its id, file name, label and type",
"Tests: pick/vote idempotence, owner and guest-key authorization, chat edit with a fake AI and budget, click-edit safety, CSP after editing, version history/undo/redo and the no-JS page forms"
"Tests: pick/vote idempotence, owner and guest-key authorization, chat edit with a fake AI and budget, click-edit safety, CSP after editing, version history/undo/redo and the no-JS page forms"
],
],
"notYet": [
"notYet": [
@@ -65,7 +67,8 @@
@@ -65,7 +67,8 @@
"Design Studio pick and Arena vote",
"Design Studio pick and Arena vote",
"Design Studio chat edit",
"Design Studio chat edit",
"Design Studio click edit",
"Design Studio click edit",
- "Design Studio versions and undo/redo"
+ "Design Studio versions and undo/redo",
+ "Design tokens as files (JSON, CSS, Tailwind, Style Dictionary, Swift, Kotlin, Android XML)"
],
],
"updated": "2026-10-31"
"updated": "2026-10-31"
}
}
@@ -366,6 +366,14 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -366,6 +366,14 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
/* ── /tokens: a theme as code (views/tokens.js) ── */
/* ── /tokens: a theme as code (views/tokens.js) ── */
.tk { max-width: 980px; }
.tk { max-width: 980px; }
+.tk-files { margin: 22px 0 8px; }
+.tk-files h2 { margin: 0 0 6px; font-size: 1.05rem; }
+.tk-file-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.tk-file { display: grid; gap: 2px; min-height: 44px; align-content: center; padding: 6px 12px; border: 1px solid var(--ovk-line); border-radius: 12px; color: inherit; text-decoration: none; }
+.tk-file b { font-size: 13.5px; }
+.tk-file code { font-size: 11.5px; color: var(--ovk-muted); background: none; padding: 0; }
+.tk-file:hover { border-color: color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 55%, var(--ovk-line)); }
+.tk-file:focus-visible { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 2px; }
.tk-pick { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 18px 0 12px; }
.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-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-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; }
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*
*
* /, /studio, /t/:slug, /arena/*, /developers the pages (http/pages.js)
* /, /studio, /t/:slug, /arena/*, /developers the pages (http/pages.js)
* /api/v1/arena/* the Arena API (http/arena-api.js)
* /api/v1/arena/* the Arena API (http/arena-api.js)
+ * /tokens/<theme>.<ext> a theme as the file every tool can read (http/tokens.js)
* /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)
* /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /internal/events ADR-033 account export/deletion (identity/account-data.js)
* /internal/events ADR-033 account export/deletion (identity/account-data.js)
@@ -37,6 +38,7 @@ const { createArenaApi } = require('./http/arena-api');
@@ -37,6 +38,7 @@ const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createTokenFileRoutes } = require('./http/tokens');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createIconRoutes } = require('./http/icons');
const { createIconRoutes } = require('./http/icons');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
@@ -180,6 +182,9 @@ async function createApp(opts = {}) {
@@ -180,6 +182,9 @@ async function createApp(opts = {}) {
// ── Crawl artifacts ─────────────────────────────────────
// ── Crawl artifacts ─────────────────────────────────────
app.use(createDiscoveryRoutes(ctx));
app.use(createDiscoveryRoutes(ctx));
+ // ── The token files: /tokens/<theme>.<ext> (a theme as the file every tool can read) ──
+ app.use('/tokens', createTokenFileRoutes(ctx));
+
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * The token files: GET /tokens/<theme>.<ext> — a theme's colours, the kit's motion and the mark's colours, as the file
+ * each tool reads (the DTCG JSON, the CSS, the Tailwind preset, the Style Dictionary source, the Swift, the Kotlin, the
+ * Android XML).
+ *
+ * A built-in theme does not change, so its file is immutable for a year; a community theme may be edited, so its file
+ * is cached for ten minutes. Every file is open to any origin: a design tool fetches it from its own page.
+ */
+const { asyncRouter } = require('./router');
+const tokenFiles = require('../tokens/exports');
+
+const IMMUTABLE = 'public, max-age=31536000, immutable';
+const SHORT = 'public, max-age=600';
+
+function createTokenFileRoutes(ctx) {
+ const { catalog, log } = ctx;
+ const r = asyncRouter();
+
+ r.get('/:file', async (req, res) => {
+ const parsed = tokenFiles.parse(req.params.file);
+ if (!parsed) return missing(res);
+ let list = [];
+ try {
+ list = (await catalog.list()).themes || [];
+ } catch (err) {
+ // The built-in themes still answer; only a community theme needs the catalogue.
+ log.warn('[OpenVibe.Design] token file: the catalogue did not answer:', (err && err.message) || err);
+ }
+ const found = tokenFiles.findTheme(list, parsed.themeId);
+ if (!found) return missing(res);
+ const body = tokenFiles.render(parsed.format.id, found.theme);
+ if (!body) return missing(res);
+ res.set('Cache-Control', found.builtin ? IMMUTABLE : SHORT);
+ res.set('Access-Control-Allow-Origin', '*');
+ res.type(`${parsed.format.type}; charset=utf-8`);
+ return res.send(body);
+ });
+
+ return r;
+}
+
+function missing(res) {
+ return res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('No such theme file.');
+}
+
+module.exports = { createTokenFileRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * The token files a tool can fetch: GET /tokens/<theme>.<ext>. One theme — a built-in or a community one — as the file
+ * each tool reads: the DTCG JSON, the CSS variables, the Tailwind preset, the Style Dictionary source, the Swift, the
+ * Kotlin and the Android XML.
+ *
+ * The list of formats is here so a page can name them all (the format, its file name and what it is).
+ *
+ * FORMATS [{ id, ext, label, type }]
+ * parse(file) a file name → { format, themeId } (null when the format is not one of ours)
+ * findTheme(list, id) a built-in theme, or a community one from the catalogue; null when there is none
+ * render(format, theme) the file's source text
+ */
+const { BUILTIN_THEMES, deriveTokens } = require('openvibe-shared/builtin-themes');
+const kit = require('./kit');
+const formats = require('./formats');
+
+// The formats, the file name each one answers to, and what it is. The order is the order a page lists them in.
+const FORMATS = [
+ { id: 'tokens', ext: 'tokens.json', label: 'Design tokens (JSON)', type: 'application/json' },
+ { id: 'css', ext: 'css', label: 'CSS variables', type: 'text/css' },
+ { id: 'tailwind', ext: 'tailwind.js', label: 'Tailwind preset', type: 'text/javascript' },
+ { id: 'style-dictionary', ext: 'style-dictionary.json', label: 'Style Dictionary', type: 'application/json' },
+ { id: 'swift', ext: 'swift', label: 'iOS (Swift)', type: 'text/plain' },
+ { id: 'kotlin', ext: 'kt', label: 'Android (Kotlin)', type: 'text/plain' },
+ { id: 'android', ext: 'xml', label: 'Android (colors.xml)', type: 'application/xml' },
+];
+
+const BUILDERS = {
+ tokens: formats.tokensJson,
+ css: formats.css,
+ tailwind: formats.tailwind,
+ 'style-dictionary': formats.styleDictionary,
+ swift: formats.swift,
+ kotlin: formats.kotlin,
+ android: formats.androidXml,
+};
+
+// The longest file name first, so "style-dictionary.json" is not read as "json".
+const BY_EXT = FORMATS.slice().sort((a, b) => b.ext.length - a.ext.length);
+
+/** A file name → the format it is and the theme it is for. Null when the format is not one of ours. */
+function parse(file) {
+ const name = String(file || '').trim();
+ if (!name) return null;
+ for (const f of BY_EXT) {
+ const suffix = `.${f.ext}`;
+ if (name.length > suffix.length && name.toLowerCase().endsWith(suffix.toLowerCase())) {
+ return { format: f, themeId: name.slice(0, name.length - suffix.length) };
+ }
+ }
+ return null;
+}
+
+/**
+ * A theme by its id or slug: a built-in one first (the network's own list, unchanged), then a community one from the
+ * catalogue with the readable text and status colours the kit derives. "default" is Vibe.
+ */
+function findTheme(list, idOrSlug) {
+ const key = String(idOrSlug || '').trim().toLowerCase();
+ if (!key) return null;
+ const wanted = key === 'default' ? 'vibe' : key;
+ const builtin = BUILTIN_THEMES.find((t) => t && (String(t.id).toLowerCase() === wanted || String(t.slug || '').toLowerCase() === wanted));
+ if (builtin) return { theme: builtin, builtin: true };
+ const community = (Array.isArray(list) ? list : []).find((t) => t && (
+ String(t.slug || '').toLowerCase() === wanted || String(t.id || '').toLowerCase() === wanted));
+ if (!community) return null;
+ const mode = community.mode === 'light' ? 'light' : 'dark';
+ return {
+ theme: { ...community, slug: community.slug || String(community.id || ''), mode, variables: deriveTokens({ ...(community.variables || {}) }, mode) },
+ builtin: false,
+ };
+}
+
+/** The file's source text for a theme. Null when the format is not one of ours. */
+function render(formatId, theme) {
+ const build = BUILDERS[formatId];
+ if (!build || !theme) return null;
+ return build(kit.tokenDocument(theme));
+}
+
+/** The format a page can list, with the file name each one is fetched by. */
+function list() {
+ return FORMATS.map(({ id, ext, label, type }) => ({ id, ext, label, type }));
+}
+
+module.exports = { FORMATS, list, parse, findTheme, render, MOTION: kit.MOTION, BRAND: kit.BRAND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
+'use strict';
+/**
+ * The compilers: the token set as the file one tool reads. Each takes the token document and writes the source text
+ * for one format — no network, no disk, no state.
+ *
+ * tokensJson(doc) the DTCG document (JSON)
+ * css(doc) :root { … } with every variable
+ * tailwind(doc) a Tailwind preset (module.exports = { theme: { extend: { … } } })
+ * styleDictionary(doc) the Style Dictionary source
+ * swift(doc) a Swift enum OVTokens (SwiftUI)
+ * kotlin(doc) a Kotlin object OVTokens (Jetpack Compose)
+ * androidXml(doc) an Android colors.xml
+ */
+const tokens = require('openvibe-shared/tokens');
+const { MOTION, BRAND, colourList } = require('./kit');
+
+const metaOf = (doc) => (doc && doc.$extensions && doc.$extensions['network.openvibe']) || { name: 'Theme', slug: 'theme', mode: 'dark' };
+const camel = (path) => String(path).split('.').map((p, i) => (i ? p[0].toUpperCase() + p.slice(1) : p)).join('').replace(/[^A-Za-z0-9]/g, '');
+// Android resource names: letters, digits and underscores only.
+const snake = (path) => String(path).split('.').map((p) => p.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()).join('_');
+const hex2 = (n) => n.toString(16).padStart(2, '0').toUpperCase();
+
+/** A colour as 0xAARRGGBB and as #RRGGBB (opaque) or #AARRGGBB (not): null when it cannot be read. */
+function argb(value) {
+ const c = tokens.parseColor(value);
+ if (!c) return null;
+ const a = Math.round(c.a * 255);
+ return { opaque: c.a >= 1, argb: `${hex2(a)}${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`, rgb: `${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}` };
+}
+
+const CSS_NAME = /^--[a-z0-9-]{1,64}$/;
+const CSS_COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|hsla?\([0-9.,\s%deg]+\))$/i;
+
+// Text for a comment: one line, no comment terminators, nothing XML would refuse.
+const safe = (s) => String(s == null ? '' : s)
+ .replace(/[\r\n\u2028\u2029]+/g, ' ')
+ .replace(/\*\//g, '* /')
+ .replace(/--/g, '- -')
+ .replace(/[^\x20-\x7e\u00a0-\uffff]/g, '')
+ .trim()
+ .slice(0, 160);
+
+const header = (doc, kind) => {
+ const m = metaOf(doc);
+ const line = `${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}) — OpenVibe design tokens, from openvibe.design. Regenerate instead of editing.`;
+ return kind === 'xml' ? `<!-- ${line} -->` : `// ${line}`;
+};
+
+/** The DTCG document, as JSON. */
+function tokensJson(doc) {
+ return `${JSON.stringify(doc, null, 2)}\n`;
+}
+
+/** :root { … } — the theme's colours by the network's own names, the kit's motion, the mark's colours. */
+function css(doc) {
+ const m = metaOf(doc);
+ const lines = [];
+ lines.push(` /* the theme's colours */`);
+ // A community theme's values reach a stylesheet people paste: only a custom property's name and a colour pass.
+ for (const c of colourList(doc)) if (CSS_NAME.test(c.css) && CSS_COLOUR.test(String(c.value))) lines.push(` ${c.css}: ${c.value};`);
+ lines.push(` /* the kit's motion (openvibe-shared/ov-fx) */`);
+ for (const d of MOTION.durations) lines.push(` ${d.css}: ${d.ms}ms;`);
+ for (const e of MOTION.easings) lines.push(` ${e.css}: cubic-bezier(${e.value.join(', ')});`);
+ lines.push(` /* the mark */`);
+ for (const b of BRAND) lines.push(` ${b.css}: ${b.value};`);
+ lines.push(` color-scheme: ${m.mode === 'light' ? 'light' : 'dark'};`);
+ return `/* ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}) — OpenVibe design tokens, from openvibe.design. Regenerate instead of editing. */\n:root {\n${lines.join('\n')}\n}\n`;
+}
+
+/** A Tailwind preset: the colours, the durations and the curves, under theme.extend. */
+function tailwind(doc) {
+ const colors = {};
+ for (const c of colourList(doc)) {
+ const parts = c.path.replace(/^color\./, '').split('.');
+ let o = colors;
+ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});
+ o[parts[parts.length - 1]] = c.value;
+ }
+ for (const b of BRAND) {
+ colors.brand = colors.brand || {};
+ colors.brand[b.id] = b.value;
+ }
+ const transitionDuration = {};
+ for (const d of MOTION.durations) transitionDuration[d.id] = `${d.ms}ms`;
+ const transitionTimingFunction = {};
+ for (const e of MOTION.easings) transitionTimingFunction[e.id] = `cubic-bezier(${e.value.join(', ')})`;
+ const config = { theme: { extend: { colors, transitionDuration, transitionTimingFunction } } };
+ return `${header(doc, 'js')}\nmodule.exports = ${JSON.stringify(config, null, 4)};\n`;
+}
+
+/** The Style Dictionary source: value and type for every token, under its path. */
+function styleDictionary(doc) {
+ const out = {};
+ const put = (obj, path, leaf) => {
+ const parts = String(path).split('.');
+ let o = obj;
+ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});
+ o[parts[parts.length - 1]] = leaf;
+ };
+ for (const c of colourList(doc)) put(out, c.path, { value: c.value, type: 'color', description: c.description });
+ for (const d of MOTION.durations) put(out, `motion.duration.${d.id}`, { value: `${d.ms}ms`, type: 'duration', description: d.description });
+ for (const e of MOTION.easings) put(out, `motion.easing.${e.id}`, { value: e.value.slice(), type: 'cubicBezier', description: e.description });
+ for (const b of BRAND) put(out, `brand.${b.id}`, { value: b.value, type: 'color', description: b.description });
+ return `${JSON.stringify(out, null, 2)}\n`;
+}
+
+/** SwiftUI: an enum OVTokens of colours (from hex) and the kit's motion (TimeInterval, in seconds). */
+function swift(doc) {
+ const m = metaOf(doc);
+ const lines = [];
+ lines.push(header(doc, 'swift'));
+ lines.push('import SwiftUI');
+ lines.push('');
+ lines.push('extension Color {');
+ lines.push(' /// A colour from a 0xAARRGGBB literal (opaque when the alpha byte is FF).');
+ lines.push(' init(hex: UInt32) {');
+ lines.push(' self.init(.sRGB,');
+ lines.push(' red: Double((hex >> 16) & 0xFF) / 255,');
+ lines.push(' green: Double((hex >> 8) & 0xFF) / 255,');
+ lines.push(' blue: Double(hex & 0xFF) / 255,');
+ lines.push(' opacity: Double((hex >> 24) & 0xFF) / 255)');
+ lines.push(' }');
+ lines.push('}');
+ lines.push('');
+ lines.push(`/// OpenVibe's tokens for ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}).`);
+ lines.push('public enum OVTokens {');
+ for (const c of colourList(doc)) {
+ const a = argb(c.value);
+ if (!a) continue;
+ lines.push(` /// ${safe(c.description)}`);
+ lines.push(` public static let ${camel(c.path.replace(/^color\./, ''))} = Color(hex: 0x${a.argb})`);
+ }
+ for (const d of MOTION.durations) {
+ lines.push(` /// The kit's motion: ${d.css} (${d.ms} ms).`);
+ lines.push(` public static let ${d.name}: TimeInterval = ${d.ms / 1000}`);
+ }
+ for (const e of MOTION.easings) {
+ lines.push(` /// The kit's motion: ${e.css}.`);
+ lines.push(` public static let ${e.name} = Animation.timingCurve(${e.value.join(', ')})`);
+ }
+ for (const b of BRAND) {
+ lines.push(` /// ${safe(b.description)}`);
+ lines.push(` public static let ${b.name} = Color(hex: 0x${argb(b.value).argb})`);
+ }
+ lines.push('}');
+ return `${lines.join('\n')}\n`;
+}
+
+/** Jetpack Compose: an object OVTokens of colours (Color(0xFF…)) and the kit's motion (milliseconds). */
+function kotlin(doc) {
+ const m = metaOf(doc);
+ const lines = [];
+ lines.push(header(doc, 'kotlin'));
+ lines.push('package network.openvibe.tokens');
+ lines.push('');
+ lines.push('import androidx.compose.animation.core.CubicBezierEasing');
+ lines.push('import androidx.compose.ui.graphics.Color');
+ lines.push('');
+ lines.push(`/** OpenVibe's tokens for ${safe(m.name)} (${m.mode === 'light' ? 'light' : 'dark'}). */`);
+ lines.push('object OVTokens {');
+ for (const c of colourList(doc)) {
+ const a = argb(c.value);
+ if (!a) continue;
+ lines.push(` /** ${safe(c.description)} */`);
+ lines.push(` val ${camel(c.path.replace(/^color\./, ''))} = Color(0x${a.argb})`);
+ }
+ for (const d of MOTION.durations) {
+ lines.push(` /** The kit's motion: ${d.css} (${d.ms} ms). */`);
+ lines.push(` const val ${d.name} = ${d.ms}L`);
+ }
+ for (const e of MOTION.easings) {
+ lines.push(` /** The kit's motion: ${e.css}. */`);
+ lines.push(` val ${e.name} = CubicBezierEasing(${e.value.map((n) => `${n}f`).join(', ')})`);
+ }
+ for (const b of BRAND) {
+ lines.push(` /** ${safe(b.description)} */`);
+ lines.push(` val ${b.name} = Color(0x${argb(b.value).argb})`);
+ }
+ lines.push('}');
+ return `${lines.join('\n')}\n`;
+}
+
+/** Android: a colors.xml. */
+function androidXml(doc) {
+ const m = metaOf(doc);
+ const lines = [];
+ lines.push('<?xml version="1.0" encoding="utf-8"?>');
+ lines.push(header(doc, 'xml'));
+ lines.push('<resources>');
+ for (const c of colourList(doc)) {
+ const a = argb(c.value);
+ if (!a) continue;
+ lines.push(` <!-- ${safe(c.description)} -->`);
+ lines.push(` <color name="ov_${snake(c.path.replace(/^color\./, ''))}">#${a.opaque ? a.rgb : a.argb}</color>`);
+ }
+ for (const b of BRAND) {
+ lines.push(` <!-- ${safe(b.description)} -->`);
+ lines.push(` <color name="ov_brand_${b.id}">#${argb(b.value).rgb}</color>`);
+ }
+ lines.push('</resources>');
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = { tokensJson, css, tailwind, styleDictionary, swift, kotlin, androidXml, argb };
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * The OpenVibe token set: a theme's colours, the kit's motion and the mark's two colours — one set, whatever the tool
+ * that reads it. The same colours, the same durations and the same curves come out of the JSON, the CSS, the Tailwind
+ * preset, the Style Dictionary source, the Swift, the Kotlin and the Android XML.
+ *
+ * The motion is the kit's (openvibe-shared/ov-fx.js): --ov-dur-1…4 and the three curves, the same on every theme.
+ * The colours come from the theme and are read through openvibe-shared/tokens' parser, so a value it cannot read is
+ * left out rather than carried into code someone will paste.
+ *
+ * colours(theme) the theme's colours: [{ path, css, value, description }]
+ * tokenDocument(theme) the W3C Design Tokens document (DTCG): color, motion.duration, motion.easing, brand
+ * colourList(doc) the colours of a document, with the CSS name each one answers to
+ */
+const tokens = require('openvibe-shared/tokens');
+
+// The kit's motion — openvibe-shared/ov-fx.js: :root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}
+const MOTION = {
+ durations: [
+ { id: '1', name: 'duration1', ms: 120, css: '--ov-dur-1', description: 'A quick answer' },
+ { id: '2', name: 'duration2', ms: 200, css: '--ov-dur-2', description: 'A change of state' },
+ { id: '3', name: 'duration3', ms: 320, css: '--ov-dur-3', description: 'A lift or a glow' },
+ { id: '4', name: 'duration4', ms: 520, css: '--ov-dur-4', description: 'The slow way back' },
+ ],
+ easings: [
+ { id: 'out', name: 'easeOut', value: [0.2, 0.8, 0.2, 1], css: '--ov-ease-out', description: 'Leaves fast, settles slow' },
+ { id: 'spring', name: 'easeSpring', value: [0.34, 1.56, 0.64, 1], css: '--ov-ease-spring', description: 'Overshoots, then comes back' },
+ { id: 'in-out', name: 'easeInOut', value: [0.65, 0, 0.35, 1], css: '--ov-ease-in-out', description: 'Even on both sides' },
+ ],
+};
+
+// The mark's own two colours (openvibe-shared/ov-fx.js names them as the mark's O and V).
+const BRAND = [
+ { id: 'o', name: 'brandO', value: '#4f7cff', css: '--ov-brand-o', description: "The mark's O" },
+ { id: 'v', name: 'brandV', value: '#8b5cf6', css: '--ov-brand-v', description: "The mark's V" },
+];
+
+const at = (obj, path) => {
+ let o = obj;
+ for (const p of String(path || '').split('.')) {
+ if (!o || typeof o !== 'object') return null;
+ o = o[p];
+ }
+ return o && typeof o === 'object' ? o : null;
+};
+
+const put = (obj, path, leaf) => {
+ const parts = String(path).split('.');
+ let o = obj;
+ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});
+ o[parts[parts.length - 1]] = leaf;
+};
+
+/** The theme's colours, read through the shared parser: an unreadable value is left out, never passed through. */
+function colours(theme) {
+ const doc = tokens.fromTheme(theme || {});
+ const out = [];
+ for (const [css, path, description] of tokens.COLOR_MAP) {
+ const token = at(doc, path);
+ if (!token || !token.$value) continue;
+ out.push({ path, css, value: token.$value, description, type: 'color' });
+ }
+ return out;
+}
+
+/** The token document (DTCG): the groups a tool reads — color, motion.duration, motion.easing, brand. */
+function tokenDocument(theme) {
+ const t = theme || {};
+ const mode = t.mode === 'light' ? 'light' : 'dark';
+ const doc = {
+ $description: String(t.description || t.name || 'An OpenVibe theme'),
+ $extensions: {
+ 'network.openvibe': {
+ name: String(t.name || 'Theme'),
+ slug: String(t.slug || t.id || 'theme'),
+ mode,
+ generator: 'openvibe.design',
+ },
+ },
+ color: {},
+ motion: { duration: {}, easing: {} },
+ brand: {},
+ };
+ for (const c of colours(t)) {
+ put(doc.color, c.path.replace(/^color\./, ''), {
+ $type: 'color',
+ $value: c.value,
+ $description: c.description,
+ $extensions: { 'network.openvibe': { css: c.css } },
+ });
+ }
+ for (const d of MOTION.durations) {
+ doc.motion.duration[d.id] = {
+ $type: 'duration',
+ $value: `${d.ms}ms`,
+ $description: d.description,
+ $extensions: { 'network.openvibe': { css: d.css } },
+ };
+ }
+ for (const e of MOTION.easings) {
+ doc.motion.easing[e.id] = {
+ $type: 'cubicBezier',
+ $value: e.value.slice(),
+ $description: e.description,
+ $extensions: { 'network.openvibe': { css: e.css } },
+ };
+ }
+ for (const b of BRAND) {
+ doc.brand[b.id] = {
+ $type: 'color',
+ $value: b.value,
+ $description: b.description,
+ $extensions: { 'network.openvibe': { css: b.css } },
+ };
+ }
+ return doc;
+}
+
+/** The colours of a document, with the CSS name each one answers to. */
+function colourList(doc) {
+ const out = [];
+ const walk = (o, path) => {
+ for (const [k, v] of Object.entries(o || {})) {
+ if (!v || typeof v !== 'object' || k.startsWith('$')) continue;
+ if ('$value' in v) {
+ const ext = (v.$extensions && v.$extensions['network.openvibe']) || {};
+ out.push({ path: path.concat(k).join('.'), css: ext.css || '', value: v.$value, description: v.$description || '' });
+ } else walk(v, path.concat(k));
+ }
+ };
+ walk((doc && doc.color) || {}, ['color']);
+ return out;
+}
+
+module.exports = { MOTION, BRAND, colours, tokenDocument, colourList };
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
+const tokenFiles = require('../tokens/exports');
const SHORT = { css: 'CSS', tailwind: 'Tailwind', compose: 'Android', swiftui: 'iOS', flutter: 'Flutter', figma: 'Figma', json: 'JSON' };
const SHORT = { css: 'CSS', tailwind: 'Tailwind', compose: 'Android', swiftui: 'iOS', flutter: 'Flutter', figma: 'Figma', json: 'JSON' };
@@ -63,6 +64,14 @@ function tokenTable(found) {
@@ -63,6 +64,14 @@ function tokenTable(found) {
<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>`;
<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>`;
}
}
+/** Files a tool can fetch by address (/tokens/<theme>.<ext>): this theme alone, with the kit's motion and the mark. */
+function filesRow(slug) {
+ const chips = tokenFiles.FORMATS.map((f) => `<li><a class="tk-file" href="/tokens/${esc(slug)}.${esc(f.ext)}"><b>${esc(f.label)}</b><code>.${esc(f.ext)}</code></a></li>`).join('');
+ return `<section class="tk-files" aria-labelledby="tk-files-h"><h2 id="tk-files-h">Files your tools can fetch</h2>
+<p class="tk-how">This theme on its own, with the motion every OpenVibe page shares and the mark’s colours. Each one has a lasting address a build or a design tool can read straight from here.</p>
+<ul class="tk-file-list">${chips}</ul></section>`;
+}
+
function tokensPage({ themes = [], found = null, format = 'css', both = true } = {}) {
function tokensPage({ themes = [], found = null, format = 'css', both = true } = {}) {
const list = (themes || []).filter((t) => t && (t.slug || t.id));
const list = (themes || []).filter((t) => t && (t.slug || t.id));
const target = themeTokens.TARGETS.find((t) => t.id === format) || themeTokens.TARGETS[0];
const target = themeTokens.TARGETS.find((t) => t.id === format) || themeTokens.TARGETS[0];
@@ -83,6 +92,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:
@@ -83,6 +92,7 @@ ${kit.tabs({ base, param: 'format', active: target.id, label: 'Platform', items:
<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>
<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>
<p class="tk-how">${howTo(target.id, name, pair)}</p>
${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>
${kit.snippet({ code, id: 'tk-code', label: 'Copy the code' })}</section>
+${filesRow(slug)}
${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-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>` })}`;
${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>` })}`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+'use strict';
+/**
+ * The token files: GET /tokens/<theme>.<ext> — a theme as the file every tool can read. Every format parses, says the
+ * theme's own values and the kit's motion; a built-in theme is immutable and a community one is not; both are open to
+ * any origin; a theme or a format that is not there is a 404, and a catalogue theme cannot put code into a file.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const { boot, check, done } = require('./helpers/boot');
+const { FORMATS } = require('../server/tokens/exports');
+
+// The kit's own motion, read from the file the kit ships (openvibe-shared/ov-fx.js).
+const KIT_CSS = /:root\{([^}]*)\}/.exec(fs.readFileSync(require.resolve('openvibe-shared/ov-fx'), 'utf8'))[1];
+const KIT_VARS = Object.fromEntries([...KIT_CSS.matchAll(/(--ov-[\w-]+):([^;]+)/g)].map((m) => [m[1], m[2].trim()]));
+const msOf = (v) => { const m = /^([\d.]+)(ms|s)$/.exec(String(v).trim()); return m ? Number(m[1]) * (m[2] === 's' ? 1000 : 1) : NaN; };
+const curveOf = (v) => { const m = /cubic-bezier\(([^)]+)\)/.exec(String(v)); return m ? m[1].split(',').map((n) => Number(n.trim())) : null; };
+
+(async () => {
+ const themes = [
+ { id: 'aurora', name: 'Aurora', slug: 'aurora', mode: 'dark', description: 'A community theme.', variables: { '--bg-primary': '#101820', '--bg-card': '#16212c', '--text-primary': '#f2f7f5', '--accent': '#00e0a4', '--border': '#243240' } },
+ { 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('the default theme is Vibe, and every format is served', async () => {
+ const d = await t.get('/tokens/default.tokens.json');
+ assert.strictEqual(d.status, 200);
+ assert.match(d.headers.get('content-type'), /^application\/json/);
+ const doc = d.json();
+ assert.strictEqual(doc.$extensions['network.openvibe'].slug, 'vibe');
+ assert.strictEqual(doc.color.bg.base.$value, '#0a0f1c');
+ for (const f of FORMATS) {
+ const r = await t.get(`/tokens/default.${f.ext}`);
+ assert.strictEqual(r.status, 200, f.id);
+ assert.ok(r.text.length > 40, `${f.id} has content`);
+ }
+ });
+
+ await check('the motion tokens are exactly the kit\'s', async () => {
+ assert.deepStrictEqual(Object.keys(KIT_VARS).sort(), ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4', '--ov-ease-in-out', '--ov-ease-out', '--ov-ease-spring']);
+ assert.strictEqual(msOf(KIT_VARS['--ov-dur-1']), 120);
+ assert.strictEqual(msOf(KIT_VARS['--ov-dur-2']), 200);
+ assert.strictEqual(msOf(KIT_VARS['--ov-dur-3']), 320);
+ assert.strictEqual(msOf(KIT_VARS['--ov-dur-4']), 520);
+ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-out']), [0.2, 0.8, 0.2, 1]);
+ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-spring']), [0.34, 1.56, 0.64, 1]);
+ assert.deepStrictEqual(curveOf(KIT_VARS['--ov-ease-in-out']), [0.65, 0, 0.35, 1]);
+ // and our files say the same
+ const css = (await t.get('/tokens/vibe.css')).text;
+ for (const [name, value] of Object.entries(KIT_VARS)) {
+ const ours = new RegExp(`${name}: ([^;]+);`).exec(css);
+ assert.ok(ours, `${name} is in the CSS`);
+ if (value.startsWith('cubic-bezier')) assert.deepStrictEqual(curveOf(ours[1]), curveOf(value), name);
+ else assert.strictEqual(msOf(ours[1]), msOf(value), name);
+ }
+ const doc = (await t.get('/tokens/vibe.tokens.json')).json();
+ assert.strictEqual(doc.motion.duration['1'].$value, '120ms');
+ assert.strictEqual(doc.motion.duration['2'].$value, '200ms');
+ assert.strictEqual(doc.motion.duration['3'].$value, '320ms');
+ assert.strictEqual(doc.motion.duration['4'].$value, '520ms');
+ assert.strictEqual(doc.motion.easing.out.$type, 'cubicBezier');
+ assert.deepStrictEqual(doc.motion.easing.out.$value, [0.2, 0.8, 0.2, 1]);
+ assert.deepStrictEqual(doc.motion.easing.spring.$value, [0.34, 1.56, 0.64, 1]);
+ assert.deepStrictEqual(doc.motion.easing['in-out'].$value, [0.65, 0, 0.35, 1]);
+ });
+
+ await check('every format parses and says the theme\'s own values', async () => {
+ const doc = (await t.get('/tokens/vibe.tokens.json')).json();
+ assert.strictEqual(doc.color.bg.base.$value, '#0a0f1c');
+ assert.strictEqual(doc.color.accent.base.$value, '#3b82f6');
+ assert.strictEqual(doc.color.text.primary.$value, '#e6edf7');
+ assert.strictEqual(doc.brand.o.$value, '#4f7cff');
+ assert.strictEqual(doc.brand.v.$value, '#8b5cf6');
+ assert.strictEqual(doc.color.bg.base.$extensions['network.openvibe'].css, '--bg-primary');
+
+ const css = (await t.get('/tokens/vibe.css')).text;
+ assert.match(css, /:root \{/);
+ assert.match(css, /--bg-primary: #0a0f1c;/);
+ assert.match(css, /--accent: #3b82f6;/);
+ assert.match(css, /--ov-dur-1: 120ms;/);
+ assert.match(css, /--ov-ease-out: cubic-bezier\(0\.2, 0\.8, 0\.2, 1\);/);
+ assert.match(css, /--ov-brand-o: #4f7cff;/);
+
+ const tw = (await t.get('/tokens/vibe.tailwind.js')).text;
+ const mod = { exports: {} };
+ new Function('module', 'exports', tw)(mod, mod.exports);
+ const preset = mod.exports.theme.extend;
+ assert.strictEqual(preset.colors.bg.base, '#0a0f1c');
+ assert.strictEqual(preset.colors.accent.base, '#3b82f6');
+ assert.strictEqual(preset.colors.brand.o, '#4f7cff');
+ assert.strictEqual(preset.transitionDuration['3'], '320ms');
+ assert.strictEqual(preset.transitionTimingFunction.spring, 'cubic-bezier(0.34, 1.56, 0.64, 1)');
+
+ const sd = (await t.get('/tokens/vibe.style-dictionary.json')).json();
+ assert.strictEqual(sd.color.bg.base.value, '#0a0f1c');
+ assert.strictEqual(sd.color.bg.base.type, 'color');
+ assert.strictEqual(sd.motion.duration['2'].value, '200ms');
+ assert.strictEqual(sd.motion.duration['2'].type, 'duration');
+ assert.strictEqual(sd.motion.easing.out.type, 'cubicBezier');
+ assert.deepStrictEqual(sd.motion.easing.out.value, [0.2, 0.8, 0.2, 1]);
+ assert.strictEqual(sd.brand.v.value, '#8b5cf6');
+
+ const swift = (await t.get('/tokens/vibe.swift')).text;
+ assert.match(swift, /public enum OVTokens \{/);
+ assert.match(swift, /public static let bgBase = Color\(hex: 0xFF0A0F1C\)/);
+ assert.match(swift, /public static let accentBase = Color\(hex: 0xFF3B82F6\)/);
+ assert.match(swift, /public static let duration1: TimeInterval = 0\.12/);
+ assert.match(swift, /public static let easeSpring = Animation\.timingCurve\(0\.34, 1\.56, 0\.64, 1\)/);
+
+ const kt = (await t.get('/tokens/vibe.kt')).text;
+ assert.match(kt, /object OVTokens \{/);
+ assert.match(kt, /val bgBase = Color\(0xFF0A0F1C\)/);
+ assert.match(kt, /const val duration1 = 120L/);
+ assert.match(kt, /val easeOut = CubicBezierEasing\(0\.2f, 0\.8f, 0\.2f, 1f\)/);
+
+ const xml = (await t.get('/tokens/vibe.xml')).text;
+ assert.match(xml, /<color name="ov_bg_base">#0A0F1C<\/color>/);
+ assert.match(xml, /<color name="ov_accent_base">#3B82F6<\/color>/);
+ assert.match(xml, /<color name="ov_brand_v">#8B5CF6<\/color>/);
+ assert.match(xml, /<\/resources>\s*$/);
+ assert.strictEqual((xml.match(/<color /g) || []).length, (xml.match(/<\/color>/g) || []).length, 'every colour is closed');
+ const names = [...xml.matchAll(/name="([^"]*)"/g)].map((m) => m[1]);
+ assert.ok(names.length > 10, 'every colour is named');
+ for (const n of names) assert.match(n, /^[a-z][a-z0-9_]*$/, n);
+ });
+
+ await check('a community theme is a theme too, by its slug and by its id', async () => {
+ const bySlug = await t.get('/tokens/aurora.css');
+ assert.strictEqual(bySlug.status, 200);
+ assert.match(bySlug.text, /--bg-primary: #101820;/);
+ assert.match(bySlug.text, /--accent: #00e0a4;/);
+ const byId = await t.get('/tokens/aurora.tokens.json');
+ assert.strictEqual(byId.status, 200);
+ assert.strictEqual(byId.json().color.bg.base.$value, '#101820');
+ });
+
+ await check('a community theme cannot write into the files people paste', async () => {
+ const formats = require('../server/tokens/formats');
+ const doc = {
+ $extensions: { 'network.openvibe': { name: 'Evil */ body{display:none} /* --> <x>', slug: 'evil', mode: 'dark */ a{}' } },
+ color: { bg: { base: { $value: '#101820', $extensions: { 'network.openvibe': { css: '--bg-primary' } } }, bad: { $value: 'red;} body{display:none', $extensions: { 'network.openvibe': { css: '--accent' } } } } },
+ };
+ const out = formats.css(doc);
+ const first = out.split('\n')[0];
+ assert.strictEqual(out.indexOf('*/'), first.length - 2, 'the name stays inside its comment: the first */ is the one that ends the line');
+ assert.ok(!out.includes('red;}'), 'a value cannot close the rule');
+ assert.match(out, /^\/\* Evil \* \/ body\{display:none\} \/\* - -> <x> \(dark\)/, 'the name is kept, harmless');
+ assert.match(out, /--bg-primary: #101820;/, 'a real colour passes');
+ assert.ok(!/--accent:/.test(out), 'anything that is not a colour is left out');
+ assert.ok(!formats.androidXml(doc).includes('--> <x>'), 'nor can it close an XML comment');
+ });
+ await check('a built-in theme is immutable, a community one is not, and both are open to any origin', async () => {
+ const b = await t.get('/tokens/vibe.css');
+ assert.strictEqual(b.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ assert.strictEqual(b.headers.get('access-control-allow-origin'), '*');
+ const c = await t.get('/tokens/aurora.css');
+ assert.strictEqual(c.status, 200);
+ assert.match(c.headers.get('cache-control'), /^public, max-age=\d+$/);
+ assert.doesNotMatch(c.headers.get('cache-control'), /immutable/);
+ assert.strictEqual(c.headers.get('access-control-allow-origin'), '*');
+ });
+
+ await check('the /tokens page links every file for the theme it shows', async () => {
+ const html = (await t.get('/tokens?theme=aurora')).text;
+ const fs = require('fs'); const path = require('path');
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/views/tokens.js'), 'utf8'), /filesRow\(slug\)/, 'the page draws the row');
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/http/tokens.js'), 'utf8'), /Access-Control-Allow-Origin/, 'files open to any origin');
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server/app.js'), 'utf8'), /createTokenFileRoutes/, 'mounted');
+ assert.ok(html.includes('Files your tools can fetch'));
+ ['tokens.json', 'css', 'tailwind.js', 'style-dictionary.json', 'swift', 'kt', 'xml'].forEach((ext) => assert.ok(html.includes(`href="/tokens/aurora.${ext}"`), ext));
+ });
+ await check('an unknown theme or an unknown format is a 404', async () => {
+ assert.strictEqual((await t.get('/tokens/nope.css')).status, 404);
+ assert.strictEqual((await t.get('/tokens/nope.tokens.json')).status, 404);
+ assert.strictEqual((await t.get('/tokens/vibe.cobol')).status, 404);
+ assert.strictEqual((await t.get('/tokens/vibe')).status, 404);
+ });
+
+ await check('a catalogue theme cannot put code into a file', async () => {
+ for (const f of FORMATS) {
+ const r = await t.get(`/tokens/evil.${f.ext}`);
+ assert.strictEqual(r.status, 200, f.id);
+ assert.doesNotMatch(r.text, /evil\.example/, `${f.id}: the bad value is dropped`);
+ }
+ const swift = (await t.get('/tokens/evil.swift')).text.replace(/\/\/[^\n]*/g, '');
+ assert.doesNotMatch(swift, /import os|fun pwn/);
+ const kt = (await t.get('/tokens/evil.kt')).text.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
+ assert.doesNotMatch(kt, /import os|fun pwn/);
+ });
+
+ await check('the format list is the one a page lists', async () => {
+ assert.deepStrictEqual(FORMATS.map((f) => f.id), ['tokens', 'css', 'tailwind', 'style-dictionary', 'swift', 'kotlin', 'android']);
+ for (const f of FORMATS) {
+ assert.ok(f.ext && f.label && f.type, f.id);
+ assert.match(f.ext, /^[a-z0-9.-]+$/, f.id);
+ }
+ });
+
+ await t.close();
+ done();
+})();