@hanzo/design 0.4.1 → 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.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.4.1",
4
- "description": "Hanzo Design System — monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
3
+ "version": "0.4.2",
4
+ "packageManager": "pnpm@11.17.0",
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.",
5
6
  "license": "BSD-3-Clause",
6
7
  "repository": {
7
8
  "type": "git",
@@ -29,6 +30,13 @@
29
30
  "./skills/*": "./skills/*",
30
31
  "./tailwind.css": "./tailwind.css"
31
32
  },
33
+ "scripts": {
34
+ "gen": "node scripts/gen-tokens.mjs",
35
+ "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs components",
36
+ "build": "pnpm gen && pnpm test && tsc -p tsconfig.json",
37
+ "prepublishOnly": "pnpm build",
38
+ "lint": "node scripts/lint.mjs"
39
+ },
32
40
  "devDependencies": {
33
41
  "typescript": "^5.9.3"
34
42
  },
@@ -61,11 +69,5 @@
61
69
  },
62
70
  "bin": {
63
71
  "hanzo-design-lint": "./scripts/lint.mjs"
64
- },
65
- "scripts": {
66
- "gen": "node scripts/gen-tokens.mjs",
67
- "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs components",
68
- "build": "pnpm gen && pnpm test && tsc -p tsconfig.json",
69
- "lint": "node scripts/lint.mjs"
70
72
  }
71
73
  }
