openvibers/openvibe.shared tokens.js
tokens.js
| 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 | ; |
| 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 |