openvibers/openvibe.shared tokens.js

tokens.js

main 27.1 KB · 419 lines · javascript

1/**
2 * openvibe-shared/tokens.js — OpenVibe Tokens: a theme as a platform-neutral design-token document, and the compilers
3 * that turn it into code for the web, Android, iOS, Flutter and Figma.
4 *
5 * The owner, 2026-10-29: "do everything in a way structure and modular so we can translate it to official and optimized
6 * 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
7 * custom properties (--bg-primary, --accent…). Here it becomes a W3C Design Tokens (DTCG) document of semantic tokens
8 * (color.bg.base, color.text.primary, color.accent.base…, plus the kit's radius, space, size, type and motion scales),
9 * and that one document compiles to every platform:
10 *
11 * const tokens = require('openvibe-shared/tokens');
12 * const doc = tokens.fromTheme(theme) theme: { name, slug, mode, variables } (builtin-themes, the studio)
13 * tokens.toTheme(doc) → { name, slug, mode, variables } (round trip, CSS names)
14 * tokens.flatten(doc) → [{ path: ['color', 'bg', 'base'], type: 'color', value }]
15 * tokens.compile(doc | [dark, light], target) → source text; target: css | tailwind | compose | swiftui | flutter
16 * | figma | json (tokens.TARGETS: label, file name, language)
17 *
18 * Compose gets an `object <Name>Tokens` and a Material 3 `darkColorScheme`/`lightColorScheme`; SwiftUI an `enum` of
19 * `Color`s and sizes with its `ColorScheme`; Flutter a `ColorScheme` and constants; Figma a variables collection with a
20 * mode per document. Pass a theme's dark and light halves together to get both schemes in one file.
21 *
22 * Values are validated, never passed through: a colour is #rgb/#rrggbb/#rrggbbaa or rgb()/rgba(), a shadow is offsets,
23 * blur, spread and such a colour; anything else is dropped. Names become identifiers of letters and digits only. So a
24 * theme someone wrote in the studio can never inject code into the Kotlin, Swift or Dart a developer pastes. Pure: no
25 * I/O. Runs in Node and in the browser (window.OpenVibeTokens).
26 */
27(function (root, factory) {
28 const api = factory();
29 if (typeof module === 'object' && module.exports) { module.exports = api; return; }
30 root.OpenVibeTokens = api;
31})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
32 'use strict';
33
34 const VERSION = 1;
35
36 // ── The network's CSS names → semantic token paths ──
37 // [css name, path, $type, description]. Order is the document's order.
38 const COLOR_MAP = [
39 ['--bg-primary', 'color.bg.base', 'The page behind everything'],
40 ['--bg-secondary', 'color.bg.surface', 'Bands and side panels'],
41 ['--bg-tertiary', 'color.bg.subtle', 'Wells, tracks and quiet fills'],
42 ['--bg-card', 'color.bg.raised', 'Cards, rows and sheets'],
43 ['--bg-hover', 'color.bg.hover', 'A control under the pointer or finger'],
44 ['--bg-input', 'color.bg.input', 'Text fields'],
45 ['--text-primary', 'color.text.primary', 'Body text and titles'],
46 ['--text-secondary', 'color.text.secondary', 'Supporting text'],
47 ['--text-muted', 'color.text.muted', 'Hints and meta lines'],
48 ['--accent', 'color.accent.base', 'Links, icons, focus rings'],
49 ['--accent-light', 'color.accent.light', 'The accent a step lighter'],
50 ['--accent-dark', 'color.accent.dark', 'The accent a step darker'],
51 ['--accent-strong', 'color.accent.strong', 'Filled buttons'],
52 ['--on-accent', 'color.accent.on', 'Text on the accent'],
53 ['--on-accent-strong', 'color.accent.onStrong', 'Text on a filled button'],
54 ['--accent-glow', 'color.accent.glow', 'Glows and halos'],
55 ['--border', 'color.line.base', 'Dividers and outlines'],
56 ['--border-light', 'color.line.strong', 'Outlines that must show'],
57 ['--success', 'color.status.ok.base', 'Success fills'],
58 ['--success-text', 'color.status.ok.text', 'Success text on any surface'],
59 ['--warning', 'color.status.warn.base', 'Warning fills'],
60 ['--warning-text', 'color.status.warn.text', 'Warning text on any surface'],
61 ['--danger', 'color.status.bad.base', 'Danger fills'],
62 ['--danger-text', 'color.status.bad.text', 'Danger text on any surface'],
63 ['--info', 'color.status.info.base', 'Information fills'],
64 ['--info-text', 'color.status.info.text', 'Information text on any surface'],
65 ['--live-red', 'color.status.live.base', 'LIVE badges'],
66 ['--live-red-text', 'color.status.live.text', 'LIVE text on any surface'],
67 ];
68 const SHADOW_MAP = [
69 ['--shadow', 'shadow.base', 'Cards and menus'],
70 ['--shadow-lg', 'shadow.large', 'Dialogs and sheets'],
71 ];
72 // Defaults for status colours a theme leaves out (as builtin-themes' STATUS_DEFAULTS).
73 const STATUS_DEFAULTS = { '--success': '#22c55e', '--warning': '#f59e0b', '--danger': '#ef4444', '--info': '#38bdf8', '--live-red': '#ef4444' };
74
75 // ── The kit's scales: the same on every theme (kit.css and room.css use these numbers) ──
76 const SCALES = {
77 radius: { $type: 'dimension', sm: 10, md: 14, lg: 20, pill: 999 },
78 space: { $type: 'dimension', xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 },
79 size: { $type: 'dimension', tap: 44, icon: 20, avatar: 34 },
80 type: {
81 family: { $type: 'fontFamily', base: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'] },
82 size: { $type: 'dimension', caption: 12, meta: 13, body: 15, lead: 17, title: 20, headline: 24, display: 34 },
83 weight: { $type: 'fontWeight', regular: 400, medium: 600, bold: 700, heavy: 800 },
84 },
85 motion: {
86 duration: { $type: 'duration', fast: 180, base: 260, slow: 360, reduced: 0 },
87 easing: {
88 $type: 'cubicBezier',
89 standard: [0.2, 0.7, 0.2, 1],
90 back: [0.34, 1.56, 0.64, 1],
91 },
92 },
93 };
94
95 // ── Values: parse strictly, never pass through ──
96 function parseColor(v) {
97 const s = String(v == null ? '' : v).trim().toLowerCase();
98 let m = /^#([0-9a-f]{3})$/.exec(s);
99 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 };
100 m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(s);
101 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 };
102 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);
103 if (m) {
104 const [r, g, b] = [m[1], m[2], m[3]].map(Number);
105 if (r > 255 || g > 255 || b > 255) return null;
106 return { r, g, b, a: m[4] == null ? 1 : Number(m[4]) };
107 }
108 return null;
109 }
110 const hex2 = (n) => n.toString(16).padStart(2, '0');
111 const toHex = (c) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${c.a < 1 ? hex2(Math.round(c.a * 255)) : ''}`;
112 const toCssColor = (c) => (c.a < 1 ? `rgba(${c.r},${c.g},${c.b},${c.a})` : toHex(c));
113
114 /** "0 2px 12px rgba(0,0,0,0.45)" → { offsetX, offsetY, blur, spread, color } (px numbers), or null. */
115 function parseShadow(v) {
116 const s = String(v == null ? '' : v).trim();
117 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);
118 if (!m) return null;
119 const color = parseColor(m[5]);
120 if (!color) return null;
121 return { offsetX: Number(m[1]), offsetY: Number(m[2]), blur: Number(m[3]), spread: Number(m[4] || 0), color };
122 }
123 const shadowCss = (sh) => `${sh.offsetX}px ${sh.offsetY}px ${sh.blur}px${sh.spread ? ` ${sh.spread}px` : ''} ${toCssColor(sh.color)}`;
124
125 // A name as an identifier: letters and digits, PascalCase, never empty, never starting with a digit.
126 function ident(name, fallback = 'Theme') {
127 const words = String(name || '').normalize('NFKD').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);
128 let id = words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');
129 if (!id) id = fallback;
130 if (/^\d/.test(id)) id = `T${id}`;
131 return id.slice(0, 48);
132 }
133 // Text for a comment: one line, no comment terminators.
134 const commentText = (s) => String(s || '').replace(/[\r\n\u2028\u2029]+/g, ' ').replace(/\*\//g, '* /').replace(/[^\x20-\x7e -￿]/g, '').slice(0, 160);
135 const slugOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'theme';
136
137 function setPath(obj, path, leaf) {
138 const parts = path.split('.');
139 let o = obj;
140 for (const p of parts.slice(0, -1)) o = o[p] || (o[p] = {});
141 o[parts[parts.length - 1]] = leaf;
142 }
143 function getPath(obj, path) {
144 let o = obj;
145 for (const p of path.split('.')) { if (!o || typeof o !== 'object') return undefined; o = o[p]; }
146 return o;
147 }
148
149 // ── Theme ⇄ document ──
150 /** A theme ({ name, slug, mode, variables, description }) as a DTCG token document. Unknown or invalid values are left out. */
151 function fromTheme(theme = {}) {
152 const vars = { ...STATUS_DEFAULTS, ...((theme && theme.variables) || {}) };
153 const mode = theme && theme.mode === 'light' ? 'light' : 'dark';
154 const doc = {
155 $description: commentText(theme.description || `${theme.name || 'A theme'} (${mode})`),
156 $extensions: { 'network.openvibe': { name: commentText(theme.name || 'Theme'), slug: slugOf(theme.slug || theme.name), mode, tokensVersion: VERSION } },
157 };
158 for (const [css, path, description] of COLOR_MAP) {
159 const c = parseColor(vars[css]);
160 if (c) setPath(doc, path, { $type: 'color', $value: toCssColor(c), $description: description });
161 }
162 for (const [css, path, description] of SHADOW_MAP) {
163 const sh = parseShadow(vars[css]);
164 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 });
165 }
166 for (const [group, def] of Object.entries(SCALES)) addScale(doc, group, def);
167 return doc;
168 }
169 function addScale(doc, path, def) {
170 const type = def.$type;
171 for (const [k, v] of Object.entries(def)) {
172 if (k === '$type') continue;
173 const p = `${path}.${k}`;
174 if (type) setPath(doc, p, { $type: type, $value: type === 'dimension' ? `${v}px` : type === 'duration' ? `${v}ms` : v });
175 else addScale(doc, p, v);
176 }
177 }
178
179 /** A document back to the network's CSS names: { name, slug, mode, variables }. */
180 function toTheme(doc = {}) {
181 const meta = (doc.$extensions && doc.$extensions['network.openvibe']) || {};
182 const variables = {};
183 for (const [css, path] of COLOR_MAP) {
184 const t = getPath(doc, path);
185 const c = t && parseColor(t.$value);
186 if (c) variables[css] = toCssColor(c);
187 }
188 for (const [css, path] of SHADOW_MAP) {
189 const t = getPath(doc, path);
190 const sh = t && t.$value && shadowOf(t.$value);
191 if (sh) variables[css] = shadowCss(sh);
192 }
193 const mode = meta.mode === 'light' ? 'light' : 'dark';
194 variables['--color-scheme'] = mode;
195 const accent = variables['--accent'] && parseColor(variables['--accent']);
196 if (accent) variables['--accent-rgb'] = `${accent.r},${accent.g},${accent.b}`;
197 return { name: meta.name || 'Theme', slug: slugOf(meta.slug || meta.name), mode, variables };
198 }
199 function shadowOf(v) {
200 const px = (x) => { const m = /^(-?\d+(?:\.\d+)?)px$/.exec(String(x)); return m ? Number(m[1]) : NaN; };
201 const color = parseColor(v.color);
202 const sh = { offsetX: px(v.offsetX), offsetY: px(v.offsetY), blur: px(v.blur), spread: px(v.spread || '0px'), color };
203 return color && [sh.offsetX, sh.offsetY, sh.blur, sh.spread].every(Number.isFinite) ? sh : null;
204 }
205
206 /** Every token as { path: [...], type, value, description }, in document order. */
207 function flatten(doc) {
208 const out = [];
209 (function walk(o, path, inherited) {
210 for (const [k, v] of Object.entries(o || {})) {
211 if (k.startsWith('$') || !v || typeof v !== 'object') continue;
212 const type = v.$type || inherited;
213 if ('$value' in v) out.push({ path: path.concat(k), type, value: v.$value, description: v.$description || '' });
214 else walk(v, path.concat(k), type);
215 }
216 })(doc, [], null);
217 return out;
218 }
219
220 // ── Compilers ──
221 const metaOf = (doc) => (doc && doc.$extensions && doc.$extensions['network.openvibe']) || { name: 'Theme', slug: 'theme', mode: 'dark' };
222 const camel = (path) => path.map((p, i) => (i ? p[0].toUpperCase() + p.slice(1) : p)).join('').replace(/[^A-Za-z0-9]/g, '');
223 const pascal = (path) => { const c = camel(path); return c[0].toUpperCase() + c.slice(1); };
224 const kebab = (path) => path.map((p) => p.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()).join('-');
225 const num = (v) => { const m = /^(-?\d+(?:\.\d+)?)(px|ms)?$/.exec(String(v)); return m ? Number(m[1]) : null; };
226 const header = (doc, lang) => {
227 const m = metaOf(doc);
228 const line = `${commentText(m.name)} (${m.mode}) — generated by openvibe-shared/tokens v${VERSION} from the OpenVibe theme "${m.slug}". Regenerate instead of editing.`;
229 return lang === 'swift' || lang === 'kotlin' || lang === 'dart' || lang === 'js' ? `// ${line}` : `/* ${line} */`;
230 };
231 const docsOf = (input) => (Array.isArray(input) ? input : [input]).filter(Boolean).slice(0, 2);
232 const colorTokens = (doc) => flatten(doc).filter((t) => t.type === 'color').map((t) => ({ ...t, c: parseColor(t.value) })).filter((t) => t.c);
233
234 function css(input, { selector = ':root', aliases = true } = {}) {
235 const out = [];
236 for (const doc of docsOf(input)) {
237 const m = metaOf(doc);
238 const sel = docsOf(input).length > 1 ? `${selector}[data-theme-mode="${m.mode}"]` : selector;
239 const lines = [];
240 for (const t of flatten(doc)) {
241 const name = `--ov-${kebab(t.path)}`;
242 let v = null;
243 if (t.type === 'color') { const c = parseColor(t.value); v = c && toCssColor(c); }
244 else if (t.type === 'shadow') { const sh = shadowOf(t.value || {}); v = sh && shadowCss(sh); }
245 else if (t.type === 'dimension' || t.type === 'duration') { const n = num(t.value); v = n == null ? null : `${n}${t.type === 'duration' ? 'ms' : 'px'}`; }
246 else if (t.type === 'fontWeight') v = Number.isFinite(Number(t.value)) ? String(Number(t.value)) : null;
247 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;
248 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;
249 if (v) lines.push(` ${name}: ${v};`);
250 }
251 if (aliases) {
252 lines.push(' /* The network\'s own names, for code written against them. */');
253 for (const [cssName, path] of COLOR_MAP.concat(SHADOW_MAP)) if (getPath(doc, path)) lines.push(` ${cssName}: var(--ov-${kebab(path.split('.'))});`);
254 lines.push(` color-scheme: ${m.mode === 'light' ? 'light' : 'dark'};`);
255 }
256 out.push(`${sel} {\n${lines.join('\n')}\n}`);
257 }
258 return `${header(docsOf(input)[0], 'css')}\n${out.join('\n\n')}\n`;
259 }
260
261 function tailwind(input) {
262 const doc = docsOf(input)[0];
263 const colors = {};
264 for (const t of colorTokens(doc).filter((x) => x.path[0] === 'color')) setPath(colors, t.path.slice(1).join('.'), `var(--ov-${kebab(t.path)})`);
265 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)})`]));
266 const config = { theme: { extend: { colors, borderRadius: dim('radius'), spacing: dim('space'), boxShadow: { DEFAULT: 'var(--ov-shadow-base)', lg: 'var(--ov-shadow-large)' } } } };
267 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`;
268 }
269
270 // Kotlin / Dart: 0xAARRGGBB.
271 const argb = (c) => `0x${hex2(Math.round(c.a * 255)).toUpperCase()}${[c.r, c.g, c.b].map((n) => hex2(n).toUpperCase()).join('')}`;
272 const pick = (doc, path) => { const t = getPath(doc, path); return t && parseColor(t.$value); };
273 // Material 3 roles from the semantic tokens (Compose and Flutter share it).
274 const M3_ROLES = [
275 ['primary', 'color.accent.base'], ['onPrimary', 'color.accent.on'],
276 ['primaryContainer', 'color.accent.strong'], ['onPrimaryContainer', 'color.accent.onStrong'],
277 ['secondary', 'color.accent.light'], ['onSecondary', 'color.accent.on'],
278 ['tertiary', 'color.status.info.base'],
279 ['background', 'color.bg.base'], ['onBackground', 'color.text.primary'],
280 ['surface', 'color.bg.raised'], ['onSurface', 'color.text.primary'],
281 ['surfaceVariant', 'color.bg.surface'], ['onSurfaceVariant', 'color.text.secondary'],
282 ['outline', 'color.line.strong'], ['outlineVariant', 'color.line.base'],
283 ['error', 'color.status.bad.base'], ['onError', 'color.accent.onStrong'],
284 ];
285 // Material 3's container roles (cards, the navigation bar and its selected pill, dialogs, chips, menus). Without them
286 // Compose fills those parts with Material's own lavender: OpenVibe Link's first screens showed it (11-07). Compose
287 // only; Flutter's ColorScheme takes them only from 3.22, and SwiftUI has no such roles.
288 const M3_COMPOSE_CONTAINERS = [
289 ['surfaceContainerLowest', 'color.bg.base'], ['surfaceContainerLow', 'color.bg.raised'],
290 ['surfaceContainer', 'color.bg.raised'], ['surfaceContainerHigh', 'color.bg.surface'],
291 ['surfaceContainerHighest', 'color.bg.subtle'], ['surfaceBright', 'color.bg.hover'], ['surfaceDim', 'color.bg.base'],
292 ['surfaceTint', 'color.bg.raised'],
293 ['secondaryContainer', 'color.bg.hover'], ['onSecondaryContainer', 'color.accent.light'],
294 ['inverseSurface', 'color.text.primary'], ['inverseOnSurface', 'color.bg.base'],
295 ];
296
297 function compose(input, { pkg = 'network.openvibe.theme' } = {}) {
298 const docs = docsOf(input);
299 const name = ident(metaOf(docs[0]).name);
300 const safePkg = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/.test(pkg) ? pkg : 'network.openvibe.theme';
301 const parts = [`${header(docs[0], 'kotlin')}`, `package ${safePkg}`, '',
302 'import androidx.compose.material3.ColorScheme', 'import androidx.compose.material3.darkColorScheme', 'import androidx.compose.material3.lightColorScheme',
303 'import androidx.compose.ui.graphics.Color', 'import androidx.compose.ui.unit.dp', 'import androidx.compose.ui.unit.sp', ''];
304 for (const doc of docs) {
305 const m = metaOf(doc);
306 const obj = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
307 parts.push(`/** ${commentText(doc.$description)} */`, `object ${obj} {`);
308 for (const t of colorTokens(doc)) parts.push(` val ${pascal(t.path)} = Color(${argb(t.c)})`);
309 parts.push('}', '');
310 const fn = m.mode === 'light' ? 'lightColorScheme' : 'darkColorScheme';
311 const roles = [...M3_ROLES, ...M3_COMPOSE_CONTAINERS].filter(([, path]) => pick(doc, path)).map(([role, path]) => ` ${role} = ${obj}.${pascal(path.split('.'))},`);
312 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, ')', '');
313 }
314 // The kit's scales: the same on every theme (openvibe-shared/components' reference views use OvScale).
315 parts.push('/** The OpenVibe kit\'s radius, space, size, type and motion scales: the same in every theme. */', 'object OvScale {');
316 for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) {
317 const n = num(t.value);
318 if (n != null) parts.push(` val ${pascal(t.path)} = ${n}.${t.path[0] === 'type' ? 'sp' : 'dp'}`);
319 }
320 for (const t of flatten(docs[0]).filter((x) => x.type === 'duration' || x.type === 'fontWeight')) {
321 const n = num(t.value);
322 if (n != null) parts.push(` const val ${pascal(t.path)} = ${Math.round(n)}`);
323 }
324 parts.push('}', '');
325 return parts.join('\n');
326 }
327
328 function swiftui(input) {
329 const docs = docsOf(input);
330 const name = ident(metaOf(docs[0]).name);
331 const f = (x) => (Math.round(x / 255 * 1000) / 1000).toString();
332 const parts = [header(docs[0], 'swift'), 'import SwiftUI', ''];
333 for (const doc of docs) {
334 const m = metaOf(doc);
335 const en = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
336 parts.push(`/// ${commentText(doc.$description)}`, `public enum ${en} {`, ` public static let colorScheme: ColorScheme = .${m.mode === 'light' ? 'light' : 'dark'}`);
337 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})`);
338 parts.push('}', '');
339 }
340 parts.push('/// The OpenVibe kit\'s radius, space, size, type and motion scales: the same in every theme.', 'public enum OvScale {');
341 for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) {
342 const n = num(t.value);
343 if (n != null) parts.push(` public static let ${camel(t.path)}: CGFloat = ${n}`);
344 }
345 for (const t of flatten(docs[0]).filter((x) => x.type === 'duration')) {
346 const n = num(t.value);
347 if (n != null) parts.push(` public static let ${camel(t.path)}: Double = ${n / 1000}`);
348 }
349 parts.push('}', '');
350 return parts.join('\n');
351 }
352
353 function flutter(input) {
354 const docs = docsOf(input);
355 const name = ident(metaOf(docs[0]).name);
356 const parts = [header(docs[0], 'dart'), "import 'package:flutter/material.dart';", ''];
357 for (const doc of docs) {
358 const m = metaOf(doc);
359 const cls = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
360 parts.push(`/// ${commentText(doc.$description)}`, `class ${cls} {`, ` ${cls}._();`);
361 for (const t of colorTokens(doc)) parts.push(` static const ${camel(t.path)} = Color(${argb(t.c)});`);
362 parts.push('}', '');
363 const roles = M3_ROLES.filter(([role, path]) => pick(doc, path) && role !== 'surfaceVariant').map(([role, path]) => ` ${role}: ${cls}.${camel(path.split('.'))},`);
364 parts.push(`const ${name[0].toLowerCase() + name.slice(1)}${m.mode === 'light' ? 'Light' : 'Dark'}Scheme = ColorScheme(`, ` brightness: Brightness.${m.mode === 'light' ? 'light' : 'dark'},`, ...roles, ');', '');
365 }
366 parts.push('/// The OpenVibe kit\'s radius, space, size and type scales: the same in every theme.', 'class OvScale {', ' OvScale._();');
367 for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) { const n = num(t.value); if (n != null) parts.push(` static const double ${camel(t.path)} = ${n};`); }
368 parts.push('}', '');
369 return parts.join('\n');
370 }
371
372 function figma(input) {
373 const docs = docsOf(input);
374 const meta = metaOf(docs[0]);
375 const modes = docs.map((d) => metaOf(d).mode);
376 const byPath = new Map();
377 docs.forEach((doc, i) => {
378 for (const t of flatten(doc)) {
379 const key = t.path.join('/');
380 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: {} });
381 const v = byPath.get(key);
382 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 }; }
383 else if (v.resolvedType === 'FLOAT') { const n = num(t.value); if (n != null) v.valuesByMode[modes[i]] = n; }
384 }
385 });
386 const variables = [...byPath.values()].filter((v) => v.resolvedType && Object.keys(v.valuesByMode).length);
387 return `${JSON.stringify({ collection: `OpenVibe · ${commentText(meta.name)}`, modes, variables, generator: `openvibe-shared/tokens v${VERSION}` }, null, 2)}\n`;
388 }
389
390 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`;
391
392 const TARGETS = [
393 { id: 'css', label: 'CSS variables', ext: 'css', lang: 'css', build: css },
394 { id: 'tailwind', label: 'Tailwind', ext: 'tailwind.config.js', lang: 'js', build: tailwind },
395 { id: 'compose', label: 'Android (Jetpack Compose)', ext: 'kt', lang: 'kotlin', build: compose },
396 { id: 'swiftui', label: 'iOS (SwiftUI)', ext: 'swift', lang: 'swift', build: swiftui },
397 { id: 'flutter', label: 'Flutter', ext: 'dart', lang: 'dart', build: flutter },
398 { id: 'figma', label: 'Figma variables', ext: 'figma.json', lang: 'json', build: figma },
399 { id: 'json', label: 'Design tokens (DTCG JSON)', ext: 'tokens.json', lang: 'json', build: json },
400 ];
401 /** Source text for one target; file: a file name for it. Unknown target → null. */
402 function compile(input, target, opts = {}) {
403 const t = TARGETS.find((x) => x.id === target);
404 if (!t || !docsOf(input).length) return null;
405 return t.build(input, opts);
406 }
407 function fileName(input, target) {
408 const t = TARGETS.find((x) => x.id === target);
409 const base = ident(metaOf(docsOf(input)[0]).name);
410 if (!t) return null;
411 return t.ext.includes('.') ? `${slugOf(base)}.${t.ext}` : `${base}Theme.${t.ext}`;
412 }
413
414 return {
415 VERSION, COLOR_MAP, SHADOW_MAP, SCALES, TARGETS: TARGETS.map(({ build, ...rest }) => rest),
416 fromTheme, toTheme, flatten, compile, fileName, parseColor, parseShadow, ident,
417 };
418});
419