openvibers/openvibe.design Commits

Design: the design tokens as files every tool can read (/tokens/<theme>.<ext>) (#53)

OP OpenVibers committed 13 days ago · c076bd19f9 · 10 files, +721 −1

* Design: the design tokens as files every tool can read

The owner's plan (L45, step 3): the tokens a theme is made of, as files a tool can fetch and use without asking a
person to copy them out of a page.

- GET /tokens/<theme>.<ext> for any theme — a built-in one or a community one from the catalogue, "default" being
  Vibe. Seven formats: the W3C Design Tokens JSON (color, motion.duration, motion.easing, brand), the CSS variables,
  a Tailwind preset, the Style Dictionary source, a Swift enum OVTokens, a Kotlin object OVTokens and an Android
  colors.xml.
- One token set behind all of them: 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 two colours. 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, so a design tool can fetch it from its own page.
- server/tokens/exports.js lists the formats (id, file name, label, type) for the page to list them.
- Colours are read through openvibe-shared/tokens' parser and every theme name that reaches a comment is made
  safe, so nothing a catalogue theme says can carry code into a file someone pastes.

* Token files: a theme's name and values cannot write into the CSS people paste; /tokens links every file (review)

Changes

ddiff --git a/README.md b/README.md
iindex 333b5d0..ce86ccb 100644
---- a/README.md
++++ b/README.md
@@@ -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
`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
ddiff --git a/STATUS.json b/STATUS.json
iindex fece7b7..82fee5e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/public/css/design.css b/public/css/design.css
iindex 40d30e3..486b24d 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex ce58762..a949d52 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/http/tokens.js b/server/http/tokens.js
nnew file mode 100644
iindex 0000000..85ec378
---- /dev/null
++++ b/server/http/tokens.js
@@@ -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 };
ddiff --git a/server/tokens/exports.js b/server/tokens/exports.js
nnew file mode 100644
iindex 0000000..d20ec21
---- /dev/null
++++ b/server/tokens/exports.js
@@@ -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 };
ddiff --git a/server/tokens/formats.js b/server/tokens/formats.js
nnew file mode 100644
iindex 0000000..649258b
---- /dev/null
++++ b/server/tokens/formats.js
@@@ -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 };
ddiff --git a/server/tokens/kit.js b/server/tokens/kit.js
nnew file mode 100644
iindex 0000000..f3c9903
---- /dev/null
++++ b/server/tokens/kit.js
@@@ -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 };
ddiff --git a/server/views/tokens.js b/server/views/tokens.js
iindex d06e62b..3eeddb8 100644
---- a/server/views/tokens.js
++++ b/server/views/tokens.js
@@@ -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&rsquo;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>` })}`;
}
ddiff --git a/test/token-files.test.js b/test/token-files.test.js
nnew file mode 100644
iindex 0000000..3fb77c4
---- /dev/null
++++ b/test/token-files.test.js
@@@ -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();
++})();