@hanzo/design 0.2.0 → 0.3.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.
@@ -44,7 +44,7 @@ export const colors = {
44
44
  'destructive-foreground': '#f5f5f5',
45
45
  'border': '#1f1f1f',
46
46
  'input': '#1f1f1f',
47
- 'ring': '#333333',
47
+ 'ring': 'var(--neutral-500)',
48
48
  'brand': '#e4e4e7',
49
49
  'brand-foreground': '#09090b',
50
50
  'brand-muted': '#a3a3a3',
@@ -56,9 +56,14 @@ export const colors = {
56
56
  'surface-card-quiet': 'rgb(23 23 23 / .4)',
57
57
  'surface-overlay': 'rgb(10 10 10 / .95)',
58
58
  'surface-header': 'rgb(0 0 0 / .7)',
59
+ 'surface-scrim': 'rgb(0 0 0 / .8)',
59
60
  'border-hairline': 'var(--neutral-800)',
60
61
  'border-card': 'var(--white-10)',
61
- 'border-strong': 'var(--neutral-700)',
62
+ 'border-strong': 'var(--neutral-500)',
63
+ 'surface-0': 'var(--background)',
64
+ 'surface-1': 'var(--card)',
65
+ 'surface-2': 'var(--muted)',
66
+ 'surface-3': 'var(--secondary)',
62
67
  'text-primary': 'var(--pure-white)',
63
68
  'text-secondary': 'var(--white-80)',
64
69
  'text-tertiary': 'var(--white-60)',
@@ -97,6 +102,23 @@ export const typography = {
97
102
  'leading-6xl': '1',
98
103
  'text-7xl': '4rem',
99
104
  'leading-7xl': '1',
105
+ 'text-8xl': '5.25rem',
106
+ 'leading-8xl': '1',
107
+ 'text-9xl': '7rem',
108
+ 'leading-9xl': '1',
109
+ 'font-size-xs': 'var(--text-xs)',
110
+ 'font-size-sm': 'var(--text-sm)',
111
+ 'font-size-base': 'var(--text-base)',
112
+ 'font-size-lg': 'var(--text-lg)',
113
+ 'font-size-xl': 'var(--text-xl)',
114
+ 'font-size-2xl': 'var(--text-2xl)',
115
+ 'font-size-3xl': 'var(--text-3xl)',
116
+ 'font-size-4xl': 'var(--text-4xl)',
117
+ 'font-size-5xl': 'var(--text-5xl)',
118
+ 'font-size-6xl': 'var(--text-6xl)',
119
+ 'font-size-7xl': 'var(--text-7xl)',
120
+ 'font-size-8xl': 'var(--text-8xl)',
121
+ 'font-size-9xl': 'var(--text-9xl)',
100
122
  'weight-normal': '400',
101
123
  'weight-medium': '500',
102
124
  'weight-semibold': '600',
@@ -176,6 +198,12 @@ export const elevation = {
176
198
  'shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
177
199
  'shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
178
200
  'ring-focus': '0 0 0 2px var(--ring)',
201
+ 'shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
202
+ 'shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
203
+ 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
204
+ 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
205
+ 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
206
+ 'shadow-2xl': 'var(--shadow-floating)',
179
207
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
180
208
  'glow-hero-blur': '120px',
181
209
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -207,6 +235,8 @@ export const zIndex = {
207
235
  'z-modal': '600',
208
236
  'z-popover': '700',
209
237
  'z-toast': '800',
238
+ 'z-tooltip': 'var(--z-popover)',
239
+ 'z-notification': 'var(--z-toast)',
210
240
  };
211
241
  /** fonts tokens (from tokens/fonts.css). Values are raw CSS. */
212
242
  export const fonts = {
@@ -261,7 +291,7 @@ export const cssVars = {
261
291
  '--destructive-foreground': '#f5f5f5',
262
292
  '--border': '#1f1f1f',
263
293
  '--input': '#1f1f1f',
264
- '--ring': '#333333',
294
+ '--ring': 'var(--neutral-500)',
265
295
  '--brand': '#e4e4e7',
266
296
  '--brand-foreground': '#09090b',
267
297
  '--brand-muted': '#a3a3a3',
@@ -273,9 +303,14 @@ export const cssVars = {
273
303
  '--surface-card-quiet': 'rgb(23 23 23 / .4)',
274
304
  '--surface-overlay': 'rgb(10 10 10 / .95)',
275
305
  '--surface-header': 'rgb(0 0 0 / .7)',
306
+ '--surface-scrim': 'rgb(0 0 0 / .8)',
276
307
  '--border-hairline': 'var(--neutral-800)',
277
308
  '--border-card': 'var(--white-10)',
278
- '--border-strong': 'var(--neutral-700)',
309
+ '--border-strong': 'var(--neutral-500)',
310
+ '--surface-0': 'var(--background)',
311
+ '--surface-1': 'var(--card)',
312
+ '--surface-2': 'var(--muted)',
313
+ '--surface-3': 'var(--secondary)',
279
314
  '--text-primary': 'var(--pure-white)',
280
315
  '--text-secondary': 'var(--white-80)',
281
316
  '--text-tertiary': 'var(--white-60)',
@@ -311,6 +346,23 @@ export const cssVars = {
311
346
  '--leading-6xl': '1',
312
347
  '--text-7xl': '4rem',
313
348
  '--leading-7xl': '1',
349
+ '--text-8xl': '5.25rem',
350
+ '--leading-8xl': '1',
351
+ '--text-9xl': '7rem',
352
+ '--leading-9xl': '1',
353
+ '--font-size-xs': 'var(--text-xs)',
354
+ '--font-size-sm': 'var(--text-sm)',
355
+ '--font-size-base': 'var(--text-base)',
356
+ '--font-size-lg': 'var(--text-lg)',
357
+ '--font-size-xl': 'var(--text-xl)',
358
+ '--font-size-2xl': 'var(--text-2xl)',
359
+ '--font-size-3xl': 'var(--text-3xl)',
360
+ '--font-size-4xl': 'var(--text-4xl)',
361
+ '--font-size-5xl': 'var(--text-5xl)',
362
+ '--font-size-6xl': 'var(--text-6xl)',
363
+ '--font-size-7xl': 'var(--text-7xl)',
364
+ '--font-size-8xl': 'var(--text-8xl)',
365
+ '--font-size-9xl': 'var(--text-9xl)',
314
366
  '--weight-normal': '400',
315
367
  '--weight-medium': '500',
316
368
  '--weight-semibold': '600',
@@ -381,6 +433,12 @@ export const cssVars = {
381
433
  '--shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
382
434
  '--shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
383
435
  '--ring-focus': '0 0 0 2px var(--ring)',
436
+ '--shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
437
+ '--shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
438
+ '--shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
439
+ '--shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
440
+ '--shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
441
+ '--shadow-2xl': 'var(--shadow-floating)',
384
442
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
385
443
  '--glow-hero-blur': '120px',
386
444
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -406,6 +464,8 @@ export const cssVars = {
406
464
  '--z-modal': '600',
407
465
  '--z-popover': '700',
408
466
  '--z-toast': '800',
467
+ '--z-tooltip': 'var(--z-popover)',
468
+ '--z-notification': 'var(--z-toast)',
409
469
  '--font-sans': '"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif',
410
470
  '--font-display': 'var(--font-sans)',
411
471
  '--font-mono': '"Geist Mono",ui-monospace,SFMono-Regular,monospace',
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ If you remember one thing: **import one file, then reach for a token instead of
8
8
  literal.** Do that and your surface is already 90% on-brand.
9
9
 
10
10
  ```css
11
- @import "@hanzoai/design/styles.css";
11
+ @import "@hanzo/design/styles.css";
12
12
  ```
13
13
 
14
14
  ## The map
package/docs/integrate.md CHANGED
@@ -3,14 +3,14 @@
3
3
  ## 1. Install
4
4
 
5
5
  ```bash
6
- pnpm add @hanzoai/design
6
+ pnpm add @hanzo/design
7
7
  ```
8
8
 
9
9
  ## 2. Import the tokens — once
10
10
 
11
11
  ```css
12
12
  /* app/globals.css, or the root of your styles */
13
- @import "@hanzoai/design/styles.css";
13
+ @import "@hanzo/design/styles.css";
14
14
  ```
15
15
 
16
16
  That one file pulls in, in order: fonts (Geist + Geist Mono), color, typography,
@@ -54,8 +54,8 @@ Each part ships as `.jsx` + `.d.ts` + a `.prompt.md` usage guide. They are
54
54
  self-contained (inline styles), so they need no framework:
55
55
 
56
56
  ```jsx
57
- import { Button } from "@hanzoai/design/components/core/Button.jsx";
58
- import { HanzoLogo } from "@hanzoai/design/components/core/HanzoLogo.jsx";
57
+ import { Button } from "@hanzo/design/components/core/Button.jsx";
58
+ import { HanzoLogo } from "@hanzo/design/components/core/HanzoLogo.jsx";
59
59
 
60
60
  <HanzoLogo />
61
61
  <Button pill>Get started</Button>
@@ -0,0 +1,106 @@
1
+ # Hanzo Design System — Canonical Tokens
2
+
3
+ `@hanzo/ui` is the single source of truth for the shared Hanzo product look across
4
+ the **Tailwind** apps (hanzo.chat, hanzo.app, hanzo console, commerce, hanzo-desktop).
5
+ Change a value here; apps converge on it. This file is that source of truth for the
6
+ three things that must read as **one product**: typography, the sidebar/panel system,
7
+ and the dark-black palette.
8
+
9
+ > One library: **`@hanzo/ui@8`** (`pkg/ui`, on **`@hanzo/gui`**) IS the component
10
+ > library — the cross-platform product/record layer every surface consumes.
11
+ > **`@hanzo/ui-shadcn`** (`pkgs/ui`) is the legacy shadcn/Tailwind/Radix kit, kept
12
+ > only for existing v5 consumers (pin `@hanzo/ui-shadcn@^5`; no new adoptions).
13
+ > This file stays the source of truth for the *token values* (fonts, dark palette,
14
+ > the sidebar glyph) both render.
15
+
16
+ ---
17
+
18
+ ## 1. Typography — Basel Grotesk + Geist Mono
19
+
20
+ | Role | Family | Notes |
21
+ |------|--------|-------|
22
+ | UI / body / display / heading (`sans`) | **Basel Grotesk** | Self-hosted. Book = weight **400**, Medium = weight **500**. |
23
+ | code / data / mono (`mono`) | **Geist Mono** | `next/font/google` (`Geist_Mono`) or the geist CDN. |
24
+ | Arabic / Hebrew (`--font-ar` / `--font-he`) | unchanged | i18n only — keep. |
25
+
26
+ **Dropped as defaults:** Geist Sans, DM Sans, Figtree, Inter, PT Sans, Roboto Mono.
27
+
28
+ Basel is a **licensed, non-Google** face — **self-host** the woff2/woff, do NOT use
29
+ `next/font/google` for it. Canonical files (mirror lux.exchange):
30
+ `Basel-Grotesk-Book.woff2/.woff` (400), `Basel-Grotesk-Medium.woff2/.woff` (500).
31
+
32
+ `@font-face` (weights 400/500, `font-display: swap`, `font-style: normal`):
33
+
34
+ ```css
35
+ @font-face {
36
+ font-family: 'Basel';
37
+ font-style: normal;
38
+ font-weight: 400; /* Book; 500 = Medium */
39
+ font-display: swap;
40
+ src: url('.../Basel-Grotesk-Book.woff2') format('woff2'),
41
+ url('.../Basel-Grotesk-Book.woff') format('woff');
42
+ }
43
+ ```
44
+
45
+ Per-app adoption (converge the value, keep each app's own mechanism):
46
+ - **@hanzo/ui / Next apps** → `next/font/local` for Basel (`--font-basel-sans`) +
47
+ `next/font/google` `Geist_Mono` (`--font-geist-mono`). See `app/lib/fonts.ts`;
48
+ tailwind `sans → var(--font-basel-sans)`, `mono → var(--font-geist-mono)`.
49
+ - **Vite + Tailwind apps** (chat, launcher, desktop) → self-host Basel `@font-face`
50
+ + geist-mono CDN import; tailwind `fontFamily.sans = ['Basel', …]`,
51
+ `mono = ['Geist Mono', …]`.
52
+ - **Tamagui (console)** → Basel `@font-face` in globals + override the Tamagui
53
+ `body`/`heading` font `family` to Basel; Geist Mono for `code`/`pre`.
54
+
55
+ ---
56
+
57
+ ## 2. Sidebar toggle icon — lucide `PanelLeft`
58
+
59
+ One glyph everywhere: lucide **`PanelLeft`** (the shadcn `SidebarTrigger` default).
60
+ Where a directional open/close affordance is wanted, use the pair
61
+ **`PanelLeftClose`** (expanded) / **`PanelLeft`** (collapsed). Never a hamburger,
62
+ a magnifier, a directional arrow, or a bespoke panel SVG for the sidebar toggle.
63
+
64
+ - Icon: stroke-2, ~16–20px, `currentColor`.
65
+ - Button: ghost/outline, square (`size-7`/`h-6 w-6`), subtle hover.
66
+
67
+ ---
68
+
69
+ ## 3. Sidebar + panels
70
+
71
+ | Spec | Value |
72
+ |------|-------|
73
+ | Sidebar width (expanded) | **16rem / 256px** (`SIDEBAR_WIDTH`) |
74
+ | Sidebar width (collapsed / icon rail) | **3rem / 48px** (apps vary 48–70px) |
75
+ | Sidebar / panel surface | resting **#0a0a0a** over the true-black page |
76
+ | Border / separation | `border-border` — subtle white-alpha ~10% in dark (`border-r` / `border-l`) |
77
+ | Item hover | subtle `white/5` |
78
+ | Item active | monochrome `white/10` — **no colored accent** (the house style is monochrome) |
79
+ | Right panel rail | `border-l border-border`, same surface, collapsible |
80
+
81
+ Prefer the `@hanzo/ui` `Sidebar` primitive (`pkgs/ui/primitives/sidebar.tsx`,
82
+ `SidebarTrigger` → `PanelLeft`) where the app can consume it; otherwise match these
83
+ classes/tokens.
84
+
85
+ ---
86
+
87
+ ## 4. Dark-black palette (true-black OLED)
88
+
89
+ The house dark theme is a **true-black** canvas (matches hanzo.ai marketing +
90
+ hanzo.chat OLED), with a shallow surface-depth ladder for cards/panels and quiet
91
+ hairline borders — never harsh pure-white on pure-black.
92
+
93
+ | Token | Value | Use |
94
+ |-------|-------|-----|
95
+ | Page background | **#000000** (`oklch(0 0 0)`) | body / canvas |
96
+ | Surface / sidebar / panel (resting) | **#0a0a0a** | sidebars, panels, cards |
97
+ | Press | **#050505** | pressed surface |
98
+ | Elevated / hover | **#171717** | hover, raised card |
99
+ | Border / divider | **rgba(255,255,255,0.10)** (≈ `#171717` opaque on black) | hairlines |
100
+ | Foreground (primary text) | near-white **#ededf1** (`oklch(0.985)`) — not pure `#fff` | body text |
101
+ | Muted / secondary text | `white/70` (≈ `#a1a1aa`) | secondary |
102
+
103
+ Keep each app's theme-token engine (CSS vars / Tailwind tokens / Tamagui `$color*`);
104
+ converge the **values/usage** to the table above, don't rip the engine.
105
+
106
+ Reference: `pkgs/ui/style/hanzo-default-colors.css` (`.dark` / `.hanzo-ui-dark-theme`).
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.2.0",
4
- "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
- "license": "MIT",
3
+ "version": "0.3.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.",
5
+ "license": "BSD-3-Clause",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/hanzoai/design.git"
@@ -25,12 +25,15 @@
25
25
  "./components/*": "./components/*",
26
26
  "./prompts/*": "./prompts/*",
27
27
  "./content/*": "./content/*",
28
- "./assets/*": "./assets/*"
28
+ "./assets/*": "./assets/*",
29
+ "./skills/*": "./skills/*"
29
30
  },
30
31
  "scripts": {
31
32
  "gen": "node scripts/gen-tokens.mjs",
32
- "build": "npm run gen && tsc -p tsconfig.json",
33
- "prepublishOnly": "npm run build"
33
+ "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs components",
34
+ "build": "npm run gen && npm test && tsc -p tsconfig.json",
35
+ "prepublishOnly": "npm run build",
36
+ "lint": "node scripts/lint.mjs"
34
37
  },
35
38
  "devDependencies": {
36
39
  "typescript": "^5.9.3"
@@ -42,6 +45,8 @@
42
45
  "tokens",
43
46
  "components",
44
47
  "prompts",
48
+ "skills",
49
+ "scripts",
45
50
  "content",
46
51
  "docs",
47
52
  "assets",
@@ -58,5 +63,8 @@
58
63
  "publishConfig": {
59
64
  "access": "public",
60
65
  "registry": "https://registry.npmjs.org/"
66
+ },
67
+ "bin": {
68
+ "hanzo-design-lint": "./scripts/lint.mjs"
61
69
  }
62
- }
70
+ }
package/prompts/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # prompts — make an AI build it Hanzo
2
2
 
3
+ > **Entry point is `skills/design-system/SKILL.md`**, not this folder. The skill
4
+ > is what chat, hanzo.app and Claude Code load; it points here for the long form
5
+ > and finishes by running `hanzo-design-lint`. Everything below is the reference
6
+ > the skill cites.
7
+
3
8
  Guidance an agent reads *before* generating a Hanzo surface. Two layers, no
4
9
  overlap:
5
10
 
@@ -20,7 +25,14 @@ place.
20
25
  Everything here assumes one import gives you the tokens:
21
26
 
22
27
  ```css
23
- @import "@hanzoai/design/styles.css";
28
+ @import "@hanzo/design/styles.css";
24
29
  ```
25
30
 
26
31
  So every rule below can be obeyed with a CSS variable, never a hardcoded hex.
32
+
33
+ ## The two prompts (one and one way)
34
+ - **`system.md`** — the design LANGUAGE prompt: paste as a system prompt to make any
35
+ model generate Hanzo-looking output (monochrome, true-black, token-only).
36
+ - **`studio-designer.md`** — the AGENTIC prompt Hanzo Studio's Design System designer
37
+ hands the Hanzo agent stack to GENERATE/EDIT this repo's file tree with its file
38
+ tools, grounded in `guidelines/DESIGN.md`. Uses `system.md`'s language rules.
@@ -0,0 +1,41 @@
1
+ # Hanzo Studio — Design System Designer (agentic prompt)
2
+
3
+ The system prompt the Studio "Design System" designer hands to the Hanzo agent
4
+ stack. The agent generates/edits the design-system file tree in **hanzoai/design**
5
+ using its file tools (write/edit/tree), grounded in the canon.
6
+
7
+ ---
8
+
9
+ You are the **Hanzo Design System designer**, an agent operating on the
10
+ `hanzoai/design` repository. You produce and maintain a complete, coherent design
11
+ system as real files, using your file tools (read, write, edit, tree).
12
+
13
+ ## The canon (never contradict)
14
+ - **Typography:** Basel Grotesk (`sans`, weights 400 Book / 500 Medium, self-hosted
15
+ woff2) + Geist Mono (`mono`). No Inter/Roboto/DM Sans as defaults.
16
+ - **Palette:** true-black OLED. Page `#000`, surface `#0a0a0a`, press `#050505`,
17
+ elevated `#171717`, border `rgba(255,255,255,.10)`, text `#ededf1`. **Monochrome —
18
+ no colored accents.** Semantic color only for live/error/warning.
19
+ - **Sidebar:** 256px expanded / 48px rail; lucide `PanelLeft` toggle; `white/5` hover,
20
+ `white/10` active.
21
+ - Full source: `guidelines/DESIGN.md`.
22
+
23
+ ## Structure you own
24
+ `tokens/` (base·colors·fonts·spacing·radius·elevation·motion.css + TS modules) ·
25
+ `guidelines/` · `assets/{brand,logos,providers,fonts}` · `components/` · `ui_kits/`.
26
+
27
+ ## How you work
28
+ 1. **Read before writing.** `tree` the repo + read `guidelines/DESIGN.md` and the
29
+ relevant `tokens/*` so every new value stays consistent with what exists.
30
+ 2. **Tokens are the source.** A component reads token vars (`var(--hz-*)`), never a
31
+ hardcoded hex/px. If a needed token is missing, add it to `tokens/` first, then use it.
32
+ 3. **One and one way.** One place per value. Don't duplicate a color/space/font.
33
+ 4. **Generate real, coherent files** — a request like "add a Card component" writes
34
+ `components/card.{css,tsx,md}` referencing the tokens + a usage note, not a mock.
35
+ 5. **Honor the request scope.** "Generate a design system" → the full tree from the
36
+ canon. "Add elevation tokens" → just `tokens/elevation.css` + wire into `base.css`.
37
+ 6. **Never fabricate brand assets.** Reference real files in `assets/`; if a logo/font
38
+ is missing, note it as a TODO, don't invent bytes.
39
+ 7. **Explain what you wrote** (files + why) so the review is legible.
40
+
41
+ Output: the files, written to the repo, plus a one-paragraph summary of the change.
package/prompts/system.md CHANGED
@@ -12,7 +12,7 @@ and frontier models. Every surface you produce obeys one atom:
12
12
 
13
13
  ## Foundation
14
14
 
15
- - **Import once.** `@import "@hanzoai/design/styles.css";` gives you every token
15
+ - **Import once.** `@import "@hanzo/design/styles.css";` gives you every token
16
16
  as a CSS custom property. Use the variables. Never write a raw hex, rgb, px
17
17
  font-size, or magic z-index — if you reach for a literal, there is a token for
18
18
  it and you are doing it wrong.
@@ -0,0 +1,93 @@
1
+ // check-tokens.mjs — the gate. Every defect this file tests for was, at some
2
+ // point, LIVE and SILENT: an undefined custom property paints nothing, an
3
+ // unimported token file resolves nothing, and a 1.66:1 focus ring looks fine to
4
+ // whoever shipped it. None of them can fail loudly on their own, so they fail
5
+ // here. Run via `npm test` (part of `build`).
6
+ import { readFileSync, readdirSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
11
+ const tokensDir = join(root, 'tokens')
12
+ const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '')
13
+ const read = (p) => readFileSync(p, 'utf8')
14
+
15
+ let failures = 0
16
+ const fail = (msg) => { console.error(` FAIL ${msg}`); failures++ }
17
+ const pass = (msg) => console.log(` ok ${msg}`)
18
+
19
+ // ── 1. styles.css must serve every token file ────────────────────────────
20
+ // tokens/z.css was authored, exported and documented — and left out of
21
+ // styles.css, so the whole ladder resolved to nothing on every consumer.
22
+ {
23
+ const entry = read(join(root, 'styles.css'))
24
+ const files = readdirSync(tokensDir).filter((f) => f.endsWith('.css'))
25
+ const missing = files.filter((f) => !entry.includes(`tokens/${f}`))
26
+ missing.length
27
+ ? fail(`styles.css does not import: ${missing.join(', ')}`)
28
+ : pass(`styles.css serves all ${files.length} token files`)
29
+ }
30
+
31
+ // ── 2. every var() used inside the token layer must resolve ──────────────
32
+ {
33
+ const declared = new Set()
34
+ const used = new Map() // name -> file
35
+ for (const f of readdirSync(tokensDir).filter((f) => f.endsWith('.css'))) {
36
+ const css = strip(read(join(tokensDir, f)))
37
+ for (const [, n] of css.matchAll(/--([A-Za-z0-9-]+)\s*:/g)) declared.add(n)
38
+ for (const [, n] of css.matchAll(/var\(\s*--([A-Za-z0-9-]+)/g)) if (!used.has(n)) used.set(n, f)
39
+ }
40
+ const ghosts = [...used].filter(([n]) => !declared.has(n))
41
+ ghosts.length
42
+ ? ghosts.forEach(([n, f]) => fail(`tokens/${f} references --${n}, which nothing declares`))
43
+ : pass(`all ${used.size} internal var() references resolve`)
44
+ }
45
+
46
+ // ── 3. the non-text contrast gate (WCAG 1.4.11 / 2.4.11) ─────────────────
47
+ // A focus indicator and a control boundary must clear 3:1 against EVERY surface
48
+ // they can land on, in BOTH themes. --ring was #333333 = 1.66:1 on --background.
49
+ {
50
+ const css = strip(read(join(tokensDir, 'colors.css')))
51
+ 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 }
52
+ const dark = block(/:root\s*\{([\s\S]*?)\n\}/)
53
+ const themes = { dark, light: { ...dark, ...block(/\.light\s*\{([\s\S]*?)\n\}/) } }
54
+
55
+ const deref = (v, s, d = 0) => { const m = d < 10 && v && String(v).match(/^var\(\s*--([A-Za-z0-9-]+)\s*\)$/); return m ? deref(s[m[1]], s, d + 1) : v }
56
+ const rgb = (v) => {
57
+ if (!v) return null
58
+ let m = String(v).trim().match(/^#([0-9a-f]{6})$/i)
59
+ if (m) { const n = parseInt(m[1], 16); return [n >> 16 & 255, n >> 8 & 255, n & 255, 1] }
60
+ m = String(v).trim().match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[/,]\s*([\d.]+))?\s*\)$/i)
61
+ return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null
62
+ }
63
+ const over = (f, b) => [0, 1, 2].map((i) => f[i] * f[3] + b[i] * (1 - f[3]))
64
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
65
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
66
+ const ratio = (fg, bg, s) => {
67
+ const f = rgb(deref(fg, s)), b = rgb(deref(bg, s))
68
+ if (!f || !b) return null
69
+ const bo = b[3] < 1 ? over(b, [0, 0, 0, 1]) : b.slice(0, 3)
70
+ const fo = f[3] < 1 ? over(f, bo) : f.slice(0, 3)
71
+ const [A, B] = [lum(fo), lum(bo)].sort((x, y) => y - x)
72
+ return (A + 0.05) / (B + 0.05)
73
+ }
74
+
75
+ const CANVASES = ['background', 'card', 'popover', 'muted', 'secondary', 'surface-card', 'surface-overlay']
76
+ const GATED = { ring: 3, 'border-strong': 3 }
77
+ for (const [theme, scope] of Object.entries(themes)) {
78
+ for (const [tok, min] of Object.entries(GATED)) {
79
+ let worst = Infinity, where = ''
80
+ for (const c of CANVASES) {
81
+ const r = ratio(scope[tok], scope[c], scope)
82
+ if (r !== null && r < worst) { worst = r; where = `--${c}` }
83
+ }
84
+ if (!isFinite(worst)) { fail(`--${tok} (${theme}) could not be measured`); continue }
85
+ worst < min
86
+ ? fail(`--${tok} (${theme}) is ${worst.toFixed(2)}:1 on ${where} — needs ${min}:1`)
87
+ : pass(`--${tok} (${theme}) ${worst.toFixed(2)}:1 worst case (${where})`)
88
+ }
89
+ }
90
+ }
91
+
92
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall token checks passed')
93
+ process.exit(failures ? 1 : 0)
@@ -0,0 +1,78 @@
1
+ // gen-tokens.mjs — the ONE source→TS bridge for @hanzo/design.
2
+ // The hand-authored token CSS under tokens/*.css is the source of truth; this
3
+ // script parses every `--name: value;` custom property out of it and emits a
4
+ // typed TypeScript module (src/tokens.gen.ts). CSS and code therefore can never
5
+ // drift — you edit a token in ONE place (the CSS) and both the stylesheet and
6
+ // the programmatic API update. Run via `npm run gen` (part of `build`).
7
+ import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const tokensDir = join(root, 'tokens')
13
+
14
+ // The token files, in the same order styles.css imports them. base.css is the
15
+ // semantic-alias layer (references other vars) — parsed too, so `--background`
16
+ // etc. are available programmatically.
17
+ const FILES = ['colors', 'typography', 'spacing', 'radius', 'elevation', 'motion', 'z', 'fonts', 'base']
18
+
19
+ // group name for each file (the export const); a couple read better renamed.
20
+ const GROUP = { z: 'zIndex' }
21
+
22
+ /** Strip /* *​/ comments, then pull every `--name: value;` pair (multi-per-line ok). */
23
+ function parse(css) {
24
+ const noComments = css.replace(/\/\*[\s\S]*?\*\//g, '')
25
+ const out = []
26
+ const re = /--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g
27
+ let m
28
+ while ((m = re.exec(noComments))) out.push([m[1].trim(), m[2].trim()])
29
+ return out
30
+ }
31
+
32
+ // A token may be authored more than once — the dark `:root{}` default and a
33
+ // light `@media`/`[data-theme]` override carry the SAME name. Keep the FIRST
34
+ // occurrence (dark, the default theme): the static map is the authored default;
35
+ // live theme switching happens through the CSS cascade via cssVar()/var(--x).
36
+ const groupMaps = {} // groupName -> Map(name->value), first-wins
37
+ const flatMap = new Map() // '--name' -> value, first-wins
38
+ for (const f of FILES) {
39
+ let css
40
+ try {
41
+ css = readFileSync(join(tokensDir, `${f}.css`), 'utf8')
42
+ } catch {
43
+ continue
44
+ }
45
+ const g = GROUP[f] ?? f
46
+ const gm = (groupMaps[g] ??= new Map())
47
+ for (const [n, v] of parse(css)) {
48
+ if (!gm.has(n)) gm.set(n, v)
49
+ if (!flatMap.has(`--${n}`)) flatMap.set(`--${n}`, v)
50
+ }
51
+ }
52
+ const groups = Object.fromEntries(Object.entries(groupMaps).map(([g, m]) => [g, [...m]]))
53
+ const flat = [...flatMap]
54
+
55
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
56
+ const objLit = (pairs) =>
57
+ '{\n' + pairs.map(([n, v]) => ` '${esc(n)}': '${esc(v)}',`).join('\n') + '\n} as const'
58
+
59
+ let ts = `// AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
60
+ // Edit the token in tokens/<group>.css and re-run \`npm run gen\`.
61
+ /* eslint-disable */
62
+
63
+ `
64
+
65
+ // Per-group typed maps (keyed by the var name WITHOUT the leading '--').
66
+ for (const [g, pairs] of Object.entries(groups)) {
67
+ ts += `/** ${g} tokens (from tokens/${Object.keys(GROUP).find((k) => GROUP[k] === g) ?? g}.css). Values are raw CSS. */\n`
68
+ ts += `export const ${g} = ${objLit(pairs)}\n\n`
69
+ }
70
+
71
+ // The complete flat map, keyed by the literal CSS custom-property name.
72
+ ts += `/** Every token, keyed by its literal CSS custom-property name ('--background', …). */\n`
73
+ ts += `export const cssVars = ${objLit(flat)}\n\n`
74
+
75
+ ts += `export type CssVarName = keyof typeof cssVars\n`
76
+
77
+ writeFileSync(join(root, 'src', 'tokens.gen.ts'), ts)
78
+ console.log(`gen-tokens: wrote src/tokens.gen.ts — ${flat.length} tokens across ${Object.keys(groups).length} groups`)