orbiq-neural-ui-kit 0.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,58 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ // Tailwind v3 does not merge `content` from a preset (see preset.js) — every
5
+ // consuming app must list frappe-ui's source globs in its own `content`.
6
+ // Resolve them relative to this file so they work whether frappe-ui sits in
7
+ // node_modules, a monorepo symlink, or a local workspace checkout.
8
+ const packageRoot = path.resolve(
9
+ path.dirname(fileURLToPath(import.meta.url)),
10
+ '..',
11
+ )
12
+
13
+ // Tailwind's scanner (fast-glob/micromatch) reads backslashes as escape
14
+ // characters, not path separators — path.join emits backslashes on Windows,
15
+ // which would silently break every glob below. Force forward slashes.
16
+ function glob(pattern) {
17
+ return path.join(packageRoot, pattern).split(path.sep).join('/')
18
+ }
19
+
20
+ // Mirror this repo's own tailwind.config.js content globs for the supported
21
+ // surfaces (`./src/**`, `./icons/**`) — for those, two lists that are
22
+ // supposed to agree must not be able to drift apart. The repo config
23
+ // also globs all of `./experimental/**`; this list does not (see below). Broad
24
+ // `src/**` also covers files like src/utils/dialog.ts, which builds markup
25
+ // with `h('div', { class: '...' })` outside any component tree.
26
+ //
27
+ // `experimental/**` is deliberately excluded: frappe-ui/experimental carries
28
+ // no P14 promise and first-party consumers are told not to depend on it, so
29
+ // it isn't part of the supported public content contract this list covers.
30
+ // The explicit exceptions are migration parking spots for previously
31
+ // supported surfaces, covered until they are removed:
32
+ // - `experimental/SpriteIcons` (moved out of `frappe-ui/icons` in #904;
33
+ // IconPicker emits classes)
34
+ // - `experimental/TextEditor` (the v0 editor family, a supported root
35
+ // surface until #974, parked in #1007 while apps migrate to
36
+ // `frappe-ui/editor`)
37
+ // - `experimental/Calendar` (a supported root surface until #1020, parked
38
+ // with its API unchanged until a redesigned calendar family replaces it)
39
+ // - `experimental/Charts` (the v1 chart family, a supported root surface
40
+ // until #942, parked while apps migrate to `frappe-ui/charts`)
41
+ // - `experimental/CommandPalette` (a supported root surface until its removal
42
+ // in `1.0.0`; the seven-part family here is where its consumers port to)
43
+ /**
44
+ * Source globs that emit Tailwind classes in frappe-ui. Spread into your
45
+ * app's `tailwind.config.js` `content` array:
46
+ *
47
+ * import { content } from 'frappe-ui/tailwind'
48
+ * export default { content: [...content, './src/**\/*.vue'] }
49
+ */
50
+ export const content = [
51
+ glob('src/**/*.{vue,js,ts,jsx,tsx}'),
52
+ glob('icons/**/*.{vue,js,ts,jsx,tsx}'),
53
+ glob('experimental/SpriteIcons/**/*.{vue,js,ts,jsx,tsx}'),
54
+ glob('experimental/TextEditor/**/*.{vue,js,ts,jsx,tsx}'),
55
+ glob('experimental/Calendar/**/*.{vue,js,ts,jsx,tsx}'),
56
+ glob('experimental/Charts/**/*.{vue,js,ts,jsx,tsx}'),
57
+ glob('experimental/CommandPalette/**/*.{vue,js,ts,jsx,tsx}'),
58
+ ]
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Generator: reads the W3C Design Tokens Community Group JSON exported from
3
+ * Figma (espresso-v2-design-tokens/) and emits theme JSON files that the
4
+ * tailwind plugin can consume.
5
+ *
6
+ * Inputs: espresso-v2-design-tokens/*.tokens.json
7
+ * Outputs: tailwind/generated/{colors,radius,typography}.json
8
+ *
9
+ * Run with: yarn sync-tokens
10
+ */
11
+
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+ import { fileURLToPath } from 'url'
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
+ const REPO_ROOT = path.resolve(__dirname, '..')
18
+ const TOKENS_DIR = path.join(REPO_ROOT, 'espresso-v2-design-tokens')
19
+ const OUT_DIR = path.join(__dirname, 'generated')
20
+
21
+ // Color families mirrored from Figma's "🔵 Colour primitives" collection.
22
+ // Each appears under `light.<family>` and `dark.<family>` plus their alpha pair.
23
+ const COLOR_FAMILIES = [
24
+ 'gray',
25
+ 'blue',
26
+ 'green',
27
+ 'red',
28
+ 'orange',
29
+ 'amber',
30
+ 'yellow',
31
+ 'teal',
32
+ 'cyan',
33
+ 'purple',
34
+ 'pink',
35
+ 'violet',
36
+ ]
37
+ // 'red-alpha' was listed here historically but Figma's primitives export has
38
+ // never contained a `light.red-alpha` / `dark.red-alpha` family — the guard
39
+ // below silently skips it, so it never emitted a token. Not listed, so the
40
+ // dead branch isn't there to skip going forward (#940).
41
+ const ALPHA_FAMILIES = ['gray-alpha']
42
+ const SEMANTIC_CATEGORIES = ['surface', 'surface-alpha', 'ink', 'outline', 'outline-alpha']
43
+
44
+ // Named aliases layered on top of Figma's numeric radius keys.
45
+ // Matched by px value, so the alias stays correct if Figma shifts.
46
+ // Only `none` survives — the deprecated size aliases (`sm`, `DEFAULT`, `md`,
47
+ // `lg`, `xl`, `2xl`) were removed in 1.0.0 per ADR-0006 (#998). Migrate old
48
+ // code with tailwind/migrate-tokens-v2.js.
49
+ const RADIUS_NAME_BY_PX = {
50
+ '0px': 'none',
51
+ }
52
+ // Preserved from current plugin.js — Figma doesn't model `full`.
53
+ const RADIUS_EXTRA = { full: '9999px' }
54
+
55
+ // Real Figma variable-font weights. Only Regular is customized (420); the rest
56
+ // are standard. NOTE: do NOT source these from the `text.styles` export — its
57
+ // fontWeight column is corrupt (Regular exports as 100/400 because the body
58
+ // styles use Inter's "Thin" named instance with a wght-axis override to 420
59
+ // that the exporter discards; Black exports as 700 instead of 800).
60
+ const FONT_WEIGHT_MAP = {
61
+ regular: 420,
62
+ medium: 500,
63
+ semibold: 600,
64
+ bold: 700,
65
+ extrabold: 800,
66
+ }
67
+
68
+ // ---------- HEX → OKLCH ----------
69
+
70
+ // Figma exports every color as hex (8-digit when it carries alpha), but
71
+ // colors.json ships oklch (fa18b8ade). Convert at generation time so a
72
+ // routine `yarn sync-tokens` can't revert the palette to hex (#986).
73
+ // Math from Björn Ottosson's OKLab reference implementation.
74
+
75
+ const fmt = (n) => String(Math.round(n * 1000) / 1000)
76
+
77
+ export function hexToOklch(hex) {
78
+ const value = hex.slice(1)
79
+ if (value.length !== 6 && value.length !== 8) {
80
+ throw new Error(`hexToOklch expects #rrggbb or #rrggbbaa, got "${hex}"`)
81
+ }
82
+ const int = (i) => parseInt(value.slice(i, i + 2), 16)
83
+ const [r, g, b] = [int(0), int(2), int(4)]
84
+ const alpha = value.length === 8 ? int(6) / 255 : 1
85
+
86
+ const lin = (c) => {
87
+ const v = c / 255
88
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
89
+ }
90
+ const [lr, lg, lb] = [lin(r), lin(g), lin(b)]
91
+
92
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
93
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
94
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
95
+
96
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
97
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
98
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
99
+
100
+ const C = Math.sqrt(A * A + B * B)
101
+ let H = (Math.atan2(B, A) * 180) / Math.PI
102
+ if (H < 0) H += 360
103
+ // Achromatic: a hue on a zero-chroma color is noise.
104
+ if (Math.round(C * 1000) === 0) H = 0
105
+
106
+ const lch = `${fmt(L)} ${fmt(C)} ${fmt(H)}`
107
+ return alpha < 1 ? `oklch(${lch} / ${fmt(alpha)})` : `oklch(${lch})`
108
+ }
109
+
110
+ // Literal color values pass through here; alias references don't.
111
+ export function toOklch(value) {
112
+ return typeof value === 'string' && value.startsWith('#') ? hexToOklch(value) : value
113
+ }
114
+
115
+ function readTokens(filename) {
116
+ return JSON.parse(fs.readFileSync(path.join(TOKENS_DIR, filename), 'utf8'))
117
+ }
118
+
119
+ function ensureOutDir() {
120
+ fs.mkdirSync(OUT_DIR, { recursive: true })
121
+ }
122
+
123
+ function writeJSON(filename, data) {
124
+ const filepath = path.join(OUT_DIR, filename)
125
+ fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n')
126
+ console.log(` wrote ${path.relative(REPO_ROOT, filepath)}`)
127
+ }
128
+
129
+ // ---------- COLORS ----------
130
+
131
+ // Build colors.json in the shape colorPalette.js already consumes:
132
+ // { lightMode, darkMode, overlay, neutral, themedVariables: { light, dark } }
133
+ function buildColors() {
134
+ const primitives = readTokens('Colour primitives.Light.tokens.json')
135
+ const stylesLight = readTokens('Styles.Light.tokens.json')
136
+ const stylesDark = readTokens('Styles.Dark.tokens.json')
137
+
138
+ const colors = {
139
+ lightMode: {},
140
+ darkMode: {},
141
+ overlay: { white: {}, black: {} },
142
+ neutral: {
143
+ white: toOklch(primitives.neutral.white.$value),
144
+ black: toOklch(primitives.neutral.black.$value),
145
+ ...(primitives.neutral.transparent
146
+ ? { transparent: toOklch(primitives.neutral.transparent.$value) }
147
+ : {}),
148
+ },
149
+ themedVariables: {
150
+ light: Object.fromEntries(SEMANTIC_CATEGORIES.map((category) => [category, {}])),
151
+ dark: Object.fromEntries(SEMANTIC_CATEGORIES.map((category) => [category, {}])),
152
+ },
153
+ }
154
+
155
+ // Primitive ramps — light.<family>.<shade>
156
+ for (const family of [...COLOR_FAMILIES, ...ALPHA_FAMILIES]) {
157
+ if (primitives.light?.[family]) {
158
+ colors.lightMode[family] = mapShades(primitives.light[family])
159
+ }
160
+ if (primitives.dark?.[family]) {
161
+ colors.darkMode[family] = mapShades(primitives.dark[family])
162
+ }
163
+ }
164
+
165
+ // Overlay ramps — white-alpha / black-alpha at top level of primitives.
166
+ if (primitives['white-alpha']) {
167
+ colors.overlay.white = mapShades(primitives['white-alpha'])
168
+ }
169
+ if (primitives['black-alpha']) {
170
+ colors.overlay.black = mapShades(primitives['black-alpha'])
171
+ }
172
+
173
+ // Semantic aliases — Styles.Light/Dark → themedVariables.{light,dark}
174
+ for (const category of SEMANTIC_CATEGORIES) {
175
+ collectSemanticCategory(stylesLight, category, colors.themedVariables.light)
176
+ collectSemanticCategory(stylesDark, category, colors.themedVariables.dark)
177
+ }
178
+
179
+ return colors
180
+ }
181
+
182
+ // No legacy aliases: retired names (surface-white, surface-modal,
183
+ // outline-gray-modals, …) are intentionally NOT emitted so straggler usage
184
+ // fails visibly instead of silently keeping old styles alive. Migrate old
185
+ // code with tailwind/migrate-tokens-v2.js.
186
+
187
+ function mapShades(family) {
188
+ const out = {}
189
+ for (const [shade, token] of Object.entries(family)) {
190
+ if (token && token.$value) {
191
+ out[shade] = toOklch(token.$value)
192
+ }
193
+ }
194
+ return out
195
+ }
196
+
197
+ // Semantic names present in Figma's Styles export with zero call sites
198
+ // anywhere (#940 rule-5 census: frappe-ui's own source/docs/stories, all 9
199
+ // consumer apps, and frappe's ui/ package):
200
+ // - `alert-button-{default,info,success,warning,error}` (surface + ink):
201
+ // Alert's actual buttons color via the shared variant+theme axes (P4),
202
+ // not a per-alert-type token — this Figma spec never got wired to code.
203
+ // - `gray-2-overlay` (surface-alpha): resolves to the black/white overlay
204
+ // ramp rather than the gray-alpha ramp its name implies, breaking the
205
+ // `{family}-{step}` pattern every other surface-alpha entry follows, and
206
+ // nothing ever reached for it under either reading.
207
+ const DROPPED_SEMANTIC_NAMES = {
208
+ surface: [
209
+ 'alert-button-default',
210
+ 'alert-button-info',
211
+ 'alert-button-success',
212
+ 'alert-button-warning',
213
+ 'alert-button-error',
214
+ ],
215
+ 'surface-alpha': ['gray-2-overlay'],
216
+ ink: [
217
+ 'alert-button-default',
218
+ 'alert-button-info',
219
+ 'alert-button-success',
220
+ 'alert-button-warning',
221
+ 'alert-button-error',
222
+ ],
223
+ }
224
+
225
+ function collectSemanticCategory(styles, category, target) {
226
+ const section = styles[category]
227
+ if (!section) return
228
+ target[category] = target[category] || {}
229
+ const dropped = DROPPED_SEMANTIC_NAMES[category] || []
230
+ for (const [name, token] of Object.entries(section)) {
231
+ if (!token?.$value) continue
232
+ if (dropped.includes(name)) continue
233
+ // Resolve `{path.to.token}` aliases into the "lightMode/family/shade" format
234
+ // that colorPalette.js#resolveColorReference understands.
235
+ target[category][name] = aliasToReference(token.$value)
236
+ }
237
+ }
238
+
239
+ // Convert a DTCG alias string like "{light.gray.50}" into the reference shape
240
+ // stored in colors.json today: "lightMode/gray/50". Non-aliases (literal hex)
241
+ // convert to oklch like every other resolved value.
242
+ function aliasToReference(value) {
243
+ if (typeof value !== 'string') return value
244
+ const match = value.match(/^\{(.+)\}$/)
245
+ if (!match) return toOklch(value)
246
+ const segments = match[1].split('.')
247
+
248
+ // {neutral.white} | {neutral.black}
249
+ if (segments[0] === 'neutral' && segments.length === 2) {
250
+ return `neutral/${segments[1]}`
251
+ }
252
+ // {white-alpha.50} | {black-alpha.50} → overlay/white/50 | overlay/black/50
253
+ if (segments[0] === 'white-alpha' || segments[0] === 'black-alpha') {
254
+ const color = segments[0].split('-')[0]
255
+ return `overlay/${color}/${segments[1]}`
256
+ }
257
+ // {light.gray.50} → lightMode/gray/50
258
+ // {light.gray-alpha.50} → lightMode/gray-alpha/50
259
+ if (segments[0] === 'light') {
260
+ return `lightMode/${segments.slice(1, -1).join('-')}/${segments[segments.length - 1]}`
261
+ }
262
+ if (segments[0] === 'dark') {
263
+ return `darkMode/${segments.slice(1, -1).join('-')}/${segments[segments.length - 1]}`
264
+ }
265
+ console.warn(` ⚠ unresolved alias: ${value}`)
266
+ return value
267
+ }
268
+
269
+ // ---------- RADIUS ----------
270
+
271
+ function buildRadius() {
272
+ const tokens = readTokens('Tokens.Mode 1.tokens.json')
273
+ const radius = { ...RADIUS_EXTRA }
274
+
275
+ for (const [key, token] of Object.entries(tokens.radius || {})) {
276
+ const px = token.$value
277
+ radius[key] = px
278
+ const name = RADIUS_NAME_BY_PX[px]
279
+ if (name) radius[name] = px
280
+ }
281
+ return radius
282
+ }
283
+
284
+ // ---------- TYPOGRAPHY ----------
285
+
286
+ // We derive the type scale from the Figma *text styles* export
287
+ // (`text.styles.tokens.json`), not the *variable* export
288
+ // (`Typography.Desktop`). Text styles carry the exact per-size pairing of size +
289
+ // line-height + letter-spacing (+ `uppercase` on `tiny`); the variable export
290
+ // rounds line-heights to px and drops per-size letter-spacing. Weights still
291
+ // come from FONT_WEIGHT_MAP because the text-styles weight column is corrupt
292
+ // (see note there).
293
+
294
+ // Figma models line-height & letter-spacing as percentages of the font size.
295
+ // Tailwind wants a unitless ratio for line-height and `em` for letter-spacing.
296
+ const pctToRatio = (v) => String(round(parseFloat(v) / 100, 4)) // "115%" -> "1.15"
297
+ // letter-spacing % of font size === em. `paragraph/5xl` exports as "0.5px" by an
298
+ // exporter bug (should be "0.5%"); parseFloat keeps the number and we treat it
299
+ // as a percent regardless of unit, which yields the intended value either way.
300
+ const lsToEm = (v) => `${round(parseFloat(v) / 100, 5)}em` // "2%" -> "0.02em"
301
+
302
+ function round(n, places) {
303
+ const f = 10 ** places
304
+ return Math.round(n * f) / f
305
+ }
306
+
307
+ // Sizes present in the Figma text-styles export that ship no vocabulary:
308
+ // audited in #940 against frappe-ui's own source, docs, and stories, plus a
309
+ // fresh rule-5 census of every consumer app (crm, helpdesk, gameplan,
310
+ // insights, builder, suite, central, frappe_calendar, frappe-ui-starter,
311
+ // frappe's ui/ package) — zero call sites anywhere for either.
312
+ // - `tiny`: also excluded from the docs type-scale page's own size lists
313
+ // (TypographyPage.vue), so even frappe-ui's own showcase doesn't use it.
314
+ // - `13xl`-`16xl`: the docs "display sizes" showcase itself stops at
315
+ // `12xl` (see DISPLAY_KEYS in TypographyPage.vue) — these four sizes are
316
+ // past what even the type-scale demo cares to show.
317
+ const DROPPED_SIZES = ['tiny', '13xl', '14xl', '15xl', '16xl']
318
+
319
+ function buildTypography() {
320
+ const styles = readTokens('text.styles.tokens.json')
321
+ const text = Object.fromEntries(
322
+ Object.entries(styles.text || {}).filter(([key]) => !DROPPED_SIZES.includes(key)),
323
+ )
324
+ // Same filter as `text` above — paragraph has no dropped-size entries today
325
+ // (it tops out at `4xl` and never had a `tiny`), but this keeps it that way
326
+ // if Figma ever adds one (#940).
327
+ const paragraphStyles = Object.fromEntries(
328
+ Object.entries(styles.paragraph || {}).filter(([key]) => !DROPPED_SIZES.includes(key)),
329
+ )
330
+
331
+ const fontFamily = { text: text.base?.regular?.$value.fontFamily || 'Inter Variable' }
332
+
333
+ const fontWeight = {
334
+ regular: FONT_WEIGHT_MAP.regular,
335
+ medium: FONT_WEIGHT_MAP.medium,
336
+ semibold: FONT_WEIGHT_MAP.semibold,
337
+ bold: FONT_WEIGHT_MAP.bold,
338
+ black: FONT_WEIGHT_MAP.extrabold,
339
+ }
340
+
341
+ // Letter-spacing is the only property that varies by weight (line-height &
342
+ // text-transform are constant per size). Capture it per (size, weight) so the
343
+ // plugin can emit `text-<size>-<weight>` classes; values are honored as-is
344
+ // from text.styles (the source of truth), oddities included.
345
+ const WEIGHTS = ['regular', 'medium', 'semibold', 'bold', 'black']
346
+ const trackingOf = (variants) =>
347
+ Object.fromEntries(
348
+ WEIGHTS.filter((w) => variants[w]).map((w) => [w, lsToEm(variants[w].$value.letterSpacing)]),
349
+ )
350
+
351
+ // Base size utilities (`text-<size>`), from each size's `regular` variant.
352
+ const fontSize = {}
353
+ const textTransform = {}
354
+ const tracking = { text: {}, paragraph: {} }
355
+ for (const [key, variants] of Object.entries(text)) {
356
+ const v = variants.regular.$value
357
+ fontSize[key] = [
358
+ v.fontSize,
359
+ {
360
+ lineHeight: pctToRatio(v.lineHeight),
361
+ letterSpacing: lsToEm(v.letterSpacing),
362
+ fontWeight: String(FONT_WEIGHT_MAP.regular),
363
+ },
364
+ ]
365
+ if (v.textTransform && v.textTransform !== 'none') textTransform[key] = v.textTransform
366
+ tracking.text[key] = trackingOf(variants)
367
+ }
368
+
369
+ // Paragraph variants (`text-p-<size>`) — same sizes, reading line-height/track.
370
+ const paragraph = {}
371
+ for (const [key, variants] of Object.entries(paragraphStyles)) {
372
+ const v = variants.regular.$value
373
+ paragraph[key] = {
374
+ lineHeight: pctToRatio(v.lineHeight),
375
+ letterSpacing: lsToEm(v.letterSpacing),
376
+ }
377
+ tracking.paragraph[key] = trackingOf(variants)
378
+ }
379
+
380
+ return { fontFamily, fontWeight, fontSize, textTransform, paragraph, tracking }
381
+ }
382
+
383
+ // ---------- EFFECTS (shadows) ----------
384
+
385
+ // Figma exports shadow effects as DTCG `$type: shadow` tokens — an array of
386
+ // layers, each with offsetX/offsetY/blur/spread/color (+ optional inset).
387
+ // Emit pre-composed CSS box-shadow strings so the plugin can drop them into
388
+ // CSS variables verbatim.
389
+ // Custom elevation names present in Figma but with zero call sites anywhere
390
+ // (#940 rule-5 census: frappe-ui's own source/docs/stories, all 9 consumer
391
+ // apps, and frappe's ui/ package). `status` isn't even wired into the docs'
392
+ // own elevation showcase (ElevationPreview.vue renders only the six numbered
393
+ // steps) — it's named in prose once and never rendered.
394
+ const DROPPED_CUSTOM_ELEVATIONS = ['status']
395
+
396
+ function buildEffects() {
397
+ const tokens = readTokens('effect.styles.tokens.json')
398
+ const out = {
399
+ elevation: { light: {}, dark: {}, custom: {} },
400
+ focus: { light: {}, dark: {} },
401
+ }
402
+
403
+ for (const step of Object.keys(tokens.elevation?.light || {})) {
404
+ out.elevation.light[step] = shadowToCss(tokens.elevation.light[step].$value)
405
+ }
406
+ for (const step of Object.keys(tokens.elevation?.dark || {})) {
407
+ out.elevation.dark[step] = shadowToCss(tokens.elevation.dark[step].$value)
408
+ }
409
+ for (const [name, token] of Object.entries(tokens.elevation?.custom || {})) {
410
+ if (DROPPED_CUSTOM_ELEVATIONS.includes(name)) continue
411
+ out.elevation.custom[name] = shadowToCss(token.$value)
412
+ }
413
+ for (const [name, token] of Object.entries(tokens.focus?.light || {})) {
414
+ out.focus.light[name] = shadowToCss(token.$value)
415
+ }
416
+ for (const [name, token] of Object.entries(tokens.focus?.dark || {})) {
417
+ out.focus.dark[name] = shadowToCss(token.$value)
418
+ }
419
+
420
+ return out
421
+ }
422
+
423
+ // Figma paints its effect array back-to-front (index 0 = bottom of the stack),
424
+ // while CSS box-shadow paints front-to-back (first listed = on top). Reverse the
425
+ // layers so the composed CSS string matches Figma's visual stacking order.
426
+ function shadowToCss(layers) {
427
+ return layers
428
+ .slice()
429
+ .reverse()
430
+ .map((layer) => {
431
+ const parts = [
432
+ layer.inset ? 'inset' : null,
433
+ layer.offsetX,
434
+ layer.offsetY,
435
+ layer.blur,
436
+ layer.spread || '0px',
437
+ layer.color,
438
+ ].filter(Boolean)
439
+ return parts.join(' ')
440
+ })
441
+ .join(', ')
442
+ }
443
+
444
+ // ---------- MAIN ----------
445
+
446
+ function main() {
447
+ if (!fs.existsSync(TOKENS_DIR)) {
448
+ console.error(`✗ tokens directory not found: ${TOKENS_DIR}`)
449
+ process.exit(1)
450
+ }
451
+
452
+ console.log(`Reading tokens from ${path.relative(REPO_ROOT, TOKENS_DIR)}/`)
453
+ ensureOutDir()
454
+
455
+ writeJSON('colors.json', buildColors())
456
+ writeJSON('radius.json', buildRadius())
457
+ writeJSON('typography.json', buildTypography())
458
+ writeJSON('effects.json', buildEffects())
459
+
460
+ // colors.json is consumed from tailwind/ (top-level) by colorPalette.js, while
461
+ // the generator emits to tailwind/generated/. Copy it up so `yarn sync-tokens`
462
+ // is the single source of truth (no manual copy step).
463
+ fs.copyFileSync(path.join(OUT_DIR, 'colors.json'), path.join(__dirname, 'colors.json'))
464
+
465
+ console.log('✓ done')
466
+ }
467
+
468
+ const scriptPath = fileURLToPath(import.meta.url)
469
+ const invokedPath = process.argv[1]
470
+ const isCLI = invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
471
+ if (isCLI) main()
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { hexToOklch, toOklch } from './figma-tokens-to-theme.js'
3
+
4
+ describe('hexToOklch', () => {
5
+ it('converts a 6-digit hex', () => {
6
+ expect(hexToOklch('#ffffff')).toBe('oklch(1 0 0)')
7
+ })
8
+
9
+ it('converts an 8-digit hex with alpha', () => {
10
+ expect(hexToOklch('#00000080')).toBe('oklch(0 0 0 / 0.502)')
11
+ })
12
+
13
+ it('throws on shorthand hex instead of emitting NaN', () => {
14
+ expect(() => hexToOklch('#fff')).toThrow(/#rrggbb/)
15
+ })
16
+ })
17
+
18
+ describe('toOklch', () => {
19
+ it('passes alias references through untouched', () => {
20
+ expect(toOklch('{light.gray.50}')).toBe('{light.gray.50}')
21
+ })
22
+ })