@@ -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,11 +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
- // The gate follows the DUTY, not the name. --border-control is the rung that
148
- // draws a control's edge and --ring the focus indicator; those two owe 3:1.
149
- // --border / --border-strong are decorative hairlines and owe nothing that
150
- // is exactly why they are allowed to be quiet enough to look like something.
151
- const GATED = { ring: 3, 'border-control': 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 }
152
161
  for (const [theme, scope] of Object.entries(themes)) {
153
162
  for (const [tok, min] of Object.entries(GATED)) {
154
163
  let worst = Infinity, where = ''
@@ -164,22 +173,57 @@ const pass = (msg) => console.log(` ok ${msg}`)
164
173
  }
165
174
 
166
175
  // ── 4. every white-alpha token must be restated for the light theme ──────
167
- // The opacity ladder does NOT invert. A semantic token whose dark value is
176
+ // The opacity ladder does NOT invert. A semantic token built from
168
177
  // rgb(255 255 255 / a) is white-on-white in `.light` — the border does not
169
- // change colour, it stops existing — and nothing renders an error. The raw
178
+ // change colour, it STOPS EXISTING — and nothing renders an error. The raw
170
179
  // ladder itself (--white-*) is exempt: it is a palette, not a semantic token,
171
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.
172
191
  {
173
192
  const PALETTE = /^(white|neutral|pure|hanzo)-/
174
- const lightKeys = new Set(Object.keys(block(/\.light\s*\{([\s\S]*?)\n\}/)))
175
- const leaked = Object.keys(dark).filter((k) => {
176
- if (PALETTE.test(k) || lightKeys.has(k)) return false
177
- const c = rgb(deref(dark[k], dark))
178
- return c && c[3] < 1 && c[0] > 200 && c[1] > 200 && c[2] > 200
179
- })
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
+ }
180
224
  leaked.length
181
- ? leaked.forEach((k) => fail(`--${k} is white-alpha and has no .light value — it vanishes in the light theme`))
182
- : pass(`every white-alpha token is restated in .light`)
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`)
183
227
  }
184
228
  }
185
229
 
package/src/tokens.gen.ts CHANGED
@@ -40,22 +40,26 @@ export const colors = {
40
40
  'popover': '#0f0f0f',
41
41
  'popover-foreground': '#fafafa',
42
42
  'primary': '#fafafa',
43
- 'primary-hover': '#d4d4d4',
43
+ 'primary-hover': '#e5e5e5',
44
44
  'primary-foreground': '#0a0a0a',
45
45
  'secondary': '#262626',
46
+ 'secondary-hover': '#333333',
46
47
  'secondary-foreground': '#fafafa',
47
48
  'muted': '#171717',
48
49
  'muted-foreground': '#a3a3a3',
49
50
  'accent': '#262626',
50
51
  'accent-foreground': '#fafafa',
51
- 'destructive': '#666666',
52
+ 'destructive': 'var(--state-error)',
53
+ 'destructive-hover': '#dc2626',
52
54
  'destructive-foreground': '#fafafa',
53
55
  'border': 'var(--white-10)',
54
56
  'border-strong': 'var(--white-16)',
55
- 'border-control': 'var(--neutral-500)',
56
- 'border-focus': 'var(--neutral-400)',
57
+ 'border-control': 'var(--white-15)',
58
+ 'border-focus': 'var(--white-22)',
59
+ 'border-selected': 'var(--white-30)',
57
60
  'input': 'var(--border-control)',
58
- 'ring': 'var(--neutral-500)',
61
+ 'ring': 'var(--white-40)',
62
+ 'ring-halo': 'var(--white-10)',
59
63
  'brand': '#e4e4e7',
60
64
  'brand-foreground': '#09090b',
61
65
  'brand-muted': '#a3a3a3',
@@ -65,10 +69,10 @@ export const colors = {
65
69
  'glass': 'var(--white-05)',
66
70
  'glass-strong': 'var(--white-08)',
67
71
  'surface-page': 'var(--background)',
68
- 'surface-card': 'rgb(23 23 23 / .5)',
69
- 'surface-card-emphasis': 'rgb(23 23 23 / .8)',
70
- 'surface-card-quiet': 'rgb(23 23 23 / .4)',
71
- 'surface-overlay': 'rgb(10 10 10 / .95)',
72
+ 'surface-card': 'rgb(38 38 38 / .5)',
73
+ 'surface-card-emphasis': 'rgb(38 38 38 / .75)',
74
+ 'surface-card-quiet': 'rgb(38 38 38 / .35)',
75
+ 'surface-overlay': 'rgb(23 23 23 / .92)',
72
76
  'surface-header': 'rgb(0 0 0 / .7)',
73
77
  'surface-scrim': 'rgb(0 0 0 / .8)',
74
78
  'surface-0': 'var(--background)',
@@ -231,9 +235,11 @@ export const radius = {
231
235
  /** elevation tokens (from tokens/elevation.css). Values are raw CSS. */
232
236
  export const elevation = {
233
237
  'shadow-none': 'none',
234
- 'shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
238
+ 'shadow-floating': '0 24px 60px -16px rgb(0 0 0 / .75)',
235
239
  'shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
236
- 'ring-focus': '0 0 0 2px var(--ring)',
240
+ 'edge-highlight': 'inset 0 1px 0 0 rgb(255 255 255 / .10)',
241
+ 'bloom': '0 0 24px -2px rgb(255 255 255 / .28)',
242
+ 'ring-focus': '0 0 0 3px var(--ring-halo)',
237
243
  'shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
238
244
  'shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
239
245
  'shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
@@ -243,6 +249,7 @@ export const elevation = {
243
249
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
244
250
  'glow-hero-blur': '120px',
245
251
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
252
+ 'sheen-edge': 'linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent)',
246
253
  'gradient-chrome': 'linear-gradient(to right,#ffffff,var(--white-80),var(--white-60))',
247
254
  'gradient-chrome-2': 'linear-gradient(to right,#ffffff,var(--neutral-500))',
248
255
  'gradient-protect': 'linear-gradient(to bottom,var(--white-10),transparent)',
@@ -255,8 +262,10 @@ export const motion = {
255
262
  'duration-slow': '400ms',
256
263
  'duration-slower': '500ms',
257
264
  'duration-glow': '9s',
265
+ 'duration-press': '90ms',
258
266
  'ease-out': 'cubic-bezier(0,0,0.2,1)',
259
267
  'ease-in-out': 'cubic-bezier(0.4,0,0.2,1)',
268
+ 'ease-emphasis': 'cubic-bezier(0.16,1,0.3,1)',
260
269
  'stagger': '60ms',
261
270
  'entry-rise': '16px',
262
271
  'entry-rise-lg': '24px',
@@ -329,22 +338,26 @@ export const cssVars = {
329
338
  '--popover': '#0f0f0f',
330
339
  '--popover-foreground': '#fafafa',
331
340
  '--primary': '#fafafa',
332
- '--primary-hover': '#d4d4d4',
341
+ '--primary-hover': '#e5e5e5',
333
342
  '--primary-foreground': '#0a0a0a',
334
343
  '--secondary': '#262626',
344
+ '--secondary-hover': '#333333',
335
345
  '--secondary-foreground': '#fafafa',
336
346
  '--muted': '#171717',
337
347
  '--muted-foreground': '#a3a3a3',
338
348
  '--accent': '#262626',
339
349
  '--accent-foreground': '#fafafa',
340
- '--destructive': '#666666',
350
+ '--destructive': 'var(--state-error)',
351
+ '--destructive-hover': '#dc2626',
341
352
  '--destructive-foreground': '#fafafa',
342
353
  '--border': 'var(--white-10)',
343
354
  '--border-strong': 'var(--white-16)',
344
- '--border-control': 'var(--neutral-500)',
345
- '--border-focus': 'var(--neutral-400)',
355
+ '--border-control': 'var(--white-15)',
356
+ '--border-focus': 'var(--white-22)',
357
+ '--border-selected': 'var(--white-30)',
346
358
  '--input': 'var(--border-control)',
347
- '--ring': 'var(--neutral-500)',
359
+ '--ring': 'var(--white-40)',
360
+ '--ring-halo': 'var(--white-10)',
348
361
  '--brand': '#e4e4e7',
349
362
  '--brand-foreground': '#09090b',
350
363
  '--brand-muted': '#a3a3a3',
@@ -354,10 +367,10 @@ export const cssVars = {
354
367
  '--glass': 'var(--white-05)',
355
368
  '--glass-strong': 'var(--white-08)',
356
369
  '--surface-page': 'var(--background)',
357
- '--surface-card': 'rgb(23 23 23 / .5)',
358
- '--surface-card-emphasis': 'rgb(23 23 23 / .8)',
359
- '--surface-card-quiet': 'rgb(23 23 23 / .4)',
360
- '--surface-overlay': 'rgb(10 10 10 / .95)',
370
+ '--surface-card': 'rgb(38 38 38 / .5)',
371
+ '--surface-card-emphasis': 'rgb(38 38 38 / .75)',
372
+ '--surface-card-quiet': 'rgb(38 38 38 / .35)',
373
+ '--surface-overlay': 'rgb(23 23 23 / .92)',
361
374
  '--surface-header': 'rgb(0 0 0 / .7)',
362
375
  '--surface-scrim': 'rgb(0 0 0 / .8)',
363
376
  '--surface-0': 'var(--background)',
@@ -500,9 +513,11 @@ export const cssVars = {
500
513
  '--radius-composer': '28px',
501
514
  '--radius-full': '9999px',
502
515
  '--shadow-none': 'none',
503
- '--shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
516
+ '--shadow-floating': '0 24px 60px -16px rgb(0 0 0 / .75)',
504
517
  '--shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
505
- '--ring-focus': '0 0 0 2px var(--ring)',
518
+ '--edge-highlight': 'inset 0 1px 0 0 rgb(255 255 255 / .10)',
519
+ '--bloom': '0 0 24px -2px rgb(255 255 255 / .28)',
520
+ '--ring-focus': '0 0 0 3px var(--ring-halo)',
506
521
  '--shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
507
522
  '--shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
508
523
  '--shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
@@ -512,6 +527,7 @@ export const cssVars = {
512
527
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
513
528
  '--glow-hero-blur': '120px',
514
529
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
530
+ '--sheen-edge': 'linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent)',
515
531
  '--gradient-chrome': 'linear-gradient(to right,#ffffff,var(--white-80),var(--white-60))',
516
532
  '--gradient-chrome-2': 'linear-gradient(to right,#ffffff,var(--neutral-500))',
517
533
  '--gradient-protect': 'linear-gradient(to bottom,var(--white-10),transparent)',
@@ -520,8 +536,10 @@ export const cssVars = {
520
536
  '--duration-slow': '400ms',
521
537
  '--duration-slower': '500ms',
522
538
  '--duration-glow': '9s',
539
+ '--duration-press': '90ms',
523
540
  '--ease-out': 'cubic-bezier(0,0,0.2,1)',
524
541
  '--ease-in-out': 'cubic-bezier(0.4,0,0.2,1)',
542
+ '--ease-emphasis': 'cubic-bezier(0.16,1,0.3,1)',
525
543
  '--stagger': '60ms',
526
544
  '--entry-rise': '16px',
527
545
  '--entry-rise-lg': '24px',