openvibers/openvibe.shared Commits
4.40.0: OpenVibe Tokens — a theme as DTCG design tokens, compiled to CSS, Tailwind, Compose, SwiftUI, Flutter and Figma (#180)
Changes
@@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.40.0 — 2026-10-29++++- **OpenVibe Tokens (`openvibe-shared/tokens`, also `/shared/tokens.js` as `window.OpenVibeTokens`).** A theme as a W3C Design Tokens (DTCG) document of semantic tokens (`color.bg.base`, `color.accent.strong`, `color.status.ok.text` … plus the kit's radius, space, size, type and motion scales), compiled to CSS variables (with the network's names as aliases), Tailwind, Jetpack Compose (tokens and a Material 3 colour scheme), SwiftUI, Flutter, Figma variables and DTCG JSON. A theme's dark and light halves compile into one file. Values are validated (colours and shadows only) and names become identifiers, so nothing typed in the studio reaches generated code. This is the first piece of making the design system native-ready: real Android and iOS apps wear the same theme, without a web view. docs/tokens.md.++## 4.39.1 — 2026-10-29- **room.css stands on its own.** A front page that loads room.css without kit.css (Media's, to skip kit.css's 25 KB)@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.39.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.40.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -652,6 +652,10 @@ piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe(`search.js`, loaded on first use), and `loading.js` is the one loading look (docs/kit.md#loading).++**OpenVibe Tokens** (`openvibe-shared/tokens`, 4.40.0) is any theme as a W3C Design Tokens document, compiled to CSS,++Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a++web view ([docs/tokens.md](docs/tokens.md)).++```jsconst kit = require('openvibe-shared/kit');kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.39.1",++ "version": "4.40.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,88 @@++# OpenVibe Tokens (`openvibe-shared/tokens`, 4.40.0)++++A theme on the network is a map of CSS custom properties (`--bg-primary`, `--accent`, …) that theme-loader.js paints++on every site. OpenVibe Tokens is the same theme as a **platform-neutral design-token document** in the++[W3C Design Tokens (DTCG)](https://www.designtokens.org/) format. That one document compiles to the web, Android,++iOS, Flutter and Figma, so a native OpenVibe app wears exactly the theme a person picked on the web, without a web++view.++++```js++const tokens = require('openvibe-shared/tokens'); // in a page: /shared/tokens.js → window.OpenVibeTokens++const doc = tokens.fromTheme(theme); // { name, slug, mode, variables } → DTCG document++tokens.toTheme(doc); // and back, with the network's CSS names++tokens.compile(doc, 'compose'); // one theme++tokens.compile([darkDoc, lightDoc], 'swiftui'); // a theme's two halves in one file++tokens.fileName(doc, 'swiftui'); // "VibeTheme.swift"++tokens.TARGETS; // [{ id, label, ext, lang }]++tokens.flatten(doc); // [{ path, type, value, description }]++```++++## The tokens++++Colours are semantic, named for what they do rather than for a CSS property.++++| Token | The network's CSS name | What it is for |++| --- | --- | --- |++| `color.bg.base` · `surface` · `subtle` · `raised` · `hover` · `input` | `--bg-primary` · `--bg-secondary` · `--bg-tertiary` · `--bg-card` · `--bg-hover` · `--bg-input` | The page, bands, wells, cards, hover, fields |++| `color.text.primary` · `secondary` · `muted` | `--text-primary` · `--text-secondary` · `--text-muted` | Body, supporting text, hints |++| `color.accent.base` · `light` · `dark` · `strong` · `on` · `onStrong` · `glow` | `--accent` … `--on-accent-strong`, `--accent-glow` | Links and focus; filled buttons and their text |++| `color.line.base` · `strong` | `--border` · `--border-light` | Dividers; outlines that must show |++| `color.status.{ok,warn,bad,info,live}.base` · `.text` | `--success` / `--success-text` … `--live-red` / `--live-red-text` | Fills, and text that reads on every surface |++| `shadow.base` · `large` | `--shadow` · `--shadow-lg` | Cards and menus; dialogs |++++The kit's scales are the same on every theme:++++- `radius.sm` · `md` · `lg` · `pill`++- `space.xs` … `xxl` (4–32)++- `size.tap` (44), `size.icon`, `size.avatar`++- `type.family.base` · `mono`++- `type.size.caption` … `display`++- `type.weight.*`++- `motion.duration.fast` · `base` · `slow`++- `motion.easing.standard`++++## Targets++++| Target | What you get |++| --- | --- |++| `css` | `--ov-color-bg-base: …` and the rest, plus the network's own names as aliases (`--bg-primary: var(--ov-color-bg-base)`) and `color-scheme`. A pair writes one block per `[data-theme-mode]` |++| `tailwind` | A `tailwind.config.js` whose colours, radii, spacing and shadows point at the CSS variables, so switching between light and dark needs no rebuild |++| `compose` | `object <Name>Tokens { val ColorBgBase = Color(0xFF0A0F1C) … val RadiusMd = 14.dp … }` and a Material 3 `darkColorScheme(…)`/`lightColorScheme(…)`: `MaterialTheme(colorScheme = VibeDarkColorScheme) { … }` |++| `swiftui` | `public enum <Name>Tokens { static let colorScheme: ColorScheme; static let colorBgBase = Color(.sRGB, …); static let radiusMd: CGFloat … }` |++| `flutter` | `class <Name>Tokens { static const colorBgBase = Color(0xFF…) … }` and a `ColorScheme(brightness: …)` |++| `figma` | A variables collection with one mode per document (`color/bg/base` → `{ r, g, b, a }` per mode), for import with a Figma variables plugin or the REST API |++| `json` | The DTCG document, or `{ dark, light }` for a pair |++++## Safety++++The tokens come from themes people write in the studio, and the output is code that developers paste into apps. So++nothing is passed through unchecked:++++- A colour must be `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()` or `rgba()` with channels of 0–255; named colours are++ refused.++- A shadow must be offsets, a blur, an optional spread and such a colour.++- Any other value is dropped.++- Names become identifiers of letters and digits only (`T` in front of a leading digit; `Theme` when nothing is left).++- Descriptions appear only inside one-line comments, with newlines and `*/` removed.++- A Compose package name that is not a valid Kotlin package falls back to `network.openvibe.theme`.++++`test/tokens.test.js` round-trips all 50 built-in themes, compiles every target, and feeds in a theme whose name and++values try to inject code.++++Status: the generated Kotlin, Swift and Dart are checked against the Compose Material 3, SwiftUI and Flutter APIs by++review and by the tests' expected lines. This host has no Kotlin, Swift or Dart toolchain to compile them; a native++CI job is the next step.++++## Why this shape (native apps, not web views)++++The owner, 2026-10-29: "translate it to official and optimized Android and iOS apps in the future that aren't just web++browser reskin kiosks." A native app needs:++++- the person's theme as values its own UI toolkit understands (Compose `ColorScheme`, SwiftUI `Color`), not a++ stylesheet;++- the kit's scales (tap size, radii, spacing, type, motion) as numbers;++- one source of truth.++++Today's CSS names stay as they are (every site keeps working), and they are aliases of the semantic tokens. The kit's++components get the same treatment next: specs by anatomy, states and roles, plus JSON documents that native renderers++can draw. See DESIGN-OVERHAUL-PLAN.md, pillars B and E.@@@ -43,6 +43,7 @@ const BROWSER = Object.freeze(['kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads'kit-data-web.js', // Kit 2's behaviour: chart readouts, scatter labels, drop zones (4.37.0; with kit-data.css)++ 'tokens.js', // OpenVibe Tokens: a theme as DTCG tokens, compiled to CSS, Tailwind, Compose, SwiftUI, Flutter, Figma (4.40.0)'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows'agent-session.js', // a coding agent's session as people read it: steps grouped, live output, diffs, helpers, hand-offs@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.39.1",++ "version": "4.40.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.39.1",++ "version": "4.40.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.39.1",++ "version": "4.40.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -63,6 +63,7 @@"./kit": "./kit.js","./kit-web": "./kit-web.js","./kit-data-web": "./kit-data-web.js",++ "./tokens": "./tokens.js","./appbar": "./appbar.js","./search": "./search.js","./agent-session": "./agent-session.js",nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++// tokens.js: every built-in theme becomes a DTCG document and back without losing a colour, every target compiles,++// and nothing a person typed in the studio reaches the generated code.++const assert = require('assert');++const tokens = require('../tokens');++const { BUILTIN_THEMES, PAIRS } = require('../builtin-themes');++++// ── Round trip: all 50 themes ──++for (const theme of BUILTIN_THEMES) {++ const doc = tokens.fromTheme(theme);++ const back = tokens.toTheme(doc);++ for (const [css] of tokens.COLOR_MAP) {++ const want = theme.variables[css] && tokens.parseColor(theme.variables[css]);++ if (!want) continue;++ const got = tokens.parseColor(back.variables[css]);++ assert.deepStrictEqual(got, want, `${theme.slug} ${css}`);++ }++ for (const [css] of tokens.SHADOW_MAP) if (theme.variables[css] && tokens.parseShadow(theme.variables[css])) assert.ok(back.variables[css], `${theme.slug} ${css} survives`);++ assert.strictEqual(back.mode, theme.mode === 'light' ? 'light' : 'dark');++ assert.strictEqual(doc['color'].bg.base.$type, 'color');++ assert.ok(doc.radius.md.$value === '14px' && doc.motion.duration.fast.$value === '150ms' && doc.size.tap.$value === '44px', 'the kit scales ride along');++}++console.log(`✓ ${BUILTIN_THEMES.length} built-in themes round-trip`);++++// ── Every target compiles, for one theme and for a dark + light pair ──++const vibe = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === 'vibe'));++const lightSlug = (PAIRS.find((p) => p.includes('vibe')) || []).find((s) => s !== 'vibe') || 'vibe-light';++const vibeLight = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === lightSlug) || BUILTIN_THEMES.find((t) => t.mode === 'light'));++for (const { id } of tokens.TARGETS) {++ const one = tokens.compile(vibe, id);++ const two = tokens.compile([vibe, vibeLight], id);++ assert.ok(one && one.length > 200 && two && two.length > one.length * 0.9, `${id} compiles`);++ assert.ok(tokens.fileName(vibe, id), `${id} has a file name`);++}++const css = tokens.compile(vibe, 'css');++assert.match(css, /--ov-color-bg-base: #0a0f1c;/);++assert.match(css, /--bg-primary: var\(--ov-color-bg-base\);/, 'the network\'s own names stay as aliases');++assert.match(css, /--ov-radius-md: 14px;/);++assert.match(tokens.compile([vibe, vibeLight], 'css'), /:root\[data-theme-mode="light"\] \{/, 'a pair gets one block per mode');++const kt = tokens.compile([vibe, vibeLight], 'compose');++assert.match(kt, /^package network\.openvibe\.theme$/m);++assert.match(kt, /val ColorBgBase = Color\(0xFF0A0F1C\)/);++assert.match(kt, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);++assert.match(kt, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);++assert.match(kt, /primary = VibeDarkTokens\.ColorAccentBase,/);++assert.match(kt, /val RadiusMd = 14\.dp/);++assert.match(kt, /val TypeSizeBody = 15\.sp/);++const swift = tokens.compile(vibe, 'swiftui');++assert.match(swift, /^import SwiftUI$/m);++assert.match(swift, /public enum VibeTokens \{/);++assert.match(swift, /public static let colorScheme: ColorScheme = \.dark/);++assert.match(swift, /public static let colorAccentBase = Color\(\.sRGB, red: 0\.231, green: 0\.51, blue: 0\.965, opacity: 1\)/);++assert.match(swift, /public static let motionDurationFast: Double = 0\.15/);++const dart = tokens.compile(vibe, 'flutter');++assert.match(dart, /static const colorBgBase = Color\(0xFF0A0F1C\);/);++assert.match(dart, /const vibeDarkScheme = ColorScheme\(\n {2}brightness: Brightness\.dark,/);++const fig = JSON.parse(tokens.compile([vibe, vibeLight], 'figma'));++assert.deepStrictEqual(fig.modes, ['dark', 'light']);++const bg = fig.variables.find((v) => v.name === 'color/bg/base');++assert.ok(bg && bg.resolvedType === 'COLOR' && bg.valuesByMode.dark.r < 0.1 && bg.valuesByMode.light, 'Figma: one variable, a value per mode');++const tw = tokens.compile(vibe, 'tailwind');++assert.match(tw, /"base": "var\(--ov-color-bg-base\)"/);++const dtcg = JSON.parse(tokens.compile(vibe, 'json'));++assert.strictEqual(dtcg.color.accent.base.$value, '#3b82f6');++assert.strictEqual(tokens.compile(vibe, 'nope'), null);++console.log('✓ css, tailwind, compose, swiftui, flutter, figma and json compile');++++// ── Nothing typed reaches the code ──++const evil = tokens.fromTheme({++ name: 'Evil */ import os; val x = 1 // "\n\u2028 }',++ slug: '../../etc',++ description: 'line one\n*/ fun pwn() {}',++ mode: 'dark',++ variables: {++ '--bg-primary': '#000; } body { background: url(https://evil.example) }',++ '--text-primary': 'expression(alert(1))',++ '--accent': 'rgb(300, 0, 0)',++ '--border': '#123456',++ '--shadow': '0 2px 12px rgba(0,0,0,0.45); background: red',++ },++});++assert.strictEqual(evil.color.bg?.base, undefined, 'a value with code after it is dropped');++assert.strictEqual(evil.color.text?.primary, undefined);++assert.strictEqual(evil.color.accent?.base, undefined, 'an out-of-range channel is dropped');++assert.strictEqual(evil.color.line.base.$value, '#123456');++assert.strictEqual(evil.shadow, undefined, 'a shadow with a declaration after it is dropped');++assert.strictEqual(evil.$extensions['network.openvibe'].slug, 'etc');++// The theme's name and description may appear inside comments (one line, "*/" broken), never as code.++const code = (src, id) => (id === 'json' || id === 'figma' ? '' : src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''));++for (const { id } of tokens.TARGETS) {++ const out = tokens.compile(evil, id);++ assert.ok(!/evil\.example|expression\(|background: red/.test(out), `${id}: a dropped value appears nowhere`);++ assert.ok(!/import os|fun pwn|val x = 1/.test(code(out, id)), `${id}: the name stays inside comments`);++ assert.ok(!/[\u2028\u2029]/.test(out), `${id}: no line separators`);++ if (id === 'json' || id === 'figma') JSON.parse(out);++ if (id === 'compose' || id === 'swiftui' || id === 'flutter') assert.match(out, /(object|enum|class) EvilImportOsValX1Tokens/, `${id}: the name is an identifier`);++}++assert.strictEqual(tokens.ident('123 go'), 'T123Go');++assert.strictEqual(tokens.ident(' '), 'Theme');++assert.strictEqual(tokens.ident('Café Noir'), 'CafeNoir');++assert.deepStrictEqual(tokens.parseColor('#abc'), { r: 170, g: 187, b: 204, a: 1 });++assert.deepStrictEqual(tokens.parseColor('#11223380'), { r: 17, g: 34, b: 51, a: 0.502 });++assert.strictEqual(tokens.parseColor('red'), null, 'named colours are not accepted (one spelling everywhere)');++assert.strictEqual(tokens.compile(tokens.fromTheme({ name: 'x', variables: { '--accent': '#fff' } }), 'compose', { pkg: 'a; import evil' }).split('\n')[1], 'package network.openvibe.theme', 'a bad package name falls back');++console.log('✓ names, values and package names are validated; nothing typed reaches generated code');++console.log('tokens: all checks passed');nnew file mode 100644@@@ -0,0 +1,400 @@++/**++ * openvibe-shared/tokens.js — OpenVibe Tokens: a theme as a platform-neutral design-token document, and the compilers++ * that turn it into code for the web, Android, iOS, Flutter and Figma.++ *++ * The owner, 2026-10-29: "do everything in a way structure and modular so we can translate it to official and optimized++ * Android and iOS apps in the future that aren't just web browser reskin kiosks." A theme on the network is a map of CSS++ * custom properties (--bg-primary, --accent…). Here it becomes a W3C Design Tokens (DTCG) document of semantic tokens++ * (color.bg.base, color.text.primary, color.accent.base…, plus the kit's radius, space, size, type and motion scales),++ * and that one document compiles to every platform:++ *++ * const tokens = require('openvibe-shared/tokens');++ * const doc = tokens.fromTheme(theme) theme: { name, slug, mode, variables } (builtin-themes, the studio)++ * tokens.toTheme(doc) → { name, slug, mode, variables } (round trip, CSS names)++ * tokens.flatten(doc) → [{ path: ['color', 'bg', 'base'], type: 'color', value }]++ * tokens.compile(doc | [dark, light], target) → source text; target: css | tailwind | compose | swiftui | flutter++ * | figma | json (tokens.TARGETS: label, file name, language)++ *++ * Compose gets an `object <Name>Tokens` and a Material 3 `darkColorScheme`/`lightColorScheme`; SwiftUI an `enum` of++ * `Color`s and sizes with its `ColorScheme`; Flutter a `ColorScheme` and constants; Figma a variables collection with a++ * mode per document. Pass a theme's dark and light halves together to get both schemes in one file.++ *++ * Values are validated, never passed through: a colour is #rgb/#rrggbb/#rrggbbaa or rgb()/rgba(), a shadow is offsets,++ * blur, spread and such a colour; anything else is dropped. Names become identifiers of letters and digits only. So a++ * theme someone wrote in the studio can never inject code into the Kotlin, Swift or Dart a developer pastes. Pure: no++ * I/O. Runs in Node and in the browser (window.OpenVibeTokens).++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }++ root.OpenVibeTokens = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++++ const VERSION = 1;++++ // ── The network's CSS names → semantic token paths ──++ // [css name, path, $type, description]. Order is the document's order.++ const COLOR_MAP = [++ ['--bg-primary', 'color.bg.base', 'The page behind everything'],++ ['--bg-secondary', 'color.bg.surface', 'Bands and side panels'],++ ['--bg-tertiary', 'color.bg.subtle', 'Wells, tracks and quiet fills'],++ ['--bg-card', 'color.bg.raised', 'Cards, rows and sheets'],++ ['--bg-hover', 'color.bg.hover', 'A control under the pointer or finger'],++ ['--bg-input', 'color.bg.input', 'Text fields'],++ ['--text-primary', 'color.text.primary', 'Body text and titles'],++ ['--text-secondary', 'color.text.secondary', 'Supporting text'],++ ['--text-muted', 'color.text.muted', 'Hints and meta lines'],++ ['--accent', 'color.accent.base', 'Links, icons, focus rings'],++ ['--accent-light', 'color.accent.light', 'The accent a step lighter'],++ ['--accent-dark', 'color.accent.dark', 'The accent a step darker'],++ ['--accent-strong', 'color.accent.strong', 'Filled buttons'],++ ['--on-accent', 'color.accent.on', 'Text on the accent'],++ ['--on-accent-strong', 'color.accent.onStrong', 'Text on a filled button'],++ ['--accent-glow', 'color.accent.glow', 'Glows and halos'],++ ['--border', 'color.line.base', 'Dividers and outlines'],++ ['--border-light', 'color.line.strong', 'Outlines that must show'],++ ['--success', 'color.status.ok.base', 'Success fills'],++ ['--success-text', 'color.status.ok.text', 'Success text on any surface'],++ ['--warning', 'color.status.warn.base', 'Warning fills'],++ ['--warning-text', 'color.status.warn.text', 'Warning text on any surface'],++ ['--danger', 'color.status.bad.base', 'Danger fills'],++ ['--danger-text', 'color.status.bad.text', 'Danger text on any surface'],++ ['--info', 'color.status.info.base', 'Information fills'],++ ['--info-text', 'color.status.info.text', 'Information text on any surface'],++ ['--live-red', 'color.status.live.base', 'LIVE badges'],++ ['--live-red-text', 'color.status.live.text', 'LIVE text on any surface'],++ ];++ const SHADOW_MAP = [++ ['--shadow', 'shadow.base', 'Cards and menus'],++ ['--shadow-lg', 'shadow.large', 'Dialogs and sheets'],++ ];++ // Defaults for status colours a theme leaves out (as builtin-themes' STATUS_DEFAULTS).++ const STATUS_DEFAULTS = { '--success': '#22c55e', '--warning': '#f59e0b', '--danger': '#ef4444', '--info': '#38bdf8', '--live-red': '#ef4444' };++++ // ── The kit's scales: the same on every theme (kit.css and room.css use these numbers) ──++ const SCALES = {++ radius: { $type: 'dimension', sm: 10, md: 14, lg: 20, pill: 999 },++ space: { $type: 'dimension', xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 },++ size: { $type: 'dimension', tap: 44, icon: 20, avatar: 34 },++ type: {++ family: { $type: 'fontFamily', base: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'] },++ size: { $type: 'dimension', caption: 12, meta: 13, body: 15, lead: 17, title: 20, headline: 24, display: 34 },++ weight: { $type: 'fontWeight', regular: 400, medium: 600, bold: 700, heavy: 800 },++ },++ motion: {++ duration: { $type: 'duration', fast: 150, base: 250, slow: 400 },++ easing: { $type: 'cubicBezier', standard: [0.2, 0.7, 0.2, 1] },++ },++ };++++ // ── Values: parse strictly, never pass through ──++ function parseColor(v) {++ const s = String(v == null ? '' : v).trim().toLowerCase();++ let m = /^#([0-9a-f]{3})$/.exec(s);++ if (m) return { r: parseInt(m[1][0] + m[1][0], 16), g: parseInt(m[1][1] + m[1][1], 16), b: parseInt(m[1][2] + m[1][2], 16), a: 1 };++ m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(s);++ if (m) return { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: m[2] ? Math.round(parseInt(m[2], 16) / 255 * 1000) / 1000 : 1 };++ m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0|1|0?\.\d+|1\.0+))?\s*\)$/.exec(s);++ if (m) {++ const [r, g, b] = [m[1], m[2], m[3]].map(Number);++ if (r > 255 || g > 255 || b > 255) return null;++ return { r, g, b, a: m[4] == null ? 1 : Number(m[4]) };++ }++ return null;++ }++ const hex2 = (n) => n.toString(16).padStart(2, '0');++ const toHex = (c) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${c.a < 1 ? hex2(Math.round(c.a * 255)) : ''}`;++ const toCssColor = (c) => (c.a < 1 ? `rgba(${c.r},${c.g},${c.b},${c.a})` : toHex(c));++++ /** "0 2px 12px rgba(0,0,0,0.45)" → { offsetX, offsetY, blur, spread, color } (px numbers), or null. */++ function parseShadow(v) {++ const s = String(v == null ? '' : v).trim();++ const m = /^(-?\d+(?:\.\d+)?)(?:px)?\s+(-?\d+(?:\.\d+)?)(?:px)?\s+(\d+(?:\.\d+)?)(?:px)?(?:\s+(-?\d+(?:\.\d+)?)(?:px)?)?\s+(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))$/.exec(s);++ if (!m) return null;++ const color = parseColor(m[5]);++ if (!color) return null;++ return { offsetX: Number(m[1]), offsetY: Number(m[2]), blur: Number(m[3]), spread: Number(m[4] || 0), color };++ }++ const shadowCss = (sh) => `${sh.offsetX}px ${sh.offsetY}px ${sh.blur}px${sh.spread ? ` ${sh.spread}px` : ''} ${toCssColor(sh.color)}`;++++ // A name as an identifier: letters and digits, PascalCase, never empty, never starting with a digit.++ function ident(name, fallback = 'Theme') {++ const words = String(name || '').normalize('NFKD').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);++ let id = words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');++ if (!id) id = fallback;++ if (/^\d/.test(id)) id = `T${id}`;++ return id.slice(0, 48);++ }++ // Text for a comment: one line, no comment terminators.++ const commentText = (s) => String(s || '').replace(/[\r\n\u2028\u2029]+/g, ' ').replace(/\*\//g, '* /').replace(/[^\x20-\x7e -]/g, '').slice(0, 160);++ const slugOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'theme';++++ function setPath(obj, path, leaf) {++ const parts = path.split('.');++ let o = obj;++ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});++ o[parts[parts.length - 1]] = leaf;++ }++ function getPath(obj, path) {++ let o = obj;++ for (const p of path.split('.')) { if (!o || typeof o !== 'object') return undefined; o = o[p]; }++ return o;++ }++++ // ── Theme ⇄ document ──++ /** A theme ({ name, slug, mode, variables, description }) as a DTCG token document. Unknown or invalid values are left out. */++ function fromTheme(theme = {}) {++ const vars = { ...STATUS_DEFAULTS, ...((theme && theme.variables) || {}) };++ const mode = theme && theme.mode === 'light' ? 'light' : 'dark';++ const doc = {++ $description: commentText(theme.description || `${theme.name || 'A theme'} (${mode})`),++ $extensions: { 'network.openvibe': { name: commentText(theme.name || 'Theme'), slug: slugOf(theme.slug || theme.name), mode, tokensVersion: VERSION } },++ };++ for (const [css, path, description] of COLOR_MAP) {++ const c = parseColor(vars[css]);++ if (c) setPath(doc, path, { $type: 'color', $value: toCssColor(c), $description: description });++ }++ for (const [css, path, description] of SHADOW_MAP) {++ const sh = parseShadow(vars[css]);++ if (sh) setPath(doc, path, { $type: 'shadow', $value: { color: toCssColor(sh.color), offsetX: `${sh.offsetX}px`, offsetY: `${sh.offsetY}px`, blur: `${sh.blur}px`, spread: `${sh.spread}px` }, $description: description });++ }++ for (const [group, def] of Object.entries(SCALES)) addScale(doc, group, def);++ return doc;++ }++ function addScale(doc, path, def) {++ const type = def.$type;++ for (const [k, v] of Object.entries(def)) {++ if (k === '$type') continue;++ const p = `${path}.${k}`;++ if (type) setPath(doc, p, { $type: type, $value: type === 'dimension' ? `${v}px` : type === 'duration' ? `${v}ms` : v });++ else addScale(doc, p, v);++ }++ }++++ /** A document back to the network's CSS names: { name, slug, mode, variables }. */++ function toTheme(doc = {}) {++ const meta = (doc.$extensions && doc.$extensions['network.openvibe']) || {};++ const variables = {};++ for (const [css, path] of COLOR_MAP) {++ const t = getPath(doc, path);++ const c = t && parseColor(t.$value);++ if (c) variables[css] = toCssColor(c);++ }++ for (const [css, path] of SHADOW_MAP) {++ const t = getPath(doc, path);++ const sh = t && t.$value && shadowOf(t.$value);++ if (sh) variables[css] = shadowCss(sh);++ }++ const mode = meta.mode === 'light' ? 'light' : 'dark';++ variables['--color-scheme'] = mode;++ const accent = variables['--accent'] && parseColor(variables['--accent']);++ if (accent) variables['--accent-rgb'] = `${accent.r},${accent.g},${accent.b}`;++ return { name: meta.name || 'Theme', slug: slugOf(meta.slug || meta.name), mode, variables };++ }++ function shadowOf(v) {++ const px = (x) => { const m = /^(-?\d+(?:\.\d+)?)px$/.exec(String(x)); return m ? Number(m[1]) : NaN; };++ const color = parseColor(v.color);++ const sh = { offsetX: px(v.offsetX), offsetY: px(v.offsetY), blur: px(v.blur), spread: px(v.spread || '0px'), color };++ return color && [sh.offsetX, sh.offsetY, sh.blur, sh.spread].every(Number.isFinite) ? sh : null;++ }++++ /** Every token as { path: [...], type, value, description }, in document order. */++ function flatten(doc) {++ const out = [];++ (function walk(o, path, inherited) {++ for (const [k, v] of Object.entries(o || {})) {++ if (k.startsWith('$') || !v || typeof v !== 'object') continue;++ const type = v.$type || inherited;++ if ('$value' in v) out.push({ path: path.concat(k), type, value: v.$value, description: v.$description || '' });++ else walk(v, path.concat(k), type);++ }++ })(doc, [], null);++ return out;++ }++++ // ── Compilers ──++ const metaOf = (doc) => (doc && doc.$extensions && doc.$extensions['network.openvibe']) || { name: 'Theme', slug: 'theme', mode: 'dark' };++ const camel = (path) => path.map((p, i) => (i ? p[0].toUpperCase() + p.slice(1) : p)).join('').replace(/[^A-Za-z0-9]/g, '');++ const pascal = (path) => { const c = camel(path); return c[0].toUpperCase() + c.slice(1); };++ const kebab = (path) => path.map((p) => p.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()).join('-');++ const num = (v) => { const m = /^(-?\d+(?:\.\d+)?)(px|ms)?$/.exec(String(v)); return m ? Number(m[1]) : null; };++ const header = (doc, lang) => {++ const m = metaOf(doc);++ const line = `${commentText(m.name)} (${m.mode}) — generated by openvibe-shared/tokens v${VERSION} from the OpenVibe theme "${m.slug}". Regenerate instead of editing.`;++ return lang === 'swift' || lang === 'kotlin' || lang === 'dart' || lang === 'js' ? `// ${line}` : `/* ${line} */`;++ };++ const docsOf = (input) => (Array.isArray(input) ? input : [input]).filter(Boolean).slice(0, 2);++ const colorTokens = (doc) => flatten(doc).filter((t) => t.type === 'color').map((t) => ({ ...t, c: parseColor(t.value) })).filter((t) => t.c);++++ function css(input, { selector = ':root', aliases = true } = {}) {++ const out = [];++ for (const doc of docsOf(input)) {++ const m = metaOf(doc);++ const sel = docsOf(input).length > 1 ? `${selector}[data-theme-mode="${m.mode}"]` : selector;++ const lines = [];++ for (const t of flatten(doc)) {++ const name = `--ov-${kebab(t.path)}`;++ let v = null;++ if (t.type === 'color') { const c = parseColor(t.value); v = c && toCssColor(c); }++ else if (t.type === 'shadow') { const sh = shadowOf(t.value || {}); v = sh && shadowCss(sh); }++ else if (t.type === 'dimension' || t.type === 'duration') { const n = num(t.value); v = n == null ? null : `${n}${t.type === 'duration' ? 'ms' : 'px'}`; }++ else if (t.type === 'fontWeight') v = Number.isFinite(Number(t.value)) ? String(Number(t.value)) : null;++ else if (t.type === 'fontFamily') v = (Array.isArray(t.value) ? t.value : [t.value]).map((f) => (/^[A-Za-z0-9 -]+$/.test(f) ? (/\s/.test(f) ? `'${f}'` : f) : null)).filter(Boolean).join(', ') || null;++ else if (t.type === 'cubicBezier') v = Array.isArray(t.value) && t.value.length === 4 && t.value.every((x) => Number.isFinite(Number(x))) ? `cubic-bezier(${t.value.map(Number).join(', ')})` : null;++ if (v) lines.push(` ${name}: ${v};`);++ }++ if (aliases) {++ lines.push(' /* The network\'s own names, for code written against them. */');++ for (const [cssName, path] of COLOR_MAP.concat(SHADOW_MAP)) if (getPath(doc, path)) lines.push(` ${cssName}: var(--ov-${kebab(path.split('.'))});`);++ lines.push(` color-scheme: ${m.mode === 'light' ? 'light' : 'dark'};`);++ }++ out.push(`${sel} {\n${lines.join('\n')}\n}`);++ }++ return `${header(docsOf(input)[0], 'css')}\n${out.join('\n\n')}\n`;++ }++++ function tailwind(input) {++ const doc = docsOf(input)[0];++ const colors = {};++ for (const t of colorTokens(doc).filter((x) => x.path[0] === 'color')) setPath(colors, t.path.slice(1).join('.'), `var(--ov-${kebab(t.path)})`);++ const dim = (group) => Object.fromEntries(flatten(doc).filter((t) => t.path[0] === group).map((t) => [t.path.slice(1).join('-'), `var(--ov-${kebab(t.path)})`]));++ const config = { theme: { extend: { colors, borderRadius: dim('radius'), spacing: dim('space'), boxShadow: { DEFAULT: 'var(--ov-shadow-base)', lg: 'var(--ov-shadow-large)' } } } };++ return `${header(doc, 'js')}\n// Pair it with the CSS export (the variables), so a light/dark switch needs no rebuild.\nmodule.exports = ${JSON.stringify(config, null, 4)};\n`;++ }++++ // Kotlin / Dart: 0xAARRGGBB.++ const argb = (c) => `0x${hex2(Math.round(c.a * 255)).toUpperCase()}${[c.r, c.g, c.b].map((n) => hex2(n).toUpperCase()).join('')}`;++ const pick = (doc, path) => { const t = getPath(doc, path); return t && parseColor(t.$value); };++ // Material 3 roles from the semantic tokens (Compose and Flutter share it).++ const M3_ROLES = [++ ['primary', 'color.accent.base'], ['onPrimary', 'color.accent.on'],++ ['primaryContainer', 'color.accent.strong'], ['onPrimaryContainer', 'color.accent.onStrong'],++ ['secondary', 'color.accent.light'], ['onSecondary', 'color.accent.on'],++ ['tertiary', 'color.status.info.base'],++ ['background', 'color.bg.base'], ['onBackground', 'color.text.primary'],++ ['surface', 'color.bg.raised'], ['onSurface', 'color.text.primary'],++ ['surfaceVariant', 'color.bg.surface'], ['onSurfaceVariant', 'color.text.secondary'],++ ['outline', 'color.line.strong'], ['outlineVariant', 'color.line.base'],++ ['error', 'color.status.bad.base'], ['onError', 'color.accent.onStrong'],++ ];++++ function compose(input, { pkg = 'network.openvibe.theme' } = {}) {++ const docs = docsOf(input);++ const name = ident(metaOf(docs[0]).name);++ const safePkg = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/.test(pkg) ? pkg : 'network.openvibe.theme';++ const parts = [`${header(docs[0], 'kotlin')}`, `package ${safePkg}`, '',++ 'import androidx.compose.material3.ColorScheme', 'import androidx.compose.material3.darkColorScheme', 'import androidx.compose.material3.lightColorScheme',++ 'import androidx.compose.ui.graphics.Color', 'import androidx.compose.ui.unit.dp', 'import androidx.compose.ui.unit.sp', ''];++ for (const doc of docs) {++ const m = metaOf(doc);++ const obj = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;++ parts.push(`/** ${commentText(doc.$description)} */`, `object ${obj} {`);++ for (const t of colorTokens(doc)) parts.push(` val ${pascal(t.path)} = Color(${argb(t.c)})`);++ if (doc === docs[0]) {++ for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {++ const n = num(t.value);++ if (n != null) parts.push(` val ${pascal(t.path)} = ${n}.${t.path[0] === 'type' ? 'sp' : 'dp'}`);++ }++ for (const t of flatten(doc).filter((x) => x.type === 'duration' || x.type === 'fontWeight')) {++ const n = num(t.value);++ if (n != null) parts.push(` const val ${pascal(t.path)} = ${Math.round(n)}`);++ }++ }++ parts.push('}', '');++ const fn = m.mode === 'light' ? 'lightColorScheme' : 'darkColorScheme';++ const roles = M3_ROLES.filter(([, path]) => pick(doc, path)).map(([role, path]) => ` ${role} = ${obj}.${pascal(path.split('.'))},`);++ parts.push(`/** Material 3 roles: MaterialTheme(colorScheme = ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme) { … } */`, `val ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme: ColorScheme = ${fn}(`, ...roles, ')', '');++ }++ return parts.join('\n');++ }++++ function swiftui(input) {++ const docs = docsOf(input);++ const name = ident(metaOf(docs[0]).name);++ const f = (x) => (Math.round(x / 255 * 1000) / 1000).toString();++ const parts = [header(docs[0], 'swift'), 'import SwiftUI', ''];++ for (const doc of docs) {++ const m = metaOf(doc);++ const en = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;++ parts.push(`/// ${commentText(doc.$description)}`, `public enum ${en} {`, ` public static let colorScheme: ColorScheme = .${m.mode === 'light' ? 'light' : 'dark'}`);++ for (const t of colorTokens(doc)) parts.push(` public static let ${camel(t.path)} = Color(.sRGB, red: ${f(t.c.r)}, green: ${f(t.c.g)}, blue: ${f(t.c.b)}, opacity: ${t.c.a})`);++ if (doc === docs[0]) {++ for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {++ const n = num(t.value);++ if (n != null) parts.push(` public static let ${camel(t.path)}: CGFloat = ${n}`);++ }++ for (const t of flatten(doc).filter((x) => x.type === 'duration')) {++ const n = num(t.value);++ if (n != null) parts.push(` public static let ${camel(t.path)}: Double = ${n / 1000}`);++ }++ }++ parts.push('}', '');++ }++ return parts.join('\n');++ }++++ function flutter(input) {++ const docs = docsOf(input);++ const name = ident(metaOf(docs[0]).name);++ const parts = [header(docs[0], 'dart'), "import 'package:flutter/material.dart';", ''];++ for (const doc of docs) {++ const m = metaOf(doc);++ const cls = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;++ parts.push(`/// ${commentText(doc.$description)}`, `class ${cls} {`, ` ${cls}._();`);++ for (const t of colorTokens(doc)) parts.push(` static const ${camel(t.path)} = Color(${argb(t.c)});`);++ if (doc === docs[0]) for (const t of flatten(doc).filter((x) => x.type === 'dimension')) { const n = num(t.value); if (n != null) parts.push(` static const double ${camel(t.path)} = ${n};`); }++ parts.push('}', '');++ const roles = M3_ROLES.filter(([role, path]) => pick(doc, path) && role !== 'surfaceVariant').map(([role, path]) => ` ${role}: ${cls}.${camel(path.split('.'))},`);++ parts.push(`const ${name[0].toLowerCase() + name.slice(1)}${m.mode === 'light' ? 'Light' : 'Dark'}Scheme = ColorScheme(`, ` brightness: Brightness.${m.mode === 'light' ? 'light' : 'dark'},`, ...roles, ');', '');++ }++ return parts.join('\n');++ }++++ function figma(input) {++ const docs = docsOf(input);++ const meta = metaOf(docs[0]);++ const modes = docs.map((d) => metaOf(d).mode);++ const byPath = new Map();++ docs.forEach((doc, i) => {++ for (const t of flatten(doc)) {++ const key = t.path.join('/');++ if (!byPath.has(key)) byPath.set(key, { name: key, resolvedType: t.type === 'color' ? 'COLOR' : t.type === 'dimension' || t.type === 'duration' || t.type === 'fontWeight' ? 'FLOAT' : null, description: t.description, valuesByMode: {} });++ const v = byPath.get(key);++ if (v.resolvedType === 'COLOR') { const c = parseColor(t.value); if (c) v.valuesByMode[modes[i]] = { r: +(c.r / 255).toFixed(4), g: +(c.g / 255).toFixed(4), b: +(c.b / 255).toFixed(4), a: c.a }; }++ else if (v.resolvedType === 'FLOAT') { const n = num(t.value); if (n != null) v.valuesByMode[modes[i]] = n; }++ }++ });++ const variables = [...byPath.values()].filter((v) => v.resolvedType && Object.keys(v.valuesByMode).length);++ return `${JSON.stringify({ collection: `OpenVibe · ${commentText(meta.name)}`, modes, variables, generator: `openvibe-shared/tokens v${VERSION}` }, null, 2)}\n`;++ }++++ const json = (input) => `${JSON.stringify(docsOf(input).length > 1 ? { $description: 'A theme\'s dark and light halves', dark: docsOf(input).find((d) => metaOf(d).mode === 'dark') || docsOf(input)[0], light: docsOf(input).find((d) => metaOf(d).mode === 'light') || docsOf(input)[1] } : docsOf(input)[0], null, 2)}\n`;++++ const TARGETS = [++ { id: 'css', label: 'CSS variables', ext: 'css', lang: 'css', build: css },++ { id: 'tailwind', label: 'Tailwind', ext: 'tailwind.config.js', lang: 'js', build: tailwind },++ { id: 'compose', label: 'Android (Jetpack Compose)', ext: 'kt', lang: 'kotlin', build: compose },++ { id: 'swiftui', label: 'iOS (SwiftUI)', ext: 'swift', lang: 'swift', build: swiftui },++ { id: 'flutter', label: 'Flutter', ext: 'dart', lang: 'dart', build: flutter },++ { id: 'figma', label: 'Figma variables', ext: 'figma.json', lang: 'json', build: figma },++ { id: 'json', label: 'Design tokens (DTCG JSON)', ext: 'tokens.json', lang: 'json', build: json },++ ];++ /** Source text for one target; file: a file name for it. Unknown target → null. */++ function compile(input, target, opts = {}) {++ const t = TARGETS.find((x) => x.id === target);++ if (!t || !docsOf(input).length) return null;++ return t.build(input, opts);++ }++ function fileName(input, target) {++ const t = TARGETS.find((x) => x.id === target);++ const base = ident(metaOf(docsOf(input)[0]).name);++ if (!t) return null;++ return t.ext.includes('.') ? `${slugOf(base)}.${t.ext}` : `${base}Theme.${t.ext}`;++ }++++ return {++ VERSION, COLOR_MAP, SHADOW_MAP, SCALES, TARGETS: TARGETS.map(({ build, ...rest }) => rest),++ fromTheme, toTheme, flatten, compile, fileName, parseColor, parseShadow, ident,++ };++});
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.40.0 — 2026-10-29
+
+- **OpenVibe Tokens (`openvibe-shared/tokens`, also `/shared/tokens.js` as `window.OpenVibeTokens`).** A theme as a W3C Design Tokens (DTCG) document of semantic tokens (`color.bg.base`, `color.accent.strong`, `color.status.ok.text` … plus the kit's radius, space, size, type and motion scales), compiled to CSS variables (with the network's names as aliases), Tailwind, Jetpack Compose (tokens and a Material 3 colour scheme), SwiftUI, Flutter, Figma variables and DTCG JSON. A theme's dark and light halves compile into one file. Values are validated (colours and shadows only) and names become identifiers, so nothing typed in the studio reaches generated code. This is the first piece of making the design system native-ready: real Android and iOS apps wear the same theme, without a web view. docs/tokens.md.
+
## 4.39.1 — 2026-10-29
## 4.39.1 — 2026-10-29
- **room.css stands on its own.** A front page that loads room.css without kit.css (Media's, to skip kit.css's 25 KB)
- **room.css stands on its own.** A front page that loads room.css without kit.css (Media's, to skip kit.css's 25 KB)
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.39.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.40.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -652,6 +652,10 @@ piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's
@@ -652,6 +652,10 @@ piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's
`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe
`node scripts/kit-gallery.js --shots <dir>` shows them all at 390, 768 and 1280 px. Every navbar also has search across OpenVibe
(`search.js`, loaded on first use), and `loading.js` is the one loading look (docs/kit.md#loading).
(`search.js`, loaded on first use), and `loading.js` is the one loading look (docs/kit.md#loading).
+**OpenVibe Tokens** (`openvibe-shared/tokens`, 4.40.0) is any theme as a W3C Design Tokens document, compiled to CSS,
+Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a
+web view ([docs/tokens.md](docs/tokens.md)).
+
```js
```js
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.39.1",
+ "version": "4.40.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+# OpenVibe Tokens (`openvibe-shared/tokens`, 4.40.0)
+
+A theme on the network is a map of CSS custom properties (`--bg-primary`, `--accent`, …) that theme-loader.js paints
+on every site. OpenVibe Tokens is the same theme as a **platform-neutral design-token document** in the
+[W3C Design Tokens (DTCG)](https://www.designtokens.org/) format. That one document compiles to the web, Android,
+iOS, Flutter and Figma, so a native OpenVibe app wears exactly the theme a person picked on the web, without a web
+view.
+
+```js
+const tokens = require('openvibe-shared/tokens'); // in a page: /shared/tokens.js → window.OpenVibeTokens
+const doc = tokens.fromTheme(theme); // { name, slug, mode, variables } → DTCG document
+tokens.toTheme(doc); // and back, with the network's CSS names
+tokens.compile(doc, 'compose'); // one theme
+tokens.compile([darkDoc, lightDoc], 'swiftui'); // a theme's two halves in one file
+tokens.fileName(doc, 'swiftui'); // "VibeTheme.swift"
+tokens.TARGETS; // [{ id, label, ext, lang }]
+tokens.flatten(doc); // [{ path, type, value, description }]
+```
+
+## The tokens
+
+Colours are semantic, named for what they do rather than for a CSS property.
+
+| Token | The network's CSS name | What it is for |
+| --- | --- | --- |
+| `color.bg.base` · `surface` · `subtle` · `raised` · `hover` · `input` | `--bg-primary` · `--bg-secondary` · `--bg-tertiary` · `--bg-card` · `--bg-hover` · `--bg-input` | The page, bands, wells, cards, hover, fields |
+| `color.text.primary` · `secondary` · `muted` | `--text-primary` · `--text-secondary` · `--text-muted` | Body, supporting text, hints |
+| `color.accent.base` · `light` · `dark` · `strong` · `on` · `onStrong` · `glow` | `--accent` … `--on-accent-strong`, `--accent-glow` | Links and focus; filled buttons and their text |
+| `color.line.base` · `strong` | `--border` · `--border-light` | Dividers; outlines that must show |
+| `color.status.{ok,warn,bad,info,live}.base` · `.text` | `--success` / `--success-text` … `--live-red` / `--live-red-text` | Fills, and text that reads on every surface |
+| `shadow.base` · `large` | `--shadow` · `--shadow-lg` | Cards and menus; dialogs |
+
+The kit's scales are the same on every theme:
+
+- `radius.sm` · `md` · `lg` · `pill`
+- `space.xs` … `xxl` (4–32)
+- `size.tap` (44), `size.icon`, `size.avatar`
+- `type.family.base` · `mono`
+- `type.size.caption` … `display`
+- `type.weight.*`
+- `motion.duration.fast` · `base` · `slow`
+- `motion.easing.standard`
+
+## Targets
+
+| Target | What you get |
+| --- | --- |
+| `css` | `--ov-color-bg-base: …` and the rest, plus the network's own names as aliases (`--bg-primary: var(--ov-color-bg-base)`) and `color-scheme`. A pair writes one block per `[data-theme-mode]` |
+| `tailwind` | A `tailwind.config.js` whose colours, radii, spacing and shadows point at the CSS variables, so switching between light and dark needs no rebuild |
+| `compose` | `object <Name>Tokens { val ColorBgBase = Color(0xFF0A0F1C) … val RadiusMd = 14.dp … }` and a Material 3 `darkColorScheme(…)`/`lightColorScheme(…)`: `MaterialTheme(colorScheme = VibeDarkColorScheme) { … }` |
+| `swiftui` | `public enum <Name>Tokens { static let colorScheme: ColorScheme; static let colorBgBase = Color(.sRGB, …); static let radiusMd: CGFloat … }` |
+| `flutter` | `class <Name>Tokens { static const colorBgBase = Color(0xFF…) … }` and a `ColorScheme(brightness: …)` |
+| `figma` | A variables collection with one mode per document (`color/bg/base` → `{ r, g, b, a }` per mode), for import with a Figma variables plugin or the REST API |
+| `json` | The DTCG document, or `{ dark, light }` for a pair |
+
+## Safety
+
+The tokens come from themes people write in the studio, and the output is code that developers paste into apps. So
+nothing is passed through unchecked:
+
+- A colour must be `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()` or `rgba()` with channels of 0–255; named colours are
+ refused.
+- A shadow must be offsets, a blur, an optional spread and such a colour.
+- Any other value is dropped.
+- Names become identifiers of letters and digits only (`T` in front of a leading digit; `Theme` when nothing is left).
+- Descriptions appear only inside one-line comments, with newlines and `*/` removed.
+- A Compose package name that is not a valid Kotlin package falls back to `network.openvibe.theme`.
+
+`test/tokens.test.js` round-trips all 50 built-in themes, compiles every target, and feeds in a theme whose name and
+values try to inject code.
+
+Status: the generated Kotlin, Swift and Dart are checked against the Compose Material 3, SwiftUI and Flutter APIs by
+review and by the tests' expected lines. This host has no Kotlin, Swift or Dart toolchain to compile them; a native
+CI job is the next step.
+
+## Why this shape (native apps, not web views)
+
+The owner, 2026-10-29: "translate it to official and optimized Android and iOS apps in the future that aren't just web
+browser reskin kiosks." A native app needs:
+
+- the person's theme as values its own UI toolkit understands (Compose `ColorScheme`, SwiftUI `Color`), not a
+ stylesheet;
+- the kit's scales (tap size, radii, spacing, type, motion) as numbers;
+- one source of truth.
+
+Today's CSS names stay as they are (every site keeps working), and they are aliases of the semantic tokens. The kit's
+components get the same treatment next: specs by anatomy, states and roles, plus JSON documents that native renderers
+can draw. See DESIGN-OVERHAUL-PLAN.md, pillars B and E.
@@ -43,6 +43,7 @@ const BROWSER = Object.freeze([
@@ -43,6 +43,7 @@ const BROWSER = Object.freeze([
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
'kit-data-web.js', // Kit 2's behaviour: chart readouts, scatter labels, drop zones (4.37.0; with kit-data.css)
'kit-data-web.js', // Kit 2's behaviour: chart readouts, scatter labels, drop zones (4.37.0; with kit-data.css)
+ 'tokens.js', // OpenVibe Tokens: a theme as DTCG tokens, compiled to CSS, Tailwind, Compose, SwiftUI, Flutter, Figma (4.40.0)
'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use
'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use
'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows
'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows
'agent-session.js', // a coding agent's session as people read it: steps grouped, live output, diffs, helpers, hand-offs
'agent-session.js', // a coding agent's session as people read it: steps grouped, live output, diffs, helpers, hand-offs
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.39.1",
+ "version": "4.40.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.39.1",
+ "version": "4.40.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.39.1",
+ "version": "4.40.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -63,6 +63,7 @@
@@ -63,6 +63,7 @@
"./kit": "./kit.js",
"./kit": "./kit.js",
"./kit-web": "./kit-web.js",
"./kit-web": "./kit-web.js",
"./kit-data-web": "./kit-data-web.js",
"./kit-data-web": "./kit-data-web.js",
+ "./tokens": "./tokens.js",
"./appbar": "./appbar.js",
"./appbar": "./appbar.js",
"./search": "./search.js",
"./search": "./search.js",
"./agent-session": "./agent-session.js",
"./agent-session": "./agent-session.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+// tokens.js: every built-in theme becomes a DTCG document and back without losing a colour, every target compiles,
+// and nothing a person typed in the studio reaches the generated code.
+const assert = require('assert');
+const tokens = require('../tokens');
+const { BUILTIN_THEMES, PAIRS } = require('../builtin-themes');
+
+// ── Round trip: all 50 themes ──
+for (const theme of BUILTIN_THEMES) {
+ const doc = tokens.fromTheme(theme);
+ const back = tokens.toTheme(doc);
+ for (const [css] of tokens.COLOR_MAP) {
+ const want = theme.variables[css] && tokens.parseColor(theme.variables[css]);
+ if (!want) continue;
+ const got = tokens.parseColor(back.variables[css]);
+ assert.deepStrictEqual(got, want, `${theme.slug} ${css}`);
+ }
+ for (const [css] of tokens.SHADOW_MAP) if (theme.variables[css] && tokens.parseShadow(theme.variables[css])) assert.ok(back.variables[css], `${theme.slug} ${css} survives`);
+ assert.strictEqual(back.mode, theme.mode === 'light' ? 'light' : 'dark');
+ assert.strictEqual(doc['color'].bg.base.$type, 'color');
+ assert.ok(doc.radius.md.$value === '14px' && doc.motion.duration.fast.$value === '150ms' && doc.size.tap.$value === '44px', 'the kit scales ride along');
+}
+console.log(`✓ ${BUILTIN_THEMES.length} built-in themes round-trip`);
+
+// ── Every target compiles, for one theme and for a dark + light pair ──
+const vibe = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === 'vibe'));
+const lightSlug = (PAIRS.find((p) => p.includes('vibe')) || []).find((s) => s !== 'vibe') || 'vibe-light';
+const vibeLight = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === lightSlug) || BUILTIN_THEMES.find((t) => t.mode === 'light'));
+for (const { id } of tokens.TARGETS) {
+ const one = tokens.compile(vibe, id);
+ const two = tokens.compile([vibe, vibeLight], id);
+ assert.ok(one && one.length > 200 && two && two.length > one.length * 0.9, `${id} compiles`);
+ assert.ok(tokens.fileName(vibe, id), `${id} has a file name`);
+}
+const css = tokens.compile(vibe, 'css');
+assert.match(css, /--ov-color-bg-base: #0a0f1c;/);
+assert.match(css, /--bg-primary: var\(--ov-color-bg-base\);/, 'the network\'s own names stay as aliases');
+assert.match(css, /--ov-radius-md: 14px;/);
+assert.match(tokens.compile([vibe, vibeLight], 'css'), /:root\[data-theme-mode="light"\] \{/, 'a pair gets one block per mode');
+const kt = tokens.compile([vibe, vibeLight], 'compose');
+assert.match(kt, /^package network\.openvibe\.theme$/m);
+assert.match(kt, /val ColorBgBase = Color\(0xFF0A0F1C\)/);
+assert.match(kt, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);
+assert.match(kt, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);
+assert.match(kt, /primary = VibeDarkTokens\.ColorAccentBase,/);
+assert.match(kt, /val RadiusMd = 14\.dp/);
+assert.match(kt, /val TypeSizeBody = 15\.sp/);
+const swift = tokens.compile(vibe, 'swiftui');
+assert.match(swift, /^import SwiftUI$/m);
+assert.match(swift, /public enum VibeTokens \{/);
+assert.match(swift, /public static let colorScheme: ColorScheme = \.dark/);
+assert.match(swift, /public static let colorAccentBase = Color\(\.sRGB, red: 0\.231, green: 0\.51, blue: 0\.965, opacity: 1\)/);
+assert.match(swift, /public static let motionDurationFast: Double = 0\.15/);
+const dart = tokens.compile(vibe, 'flutter');
+assert.match(dart, /static const colorBgBase = Color\(0xFF0A0F1C\);/);
+assert.match(dart, /const vibeDarkScheme = ColorScheme\(\n {2}brightness: Brightness\.dark,/);
+const fig = JSON.parse(tokens.compile([vibe, vibeLight], 'figma'));
+assert.deepStrictEqual(fig.modes, ['dark', 'light']);
+const bg = fig.variables.find((v) => v.name === 'color/bg/base');
+assert.ok(bg && bg.resolvedType === 'COLOR' && bg.valuesByMode.dark.r < 0.1 && bg.valuesByMode.light, 'Figma: one variable, a value per mode');
+const tw = tokens.compile(vibe, 'tailwind');
+assert.match(tw, /"base": "var\(--ov-color-bg-base\)"/);
+const dtcg = JSON.parse(tokens.compile(vibe, 'json'));
+assert.strictEqual(dtcg.color.accent.base.$value, '#3b82f6');
+assert.strictEqual(tokens.compile(vibe, 'nope'), null);
+console.log('✓ css, tailwind, compose, swiftui, flutter, figma and json compile');
+
+// ── Nothing typed reaches the code ──
+const evil = tokens.fromTheme({
+ name: 'Evil */ import os; val x = 1 // "\n\u2028 }',
+ slug: '../../etc',
+ description: 'line one\n*/ fun pwn() {}',
+ mode: 'dark',
+ variables: {
+ '--bg-primary': '#000; } body { background: url(https://evil.example) }',
+ '--text-primary': 'expression(alert(1))',
+ '--accent': 'rgb(300, 0, 0)',
+ '--border': '#123456',
+ '--shadow': '0 2px 12px rgba(0,0,0,0.45); background: red',
+ },
+});
+assert.strictEqual(evil.color.bg?.base, undefined, 'a value with code after it is dropped');
+assert.strictEqual(evil.color.text?.primary, undefined);
+assert.strictEqual(evil.color.accent?.base, undefined, 'an out-of-range channel is dropped');
+assert.strictEqual(evil.color.line.base.$value, '#123456');
+assert.strictEqual(evil.shadow, undefined, 'a shadow with a declaration after it is dropped');
+assert.strictEqual(evil.$extensions['network.openvibe'].slug, 'etc');
+// The theme's name and description may appear inside comments (one line, "*/" broken), never as code.
+const code = (src, id) => (id === 'json' || id === 'figma' ? '' : src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''));
+for (const { id } of tokens.TARGETS) {
+ const out = tokens.compile(evil, id);
+ assert.ok(!/evil\.example|expression\(|background: red/.test(out), `${id}: a dropped value appears nowhere`);
+ assert.ok(!/import os|fun pwn|val x = 1/.test(code(out, id)), `${id}: the name stays inside comments`);
+ assert.ok(!/[\u2028\u2029]/.test(out), `${id}: no line separators`);
+ if (id === 'json' || id === 'figma') JSON.parse(out);
+ if (id === 'compose' || id === 'swiftui' || id === 'flutter') assert.match(out, /(object|enum|class) EvilImportOsValX1Tokens/, `${id}: the name is an identifier`);
+}
+assert.strictEqual(tokens.ident('123 go'), 'T123Go');
+assert.strictEqual(tokens.ident(' '), 'Theme');
+assert.strictEqual(tokens.ident('Café Noir'), 'CafeNoir');
+assert.deepStrictEqual(tokens.parseColor('#abc'), { r: 170, g: 187, b: 204, a: 1 });
+assert.deepStrictEqual(tokens.parseColor('#11223380'), { r: 17, g: 34, b: 51, a: 0.502 });
+assert.strictEqual(tokens.parseColor('red'), null, 'named colours are not accepted (one spelling everywhere)');
+assert.strictEqual(tokens.compile(tokens.fromTheme({ name: 'x', variables: { '--accent': '#fff' } }), 'compose', { pkg: 'a; import evil' }).split('\n')[1], 'package network.openvibe.theme', 'a bad package name falls back');
+console.log('✓ names, values and package names are validated; nothing typed reaches generated code');
+console.log('tokens: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,400 @@
@@ -0,0 +1,400 @@
+/**
+ * openvibe-shared/tokens.js — OpenVibe Tokens: a theme as a platform-neutral design-token document, and the compilers
+ * that turn it into code for the web, Android, iOS, Flutter and Figma.
+ *
+ * The owner, 2026-10-29: "do everything in a way structure and modular so we can translate it to official and optimized
+ * Android and iOS apps in the future that aren't just web browser reskin kiosks." A theme on the network is a map of CSS
+ * custom properties (--bg-primary, --accent…). Here it becomes a W3C Design Tokens (DTCG) document of semantic tokens
+ * (color.bg.base, color.text.primary, color.accent.base…, plus the kit's radius, space, size, type and motion scales),
+ * and that one document compiles to every platform:
+ *
+ * const tokens = require('openvibe-shared/tokens');
+ * const doc = tokens.fromTheme(theme) theme: { name, slug, mode, variables } (builtin-themes, the studio)
+ * tokens.toTheme(doc) → { name, slug, mode, variables } (round trip, CSS names)
+ * tokens.flatten(doc) → [{ path: ['color', 'bg', 'base'], type: 'color', value }]
+ * tokens.compile(doc | [dark, light], target) → source text; target: css | tailwind | compose | swiftui | flutter
+ * | figma | json (tokens.TARGETS: label, file name, language)
+ *
+ * Compose gets an `object <Name>Tokens` and a Material 3 `darkColorScheme`/`lightColorScheme`; SwiftUI an `enum` of
+ * `Color`s and sizes with its `ColorScheme`; Flutter a `ColorScheme` and constants; Figma a variables collection with a
+ * mode per document. Pass a theme's dark and light halves together to get both schemes in one file.
+ *
+ * Values are validated, never passed through: a colour is #rgb/#rrggbb/#rrggbbaa or rgb()/rgba(), a shadow is offsets,
+ * blur, spread and such a colour; anything else is dropped. Names become identifiers of letters and digits only. So a
+ * theme someone wrote in the studio can never inject code into the Kotlin, Swift or Dart a developer pastes. Pure: no
+ * I/O. Runs in Node and in the browser (window.OpenVibeTokens).
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
+ root.OpenVibeTokens = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ const VERSION = 1;
+
+ // ── The network's CSS names → semantic token paths ──
+ // [css name, path, $type, description]. Order is the document's order.
+ const COLOR_MAP = [
+ ['--bg-primary', 'color.bg.base', 'The page behind everything'],
+ ['--bg-secondary', 'color.bg.surface', 'Bands and side panels'],
+ ['--bg-tertiary', 'color.bg.subtle', 'Wells, tracks and quiet fills'],
+ ['--bg-card', 'color.bg.raised', 'Cards, rows and sheets'],
+ ['--bg-hover', 'color.bg.hover', 'A control under the pointer or finger'],
+ ['--bg-input', 'color.bg.input', 'Text fields'],
+ ['--text-primary', 'color.text.primary', 'Body text and titles'],
+ ['--text-secondary', 'color.text.secondary', 'Supporting text'],
+ ['--text-muted', 'color.text.muted', 'Hints and meta lines'],
+ ['--accent', 'color.accent.base', 'Links, icons, focus rings'],
+ ['--accent-light', 'color.accent.light', 'The accent a step lighter'],
+ ['--accent-dark', 'color.accent.dark', 'The accent a step darker'],
+ ['--accent-strong', 'color.accent.strong', 'Filled buttons'],
+ ['--on-accent', 'color.accent.on', 'Text on the accent'],
+ ['--on-accent-strong', 'color.accent.onStrong', 'Text on a filled button'],
+ ['--accent-glow', 'color.accent.glow', 'Glows and halos'],
+ ['--border', 'color.line.base', 'Dividers and outlines'],
+ ['--border-light', 'color.line.strong', 'Outlines that must show'],
+ ['--success', 'color.status.ok.base', 'Success fills'],
+ ['--success-text', 'color.status.ok.text', 'Success text on any surface'],
+ ['--warning', 'color.status.warn.base', 'Warning fills'],
+ ['--warning-text', 'color.status.warn.text', 'Warning text on any surface'],
+ ['--danger', 'color.status.bad.base', 'Danger fills'],
+ ['--danger-text', 'color.status.bad.text', 'Danger text on any surface'],
+ ['--info', 'color.status.info.base', 'Information fills'],
+ ['--info-text', 'color.status.info.text', 'Information text on any surface'],
+ ['--live-red', 'color.status.live.base', 'LIVE badges'],
+ ['--live-red-text', 'color.status.live.text', 'LIVE text on any surface'],
+ ];
+ const SHADOW_MAP = [
+ ['--shadow', 'shadow.base', 'Cards and menus'],
+ ['--shadow-lg', 'shadow.large', 'Dialogs and sheets'],
+ ];
+ // Defaults for status colours a theme leaves out (as builtin-themes' STATUS_DEFAULTS).
+ const STATUS_DEFAULTS = { '--success': '#22c55e', '--warning': '#f59e0b', '--danger': '#ef4444', '--info': '#38bdf8', '--live-red': '#ef4444' };
+
+ // ── The kit's scales: the same on every theme (kit.css and room.css use these numbers) ──
+ const SCALES = {
+ radius: { $type: 'dimension', sm: 10, md: 14, lg: 20, pill: 999 },
+ space: { $type: 'dimension', xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 },
+ size: { $type: 'dimension', tap: 44, icon: 20, avatar: 34 },
+ type: {
+ family: { $type: 'fontFamily', base: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'] },
+ size: { $type: 'dimension', caption: 12, meta: 13, body: 15, lead: 17, title: 20, headline: 24, display: 34 },
+ weight: { $type: 'fontWeight', regular: 400, medium: 600, bold: 700, heavy: 800 },
+ },
+ motion: {
+ duration: { $type: 'duration', fast: 150, base: 250, slow: 400 },
+ easing: { $type: 'cubicBezier', standard: [0.2, 0.7, 0.2, 1] },
+ },
+ };
+
+ // ── Values: parse strictly, never pass through ──
+ function parseColor(v) {
+ const s = String(v == null ? '' : v).trim().toLowerCase();
+ let m = /^#([0-9a-f]{3})$/.exec(s);
+ if (m) return { r: parseInt(m[1][0] + m[1][0], 16), g: parseInt(m[1][1] + m[1][1], 16), b: parseInt(m[1][2] + m[1][2], 16), a: 1 };
+ m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(s);
+ if (m) return { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: m[2] ? Math.round(parseInt(m[2], 16) / 255 * 1000) / 1000 : 1 };
+ m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0|1|0?\.\d+|1\.0+))?\s*\)$/.exec(s);
+ if (m) {
+ const [r, g, b] = [m[1], m[2], m[3]].map(Number);
+ if (r > 255 || g > 255 || b > 255) return null;
+ return { r, g, b, a: m[4] == null ? 1 : Number(m[4]) };
+ }
+ return null;
+ }
+ const hex2 = (n) => n.toString(16).padStart(2, '0');
+ const toHex = (c) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${c.a < 1 ? hex2(Math.round(c.a * 255)) : ''}`;
+ const toCssColor = (c) => (c.a < 1 ? `rgba(${c.r},${c.g},${c.b},${c.a})` : toHex(c));
+
+ /** "0 2px 12px rgba(0,0,0,0.45)" → { offsetX, offsetY, blur, spread, color } (px numbers), or null. */
+ function parseShadow(v) {
+ const s = String(v == null ? '' : v).trim();
+ const m = /^(-?\d+(?:\.\d+)?)(?:px)?\s+(-?\d+(?:\.\d+)?)(?:px)?\s+(\d+(?:\.\d+)?)(?:px)?(?:\s+(-?\d+(?:\.\d+)?)(?:px)?)?\s+(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))$/.exec(s);
+ if (!m) return null;
+ const color = parseColor(m[5]);
+ if (!color) return null;
+ return { offsetX: Number(m[1]), offsetY: Number(m[2]), blur: Number(m[3]), spread: Number(m[4] || 0), color };
+ }
+ const shadowCss = (sh) => `${sh.offsetX}px ${sh.offsetY}px ${sh.blur}px${sh.spread ? ` ${sh.spread}px` : ''} ${toCssColor(sh.color)}`;
+
+ // A name as an identifier: letters and digits, PascalCase, never empty, never starting with a digit.
+ function ident(name, fallback = 'Theme') {
+ const words = String(name || '').normalize('NFKD').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);
+ let id = words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');
+ if (!id) id = fallback;
+ if (/^\d/.test(id)) id = `T${id}`;
+ return id.slice(0, 48);
+ }
+ // Text for a comment: one line, no comment terminators.
+ const commentText = (s) => String(s || '').replace(/[\r\n\u2028\u2029]+/g, ' ').replace(/\*\//g, '* /').replace(/[^\x20-\x7e -]/g, '').slice(0, 160);
+ const slugOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'theme';
+
+ function setPath(obj, path, leaf) {
+ const parts = path.split('.');
+ let o = obj;
+ for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});
+ o[parts[parts.length - 1]] = leaf;
+ }
+ function getPath(obj, path) {
+ let o = obj;
+ for (const p of path.split('.')) { if (!o || typeof o !== 'object') return undefined; o = o[p]; }
+ return o;
+ }
+
+ // ── Theme ⇄ document ──
+ /** A theme ({ name, slug, mode, variables, description }) as a DTCG token document. Unknown or invalid values are left out. */
+ function fromTheme(theme = {}) {
+ const vars = { ...STATUS_DEFAULTS, ...((theme && theme.variables) || {}) };
+ const mode = theme && theme.mode === 'light' ? 'light' : 'dark';
+ const doc = {
+ $description: commentText(theme.description || `${theme.name || 'A theme'} (${mode})`),
+ $extensions: { 'network.openvibe': { name: commentText(theme.name || 'Theme'), slug: slugOf(theme.slug || theme.name), mode, tokensVersion: VERSION } },
+ };
+ for (const [css, path, description] of COLOR_MAP) {
+ const c = parseColor(vars[css]);
+ if (c) setPath(doc, path, { $type: 'color', $value: toCssColor(c), $description: description });
+ }
+ for (const [css, path, description] of SHADOW_MAP) {
+ const sh = parseShadow(vars[css]);
+ if (sh) setPath(doc, path, { $type: 'shadow', $value: { color: toCssColor(sh.color), offsetX: `${sh.offsetX}px`, offsetY: `${sh.offsetY}px`, blur: `${sh.blur}px`, spread: `${sh.spread}px` }, $description: description });
+ }
+ for (const [group, def] of Object.entries(SCALES)) addScale(doc, group, def);
+ return doc;
+ }
+ function addScale(doc, path, def) {
+ const type = def.$type;
+ for (const [k, v] of Object.entries(def)) {
+ if (k === '$type') continue;
+ const p = `${path}.${k}`;
+ if (type) setPath(doc, p, { $type: type, $value: type === 'dimension' ? `${v}px` : type === 'duration' ? `${v}ms` : v });
+ else addScale(doc, p, v);
+ }
+ }
+
+ /** A document back to the network's CSS names: { name, slug, mode, variables }. */
+ function toTheme(doc = {}) {
+ const meta = (doc.$extensions && doc.$extensions['network.openvibe']) || {};
+ const variables = {};
+ for (const [css, path] of COLOR_MAP) {
+ const t = getPath(doc, path);
+ const c = t && parseColor(t.$value);
+ if (c) variables[css] = toCssColor(c);
+ }
+ for (const [css, path] of SHADOW_MAP) {
+ const t = getPath(doc, path);
+ const sh = t && t.$value && shadowOf(t.$value);
+ if (sh) variables[css] = shadowCss(sh);
+ }
+ const mode = meta.mode === 'light' ? 'light' : 'dark';
+ variables['--color-scheme'] = mode;
+ const accent = variables['--accent'] && parseColor(variables['--accent']);
+ if (accent) variables['--accent-rgb'] = `${accent.r},${accent.g},${accent.b}`;
+ return { name: meta.name || 'Theme', slug: slugOf(meta.slug || meta.name), mode, variables };
+ }
+ function shadowOf(v) {
+ const px = (x) => { const m = /^(-?\d+(?:\.\d+)?)px$/.exec(String(x)); return m ? Number(m[1]) : NaN; };
+ const color = parseColor(v.color);
+ const sh = { offsetX: px(v.offsetX), offsetY: px(v.offsetY), blur: px(v.blur), spread: px(v.spread || '0px'), color };
+ return color && [sh.offsetX, sh.offsetY, sh.blur, sh.spread].every(Number.isFinite) ? sh : null;
+ }
+
+ /** Every token as { path: [...], type, value, description }, in document order. */
+ function flatten(doc) {
+ const out = [];
+ (function walk(o, path, inherited) {
+ for (const [k, v] of Object.entries(o || {})) {
+ if (k.startsWith('$') || !v || typeof v !== 'object') continue;
+ const type = v.$type || inherited;
+ if ('$value' in v) out.push({ path: path.concat(k), type, value: v.$value, description: v.$description || '' });
+ else walk(v, path.concat(k), type);
+ }
+ })(doc, [], null);
+ return out;
+ }
+
+ // ── Compilers ──
+ const metaOf = (doc) => (doc && doc.$extensions && doc.$extensions['network.openvibe']) || { name: 'Theme', slug: 'theme', mode: 'dark' };
+ const camel = (path) => path.map((p, i) => (i ? p[0].toUpperCase() + p.slice(1) : p)).join('').replace(/[^A-Za-z0-9]/g, '');
+ const pascal = (path) => { const c = camel(path); return c[0].toUpperCase() + c.slice(1); };
+ const kebab = (path) => path.map((p) => p.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()).join('-');
+ const num = (v) => { const m = /^(-?\d+(?:\.\d+)?)(px|ms)?$/.exec(String(v)); return m ? Number(m[1]) : null; };
+ const header = (doc, lang) => {
+ const m = metaOf(doc);
+ const line = `${commentText(m.name)} (${m.mode}) — generated by openvibe-shared/tokens v${VERSION} from the OpenVibe theme "${m.slug}". Regenerate instead of editing.`;
+ return lang === 'swift' || lang === 'kotlin' || lang === 'dart' || lang === 'js' ? `// ${line}` : `/* ${line} */`;
+ };
+ const docsOf = (input) => (Array.isArray(input) ? input : [input]).filter(Boolean).slice(0, 2);
+ const colorTokens = (doc) => flatten(doc).filter((t) => t.type === 'color').map((t) => ({ ...t, c: parseColor(t.value) })).filter((t) => t.c);
+
+ function css(input, { selector = ':root', aliases = true } = {}) {
+ const out = [];
+ for (const doc of docsOf(input)) {
+ const m = metaOf(doc);
+ const sel = docsOf(input).length > 1 ? `${selector}[data-theme-mode="${m.mode}"]` : selector;
+ const lines = [];
+ for (const t of flatten(doc)) {
+ const name = `--ov-${kebab(t.path)}`;
+ let v = null;
+ if (t.type === 'color') { const c = parseColor(t.value); v = c && toCssColor(c); }
+ else if (t.type === 'shadow') { const sh = shadowOf(t.value || {}); v = sh && shadowCss(sh); }
+ else if (t.type === 'dimension' || t.type === 'duration') { const n = num(t.value); v = n == null ? null : `${n}${t.type === 'duration' ? 'ms' : 'px'}`; }
+ else if (t.type === 'fontWeight') v = Number.isFinite(Number(t.value)) ? String(Number(t.value)) : null;
+ else if (t.type === 'fontFamily') v = (Array.isArray(t.value) ? t.value : [t.value]).map((f) => (/^[A-Za-z0-9 -]+$/.test(f) ? (/\s/.test(f) ? `'${f}'` : f) : null)).filter(Boolean).join(', ') || null;
+ else if (t.type === 'cubicBezier') v = Array.isArray(t.value) && t.value.length === 4 && t.value.every((x) => Number.isFinite(Number(x))) ? `cubic-bezier(${t.value.map(Number).join(', ')})` : null;
+ if (v) lines.push(` ${name}: ${v};`);
+ }
+ if (aliases) {
+ lines.push(' /* The network\'s own names, for code written against them. */');
+ for (const [cssName, path] of COLOR_MAP.concat(SHADOW_MAP)) if (getPath(doc, path)) lines.push(` ${cssName}: var(--ov-${kebab(path.split('.'))});`);
+ lines.push(` color-scheme: ${m.mode === 'light' ? 'light' : 'dark'};`);
+ }
+ out.push(`${sel} {\n${lines.join('\n')}\n}`);
+ }
+ return `${header(docsOf(input)[0], 'css')}\n${out.join('\n\n')}\n`;
+ }
+
+ function tailwind(input) {
+ const doc = docsOf(input)[0];
+ const colors = {};
+ for (const t of colorTokens(doc).filter((x) => x.path[0] === 'color')) setPath(colors, t.path.slice(1).join('.'), `var(--ov-${kebab(t.path)})`);
+ const dim = (group) => Object.fromEntries(flatten(doc).filter((t) => t.path[0] === group).map((t) => [t.path.slice(1).join('-'), `var(--ov-${kebab(t.path)})`]));
+ const config = { theme: { extend: { colors, borderRadius: dim('radius'), spacing: dim('space'), boxShadow: { DEFAULT: 'var(--ov-shadow-base)', lg: 'var(--ov-shadow-large)' } } } };
+ return `${header(doc, 'js')}\n// Pair it with the CSS export (the variables), so a light/dark switch needs no rebuild.\nmodule.exports = ${JSON.stringify(config, null, 4)};\n`;
+ }
+
+ // Kotlin / Dart: 0xAARRGGBB.
+ const argb = (c) => `0x${hex2(Math.round(c.a * 255)).toUpperCase()}${[c.r, c.g, c.b].map((n) => hex2(n).toUpperCase()).join('')}`;
+ const pick = (doc, path) => { const t = getPath(doc, path); return t && parseColor(t.$value); };
+ // Material 3 roles from the semantic tokens (Compose and Flutter share it).
+ const M3_ROLES = [
+ ['primary', 'color.accent.base'], ['onPrimary', 'color.accent.on'],
+ ['primaryContainer', 'color.accent.strong'], ['onPrimaryContainer', 'color.accent.onStrong'],
+ ['secondary', 'color.accent.light'], ['onSecondary', 'color.accent.on'],
+ ['tertiary', 'color.status.info.base'],
+ ['background', 'color.bg.base'], ['onBackground', 'color.text.primary'],
+ ['surface', 'color.bg.raised'], ['onSurface', 'color.text.primary'],
+ ['surfaceVariant', 'color.bg.surface'], ['onSurfaceVariant', 'color.text.secondary'],
+ ['outline', 'color.line.strong'], ['outlineVariant', 'color.line.base'],
+ ['error', 'color.status.bad.base'], ['onError', 'color.accent.onStrong'],
+ ];
+
+ function compose(input, { pkg = 'network.openvibe.theme' } = {}) {
+ const docs = docsOf(input);
+ const name = ident(metaOf(docs[0]).name);
+ const safePkg = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/.test(pkg) ? pkg : 'network.openvibe.theme';
+ const parts = [`${header(docs[0], 'kotlin')}`, `package ${safePkg}`, '',
+ 'import androidx.compose.material3.ColorScheme', 'import androidx.compose.material3.darkColorScheme', 'import androidx.compose.material3.lightColorScheme',
+ 'import androidx.compose.ui.graphics.Color', 'import androidx.compose.ui.unit.dp', 'import androidx.compose.ui.unit.sp', ''];
+ for (const doc of docs) {
+ const m = metaOf(doc);
+ const obj = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
+ parts.push(`/** ${commentText(doc.$description)} */`, `object ${obj} {`);
+ for (const t of colorTokens(doc)) parts.push(` val ${pascal(t.path)} = Color(${argb(t.c)})`);
+ if (doc === docs[0]) {
+ for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {
+ const n = num(t.value);
+ if (n != null) parts.push(` val ${pascal(t.path)} = ${n}.${t.path[0] === 'type' ? 'sp' : 'dp'}`);
+ }
+ for (const t of flatten(doc).filter((x) => x.type === 'duration' || x.type === 'fontWeight')) {
+ const n = num(t.value);
+ if (n != null) parts.push(` const val ${pascal(t.path)} = ${Math.round(n)}`);
+ }
+ }
+ parts.push('}', '');
+ const fn = m.mode === 'light' ? 'lightColorScheme' : 'darkColorScheme';
+ const roles = M3_ROLES.filter(([, path]) => pick(doc, path)).map(([role, path]) => ` ${role} = ${obj}.${pascal(path.split('.'))},`);
+ parts.push(`/** Material 3 roles: MaterialTheme(colorScheme = ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme) { … } */`, `val ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme: ColorScheme = ${fn}(`, ...roles, ')', '');
+ }
+ return parts.join('\n');
+ }
+
+ function swiftui(input) {
+ const docs = docsOf(input);
+ const name = ident(metaOf(docs[0]).name);
+ const f = (x) => (Math.round(x / 255 * 1000) / 1000).toString();
+ const parts = [header(docs[0], 'swift'), 'import SwiftUI', ''];
+ for (const doc of docs) {
+ const m = metaOf(doc);
+ const en = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
+ parts.push(`/// ${commentText(doc.$description)}`, `public enum ${en} {`, ` public static let colorScheme: ColorScheme = .${m.mode === 'light' ? 'light' : 'dark'}`);
+ for (const t of colorTokens(doc)) parts.push(` public static let ${camel(t.path)} = Color(.sRGB, red: ${f(t.c.r)}, green: ${f(t.c.g)}, blue: ${f(t.c.b)}, opacity: ${t.c.a})`);
+ if (doc === docs[0]) {
+ for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {
+ const n = num(t.value);
+ if (n != null) parts.push(` public static let ${camel(t.path)}: CGFloat = ${n}`);
+ }
+ for (const t of flatten(doc).filter((x) => x.type === 'duration')) {
+ const n = num(t.value);
+ if (n != null) parts.push(` public static let ${camel(t.path)}: Double = ${n / 1000}`);
+ }
+ }
+ parts.push('}', '');
+ }
+ return parts.join('\n');
+ }
+
+ function flutter(input) {
+ const docs = docsOf(input);
+ const name = ident(metaOf(docs[0]).name);
+ const parts = [header(docs[0], 'dart'), "import 'package:flutter/material.dart';", ''];
+ for (const doc of docs) {
+ const m = metaOf(doc);
+ const cls = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
+ parts.push(`/// ${commentText(doc.$description)}`, `class ${cls} {`, ` ${cls}._();`);
+ for (const t of colorTokens(doc)) parts.push(` static const ${camel(t.path)} = Color(${argb(t.c)});`);
+ if (doc === docs[0]) for (const t of flatten(doc).filter((x) => x.type === 'dimension')) { const n = num(t.value); if (n != null) parts.push(` static const double ${camel(t.path)} = ${n};`); }
+ parts.push('}', '');
+ const roles = M3_ROLES.filter(([role, path]) => pick(doc, path) && role !== 'surfaceVariant').map(([role, path]) => ` ${role}: ${cls}.${camel(path.split('.'))},`);
+ parts.push(`const ${name[0].toLowerCase() + name.slice(1)}${m.mode === 'light' ? 'Light' : 'Dark'}Scheme = ColorScheme(`, ` brightness: Brightness.${m.mode === 'light' ? 'light' : 'dark'},`, ...roles, ');', '');
+ }
+ return parts.join('\n');
+ }
+
+ function figma(input) {
+ const docs = docsOf(input);
+ const meta = metaOf(docs[0]);
+ const modes = docs.map((d) => metaOf(d).mode);
+ const byPath = new Map();
+ docs.forEach((doc, i) => {
+ for (const t of flatten(doc)) {
+ const key = t.path.join('/');
+ if (!byPath.has(key)) byPath.set(key, { name: key, resolvedType: t.type === 'color' ? 'COLOR' : t.type === 'dimension' || t.type === 'duration' || t.type === 'fontWeight' ? 'FLOAT' : null, description: t.description, valuesByMode: {} });
+ const v = byPath.get(key);
+ if (v.resolvedType === 'COLOR') { const c = parseColor(t.value); if (c) v.valuesByMode[modes[i]] = { r: +(c.r / 255).toFixed(4), g: +(c.g / 255).toFixed(4), b: +(c.b / 255).toFixed(4), a: c.a }; }
+ else if (v.resolvedType === 'FLOAT') { const n = num(t.value); if (n != null) v.valuesByMode[modes[i]] = n; }
+ }
+ });
+ const variables = [...byPath.values()].filter((v) => v.resolvedType && Object.keys(v.valuesByMode).length);
+ return `${JSON.stringify({ collection: `OpenVibe · ${commentText(meta.name)}`, modes, variables, generator: `openvibe-shared/tokens v${VERSION}` }, null, 2)}\n`;
+ }
+
+ const json = (input) => `${JSON.stringify(docsOf(input).length > 1 ? { $description: 'A theme\'s dark and light halves', dark: docsOf(input).find((d) => metaOf(d).mode === 'dark') || docsOf(input)[0], light: docsOf(input).find((d) => metaOf(d).mode === 'light') || docsOf(input)[1] } : docsOf(input)[0], null, 2)}\n`;
+
+ const TARGETS = [
+ { id: 'css', label: 'CSS variables', ext: 'css', lang: 'css', build: css },
+ { id: 'tailwind', label: 'Tailwind', ext: 'tailwind.config.js', lang: 'js', build: tailwind },
+ { id: 'compose', label: 'Android (Jetpack Compose)', ext: 'kt', lang: 'kotlin', build: compose },
+ { id: 'swiftui', label: 'iOS (SwiftUI)', ext: 'swift', lang: 'swift', build: swiftui },
+ { id: 'flutter', label: 'Flutter', ext: 'dart', lang: 'dart', build: flutter },
+ { id: 'figma', label: 'Figma variables', ext: 'figma.json', lang: 'json', build: figma },
+ { id: 'json', label: 'Design tokens (DTCG JSON)', ext: 'tokens.json', lang: 'json', build: json },
+ ];
+ /** Source text for one target; file: a file name for it. Unknown target → null. */
+ function compile(input, target, opts = {}) {
+ const t = TARGETS.find((x) => x.id === target);
+ if (!t || !docsOf(input).length) return null;
+ return t.build(input, opts);
+ }
+ function fileName(input, target) {
+ const t = TARGETS.find((x) => x.id === target);
+ const base = ident(metaOf(docsOf(input)[0]).name);
+ if (!t) return null;
+ return t.ext.includes('.') ? `${slugOf(base)}.${t.ext}` : `${base}Theme.${t.ext}`;
+ }
+
+ return {
+ VERSION, COLOR_MAP, SHADOW_MAP, SCALES, TARGETS: TARGETS.map(({ build, ...rest }) => rest),
+ fromTheme, toTheme, flatten, compile, fileName, parseColor, parseShadow, ident,
+ };
+});