@hanzo/design 0.4.0 → 0.4.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.
@@ -19,54 +19,68 @@ export const colors = {
19
19
  'hanzo-black': '#0A0A0B',
20
20
  'hanzo-white': '#FFFFFF',
21
21
  'white-05': 'rgb(255 255 255 / .05)',
22
+ 'white-06': 'rgb(255 255 255 / .06)',
23
+ 'white-08': 'rgb(255 255 255 / .08)',
22
24
  'white-10': 'rgb(255 255 255 / .10)',
23
25
  'white-15': 'rgb(255 255 255 / .15)',
26
+ 'white-16': 'rgb(255 255 255 / .16)',
24
27
  'white-20': 'rgb(255 255 255 / .20)',
28
+ 'white-22': 'rgb(255 255 255 / .22)',
25
29
  'white-30': 'rgb(255 255 255 / .30)',
26
30
  'white-40': 'rgb(255 255 255 / .40)',
31
+ 'white-55': 'rgb(255 255 255 / .55)',
27
32
  'white-60': 'rgb(255 255 255 / .60)',
33
+ 'white-78': 'rgb(255 255 255 / .78)',
28
34
  'white-80': 'rgb(255 255 255 / .80)',
29
- 'background': '#000000',
30
- 'foreground': '#ededed',
31
- 'card': '#0a0a0a',
32
- 'card-foreground': '#f5f5f5',
33
- 'popover': '#0a0a0a',
34
- 'popover-foreground': '#f5f5f5',
35
- 'primary': '#ffffff',
36
- 'primary-foreground': '#000000',
37
- 'secondary': '#1a1a1a',
38
- 'secondary-foreground': '#f5f5f5',
39
- 'muted': '#101010',
40
- 'muted-foreground': '#888888',
41
- 'accent': '#1a1a1a',
42
- 'accent-foreground': '#f5f5f5',
43
- 'destructive': '#666666',
44
- 'destructive-foreground': '#f5f5f5',
45
- 'border': '#1f1f1f',
46
- 'input': '#1f1f1f',
47
- 'ring': 'var(--neutral-500)',
35
+ 'background': '#0a0a0a',
36
+ 'foreground': '#fafafa',
37
+ 'card': '#0f0f0f',
38
+ 'card-foreground': '#fafafa',
39
+ 'popover': '#0f0f0f',
40
+ 'popover-foreground': '#fafafa',
41
+ 'primary': '#fafafa',
42
+ 'primary-hover': '#e5e5e5',
43
+ 'primary-foreground': '#0a0a0a',
44
+ 'secondary': '#262626',
45
+ 'secondary-hover': '#333333',
46
+ 'secondary-foreground': '#fafafa',
47
+ 'muted': '#171717',
48
+ 'muted-foreground': '#a3a3a3',
49
+ 'accent': '#262626',
50
+ 'accent-foreground': '#fafafa',
51
+ 'destructive': 'var(--state-error)',
52
+ 'destructive-hover': '#dc2626',
53
+ 'destructive-foreground': '#fafafa',
54
+ 'border': 'var(--white-10)',
55
+ 'border-strong': 'var(--white-16)',
56
+ 'border-control': 'var(--white-15)',
57
+ 'border-focus': 'var(--white-22)',
58
+ 'border-selected': 'var(--white-30)',
59
+ 'input': 'var(--border-control)',
60
+ 'ring': 'var(--white-40)',
61
+ 'ring-halo': 'var(--white-10)',
48
62
  'brand': '#e4e4e7',
49
63
  'brand-foreground': '#09090b',
50
64
  'brand-muted': '#a3a3a3',
51
65
  'black': '#000000',
52
- 'white': '#f5f5f5',
66
+ 'white': '#fafafa',
67
+ 'selection': 'var(--white-20)',
68
+ 'glass': 'var(--white-05)',
69
+ 'glass-strong': 'var(--white-08)',
53
70
  'surface-page': 'var(--background)',
54
- 'surface-card': 'rgb(23 23 23 / .5)',
55
- 'surface-card-emphasis': 'rgb(23 23 23 / .8)',
56
- 'surface-card-quiet': 'rgb(23 23 23 / .4)',
57
- 'surface-overlay': 'rgb(10 10 10 / .95)',
71
+ 'surface-card': 'rgb(38 38 38 / .5)',
72
+ 'surface-card-emphasis': 'rgb(38 38 38 / .75)',
73
+ 'surface-card-quiet': 'rgb(38 38 38 / .35)',
74
+ 'surface-overlay': 'rgb(23 23 23 / .92)',
58
75
  'surface-header': 'rgb(0 0 0 / .7)',
59
76
  'surface-scrim': 'rgb(0 0 0 / .8)',
60
- 'border-hairline': 'var(--neutral-800)',
61
- 'border-card': 'var(--white-10)',
62
- 'border-strong': 'var(--neutral-500)',
63
77
  'surface-0': 'var(--background)',
64
78
  'surface-1': 'var(--card)',
65
79
  'surface-2': 'var(--muted)',
66
80
  'surface-3': 'var(--secondary)',
67
- 'text-primary': 'var(--pure-white)',
68
- 'text-secondary': 'var(--white-80)',
69
- 'text-tertiary': 'var(--white-60)',
81
+ 'text-primary': '#fafafa',
82
+ 'text-secondary': 'var(--white-78)',
83
+ 'text-tertiary': 'var(--white-55)',
70
84
  'text-helper': 'var(--muted-foreground)',
71
85
  'text-disabled': 'var(--white-30)',
72
86
  'state-error': '#ef4444',
@@ -119,6 +133,7 @@ export const typography = {
119
133
  'font-size-7xl': 'var(--text-7xl)',
120
134
  'font-size-8xl': 'var(--text-8xl)',
121
135
  'font-size-9xl': 'var(--text-9xl)',
136
+ 'text-control': 'var(--text-sm)',
122
137
  'weight-normal': '400',
123
138
  'weight-medium': '500',
124
139
  'weight-semibold': '600',
@@ -172,14 +187,19 @@ export const spacing = {
172
187
  'container-max': '80rem',
173
188
  'container-prose': '48rem',
174
189
  'container-wide': '72rem',
175
- 'gutter': '1rem',
190
+ 'gutter': 'max(1rem,var(--safe-left),var(--safe-right))',
176
191
  'gutter-sm': '1.5rem',
177
192
  'gutter-lg': '2rem',
178
- 'section-y': '4rem',
179
- 'section-y-lg': '6rem',
180
- 'hero-y': '5rem',
181
- 'hero-y-lg': '8rem',
182
- 'header-height': '4rem',
193
+ 'section-y': '2.5rem',
194
+ 'section-y-lg': '3.5rem',
195
+ 'hero-y': '3rem',
196
+ 'hero-y-lg': '4.5rem',
197
+ 'header-height': '3.5rem',
198
+ 'tap-target': '44px',
199
+ 'safe-top': 'env(safe-area-inset-top,0px)',
200
+ 'safe-right': 'env(safe-area-inset-right,0px)',
201
+ 'safe-bottom': 'env(safe-area-inset-bottom,0px)',
202
+ 'safe-left': 'env(safe-area-inset-left,0px)',
183
203
  };
184
204
  /** grid tokens (from tokens/grid.css). Values are raw CSS. */
185
205
  export const grid = {
@@ -209,9 +229,11 @@ export const radius = {
209
229
  /** elevation tokens (from tokens/elevation.css). Values are raw CSS. */
210
230
  export const elevation = {
211
231
  'shadow-none': 'none',
212
- 'shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
232
+ 'shadow-floating': '0 24px 60px -16px rgb(0 0 0 / .75)',
213
233
  'shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
214
- 'ring-focus': '0 0 0 2px var(--ring)',
234
+ 'edge-highlight': 'inset 0 1px 0 0 rgb(255 255 255 / .10)',
235
+ 'bloom': '0 0 24px -2px rgb(255 255 255 / .28)',
236
+ 'ring-focus': '0 0 0 3px var(--ring-halo)',
215
237
  'shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
216
238
  'shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
217
239
  'shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
@@ -221,6 +243,7 @@ export const elevation = {
221
243
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
222
244
  'glow-hero-blur': '120px',
223
245
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
246
+ 'sheen-edge': 'linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent)',
224
247
  'gradient-chrome': 'linear-gradient(to right,#ffffff,var(--white-80),var(--white-60))',
225
248
  'gradient-chrome-2': 'linear-gradient(to right,#ffffff,var(--neutral-500))',
226
249
  'gradient-protect': 'linear-gradient(to bottom,var(--white-10),transparent)',
@@ -232,8 +255,10 @@ export const motion = {
232
255
  'duration-slow': '400ms',
233
256
  'duration-slower': '500ms',
234
257
  'duration-glow': '9s',
258
+ 'duration-press': '90ms',
235
259
  'ease-out': 'cubic-bezier(0,0,0.2,1)',
236
260
  'ease-in-out': 'cubic-bezier(0.4,0,0.2,1)',
261
+ 'ease-emphasis': 'cubic-bezier(0.16,1,0.3,1)',
237
262
  'stagger': '60ms',
238
263
  'entry-rise': '16px',
239
264
  'entry-rise-lg': '24px',
@@ -280,54 +305,68 @@ export const cssVars = {
280
305
  '--hanzo-black': '#0A0A0B',
281
306
  '--hanzo-white': '#FFFFFF',
282
307
  '--white-05': 'rgb(255 255 255 / .05)',
308
+ '--white-06': 'rgb(255 255 255 / .06)',
309
+ '--white-08': 'rgb(255 255 255 / .08)',
283
310
  '--white-10': 'rgb(255 255 255 / .10)',
284
311
  '--white-15': 'rgb(255 255 255 / .15)',
312
+ '--white-16': 'rgb(255 255 255 / .16)',
285
313
  '--white-20': 'rgb(255 255 255 / .20)',
314
+ '--white-22': 'rgb(255 255 255 / .22)',
286
315
  '--white-30': 'rgb(255 255 255 / .30)',
287
316
  '--white-40': 'rgb(255 255 255 / .40)',
317
+ '--white-55': 'rgb(255 255 255 / .55)',
288
318
  '--white-60': 'rgb(255 255 255 / .60)',
319
+ '--white-78': 'rgb(255 255 255 / .78)',
289
320
  '--white-80': 'rgb(255 255 255 / .80)',
290
- '--background': '#000000',
291
- '--foreground': '#ededed',
292
- '--card': '#0a0a0a',
293
- '--card-foreground': '#f5f5f5',
294
- '--popover': '#0a0a0a',
295
- '--popover-foreground': '#f5f5f5',
296
- '--primary': '#ffffff',
297
- '--primary-foreground': '#000000',
298
- '--secondary': '#1a1a1a',
299
- '--secondary-foreground': '#f5f5f5',
300
- '--muted': '#101010',
301
- '--muted-foreground': '#888888',
302
- '--accent': '#1a1a1a',
303
- '--accent-foreground': '#f5f5f5',
304
- '--destructive': '#666666',
305
- '--destructive-foreground': '#f5f5f5',
306
- '--border': '#1f1f1f',
307
- '--input': '#1f1f1f',
308
- '--ring': 'var(--neutral-500)',
321
+ '--background': '#0a0a0a',
322
+ '--foreground': '#fafafa',
323
+ '--card': '#0f0f0f',
324
+ '--card-foreground': '#fafafa',
325
+ '--popover': '#0f0f0f',
326
+ '--popover-foreground': '#fafafa',
327
+ '--primary': '#fafafa',
328
+ '--primary-hover': '#e5e5e5',
329
+ '--primary-foreground': '#0a0a0a',
330
+ '--secondary': '#262626',
331
+ '--secondary-hover': '#333333',
332
+ '--secondary-foreground': '#fafafa',
333
+ '--muted': '#171717',
334
+ '--muted-foreground': '#a3a3a3',
335
+ '--accent': '#262626',
336
+ '--accent-foreground': '#fafafa',
337
+ '--destructive': 'var(--state-error)',
338
+ '--destructive-hover': '#dc2626',
339
+ '--destructive-foreground': '#fafafa',
340
+ '--border': 'var(--white-10)',
341
+ '--border-strong': 'var(--white-16)',
342
+ '--border-control': 'var(--white-15)',
343
+ '--border-focus': 'var(--white-22)',
344
+ '--border-selected': 'var(--white-30)',
345
+ '--input': 'var(--border-control)',
346
+ '--ring': 'var(--white-40)',
347
+ '--ring-halo': 'var(--white-10)',
309
348
  '--brand': '#e4e4e7',
310
349
  '--brand-foreground': '#09090b',
311
350
  '--brand-muted': '#a3a3a3',
312
351
  '--black': '#000000',
313
- '--white': '#f5f5f5',
352
+ '--white': '#fafafa',
353
+ '--selection': 'var(--white-20)',
354
+ '--glass': 'var(--white-05)',
355
+ '--glass-strong': 'var(--white-08)',
314
356
  '--surface-page': 'var(--background)',
315
- '--surface-card': 'rgb(23 23 23 / .5)',
316
- '--surface-card-emphasis': 'rgb(23 23 23 / .8)',
317
- '--surface-card-quiet': 'rgb(23 23 23 / .4)',
318
- '--surface-overlay': 'rgb(10 10 10 / .95)',
357
+ '--surface-card': 'rgb(38 38 38 / .5)',
358
+ '--surface-card-emphasis': 'rgb(38 38 38 / .75)',
359
+ '--surface-card-quiet': 'rgb(38 38 38 / .35)',
360
+ '--surface-overlay': 'rgb(23 23 23 / .92)',
319
361
  '--surface-header': 'rgb(0 0 0 / .7)',
320
362
  '--surface-scrim': 'rgb(0 0 0 / .8)',
321
- '--border-hairline': 'var(--neutral-800)',
322
- '--border-card': 'var(--white-10)',
323
- '--border-strong': 'var(--neutral-500)',
324
363
  '--surface-0': 'var(--background)',
325
364
  '--surface-1': 'var(--card)',
326
365
  '--surface-2': 'var(--muted)',
327
366
  '--surface-3': 'var(--secondary)',
328
- '--text-primary': 'var(--pure-white)',
329
- '--text-secondary': 'var(--white-80)',
330
- '--text-tertiary': 'var(--white-60)',
367
+ '--text-primary': '#fafafa',
368
+ '--text-secondary': 'var(--white-78)',
369
+ '--text-tertiary': 'var(--white-55)',
331
370
  '--text-helper': 'var(--muted-foreground)',
332
371
  '--text-disabled': 'var(--white-30)',
333
372
  '--state-error': '#ef4444',
@@ -377,6 +416,7 @@ export const cssVars = {
377
416
  '--font-size-7xl': 'var(--text-7xl)',
378
417
  '--font-size-8xl': 'var(--text-8xl)',
379
418
  '--font-size-9xl': 'var(--text-9xl)',
419
+ '--text-control': 'var(--text-sm)',
380
420
  '--weight-normal': '400',
381
421
  '--weight-medium': '500',
382
422
  '--weight-semibold': '600',
@@ -427,14 +467,19 @@ export const cssVars = {
427
467
  '--container-max': '80rem',
428
468
  '--container-prose': '48rem',
429
469
  '--container-wide': '72rem',
430
- '--gutter': '1rem',
470
+ '--gutter': 'max(1rem,var(--safe-left),var(--safe-right))',
431
471
  '--gutter-sm': '1.5rem',
432
472
  '--gutter-lg': '2rem',
433
- '--section-y': '4rem',
434
- '--section-y-lg': '6rem',
435
- '--hero-y': '5rem',
436
- '--hero-y-lg': '8rem',
437
- '--header-height': '4rem',
473
+ '--section-y': '2.5rem',
474
+ '--section-y-lg': '3.5rem',
475
+ '--hero-y': '3rem',
476
+ '--hero-y-lg': '4.5rem',
477
+ '--header-height': '3.5rem',
478
+ '--tap-target': '44px',
479
+ '--safe-top': 'env(safe-area-inset-top,0px)',
480
+ '--safe-right': 'env(safe-area-inset-right,0px)',
481
+ '--safe-bottom': 'env(safe-area-inset-bottom,0px)',
482
+ '--safe-left': 'env(safe-area-inset-left,0px)',
438
483
  '--grid-columns': '12',
439
484
  '--grid-gap': 'var(--space-6)',
440
485
  '--grid-gap-tight': 'var(--space-4)',
@@ -455,9 +500,11 @@ export const cssVars = {
455
500
  '--radius-composer': '28px',
456
501
  '--radius-full': '9999px',
457
502
  '--shadow-none': 'none',
458
- '--shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
503
+ '--shadow-floating': '0 24px 60px -16px rgb(0 0 0 / .75)',
459
504
  '--shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
460
- '--ring-focus': '0 0 0 2px var(--ring)',
505
+ '--edge-highlight': 'inset 0 1px 0 0 rgb(255 255 255 / .10)',
506
+ '--bloom': '0 0 24px -2px rgb(255 255 255 / .28)',
507
+ '--ring-focus': '0 0 0 3px var(--ring-halo)',
461
508
  '--shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
462
509
  '--shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
463
510
  '--shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
@@ -467,6 +514,7 @@ export const cssVars = {
467
514
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
468
515
  '--glow-hero-blur': '120px',
469
516
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
517
+ '--sheen-edge': 'linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent)',
470
518
  '--gradient-chrome': 'linear-gradient(to right,#ffffff,var(--white-80),var(--white-60))',
471
519
  '--gradient-chrome-2': 'linear-gradient(to right,#ffffff,var(--neutral-500))',
472
520
  '--gradient-protect': 'linear-gradient(to bottom,var(--white-10),transparent)',
@@ -475,8 +523,10 @@ export const cssVars = {
475
523
  '--duration-slow': '400ms',
476
524
  '--duration-slower': '500ms',
477
525
  '--duration-glow': '9s',
526
+ '--duration-press': '90ms',
478
527
  '--ease-out': 'cubic-bezier(0,0,0.2,1)',
479
528
  '--ease-in-out': 'cubic-bezier(0.4,0,0.2,1)',
529
+ '--ease-emphasis': 'cubic-bezier(0.16,1,0.3,1)',
480
530
  '--stagger': '60ms',
481
531
  '--entry-rise': '16px',
482
532
  '--entry-rise-lg': '24px',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "packageManager": "pnpm@11.17.0",
5
5
  "description": "Hanzo Design System \u2014 monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
6
6
  "license": "BSD-3-Clause",
@@ -114,9 +114,17 @@ const pass = (msg) => console.log(` ok ${msg}`)
114
114
  : pass(`all ${used.size} internal var() references resolve`)
115
115
  }
116
116
 
117
- // ── 3. the non-text contrast gate (WCAG 1.4.11 / 2.4.11) ─────────────────
118
- // A focus indicator and a control boundary must clear 3:1 against EVERY surface
119
- // they can land on, in BOTH themes. --ring was #333333 = 1.66:1 on --background.
117
+ // ── 3. the focus-indicator contrast gate (WCAG 2.4.11) ───────────────────
118
+ // The focus indicator must clear 3:1 against EVERY surface it can land on, in
119
+ // BOTH themes. --ring was #333333 = 1.66:1 on --background.
120
+ //
121
+ // --border-control was gated here too and no longer is. That was not a
122
+ // regression, it was the point: pinning a control's RESTING edge at 3:1 forces
123
+ // it to a mid-grey on a near-black ground, four times heavier than the hairline
124
+ // beside it, and a form drawn that way reads as a wireframe. The edge is now
125
+ // rgb(255 255 255 / .15) — the reference's own value — and the contrast budget
126
+ // is spent entirely on --ring, which is what a keyboard user actually navigates
127
+ // by. A resting edge is an affordance; a focus ring is a position.
120
128
  {
121
129
  const css = strip(read(join(tokensDir, 'colors.css')))
122
130
  const block = (re) => { const m = css.match(re); const o = {}; if (m) for (const [, n, v] of m[1].matchAll(/--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g)) o[n] = v.trim(); return o }
@@ -144,7 +152,12 @@ const pass = (msg) => console.log(` ok ${msg}`)
144
152
  }
145
153
 
146
154
  const CANVASES = ['background', 'card', 'popover', 'muted', 'secondary', 'surface-card', 'surface-overlay']
147
- const GATED = { ring: 3, 'border-strong': 3 }
155
+ // The gate follows the DUTY, not the name — and exactly ONE duty owes a
156
+ // ratio. --ring is the focus indicator: transient, keyboard-only, and the
157
+ // whole of how someone knows where they are. Every boundary that is merely an
158
+ // affordance — --border, --border-strong, --border-control, --border-focus,
159
+ // --border-selected — is free to be as quiet as it looks.
160
+ const GATED = { ring: 3 }
148
161
  for (const [theme, scope] of Object.entries(themes)) {
149
162
  for (const [tok, min] of Object.entries(GATED)) {
150
163
  let worst = Infinity, where = ''
@@ -158,6 +171,60 @@ const pass = (msg) => console.log(` ok ${msg}`)
158
171
  : pass(`--${tok} (${theme}) ${worst.toFixed(2)}:1 worst case (${where})`)
159
172
  }
160
173
  }
174
+
175
+ // ── 4. every white-alpha token must be restated for the light theme ──────
176
+ // The opacity ladder does NOT invert. A semantic token built from
177
+ // rgb(255 255 255 / a) is white-on-white in `.light` — the border does not
178
+ // change colour, it STOPS EXISTING — and nothing renders an error. The raw
179
+ // ladder itself (--white-*) is exempt: it is a palette, not a semantic token,
180
+ // and light-theme tokens are expected to restate rather than invert it.
181
+ //
182
+ // Two things widen this from where it started. It scans tokens/elevation.css
183
+ // as well as tokens/colors.css, because that is where the light lives now —
184
+ // an inset white highlight, a white hover bloom and a white panel sheen all
185
+ // fail in `.light` exactly the way a white border does, and more invisibly,
186
+ // since a missing lift looks like a design choice. And it matches white-alpha
187
+ // ANYWHERE INSIDE a value rather than requiring the whole value to be one
188
+ // colour, because every one of those tokens is a shadow or a gradient with
189
+ // the colour buried in it — `inset 0 1px 0 0 rgb(255 255 255 / .07)` would
190
+ // have sailed through a whole-value parse.
191
+ {
192
+ const PALETTE = /^(white|neutral|pure|hanzo)-/
193
+ const FILES = ['colors', 'elevation']
194
+ const blockOf = (src, re) => { const m = strip(src).match(re); const o = {}; if (m) for (const [, n, v] of m[1].matchAll(/--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g)) o[n] = v.trim(); return o }
195
+ const ROOT = /:root\s*\{([\s\S]*?)\n\}/
196
+ const LIGHT = /\.light\s*\{([\s\S]*?)\n\}/
197
+
198
+ const sources = Object.fromEntries(FILES.map((f) => [f, read(join(tokensDir, `${f}.css`))]))
199
+ // One scope to resolve against: elevation references --white-* rungs that
200
+ // colors declares, so a per-file scope could not expand them.
201
+ const scope = {}
202
+ for (const f of FILES) Object.assign(scope, blockOf(sources[f], ROOT))
203
+ // Restating in ANY file's .light block counts — the cascade does not care
204
+ // which file a declaration came from.
205
+ const lightKeys = new Set()
206
+ for (const f of FILES) for (const k of Object.keys(blockOf(sources[f], LIGHT))) lightKeys.add(k)
207
+
208
+ // Expansion STOPS at any token that is itself restated in `.light`.
209
+ // `--ring-focus: 0 0 0 3px var(--ring-halo)` is theme-safe precisely
210
+ // because --ring-halo inverts underneath it — var() resolves per theme at
211
+ // use time, so deferring to a token that flips IS the fix, and a checker
212
+ // that expanded through it would demand every wrapper be restated too.
213
+ const expand = (v, d = 0) =>
214
+ d > 10 ? v : String(v).replace(/var\(\s*--([A-Za-z0-9-]+)\s*\)/g,
215
+ (m, n) => (n in scope && !lightKeys.has(n) ? expand(scope[n], d + 1) : m))
216
+ const WHITE_ALPHA = /rgba?\(\s*255[\s,]+255[\s,]+255\s*[/,]\s*(?:0?\.\d+|0)\s*\)/i
217
+
218
+ const leaked = []
219
+ for (const f of FILES)
220
+ for (const k of Object.keys(blockOf(sources[f], ROOT))) {
221
+ if (PALETTE.test(k) || lightKeys.has(k)) continue
222
+ if (WHITE_ALPHA.test(expand(scope[k]))) leaked.push(`${k} (tokens/${f}.css)`)
223
+ }
224
+ leaked.length
225
+ ? leaked.forEach((k) => fail(`--${k} is built from white-alpha and has no .light value — it vanishes in the light theme`))
226
+ : pass(`every white-alpha token in ${FILES.join('/')}.css is restated in .light`)
227
+ }
161
228
  }
162
229
 
163
230
  console.log(failures ? `\n${failures} check(s) failed` : '\nall token checks passed')