@hanzo/design 0.5.0 → 0.5.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.
@@ -94,31 +94,32 @@ export const colors = {
94
94
  };
95
95
  /** typography tokens (from tokens/typography.css). Values are raw CSS. */
96
96
  export const typography = {
97
- 'text-xs': '0.6875rem',
98
- 'leading-xs': '1rem',
99
- 'text-sm': '0.8125rem',
100
- 'leading-sm': '1.15rem',
101
- 'text-base': '0.875rem',
102
- 'leading-base': '1.35rem',
103
- 'text-lg': '0.9375rem',
104
- 'leading-lg': '1.4rem',
105
- 'text-xl': '1.0625rem',
106
- 'leading-xl': '1.55rem',
107
- 'text-2xl': '1.3125rem',
108
- 'leading-2xl': '1.7rem',
109
- 'text-3xl': '1.625rem',
110
- 'leading-3xl': '1.95rem',
111
- 'text-4xl': '2rem',
112
- 'leading-4xl': '2.25rem',
113
- 'text-5xl': '2.5rem',
97
+ 'type-scale': '1',
98
+ 'text-xs': 'calc(0.6875rem * var(--type-scale, 1))',
99
+ 'leading-xs': 'calc(1rem * var(--type-scale, 1))',
100
+ 'text-sm': 'calc(0.8125rem * var(--type-scale, 1))',
101
+ 'leading-sm': 'calc(1.15rem * var(--type-scale, 1))',
102
+ 'text-base': 'calc(0.875rem * var(--type-scale, 1))',
103
+ 'leading-base': 'calc(1.35rem * var(--type-scale, 1))',
104
+ 'text-lg': 'calc(0.9375rem * var(--type-scale, 1))',
105
+ 'leading-lg': 'calc(1.4rem * var(--type-scale, 1))',
106
+ 'text-xl': 'calc(1.0625rem * var(--type-scale, 1))',
107
+ 'leading-xl': 'calc(1.55rem * var(--type-scale, 1))',
108
+ 'text-2xl': 'calc(1.3125rem * var(--type-scale, 1))',
109
+ 'leading-2xl': 'calc(1.7rem * var(--type-scale, 1))',
110
+ 'text-3xl': 'calc(1.625rem * var(--type-scale, 1))',
111
+ 'leading-3xl': 'calc(1.95rem * var(--type-scale, 1))',
112
+ 'text-4xl': 'calc(2rem * var(--type-scale, 1))',
113
+ 'leading-4xl': 'calc(2.25rem * var(--type-scale, 1))',
114
+ 'text-5xl': 'calc(2.5rem * var(--type-scale, 1))',
114
115
  'leading-5xl': '1.05',
115
- 'text-6xl': '3.25rem',
116
+ 'text-6xl': 'calc(3.25rem * var(--type-scale, 1))',
116
117
  'leading-6xl': '1',
117
- 'text-7xl': '4rem',
118
+ 'text-7xl': 'calc(4rem * var(--type-scale, 1))',
118
119
  'leading-7xl': '1',
119
- 'text-8xl': '5.25rem',
120
+ 'text-8xl': 'calc(5.25rem * var(--type-scale, 1))',
120
121
  'leading-8xl': '1',
121
- 'text-9xl': '7rem',
122
+ 'text-9xl': 'calc(7rem * var(--type-scale, 1))',
122
123
  'leading-9xl': '1',
123
124
  'font-size-xs': 'var(--text-xs)',
124
125
  'font-size-sm': 'var(--text-sm)',
@@ -159,21 +160,22 @@ export const typography = {
159
160
  };
160
161
  /** spacing tokens (from tokens/spacing.css). Values are raw CSS. */
161
162
  export const spacing = {
163
+ 'density': '1',
162
164
  'space-0': '0',
163
- 'space-1': '0.25rem',
164
- 'space-2': '0.5rem',
165
- 'space-3': '0.75rem',
166
- 'space-4': '1rem',
167
- 'space-5': '1.25rem',
168
- 'space-6': '1.5rem',
169
- 'space-8': '2rem',
170
- 'space-10': '2.5rem',
171
- 'space-12': '3rem',
172
- 'space-14': '3.5rem',
173
- 'space-16': '4rem',
174
- 'space-20': '5rem',
175
- 'space-24': '6rem',
176
- 'space-32': '8rem',
165
+ 'space-1': 'calc(0.25rem * var(--density, 1))',
166
+ 'space-2': 'calc(0.5rem * var(--density, 1))',
167
+ 'space-3': 'calc(0.75rem * var(--density, 1))',
168
+ 'space-4': 'calc(1rem * var(--density, 1))',
169
+ 'space-5': 'calc(1.25rem * var(--density, 1))',
170
+ 'space-6': 'calc(1.5rem * var(--density, 1))',
171
+ 'space-8': 'calc(2rem * var(--density, 1))',
172
+ 'space-10': 'calc(2.5rem * var(--density, 1))',
173
+ 'space-12': 'calc(3rem * var(--density, 1))',
174
+ 'space-14': 'calc(3.5rem * var(--density, 1))',
175
+ 'space-16': 'calc(4rem * var(--density, 1))',
176
+ 'space-20': 'calc(5rem * var(--density, 1))',
177
+ 'space-24': 'calc(6rem * var(--density, 1))',
178
+ 'space-32': 'calc(8rem * var(--density, 1))',
177
179
  'golden-1': '0.25rem',
178
180
  'golden-2': '0.405rem',
179
181
  'golden-3': '0.654rem',
@@ -240,6 +242,13 @@ export const elevation = {
240
242
  'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
241
243
  'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
242
244
  'shadow-2xl': 'var(--shadow-floating)',
245
+ 'sheet-0': 'var(--surface-page)',
246
+ 'sheet-1': 'var(--surface-card)',
247
+ 'sheet-2': 'var(--surface-card-emphasis)',
248
+ 'shadow-sheet-1': 'var(--edge-highlight),var(--shadow-md)',
249
+ 'shadow-sheet-2': 'var(--edge-highlight),var(--shadow-lg)',
250
+ 'fold': '12px',
251
+ 'fold-face': 'linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold))',
243
252
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
244
253
  'glow-hero-blur': '120px',
245
254
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -377,31 +386,32 @@ export const cssVars = {
377
386
  '--chrome-dot-red': 'rgb(239 68 68 / .6)',
378
387
  '--chrome-dot-yellow': 'rgb(234 179 8 / .6)',
379
388
  '--chrome-dot-green': 'rgb(34 197 94 / .6)',
380
- '--text-xs': '0.6875rem',
381
- '--leading-xs': '1rem',
382
- '--text-sm': '0.8125rem',
383
- '--leading-sm': '1.15rem',
384
- '--text-base': '0.875rem',
385
- '--leading-base': '1.35rem',
386
- '--text-lg': '0.9375rem',
387
- '--leading-lg': '1.4rem',
388
- '--text-xl': '1.0625rem',
389
- '--leading-xl': '1.55rem',
390
- '--text-2xl': '1.3125rem',
391
- '--leading-2xl': '1.7rem',
392
- '--text-3xl': '1.625rem',
393
- '--leading-3xl': '1.95rem',
394
- '--text-4xl': '2rem',
395
- '--leading-4xl': '2.25rem',
396
- '--text-5xl': '2.5rem',
389
+ '--type-scale': '1',
390
+ '--text-xs': 'calc(0.6875rem * var(--type-scale, 1))',
391
+ '--leading-xs': 'calc(1rem * var(--type-scale, 1))',
392
+ '--text-sm': 'calc(0.8125rem * var(--type-scale, 1))',
393
+ '--leading-sm': 'calc(1.15rem * var(--type-scale, 1))',
394
+ '--text-base': 'calc(0.875rem * var(--type-scale, 1))',
395
+ '--leading-base': 'calc(1.35rem * var(--type-scale, 1))',
396
+ '--text-lg': 'calc(0.9375rem * var(--type-scale, 1))',
397
+ '--leading-lg': 'calc(1.4rem * var(--type-scale, 1))',
398
+ '--text-xl': 'calc(1.0625rem * var(--type-scale, 1))',
399
+ '--leading-xl': 'calc(1.55rem * var(--type-scale, 1))',
400
+ '--text-2xl': 'calc(1.3125rem * var(--type-scale, 1))',
401
+ '--leading-2xl': 'calc(1.7rem * var(--type-scale, 1))',
402
+ '--text-3xl': 'calc(1.625rem * var(--type-scale, 1))',
403
+ '--leading-3xl': 'calc(1.95rem * var(--type-scale, 1))',
404
+ '--text-4xl': 'calc(2rem * var(--type-scale, 1))',
405
+ '--leading-4xl': 'calc(2.25rem * var(--type-scale, 1))',
406
+ '--text-5xl': 'calc(2.5rem * var(--type-scale, 1))',
397
407
  '--leading-5xl': '1.05',
398
- '--text-6xl': '3.25rem',
408
+ '--text-6xl': 'calc(3.25rem * var(--type-scale, 1))',
399
409
  '--leading-6xl': '1',
400
- '--text-7xl': '4rem',
410
+ '--text-7xl': 'calc(4rem * var(--type-scale, 1))',
401
411
  '--leading-7xl': '1',
402
- '--text-8xl': '5.25rem',
412
+ '--text-8xl': 'calc(5.25rem * var(--type-scale, 1))',
403
413
  '--leading-8xl': '1',
404
- '--text-9xl': '7rem',
414
+ '--text-9xl': 'calc(7rem * var(--type-scale, 1))',
405
415
  '--leading-9xl': '1',
406
416
  '--font-size-xs': 'var(--text-xs)',
407
417
  '--font-size-sm': 'var(--text-sm)',
@@ -439,21 +449,22 @@ export const cssVars = {
439
449
  '--type-caption': '400 var(--text-xs)/var(--leading-xs) var(--font-sans)',
440
450
  '--type-code': '400 var(--text-sm)/var(--leading-relaxed) var(--font-mono)',
441
451
  '--type-eyebrow': '600 0.625rem/1 var(--font-sans)',
452
+ '--density': '1',
442
453
  '--space-0': '0',
443
- '--space-1': '0.25rem',
444
- '--space-2': '0.5rem',
445
- '--space-3': '0.75rem',
446
- '--space-4': '1rem',
447
- '--space-5': '1.25rem',
448
- '--space-6': '1.5rem',
449
- '--space-8': '2rem',
450
- '--space-10': '2.5rem',
451
- '--space-12': '3rem',
452
- '--space-14': '3.5rem',
453
- '--space-16': '4rem',
454
- '--space-20': '5rem',
455
- '--space-24': '6rem',
456
- '--space-32': '8rem',
454
+ '--space-1': 'calc(0.25rem * var(--density, 1))',
455
+ '--space-2': 'calc(0.5rem * var(--density, 1))',
456
+ '--space-3': 'calc(0.75rem * var(--density, 1))',
457
+ '--space-4': 'calc(1rem * var(--density, 1))',
458
+ '--space-5': 'calc(1.25rem * var(--density, 1))',
459
+ '--space-6': 'calc(1.5rem * var(--density, 1))',
460
+ '--space-8': 'calc(2rem * var(--density, 1))',
461
+ '--space-10': 'calc(2.5rem * var(--density, 1))',
462
+ '--space-12': 'calc(3rem * var(--density, 1))',
463
+ '--space-14': 'calc(3.5rem * var(--density, 1))',
464
+ '--space-16': 'calc(4rem * var(--density, 1))',
465
+ '--space-20': 'calc(5rem * var(--density, 1))',
466
+ '--space-24': 'calc(6rem * var(--density, 1))',
467
+ '--space-32': 'calc(8rem * var(--density, 1))',
457
468
  '--golden-1': '0.25rem',
458
469
  '--golden-2': '0.405rem',
459
470
  '--golden-3': '0.654rem',
@@ -511,6 +522,13 @@ export const cssVars = {
511
522
  '--shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
512
523
  '--shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
513
524
  '--shadow-2xl': 'var(--shadow-floating)',
525
+ '--sheet-0': 'var(--surface-page)',
526
+ '--sheet-1': 'var(--surface-card)',
527
+ '--sheet-2': 'var(--surface-card-emphasis)',
528
+ '--shadow-sheet-1': 'var(--edge-highlight),var(--shadow-md)',
529
+ '--shadow-sheet-2': 'var(--edge-highlight),var(--shadow-lg)',
530
+ '--fold': '12px',
531
+ '--fold-face': 'linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold))',
514
532
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
515
533
  '--glow-hero-blur': '120px',
516
534
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -0,0 +1,22 @@
1
+ <!-- @dsCard group="Brand" viewport="700x240" name="Paper" subtitle="Three sheets, one light, and a fold that means this opens" -->
2
+ <!doctype html><html class="dark"><head><meta charset="utf-8"><title>Paper</title><link rel="stylesheet" href="../styles.css">
3
+ <style>body{margin:0;padding:20px;background:var(--sheet-0);font-family:var(--font-sans);color:var(--foreground)}
4
+ .n{font-size:10px;font-weight:600;letter-spacing:.06em;color:var(--neutral-300);font-family:var(--font-mono)}
5
+ .v{font-size:9px;color:var(--neutral-600);font-family:var(--font-mono);margin-top:3px}
6
+ .sheet{padding:14px;border-radius:var(--radius-lg);font-size:12px;color:var(--neutral-400)}
7
+ .s1{background:var(--sheet-1);box-shadow:var(--shadow-sheet-1)}
8
+ .s2{background:var(--sheet-2);box-shadow:var(--shadow-sheet-2)}
9
+ .fold{background-image:var(--fold-face);border-bottom-right-radius:0}
10
+ </style></head><body>
11
+ <div style="display:flex;gap:16px;align-items:stretch">
12
+ <div class="sheet" style="flex:1">the workspace — no lift, no box<div class="n" style="margin-top:10px">--sheet-0</div><div class="v">the ground</div></div>
13
+ <div class="sheet s1" style="flex:1">an active tool<div class="n" style="margin-top:10px">--sheet-1</div><div class="v">+ --shadow-sheet-1</div></div>
14
+ <div class="sheet s2" style="flex:1">what opened<div class="n" style="margin-top:10px">--sheet-2</div><div class="v">+ --shadow-sheet-2</div></div>
15
+ <div class="sheet s1 fold" style="flex:1">this one expands<div class="n" style="margin-top:10px">--fold-face</div><div class="v">the only mark</div></div>
16
+ </div>
17
+ <div style="margin-top:18px;display:flex;gap:16px">
18
+ <div class="sheet s1" style="flex:1">a sheet on a sheet lands a step lighter
19
+ <div class="sheet s1" style="margin-top:10px">nested<div class="sheet s1" style="margin-top:10px">nested again</div></div>
20
+ </div>
21
+ </div>
22
+ </body></html>
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.5.0",
3
+ "version": "0.5.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
- "license": "BSD-3-Clause",
6
+ "license": "MIT OR Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/hanzoai/design.git"
@@ -19,8 +19,10 @@
19
19
  "exports": {
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
- "import": "./dist/index.js"
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
23
24
  },
25
+ "./package.json": "./package.json",
24
26
  "./styles.css": "./styles.css",
25
27
  "./tokens/*": "./tokens/*",
26
28
  "./components/*": "./components/*",
@@ -32,8 +34,8 @@
32
34
  },
33
35
  "scripts": {
34
36
  "gen": "node scripts/gen-tokens.mjs",
35
- "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs .",
36
- "build": "pnpm gen && pnpm test && tsc -p tsconfig.json",
37
+ "test": "node scripts/check-tokens.mjs && node scripts/check-preference.mjs && node scripts/lint.mjs .",
38
+ "build": "pnpm gen && pnpm test && tsc -p tsconfig.json && node scripts/check-dist.mjs",
37
39
  "prepublishOnly": "pnpm build",
38
40
  "lint": "node scripts/lint.mjs"
39
41
  },
@@ -70,4 +72,4 @@
70
72
  "bin": {
71
73
  "hanzo-design-lint": "./scripts/lint.mjs"
72
74
  }
73
- }
75
+ }
package/prompts/rules.md CHANGED
@@ -33,7 +33,9 @@ token or pattern that makes the "do" trivial.
33
33
  |----|-------|
34
34
  | One primary (white) action per view; everything else outline or ghost. | Two competing primary buttons. |
35
35
  | Plain `<a>` / `<Link>` with className, or the shipped `<Button>`. | `<Button asChild>` — it breaks the build under React 18 strict children. |
36
- | Elevation as a hairline border + `--shadow-floating` only when floating. | Drop shadows on flat, in-flow cards. |
36
+ | Separate two surfaces by RAISING one `--sheet-1` + `--shadow-sheet-1`. | Outlining both, then raising one anyway. |
37
+ | A level because the surface means something: workspace, tool, opened. | A drop shadow for looks, or a second lamp — every shadow drops straight down. |
38
+ | A fold (`--fold-face`) only where the item expands. | A fold as ornament. |
37
39
 
38
40
  ## Motion
39
41
 
package/prompts/system.md CHANGED
@@ -73,8 +73,14 @@ That is not Hanzo. Make it white-on-black and let the content carry it.
73
73
 
74
74
  - `--radius-sm` on buttons/inputs, `--radius-lg` on cards, `--radius-full` on
75
75
  pills and avatars. `--radius-composer` (28px) only for the chat composer.
76
- - Hanzo barely uses shadow. On black, elevation is a hairline border plus, for
77
- truly floating surfaces, `--shadow-floating`. Heroes get one ambient
76
+ - Depth is the hierarchy. Three levels of paper: `--sheet-0` is the workspace,
77
+ `--sheet-1` an active tool, `--sheet-2` what opened each with the matching
78
+ `--shadow-sheet-*`, which carries the top edge-light and the drop together.
79
+ Raise a surface before you outline it, and never do both.
80
+ - One light, above: every shadow in the system is `0 <y> …`. A sideways offset
81
+ is a second lamp, and the screen stops reading as one space.
82
+ - `--fold-face` marks an item that expands, and nothing else. `--shadow-floating`
83
+ is for what actually floats — menus, dialogs. Heroes get one ambient
78
84
  `--glow-hero` radial, nothing more.
79
85
 
80
86
  ## Motion
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Import the BUILT package the way a consumer does.
3
+ *
4
+ * Everything else in this repo checks the sources: the token files, the
5
+ * generator's output, the preference contract. All of it passed while
6
+ * `dist/index.js` was unimportable — 0.4.10 and 0.4.11 both shipped a barrel
7
+ * that re-exported `'./preference'` with no extension, which TypeScript emits
8
+ * verbatim and Node ESM refuses to resolve. `pnpm build` was green, `pnpm test`
9
+ * was green, and `import '@hanzo/design'` threw ERR_MODULE_NOT_FOUND for every
10
+ * consumer.
11
+ *
12
+ * A test that reads source cannot see that. This one loads the artifact.
13
+ */
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url))
19
+ const root = join(here, '..')
20
+
21
+ let failed = 0
22
+ const check = async (name, fn) => {
23
+ try {
24
+ await fn()
25
+ console.log(` ok ${name}`)
26
+ } catch (e) {
27
+ failed++
28
+ console.error(` FAIL ${name}\n ${e.message}`)
29
+ }
30
+ }
31
+
32
+ console.log('dist:')
33
+
34
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
35
+
36
+ await check('every export the package advertises actually exists on disk', () => {
37
+ const missing = []
38
+ for (const [name, entry] of Object.entries(pkg.exports ?? {})) {
39
+ for (const target of typeof entry === 'string' ? [entry] : Object.values(entry)) {
40
+ if (typeof target !== 'string' || target.includes('*')) continue
41
+ if (!existsSync(join(root, target))) missing.push(`${name} -> ${target}`)
42
+ }
43
+ }
44
+ if (missing.length) throw new Error(`exports pointing at nothing:\n ${missing.join('\n ')}`)
45
+ })
46
+
47
+ await check('the barrel IMPORTS — the check that 0.4.10 and 0.4.11 needed', async () => {
48
+ const entry = join(root, 'dist/index.js')
49
+ if (!existsSync(entry)) throw new Error('dist/index.js is missing — run the build first')
50
+ const mod = await import(pathToFileURL(entry).href)
51
+ // The preference API is the part that was unreachable; naming it here means a
52
+ // future re-export losing its extension fails by name rather than by silence.
53
+ for (const name of ['vars', 'css', 'isColor', 'TYPE_MIN', 'TYPE_MAX', 'cssVar', 'tokenValue']) {
54
+ if (mod[name] === undefined) throw new Error(`dist/index.js does not export ${name}`)
55
+ }
56
+ if (typeof mod.vars !== 'function') throw new Error('vars is not callable from the built package')
57
+ const v = mod.vars({ type: 1.2, density: 'compact' })
58
+ if (v['--type-scale'] !== '1.2') throw new Error(`built vars() returned ${JSON.stringify(v)}`)
59
+ })
60
+
61
+ await check('no emitted module uses an extensionless relative specifier', () => {
62
+ // The class of bug, not just the one instance: Node ESM resolves relative
63
+ // specifiers literally, so a missing `.js` is always a runtime failure.
64
+ const bad = []
65
+ for (const [, entry] of Object.entries(pkg.exports ?? {})) {
66
+ const target = typeof entry === 'string' ? entry : entry.import
67
+ if (typeof target !== 'string' || target.includes('*') || !target.endsWith('.js')) continue
68
+ const file = join(root, target)
69
+ if (!existsSync(file)) continue
70
+ for (const m of readFileSync(file, 'utf8').matchAll(/from\s+['"](\.[^'"]*)['"]/g)) {
71
+ if (!/\.(js|mjs|cjs|json|css)$/.test(m[1])) bad.push(`${target}: ${m[1]}`)
72
+ }
73
+ }
74
+ if (bad.length) throw new Error(`extensionless relative imports:\n ${bad.join('\n ')}`)
75
+ })
76
+
77
+ if (failed) {
78
+ console.error(`\n${failed} dist check(s) failed`)
79
+ process.exit(1)
80
+ }
81
+ console.log('dist: all checks passed')
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Preference contract, checked the way this package already checks tokens:
3
+ * a plain node script, no framework, run by `npm test`.
4
+ *
5
+ * The load-bearing check is the last one. The first version of preference.ts
6
+ * kept its own copy of the type ramp and the copy was WRONG (lg/xl were 16/18
7
+ * against the tokens' 15/17), so a preference of 1 — "leave it alone" — would
8
+ * have resized two rungs of the published design. Nothing caught it, because
9
+ * nothing compared the copy to the source. Now there is no copy: the knobs are
10
+ * multipliers and the ramps live only in tokens/*.css, and check 5 fails if a
11
+ * rung ever stops carrying its multiplier.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, join } from "node:path";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const root = join(here, "..");
19
+
20
+ // Load the source directly — this package ships tokens, and the preference
21
+ // module is small enough to evaluate without a build step in the check.
22
+ const src = readFileSync(join(root, "src/preference.ts"), "utf8");
23
+ const js = src
24
+ .replace(/^import[^\n]*\n/gm, "")
25
+ .replace(/export (type|interface) [\s\S]*?\n}\n/g, "")
26
+ .replace(/export type [^\n]*\n/g, "")
27
+ .replace(/: Record<[^>]*>/g, "")
28
+ .replace(/: Preference/g, "")
29
+ .replace(/: Density/g, "")
30
+ .replace(/: string/g, "")
31
+ .replace(/: number/g, "")
32
+ .replace(/: boolean/g, "")
33
+ .replace(/export /g, "");
34
+
35
+ const mod = new Function(`${js}; return { vars, css, isColor, TYPE_MIN, TYPE_MAX };`)();
36
+ const { vars, css, isColor, TYPE_MIN, TYPE_MAX } = mod;
37
+
38
+ let failed = 0;
39
+ const check = (name, fn) => {
40
+ try {
41
+ fn();
42
+ console.log(` ok ${name}`);
43
+ } catch (e) {
44
+ failed++;
45
+ console.error(` FAIL ${name}\n ${e.message}`);
46
+ }
47
+ };
48
+ const eq = (a, b, m) => {
49
+ const A = JSON.stringify(a), B = JSON.stringify(b);
50
+ if (A !== B) throw new Error(`${m || ""} got ${A} want ${B}`);
51
+ };
52
+ const ok = (v, m) => { if (!v) throw new Error(m || "expected truthy"); };
53
+
54
+ console.log("preference:");
55
+
56
+ check("an unset preference changes nothing", () => {
57
+ eq(vars({}), {});
58
+ eq(css({}), "");
59
+ });
60
+
61
+ check("type is ONE knob, not a restated ramp", () => {
62
+ const v = vars({ type: 1.2 });
63
+ eq(Object.keys(v), ["--type-scale"], "type must emit exactly one name:");
64
+ eq(v["--type-scale"], "1.2");
65
+ });
66
+
67
+ check("type is CLAMPED — a preference cannot make the UI illegible", () => {
68
+ eq(vars({ type: 0.1 })["--type-scale"], String(TYPE_MIN), "min");
69
+ eq(vars({ type: 99 })["--type-scale"], String(TYPE_MAX), "max");
70
+ // the smallest rung must stay readable at the floor: 11px * TYPE_MIN
71
+ ok(11 * TYPE_MIN >= 9, "xs falls below 9px at the floor");
72
+ });
73
+
74
+ check("density moves spacing only, and never touches type", () => {
75
+ const v = vars({ density: "compact" });
76
+ eq(Object.keys(v), ["--density"]);
77
+ ok(Number(v["--density"]) < 1, "compact should tighten");
78
+ ok(Number(vars({ density: "comfortable" })["--density"]) > 1, "comfortable should loosen");
79
+ ok(!("--type-scale" in v), "density must not touch type");
80
+ // Spacing compounds through nesting, so the range stays tight enough that a
81
+ // 44px coarse-pointer target does not fall under the floor.
82
+ ok(Number(vars({ density: "compact" })["--density"]) >= 0.8, "compact is too tight to stay tappable");
83
+ });
84
+
85
+ check("EVERY ramp rung carries its multiplier — no rung can opt out", () => {
86
+ // This is the check that the drifting copy would have failed.
87
+ const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
88
+ const space = readFileSync(join(root, "tokens/spacing.css"), "utf8");
89
+
90
+ const bare = [];
91
+ for (const [file, text, knob, re] of [
92
+ ["typography.css", type, "--type-scale", /--text-[a-z0-9]+:\s*([^;]+);/g],
93
+ ["spacing.css", space, "--density", /--space-\d+:\s*([^;]+);/g],
94
+ ]) {
95
+ for (const m of text.matchAll(re)) {
96
+ const value = m[1].trim();
97
+ if (value === "0") continue; // zero times anything is zero
98
+ if (value.startsWith("var(")) continue; // an alias inherits its target's calc
99
+ if (!value.includes(`var(${knob}`)) bare.push(`${file}: ${m[0].trim()}`);
100
+ }
101
+ }
102
+ eq(bare, [], "rungs that do NOT scale with their knob:");
103
+ });
104
+
105
+ check("a unitless leading stays a RATIO — scaling it would double-apply", () => {
106
+ const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
107
+ const bad = [];
108
+ for (const m of type.matchAll(/--leading-[a-z0-9]+:\s*([^;]+);/g)) {
109
+ const v = m[1].trim();
110
+ // A ratio (`1.05`) already scales with the font size it multiplies.
111
+ if (/^[0-9.]+$/.test(v) === false) continue;
112
+ if (v.includes("var(")) bad.push(m[0].trim());
113
+ }
114
+ eq(bad, [], "unitless leadings must not carry a multiplier:");
115
+ });
116
+
117
+ check("a colour lands on both --primary and --accent", () => {
118
+ const v = vars({ accent: "#808000" });
119
+ eq(v["--primary"], "#808000");
120
+ eq(v["--accent"], "#808000");
121
+ });
122
+
123
+ check("a NON-colour is dropped, never sanitised into something plausible", () => {
124
+ for (const bad of [
125
+ "red; background-image:url(//evil/x)",
126
+ "}html{display:none",
127
+ "url(//evil/x)",
128
+ "expression(alert(1))",
129
+ "",
130
+ " ",
131
+ ]) {
132
+ const v = vars({ accent: bad });
133
+ ok(!("--primary" in v), `accepted ${JSON.stringify(bad)}`);
134
+ ok(!isColor(bad), `isColor said yes to ${JSON.stringify(bad)}`);
135
+ }
136
+ });
137
+
138
+ check("real colour notations are accepted", () => {
139
+ for (const good of ["#fff", "#ffffff", "#ffffffff", "rgb(1 2 3)", "oklch(.7 .1 120)", "rebeccapurple"]) {
140
+ ok(isColor(good), `isColor said no to ${good}`);
141
+ }
142
+ });
143
+
144
+ check("css() emits ONE block for the selector it is given", () => {
145
+ const out = css({ type: 1.1, density: "compact", accent: "#fff" }, ":root");
146
+ ok(out.startsWith(":root{") && out.endsWith("}"), out.slice(0, 40));
147
+ ok(!out.includes("}" + "{"), "emitted more than one block");
148
+ });
149
+
150
+ check("every emitted name is one the token files actually read", () => {
151
+ // A variable nothing reads is a write into another document — the exact
152
+ // mistake this package exists to prevent. The knobs are READ by the ramps
153
+ // (as var(--knob, 1)); the colours are DECLARED by colors.css.
154
+ const files = ["tokens/typography.css", "tokens/spacing.css", "tokens/grid.css", "tokens/colors.css"];
155
+ const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
156
+ const declared = new Set([...text.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((m) => m[1]));
157
+ const read = new Set([...text.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]));
158
+
159
+ const emitted = Object.keys(vars({ type: 1.1, density: "compact", accent: "#fff" }));
160
+ const orphans = emitted.filter((k) => !declared.has(k) && !read.has(k));
161
+ eq(orphans, [], "emitted names no token file declares or reads:");
162
+ });
163
+
164
+ if (failed) {
165
+ console.error(`\n${failed} preference check(s) failed`);
166
+ process.exit(1);
167
+ }
168
+ console.log("preference: all checks passed");
@@ -140,6 +140,40 @@ const pass = (msg) => console.log(` ok ${msg}`)
140
140
  : fail('base.css is UNLAYERED — its element rules outrank every utility an app writes')
141
141
  }
142
142
 
143
+ // ── 1d. exactly ONE rule decides focus ───────────────────────────────────
144
+ // Until 0.4.9 there were two: a field rule that suppressed the outline and drew
145
+ // a brightened edge + halo, and the generic ring. Both computed to (0,1,0) —
146
+ // :where() zeroes its contents and each side keeps one pseudo-class — so the
147
+ // cascade fell through to SOURCE ORDER inside @layer base, the generic rule was
148
+ // written later, and it overrode the `outline:none` the field rule stated
149
+ // expressly to prevent it. Every focused input on every consumer drew BOTH.
150
+ // Each rule read as correct alone; the defect existed only in their order,
151
+ // which is why it survived review in both files.
152
+ //
153
+ // Order is not testable as intent, so this tests the property that replaced it:
154
+ // one rule paints the indicator, and nothing else touches focus. A second rule
155
+ // is how they disagree, `outline:none` is how an indicator disappears, and
156
+ // box-shadow is how a second one appears — none of the three can return quietly.
157
+ {
158
+ const base = strip(read(join(tokensDir, 'base.css')))
159
+ // `outline-offset` is a different property and never matches: the colon must
160
+ // follow `outline` itself.
161
+ const OUTLINE = /(?:^|[;{\s])outline\s*:\s*([^;}]+)/
162
+ const rules = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
163
+ .map(([, sel, body]) => ({ sel: sel.trim().replace(/\s+/g, ' '), body }))
164
+ .filter(({ sel }) => sel.includes(':focus-visible'))
165
+
166
+ const paints = rules.filter(({ body }) => { const m = body.match(OUTLINE); return m && m[1].trim() !== 'none' })
167
+ const mutes = rules.filter(({ body }) => { const m = body.match(OUTLINE); return m && m[1].trim() === 'none' })
168
+ const halos = rules.filter(({ body }) => /(?:^|[;{\s])box-shadow\s*:/.test(body))
169
+
170
+ paints.length === 1
171
+ ? pass(`one focus indicator, one rule: \`${paints[0].sel}\``)
172
+ : fail(`${paints.length} rules paint a focus outline (${paints.map((r) => r.sel).join(' / ')}) — equal specificity inside @layer base, so source order decides which one a user actually sees`)
173
+ mutes.forEach(({ sel }) => fail(`\`${sel}\` sets outline:none — it removes the focus indicator instead of replacing it`))
174
+ halos.forEach(({ sel }) => fail(`\`${sel}\` adds a box-shadow on focus — a second indicator beside the ring`))
175
+ }
176
+
143
177
  // ── 2. every var() used inside the token layer must resolve ──────────────
144
178
  {
145
179
  const declared = new Set()
@@ -268,5 +302,31 @@ const pass = (msg) => console.log(` ok ${msg}`)
268
302
  }
269
303
  }
270
304
 
305
+ // ── 5. one light, from one direction ─────────────────────────────────────
306
+ // The paper ramp reads as one space only while every edge and every drop in
307
+ // the system agrees where the light is. A single `2px 4px` — the spelling
308
+ // every other library ships — puts a second lamp in the room, and the failure
309
+ // is not visible on any one component: the panel with the offset shadow looks
310
+ // fine, and it is the SCREEN that stops making sense. So the rule is checked
311
+ // on the values instead of trusted to whoever writes the next one: the light
312
+ // is directly above, so a shadow moves down and never sideways.
313
+ //
314
+ // Structural, not by name — a layer counts as a shadow when its first two
315
+ // lengths are lengths, which is what a shadow is and what a size is not.
316
+ {
317
+ const css = strip(read(join(tokensDir, 'elevation.css')))
318
+ const LEN = /^-?[\d.]+(px|rem|em)?$/
319
+ const offenders = []
320
+ for (const [, name, value] of css.matchAll(/--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g))
321
+ for (const layer of value.split(',')) {
322
+ const t = layer.trim().replace(/^inset\s+/, '').split(/\s+/)
323
+ if (LEN.test(t[0]) && LEN.test(t[1] ?? '') && parseFloat(t[0]) !== 0)
324
+ offenders.push(`--${name} (${layer.trim()})`)
325
+ }
326
+ offenders.length
327
+ ? offenders.forEach((o) => fail(`${o} is offset sideways — the light is above, so every shadow is \`0 <y> …\``))
328
+ : pass('one light direction — every shadow in elevation.css drops straight down')
329
+ }
330
+
271
331
  console.log(failures ? `\n${failures} check(s) failed` : '\nall token checks passed')
272
332
  process.exit(failures ? 1 : 0)