orbcafe-ui 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * theme-core.mjs — Open Design preset → ORBCAFE theme pack. Core library.
3
+ *
4
+ * Zero-dependency, side-effect-free helpers used by both the published
5
+ * `orbcafe-theme` CLI (bin/orbcafe-theme.mjs) and the repo-internal
6
+ * scripts/build-theme.mjs.
7
+ *
8
+ * Open Design stores design systems on the developer's machine at:
9
+ * <app-support>/Open Design/namespaces/<ns>/data/design-systems/<slug>/
10
+ * ├── metadata.json / brand.json
11
+ * ├── system/tokens.default.json (Ant-Design-style tokens, light)
12
+ * ├── system/tokens.dark.json (same keys, dark)
13
+ * └── fonts/ + fonts.css (optional brand fonts)
14
+ *
15
+ * Only BASE --orb-* tokens are overridden; every derived token re-derives
16
+ * through color-mix() inside orbis.css.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
20
+ import { join, resolve } from 'node:path'
21
+ import { homedir, platform } from 'node:os'
22
+
23
+ /* ----------------------------------------------------- Open Design discovery */
24
+
25
+ /** Candidate Open Design data roots for the current platform (existing only). */
26
+ export const findOpenDesignRoots = () => {
27
+ const home = homedir()
28
+ const os = platform()
29
+ const candidates = []
30
+ if (process.env.OPEN_DESIGN_HOME) candidates.push(process.env.OPEN_DESIGN_HOME)
31
+ if (os === 'darwin') candidates.push(join(home, 'Library', 'Application Support', 'Open Design'))
32
+ if (os === 'win32') candidates.push(join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Open Design'))
33
+ if (os === 'linux') candidates.push(join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'Open Design'))
34
+ return [...new Set(candidates.map((p) => resolve(p)))].filter((p) => existsSync(p))
35
+ }
36
+
37
+ const readJson = (path) => {
38
+ try {
39
+ return JSON.parse(readFileSync(path, 'utf8'))
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Scan all roots for design-system presets.
47
+ * Returns [{ slug, title, updatedAt, dir }] sorted by title; duplicate slugs
48
+ * across namespaces resolve to the newest updatedAt.
49
+ */
50
+ export const listPresets = (roots = findOpenDesignRoots()) => {
51
+ const bySlug = new Map()
52
+ for (const root of roots) {
53
+ const nsDir = join(root, 'namespaces')
54
+ if (!existsSync(nsDir)) continue
55
+ for (const ns of readdirSync(nsDir)) {
56
+ const dsDir = join(nsDir, ns, 'data', 'design-systems')
57
+ if (!existsSync(dsDir)) continue
58
+ for (const slug of readdirSync(dsDir)) {
59
+ const dir = join(dsDir, slug)
60
+ if (!existsSync(join(dir, 'system', 'tokens.default.json'))) continue
61
+ const brand = readJson(join(dir, 'brand.json'))
62
+ const meta = readJson(join(dir, 'metadata.json'))
63
+ const entry = {
64
+ slug,
65
+ title: brand?.name ?? meta?.title ?? slug,
66
+ updatedAt: meta?.updatedAt ?? null,
67
+ dir,
68
+ }
69
+ const prev = bySlug.get(slug)
70
+ if (!prev || String(entry.updatedAt) > String(prev.updatedAt)) bySlug.set(slug, entry)
71
+ }
72
+ }
73
+ }
74
+ return [...bySlug.values()].sort((a, b) => a.title.localeCompare(b.title))
75
+ }
76
+
77
+ /* --------------------------------------------------------- color utilities */
78
+
79
+ /** sRGB hex → [r,g,b] 0..1 */
80
+ const hexToRgb = (hex) => {
81
+ const h = hex.replace('#', '')
82
+ const v = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
83
+ return [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16) / 255)
84
+ }
85
+ const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
86
+ const linearToSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055)
87
+
88
+ /** sRGB hex → OKLCh [L, C, h] */
89
+ export const hexToOklch = (hex) => {
90
+ const [r, g, b] = hexToRgb(hex).map(srgbToLinear)
91
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
92
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
93
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
94
+ const l_ = Math.cbrt(l)
95
+ const m_ = Math.cbrt(m)
96
+ const s_ = Math.cbrt(s)
97
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_
98
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_
99
+ const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
100
+ const C = Math.sqrt(a * a + bb * bb)
101
+ let h = (Math.atan2(bb, a) * 180) / Math.PI
102
+ if (h < 0) h += 360
103
+ return [L, C, h]
104
+ }
105
+
106
+ /** OKLCh [L, C, h] → sRGB hex (gamut-clamped) */
107
+ const oklchToHex = ([L, C, h]) => {
108
+ const hr = (h * Math.PI) / 180
109
+ const a = C * Math.cos(hr)
110
+ const bb = C * Math.sin(hr)
111
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * bb
112
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * bb
113
+ const s_ = L - 0.0894841775 * a - 1.291485548 * bb
114
+ const l = l_ ** 3
115
+ const m = m_ ** 3
116
+ const s = s_ ** 3
117
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
118
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
119
+ const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
120
+ const to255 = (x) => Math.round(Math.min(1, Math.max(0, linearToSrgb(x))) * 255)
121
+ return `#${[r, g, b].map((x) => to255(x).toString(16).padStart(2, '0')).join('')}`
122
+ }
123
+
124
+ /** Replicates CSS `color-mix(in oklch, A p%, B)` — shorter-arc hue, p in 0..1. */
125
+ const mix = (hexA, p, hexB) => {
126
+ const [L1, C1, h1] = hexToOklch(hexA)
127
+ const [L2, C2, h2] = hexToOklch(hexB)
128
+ let dh = h2 - h1
129
+ if (dh > 180) dh -= 360
130
+ if (dh < -180) dh += 360
131
+ // achromatic hues are "powerless": take the chromatic one's hue
132
+ const h = C1 < 1e-4 ? h2 : C2 < 1e-4 ? h1 : (h1 + dh * (1 - p) + 360) % 360
133
+ return oklchToHex([L1 * p + L2 * (1 - p), C1 * p + C2 * (1 - p), h])
134
+ }
135
+
136
+ /** rgba() string from a hex + alpha (mirrors primary-alpha tokens in JS). */
137
+ const rgbaOf = (hex, alpha) => {
138
+ const [r, g, b] = hexToRgb(hex).map((x) => Math.round(x * 255))
139
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
140
+ }
141
+
142
+ /** First family of a CSS font stack, unquoted. */
143
+ const firstFamily = (stack) => {
144
+ const first = String(stack ?? '').split(',')[0].trim().replace(/^['"]|['"]$/g, '')
145
+ return first || null
146
+ }
147
+
148
+ /* ----------------------------------------------------------------- mapping */
149
+
150
+ const BLACK = '#000000'
151
+ const WHITE = '#ffffff'
152
+
153
+ /**
154
+ * Base-token overrides for one mode. Colors + fonts by default; `full` also
155
+ * maps borderRadius → --orb-r.
156
+ */
157
+ const mapMode = (t, mode, { full = false } = {}) => {
158
+ const o = {}
159
+ const put = (k, v) => {
160
+ if (v !== undefined && v !== null && v !== '') o[k] = v
161
+ }
162
+ if (mode === 'light') {
163
+ put('--orb-primary', t.colorPrimary)
164
+ put('--orb-accent', t.colorWarning)
165
+ if (t.colorError && t.colorError !== t.colorWarning) put('--orb-err', t.colorError)
166
+ put('--orb-fg', t.colorText)
167
+ put('--orb-fg-strong', t.colorText)
168
+ put('--orb-muted', t.colorTextSecondary)
169
+ put('--orb-canvas', t.colorBgContainer)
170
+ put('--orb-surface', t.colorFillSecondary)
171
+ put('--orb-border', t.colorBorder)
172
+ if (t.colorLink && t.colorLink !== t.colorPrimary) put('--orb-link', t.colorLink)
173
+ } else {
174
+ put('--orb-primary', t.colorPrimary)
175
+ put('--orb-accent', t.colorWarning)
176
+ if (t.colorError && t.colorError !== t.colorWarning) put('--orb-err', t.colorError)
177
+ put('--orb-fg', t.colorText)
178
+ put('--orb-fg-strong', t.colorText)
179
+ put('--orb-muted', t.colorTextSecondary)
180
+ put('--orb-canvas', t.colorBgLayout)
181
+ put('--orb-surface', t.colorBgContainer)
182
+ put('--orb-surface-2', t.colorBgElevated)
183
+ put('--orb-border', t.colorBorder)
184
+ put('--orb-link', t.colorPrimaryText ?? t.colorLink)
185
+ }
186
+ const brand0 = firstFamily(t.fontFamily)
187
+ if (brand0) put('--orb-font-brand', `"${brand0}"`)
188
+ if (full && typeof t.borderRadius === 'number') put('--orb-r', `${t.borderRadius}px`)
189
+ return o
190
+ }
191
+
192
+ const emitBlock = (selector, vars, indent = ' ') =>
193
+ `${selector} {\n${Object.entries(vars)
194
+ .map(([k, v]) => `${indent}${k}: ${v};`)
195
+ .join('\n')}\n}\n`
196
+
197
+ /** Replicates orbis.css derivation formulas for a full OrbTokens-shaped object. */
198
+ const deriveTokens = (t, mode) => {
199
+ const isLight = mode === 'light'
200
+ const canvas = isLight ? t.colorBgContainer : t.colorBgLayout
201
+ const surface = isLight ? t.colorFillSecondary : t.colorBgContainer
202
+ const primary = t.colorPrimary
203
+ const accent = t.colorWarning
204
+ const fg = t.colorText
205
+ const muted = t.colorTextSecondary
206
+ const border = t.colorBorder
207
+ const p50 = mix(primary, isLight ? 0.06 : 0.22, canvas)
208
+ const p100 = mix(primary, isLight ? 0.14 : 0.32, canvas)
209
+ const p200 = mix(primary, 0.28, WHITE)
210
+ const p300 = mix(primary, 0.45, WHITE)
211
+ const p600 = mix(primary, 0.85, isLight ? BLACK : WHITE)
212
+ const p700 = mix(primary, isLight ? 0.7 : 0.6, isLight ? BLACK : WHITE)
213
+ const err =
214
+ t.colorError && t.colorError !== t.colorWarning
215
+ ? t.colorError
216
+ : mix(accent, isLight ? 0.75 : 0.88, BLACK)
217
+ return {
218
+ canvas,
219
+ surface,
220
+ surface2: isLight ? surface : t.colorBgElevated,
221
+ surface3: isLight ? surface : mix(WHITE, 0.09, canvas),
222
+ fg,
223
+ muted,
224
+ border,
225
+ primary,
226
+ onPrimary: WHITE,
227
+ link: isLight ? (t.colorLink ?? primary) : (t.colorPrimaryText ?? t.colorLink ?? p300),
228
+ p50,
229
+ p100,
230
+ p200,
231
+ p300,
232
+ p600,
233
+ p700,
234
+ accent,
235
+ err,
236
+ errBg: mix(accent, isLight ? 0.07 : 0.16, canvas),
237
+ warnBg: mix(accent, isLight ? 0.08 : 0.18, canvas),
238
+ hover: isLight ? rgbaOf(primary, 0.06) : 'rgba(255, 255, 255, 0.08)',
239
+ selected: rgbaOf(primary, isLight ? 0.1 : 0.45),
240
+ focusRing: isLight ? rgbaOf(primary, 0.18) : rgbaOf(p300.slice(0, 7), 0.45),
241
+ focusSolid: isLight ? primary : p300,
242
+ disabledFg: isLight ? mix(fg, 0.45, canvas) : 'rgba(255, 255, 255, 0.38)',
243
+ disabledBg: isLight ? mix(fg, 0.08, canvas) : 'rgba(255, 255, 255, 0.07)',
244
+ chartGrid: isLight ? border : 'rgba(255, 255, 255, 0.12)',
245
+ chartText: isLight ? muted : 'rgba(255, 255, 255, 0.7)',
246
+ track: isLight ? surface : 'rgba(255, 255, 255, 0.1)',
247
+ shadow1: isLight
248
+ ? '0 1px 2px rgba(1,9,26,.05),0 2px 6px rgba(1,9,26,.06)'
249
+ : '0 1px 2px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.35)',
250
+ shadow2: isLight ? '0 4px 14px rgba(1,9,26,.10)' : '0 4px 16px rgba(0,0,0,.5)',
251
+ shadow3: isLight ? '0 12px 32px rgba(1,9,26,.16)' : '0 12px 32px rgba(0,0,0,.55)',
252
+ fontBrand: firstFamily(t.fontFamily) ?? 'Montserrat',
253
+ inverseSurface: '#01091a',
254
+ inverseFg: WHITE,
255
+ inverseLink: p300,
256
+ aiAccent: '#21bcff',
257
+ // status hues stay brand-neutral (not owned by the preset)
258
+ statusFg: isLight ? '#475569' : '#94a3b8',
259
+ statusPrimary: isLight ? '#1d4ed8' : '#60a5fa',
260
+ statusSuccess: isLight ? '#047857' : '#34d399',
261
+ statusWarning: isLight ? '#b45309' : '#fbbf24',
262
+ statusError: isLight ? '#b91c1c' : '#f87171',
263
+ statusInfo: isLight ? '#0369a1' : '#38bdf8',
264
+ chart1: primary,
265
+ chart2: p300,
266
+ chart3: muted,
267
+ chart4: p700,
268
+ chart5: accent,
269
+ chart6: p200,
270
+ chartPositive: '#66bb6a',
271
+ chartNegative: '#ef5350',
272
+ }
273
+ }
274
+
275
+ const toJsObject = (obj, indent = ' ') =>
276
+ `{\n${Object.entries(obj)
277
+ .map(([k, v]) => `${indent}${k}: ${typeof v === 'number' ? v : `'${String(v).replace(/'/g, "\\'")}'`},`)
278
+ .join('\n')}\n}`
279
+
280
+ /* -------------------------------------------------------------- generation */
281
+
282
+ const FONT_FILE_RE = /\.(otf|ttf|woff2?)$/i
283
+
284
+ const FONT_WEIGHT_WORDS = [
285
+ [/hairline|thin/i, 100],
286
+ [/xtra[\s_-]?light|extra[\s_-]?light|ultra[\s_-]?light/i, 200],
287
+ [/light/i, 300],
288
+ [/regular|book|normal|roman/i, 400],
289
+ [/medium|\bmed\b|\bmd\b/i, 500],
290
+ [/semi[\s_-]?b(ol)?d|demi[\s_-]?bold|\bsmbd\b|\bsb\b/i, 600],
291
+ [/xtra[\s_-]?b(ol)?d|extra[\s_-]?bold|ultra[\s_-]?bold|\bxb\b/i, 800],
292
+ [/bold|\bbd\b/i, 700],
293
+ [/black|heavy/i, 900],
294
+ ]
295
+
296
+ /**
297
+ * Parse a font filename like "NespressoLucas-SemiBd.woff2" or "roboto-300.woff2"
298
+ * into { weight, italic }.
299
+ */
300
+ const parseFontFileName = (file) => {
301
+ const stem = file.replace(FONT_FILE_RE, '')
302
+ const italic = /italic|oblique/i.test(stem)
303
+ const numeric = stem.match(/(?:^|[^0-9])([1-9])00(?:[^0-9]|$)/)
304
+ if (numeric) return { weight: Number(numeric[1]) * 100, italic }
305
+ for (const [re, weight] of FONT_WEIGHT_WORDS) {
306
+ if (re.test(stem.replace(/[-_]/g, ' ')) || re.test(stem)) return { weight, italic }
307
+ }
308
+ return { weight: 400, italic }
309
+ }
310
+
311
+ /**
312
+ * Some presets ship font files without a fonts.css. Synthesize @font-face rules
313
+ * using the preset's own fontFamily token as the family name so the generated
314
+ * --orb-font-brand stack resolves to these files.
315
+ */
316
+ const synthesizeFontsCss = (fontFiles, family) =>
317
+ fontFiles
318
+ .map((file) => {
319
+ const { weight, italic } = parseFontFileName(file)
320
+ const format = file.endsWith('.woff2') ? 'woff2' : file.endsWith('.woff') ? 'woff' : file.endsWith('.otf') ? 'opentype' : 'truetype'
321
+ return `@font-face {
322
+ font-family: "${family}";
323
+ src: url("./${file}") format("${format}");
324
+ font-weight: ${weight};
325
+ font-style: ${italic ? 'italic' : 'normal'};
326
+ font-display: swap;
327
+ }`
328
+ })
329
+ .join('\n')
330
+
331
+ /**
332
+ * Build the theme-pack contents for a preset without writing anything.
333
+ * Returns { brand, light, dark, css, tokensMjs, tokensTs, fontsCss, warning }.
334
+ */
335
+ export const buildTheme = (presetDir, { brand, tsImportFrom = 'orbcafe-ui', full = false } = {}) => {
336
+ const systemDir = existsSync(join(presetDir, 'system')) ? join(presetDir, 'system') : presetDir
337
+ const lightPath = join(systemDir, 'tokens.default.json')
338
+ if (!existsSync(lightPath)) throw new Error(`tokens.default.json not found in ${systemDir}`)
339
+ const light = readJson(lightPath)
340
+ const darkPath = join(systemDir, 'tokens.dark.json')
341
+ const dark = existsSync(darkPath) ? readJson(darkPath) : null
342
+ const name = brand ?? 'theme'
343
+
344
+ let warning = null
345
+ const lum = hexToOklch(light.colorPrimary ?? '#000000')[0]
346
+ if (lum > 0.65) {
347
+ warning =
348
+ `primary ${light.colorPrimary} is bright (oklch L=${lum.toFixed(2)}): white --orb-on-primary text ` +
349
+ `may be low-contrast. Consider adding '--orb-on-primary: #000000' to the :root block of ${name}.css.`
350
+ }
351
+
352
+ const lightVars = mapMode(light, 'light', { full })
353
+ const darkVars = dark ? mapMode(dark, 'dark', { full }) : {}
354
+
355
+ // brand fonts: rewrite fonts.css urls to ./<brand>/fonts/
356
+ // presets without fonts.css get @font-face synthesized from their font files
357
+ const presetFontsDir = join(presetDir, 'fonts')
358
+ const fontsCssPath = join(presetFontsDir, 'fonts.css')
359
+ let rawFontsCss = null
360
+ if (existsSync(fontsCssPath)) {
361
+ rawFontsCss = readFileSync(fontsCssPath, 'utf8')
362
+ } else if (existsSync(presetFontsDir)) {
363
+ const fontFiles = readdirSync(presetFontsDir).filter((f) => FONT_FILE_RE.test(f)).sort()
364
+ const family = firstFamily(light.fontFamily)
365
+ if (fontFiles.length && family) rawFontsCss = synthesizeFontsCss(fontFiles, family)
366
+ }
367
+ const fontsCss = rawFontsCss
368
+ ? rawFontsCss.replace(/url\((['"]?)\.\//g, `url($1./${name}/fonts/`)
369
+ : null
370
+
371
+ const css = `/* ${name}.css — generated from the local Open Design preset "${name}". Do not edit.
372
+ Regenerate with: npx orbcafe-theme apply ${name}
373
+ Import AFTER the base styles (import 'orbcafe-ui/styles.css').
374
+ Only base tokens are overridden; all derived tokens re-derive via color-mix. */
375
+ ${fontsCss ? `\n${fontsCss.trim()}\n` : ''}
376
+ ${emitBlock(':root', lightVars)}${
377
+ dark ? `\n${emitBlock('.orb-dark,\n[data-orb-mode="dark"]', darkVars)}` : ''
378
+ }`
379
+
380
+ const lightTokens = deriveTokens(light, 'light')
381
+ const darkTokens = dark ? deriveTokens(dark, 'dark') : lightTokens
382
+
383
+ const tokensMjs = `/**
384
+ * ORBCAFE × ${name} theme tokens (JS side) — generated. Do not edit.
385
+ * Mirrors ${name}.css for consumers that cannot read CSS variables
386
+ * (SVG charts, canvas, inline-style computations).
387
+ * Shape matches OrbTokens from 'orbcafe-ui'.
388
+ */
389
+ export const ORB_LIGHT = ${toJsObject(lightTokens)};
390
+
391
+ export const ORB_DARK = ${toJsObject(darkTokens)};
392
+
393
+ export const ORB_TOKENS = { light: ORB_LIGHT, dark: ORB_DARK };
394
+
395
+ export default ORB_TOKENS;
396
+ `
397
+
398
+ const tokensTs = `/**
399
+ * ORBCAFE × ${name} theme tokens (JS side) — generated. Do not edit.
400
+ * Mirrors ${name}.css for consumers that cannot read CSS variables
401
+ * (SVG charts, canvas, inline-style computations).
402
+ */
403
+ import type { OrbMode, OrbTokens } from '${tsImportFrom}';
404
+
405
+ export const ORB_LIGHT: OrbTokens = ${toJsObject(lightTokens)};
406
+
407
+ export const ORB_DARK: OrbTokens = ${toJsObject(darkTokens)};
408
+
409
+ export const ORB_TOKENS: Record<OrbMode, OrbTokens> = { light: ORB_LIGHT, dark: ORB_DARK };
410
+
411
+ export default ORB_TOKENS;
412
+ `
413
+
414
+ return { brand: name, light, dark, css, tokensMjs, tokensTs, fontsCss, warning }
415
+ }
416
+
417
+ /**
418
+ * Write a theme pack to disk:
419
+ * <outDir>/<brand>.css
420
+ * <outDir>/<brand>.tokens.mjs
421
+ * <outDir>/<brand>.tokens.ts (or to tokensTsPath when given)
422
+ * <outDir>/<brand>/fonts/* (when the preset ships fonts)
423
+ */
424
+ export const generateTheme = (presetDir, { brand, outDir, tsImportFrom, full = false, tokensTsPath } = {}) => {
425
+ const built = buildTheme(presetDir, { brand, tsImportFrom, full })
426
+ const name = built.brand
427
+ mkdirSync(outDir, { recursive: true })
428
+ const cssPath = join(outDir, `${name}.css`)
429
+ const mjsPath = join(outDir, `${name}.tokens.mjs`)
430
+ const tsPath = tokensTsPath ?? join(outDir, `${name}.tokens.ts`)
431
+ writeFileSync(cssPath, built.css)
432
+ writeFileSync(mjsPath, built.tokensMjs)
433
+ writeFileSync(tsPath, built.tokensTs)
434
+ let fonts = []
435
+ const presetFontsDir = join(presetDir, 'fonts')
436
+ if (built.fontsCss && existsSync(presetFontsDir)) {
437
+ const fontsDir = join(outDir, name, 'fonts')
438
+ mkdirSync(fontsDir, { recursive: true })
439
+ for (const f of readdirSync(presetFontsDir)) {
440
+ if (!FONT_FILE_RE.test(f)) continue
441
+ copyFileSync(join(presetFontsDir, f), join(fontsDir, f))
442
+ fonts.push(join(fontsDir, f))
443
+ }
444
+ }
445
+ return { ...built, cssPath, mjsPath, tsPath, fonts }
446
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orbcafe-theme — bridge ORBCAFE UI to local Open Design brand presets.
4
+ *
5
+ * Developers WITHOUT Open Design: the default ORBIS theme shipped in
6
+ * orbcafe-ui/styles.css is active. Built-in theme packs are also available at
7
+ * orbcafe-ui/themes/orbis.css and orbcafe-ui/themes/nvidia.css.
8
+ *
9
+ * Developers WITH Open Design: apply any brand preset (colors + fonts)
10
+ * straight from the app's local data — no export step:
11
+ *
12
+ * npx orbcafe-theme # status: Open Design found? presets?
13
+ * npx orbcafe-theme list [--json] # list discovered brand presets
14
+ * npx orbcafe-theme show <slug> # preview the token overrides
15
+ * npx orbcafe-theme apply [slug] [--out ./orbcafe-theme] [--full] [--json]
16
+ *
17
+ * apply writes <out>/<brand>.css (+ fonts + JS tokens) into your project.
18
+ * Wire it up once, AFTER the base styles:
19
+ * import 'orbcafe-ui/styles.css'
20
+ * import './orbcafe-theme/<brand>.css'
21
+ *
22
+ * --full also maps borderRadius → --orb-r (default maps colors + fonts only).
23
+ * --preset <dir> bypasses Open Design discovery and reads a preset directly.
24
+ */
25
+
26
+ import { createInterface } from 'node:readline/promises'
27
+ import { basename, relative, resolve } from 'node:path'
28
+ import { findOpenDesignRoots, listPresets, buildTheme, generateTheme } from './lib/theme-core.mjs'
29
+
30
+ const argv = process.argv.slice(2)
31
+ const command = argv.find((a) => !a.startsWith('-') && ['status', 'list', 'show', 'apply'].includes(a)) ?? 'status'
32
+ const positional = argv.filter((a) => !a.startsWith('-') && a !== command)
33
+ const flag = (name) => argv.includes(`--${name}`)
34
+ const option = (name) => {
35
+ const i = argv.indexOf(`--${name}`)
36
+ return i === -1 ? undefined : argv[i + 1]
37
+ }
38
+ const asJson = flag('json')
39
+
40
+ const fail = (message, code = 1) => {
41
+ if (asJson) {
42
+ console.log(JSON.stringify({ ok: false, error: message }))
43
+ } else {
44
+ console.error(`✗ ${message}`)
45
+ }
46
+ process.exit(code)
47
+ }
48
+
49
+ const NO_OD_MESSAGE =
50
+ 'Open Design not found on this machine — the default ORBIS theme from orbcafe-ui/styles.css is active. ' +
51
+ 'Install Open Design and create a brand design system to unlock one-command brand theming.'
52
+
53
+ /** Resolve which preset to use: --preset dir, explicit slug, single preset, or ask. */
54
+ const resolvePreset = async (slug) => {
55
+ const presetDir = option('preset')
56
+ if (presetDir) {
57
+ const dir = resolve(presetDir)
58
+ return { slug: option('name') ?? basename(dir), dir }
59
+ }
60
+ const roots = findOpenDesignRoots()
61
+ if (!roots.length) fail(NO_OD_MESSAGE)
62
+ const presets = listPresets(roots)
63
+ if (!presets.length) fail(`Open Design found (${roots[0]}) but no design systems yet. Create one in Open Design first.`)
64
+ if (slug) {
65
+ const hit = presets.find((p) => p.slug === slug)
66
+ if (!hit) fail(`No preset named "${slug}". Available: ${presets.map((p) => p.slug).join(', ')}`)
67
+ return hit
68
+ }
69
+ if (presets.length === 1) return presets[0]
70
+ if (!process.stdout.isTTY) {
71
+ fail(`Multiple presets found; pick one: ${presets.map((p) => p.slug).join(', ')}`)
72
+ }
73
+ console.log('Multiple brand presets found:')
74
+ presets.forEach((p, i) => console.log(` ${i + 1}) ${p.title} (${p.slug})`))
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
76
+ const answer = await rl.question(`Apply which? [1-${presets.length}]: `)
77
+ rl.close()
78
+ const idx = Number(answer.trim()) - 1
79
+ if (!presets[idx]) fail('Aborted.')
80
+ return presets[idx]
81
+ }
82
+
83
+ switch (command) {
84
+ case 'status': {
85
+ const roots = findOpenDesignRoots()
86
+ const presets = roots.length ? listPresets(roots) : []
87
+ if (asJson) {
88
+ console.log(JSON.stringify({ ok: true, openDesignRoots: roots, presets }, null, 2))
89
+ break
90
+ }
91
+ if (!roots.length) {
92
+ console.log(NO_OD_MESSAGE)
93
+ break
94
+ }
95
+ console.log(`Open Design: ${roots.join(', ')}`)
96
+ console.log(`Brand presets: ${presets.length}`)
97
+ for (const p of presets) console.log(` • ${p.title} (${p.slug})${p.updatedAt ? ` — updated ${p.updatedAt}` : ''}`)
98
+ console.log('\nApply one with: npx orbcafe-theme apply <slug>')
99
+ break
100
+ }
101
+
102
+ case 'list': {
103
+ const roots = findOpenDesignRoots()
104
+ if (!roots.length) fail(NO_OD_MESSAGE)
105
+ const presets = listPresets(roots)
106
+ if (asJson) {
107
+ console.log(JSON.stringify({ ok: true, presets }, null, 2))
108
+ } else {
109
+ if (!presets.length) console.log('No design systems found. Create one in Open Design first.')
110
+ for (const p of presets) console.log(`${p.slug}\t${p.title}${p.updatedAt ? `\t${p.updatedAt}` : ''}`)
111
+ }
112
+ break
113
+ }
114
+
115
+ case 'show': {
116
+ const preset = await resolvePreset(positional[0])
117
+ const built = buildTheme(preset.dir, { brand: preset.slug, full: flag('full') })
118
+ if (asJson) {
119
+ console.log(JSON.stringify({ ok: true, brand: preset.slug, warning: built.warning, css: built.css }, null, 2))
120
+ } else {
121
+ console.log(built.css)
122
+ if (built.warning) console.warn(`⚠ ${built.warning}`)
123
+ }
124
+ break
125
+ }
126
+
127
+ case 'apply': {
128
+ const preset = await resolvePreset(positional[0])
129
+ const outDir = resolve(option('out') ?? './orbcafe-theme')
130
+ const result = generateTheme(preset.dir, {
131
+ brand: preset.slug,
132
+ outDir,
133
+ tsImportFrom: 'orbcafe-ui',
134
+ full: flag('full'),
135
+ })
136
+ const relCss = './' + relative(process.cwd(), result.cssPath)
137
+ const importLine = `import '${relCss}' // after: import 'orbcafe-ui/styles.css'`
138
+ if (asJson) {
139
+ console.log(
140
+ JSON.stringify(
141
+ {
142
+ ok: true,
143
+ brand: result.brand,
144
+ cssPath: result.cssPath,
145
+ tokensMjsPath: result.mjsPath,
146
+ tokensTsPath: result.tsPath,
147
+ fonts: result.fonts,
148
+ warning: result.warning,
149
+ importLine,
150
+ },
151
+ null,
152
+ 2
153
+ )
154
+ )
155
+ break
156
+ }
157
+ console.log(`✓ ${result.cssPath}`)
158
+ console.log(`✓ ${result.mjsPath}`)
159
+ console.log(`✓ ${result.tsPath}`)
160
+ if (result.fonts.length) console.log(`✓ ${result.fonts.length} font file(s) → ${resolve(outDir, result.brand, 'fonts')}`)
161
+ if (result.warning) console.warn(`⚠ ${result.warning}`)
162
+ console.log('\nWire it up once, AFTER the base styles:')
163
+ console.log(` import 'orbcafe-ui/styles.css'`)
164
+ console.log(` import '${relCss}'`)
165
+ console.log('\nJS tokens for charts/canvas:')
166
+ console.log(` import ORB_TOKENS from './${relative(process.cwd(), result.mjsPath)}'`)
167
+ break
168
+ }
169
+ }
package/dist/index.css ADDED
@@ -0,0 +1 @@
1
+ @import "./orbis.css";