orbcafe-ui 2.0.0 → 2.0.2
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.
- package/BRAND_THEMING.md +45 -0
- package/MIGRATION_V2.md +161 -0
- package/README.md +8 -10
- package/bin/lib/theme-core.mjs +446 -0
- package/bin/orbcafe-theme.mjs +169 -0
- package/dist/index.css +1 -0
- package/dist/index.d.mts +231 -4
- package/dist/index.d.ts +231 -4
- package/dist/index.js +18 -18
- package/dist/index.mjs +16 -16
- package/dist/orbis.css +249 -322
- package/dist/themes/nvidia.css +36 -0
- package/dist/themes/nvidia.tokens.mjs +117 -0
- package/dist/themes/orbis/fonts/montserrat-300.otf +0 -0
- package/dist/themes/orbis/fonts/montserrat-500.otf +0 -0
- package/dist/themes/orbis/fonts/montserrat-600.otf +0 -0
- package/dist/themes/orbis/fonts/montserrat-700.otf +0 -0
- package/dist/themes/orbis.css +62 -0
- package/dist/themes/orbis.tokens.mjs +117 -0
- package/package.json +23 -7
|
@@ -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";
|