@hanzo/design 0.3.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.
package/README.md CHANGED
@@ -6,10 +6,25 @@
6
6
 
7
7
  The single source of truth for how every Hanzo surface looks — tokens, components, brand assets, and the guidelines that hold them together.
8
8
 
9
- `@hanzoai/design`
9
+ `@hanzo/design`
10
10
 
11
11
  </div>
12
12
 
13
+ ## If you are an agent
14
+
15
+ Read [`skills/design-system/SKILL.md`](skills/design-system/SKILL.md). It is the
16
+ **one** way an AI surface learns this design system — chat, hanzo.app and Claude
17
+ Code all load the same file, so there is a single author and nothing to drift.
18
+ It ends by running the gate below, which is what makes the rules enforceable
19
+ rather than advisory:
20
+
21
+ ```sh
22
+ npx hanzo-design-lint <paths…>
23
+ ```
24
+
25
+ `prompts/` holds the longer material the skill points at. It is reference, not a
26
+ second entry point.
27
+
13
28
  ## Use it
14
29
 
15
30
  One import pulls in the whole token layer — fonts, colour, type, spacing, radius,
@@ -49,8 +64,8 @@ Groups: `colors`, `typography`, `spacing`, `radius`, `elevation`, `motion`, `zIn
49
64
  Everything below is expressed as CSS custom properties, so code copies over 1:1 — the semantic names match `hanzo.ai`'s variables exactly.
50
65
 
51
66
  ```jsx
52
- import { Button } from "@hanzoai/design/components/core/Button.jsx";
53
- import { HanzoLogo } from "@hanzoai/design/components/core/HanzoLogo.jsx";
67
+ import { Button } from "@hanzo/design/components/core/Button.jsx";
68
+ import { HanzoLogo } from "@hanzo/design/components/core/HanzoLogo.jsx";
54
69
 
55
70
  <Button pill>Try Hanzo</Button>
56
71
  ```
@@ -22,7 +22,7 @@ export function HanzoWordmark({ size = 22, label = 'Hanzo AI', variant = 'white'
22
22
  return (
23
23
  <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, ...style }}>
24
24
  <HanzoLogo size={size} variant={variant} />
25
- <span style={{ fontSize: 15, fontWeight: 600, letterSpacing: 'var(--tracking-tight)', color: 'var(--text-primary)' }}>{label}</span>
25
+ <span style={{ fontSize: 'var(--text-sm)', fontWeight: 600, letterSpacing: 'var(--tracking-tight)', color: 'var(--text-primary)' }}>{label}</span>
26
26
  </span>
27
27
  )
28
28
  }
@@ -11,7 +11,7 @@ export function Dialog({ open, onOpenChange, children }) {
11
11
  return (
12
12
  <div
13
13
  onClick={() => onOpenChange && onOpenChange(false)}
14
- style={{position:'fixed',inset:0,zIndex:80,display:'flex',alignItems:'center',justifyContent:'center',padding:24,background:'rgb(0 0 0 / .8)',backdropFilter:'blur(4px)',animation:'hanzo-fade-up var(--duration-fast) var(--ease-out)'}}
14
+ style={{position:'fixed',inset:0,zIndex:'var(--z-modal)',display:'flex',alignItems:'center',justifyContent:'center',padding:24,background:'var(--surface-scrim)',backdropFilter:'blur(4px)',animation:'hanzo-fade-up var(--duration-fast) var(--ease-out)'}}
15
15
  >
16
16
  {children}
17
17
  </div>
@@ -18,7 +18,7 @@ export function DropdownMenu({ align = 'right', hover = false, trigger, style, c
18
18
  >
19
19
  <span onClick={() => setOpen((v) => !v)} style={{display:'inline-flex'}}>{trigger}</span>
20
20
  {open && (
21
- <div style={{position:'absolute',top:'100%',[align]:0,paddingTop:12,zIndex:70,minWidth:208}}>
21
+ <div style={{position:'absolute',top:'100%',[align]:0,paddingTop:12,zIndex:'var(--z-dropdown)',minWidth:208}}>
22
22
  <div style={{display:'flex',flexDirection:'column',padding:8,background:'var(--surface-overlay)',border:'1px solid var(--border-hairline)',borderRadius:'var(--radius-xl)',boxShadow:'var(--shadow-floating)',backdropFilter:'blur(16px)',animation:'hanzo-fade-down var(--duration-fast) var(--ease-out)'}}>
23
23
  {children}
24
24
  </div>
@@ -12,7 +12,7 @@ export function Sheet({ open, onOpenChange, side = 'right', width = 380, childre
12
12
  ? { left: 0, top: 0, bottom: 0, width, borderRight: '1px solid var(--border-hairline)' }
13
13
  : { right: 0, top: 0, bottom: 0, width, borderLeft: '1px solid var(--border-hairline)' }
14
14
  return (
15
- <div onClick={() => onOpenChange && onOpenChange(false)} style={{position:'fixed',inset:0,zIndex:80,background:'rgb(0 0 0 / .7)'}}>
15
+ <div onClick={() => onOpenChange && onOpenChange(false)} style={{position:'fixed',inset:0,zIndex:'var(--z-modal)',background:'var(--surface-scrim)'}}>
16
16
  <div
17
17
  onClick={(e) => e.stopPropagation()}
18
18
  style={{position:'absolute',display:'flex',flexDirection:'column',background:'var(--popover)',boxShadow:'var(--shadow-floating)',animation:'hanzo-fade-up var(--duration-base) var(--ease-out)',...pos}}
@@ -29,7 +29,7 @@ export function Toaster({ duration = 3500, position = 'bottom-right' }) {
29
29
  }, [duration])
30
30
  const anchor = position === 'top-right' ? { top: 24, right: 24 } : { bottom: 24, right: 24 }
31
31
  return (
32
- <div style={{position:'fixed',zIndex:90,display:'flex',flexDirection:'column',gap:8,...anchor}}>
32
+ <div style={{position:'fixed',zIndex:'var(--z-toast)',display:'flex',flexDirection:'column',gap:8,...anchor}}>
33
33
  {items.map((i) => (
34
34
  <div key={i.id} style={{
35
35
  minWidth:260,maxWidth:380,padding:'12px 14px',display:'flex',alignItems:'flex-start',gap:10,
@@ -53,6 +53,7 @@ export declare const colors: {
53
53
  readonly 'surface-card-quiet': "rgb(23 23 23 / .4)";
54
54
  readonly 'surface-overlay': "rgb(10 10 10 / .95)";
55
55
  readonly 'surface-header': "rgb(0 0 0 / .7)";
56
+ readonly 'surface-scrim': "rgb(0 0 0 / .8)";
56
57
  readonly 'border-hairline': "var(--neutral-800)";
57
58
  readonly 'border-card': "var(--white-10)";
58
59
  readonly 'border-strong': "var(--neutral-500)";
@@ -299,6 +300,7 @@ export declare const cssVars: {
299
300
  readonly '--surface-card-quiet': "rgb(23 23 23 / .4)";
300
301
  readonly '--surface-overlay': "rgb(10 10 10 / .95)";
301
302
  readonly '--surface-header': "rgb(0 0 0 / .7)";
303
+ readonly '--surface-scrim': "rgb(0 0 0 / .8)";
302
304
  readonly '--border-hairline': "var(--neutral-800)";
303
305
  readonly '--border-card': "var(--white-10)";
304
306
  readonly '--border-strong': "var(--neutral-500)";
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.gen.d.ts","sourceRoot":"","sources":["../src/tokens.gen.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0ET,CAAA;AAEV,0EAA0E;AAC1E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8Db,CAAA;AAEV,oEAAoE;AACpE,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCV,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;CAST,CAAA;AAEV,wEAAwE;AACxE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;CAiBZ,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;CAWT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,MAAM;;;;;;;;;;;;CAYT,CAAA;AAEV,gEAAgE;AAChE,eAAO,MAAM,KAAK;;;;;;CAMR,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI,IAEP,CAAA;AAEV,sFAAsF;AACtF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6NV,CAAA;AAEV,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,OAAO,CAAA"}
1
+ {"version":3,"file":"tokens.gen.d.ts","sourceRoot":"","sources":["../src/tokens.gen.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2ET,CAAA;AAEV,0EAA0E;AAC1E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8Db,CAAA;AAEV,oEAAoE;AACpE,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCV,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;CAST,CAAA;AAEV,wEAAwE;AACxE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;CAiBZ,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;CAWT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,MAAM;;;;;;;;;;;;CAYT,CAAA;AAEV,gEAAgE;AAChE,eAAO,MAAM,KAAK;;;;;;CAMR,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI,IAEP,CAAA;AAEV,sFAAsF;AACtF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8NV,CAAA;AAEV,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,OAAO,CAAA"}
@@ -56,6 +56,7 @@ 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
62
  'border-strong': 'var(--neutral-500)',
@@ -302,6 +303,7 @@ export const cssVars = {
302
303
  '--surface-card-quiet': 'rgb(23 23 23 / .4)',
303
304
  '--surface-overlay': 'rgb(10 10 10 / .95)',
304
305
  '--surface-header': 'rgb(0 0 0 / .7)',
306
+ '--surface-scrim': 'rgb(0 0 0 / .8)',
305
307
  '--border-hairline': 'var(--neutral-800)',
306
308
  '--border-card': 'var(--white-10)',
307
309
  '--border-strong': 'var(--neutral-500)',
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>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.3.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.",
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
5
  "license": "BSD-3-Clause",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,13 +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
- "test": "node scripts/check-tokens.mjs",
33
+ "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs components",
33
34
  "build": "npm run gen && npm test && tsc -p tsconfig.json",
34
- "prepublishOnly": "npm run build"
35
+ "prepublishOnly": "npm run build",
36
+ "lint": "node scripts/lint.mjs"
35
37
  },
36
38
  "devDependencies": {
37
39
  "typescript": "^5.9.3"
@@ -43,6 +45,8 @@
43
45
  "tokens",
44
46
  "components",
45
47
  "prompts",
48
+ "skills",
49
+ "scripts",
46
50
  "content",
47
51
  "docs",
48
52
  "assets",
@@ -59,5 +63,8 @@
59
63
  "publishConfig": {
60
64
  "access": "public",
61
65
  "registry": "https://registry.npmjs.org/"
66
+ },
67
+ "bin": {
68
+ "hanzo-design-lint": "./scripts/lint.mjs"
62
69
  }
63
- }
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,7 @@ 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.
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`)
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ // lint.mjs — the gate on CONSUMER code. `check-tokens.mjs` proves the token
3
+ // layer is sound; this proves the code that USES it actually reaches it.
4
+ //
5
+ // Every rule here is a defect that shipped, silently, to a live Hanzo surface:
6
+ // a var() nothing declares paints nothing, a raw hex ignores the theme, a bare
7
+ // z-index wins a fight it should have lost, an ALL-CAPS label is a different
8
+ // brand. None of them throw. So they fail here.
9
+ //
10
+ // npx hanzo-design-lint <path...> # default: cwd
11
+ //
12
+ // Exit 1 on any violation. That is the point.
13
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname, join, relative, extname, sep } from 'node:path'
16
+
17
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
18
+
19
+ // ── the vocabulary: every token the design system actually declares ──────
20
+ const TOKENS = new Set()
21
+ {
22
+ const dir = join(pkgRoot, 'tokens')
23
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.css')))
24
+ for (const [, n] of readFileSync(join(dir, f), 'utf8').matchAll(/--([A-Za-z0-9-]+)\s*:/g))
25
+ TOKENS.add(n)
26
+ }
27
+
28
+ // ── what we read ─────────────────────────────────────────────────────────
29
+ const EXT = new Set(['.css', '.scss', '.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.html'])
30
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', 'vendor', '__pycache__'])
31
+ // Third-party marks keep their own hex by design (DESIGN.md §2.4); so do the
32
+ // token files themselves, which are where raw values are SUPPOSED to live.
33
+ const EXEMPT = /(^|\/)(tokens|assets|logos|providers|ui_kits|guidelines)(\/|$)|\.card\.html$/
34
+
35
+ const walk = (p, out = []) => {
36
+ const st = statSync(p)
37
+ if (st.isFile()) { if (EXT.has(extname(p))) out.push(p); return out }
38
+ for (const e of readdirSync(p)) if (!SKIP.has(e) && !e.startsWith('.')) walk(join(p, e), out)
39
+ return out
40
+ }
41
+
42
+ const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
43
+ const lineOf = (src, i) => src.slice(0, i).split('\n').length
44
+
45
+ // Read one property's value out of a JSX style object, respecting quotes and
46
+ // nested parens — `linear-gradient(a, var(--x))` is ONE value, not two.
47
+ function styleValue(body, key) {
48
+ const at = body.search(new RegExp(`\\b${key}\\s*:`))
49
+ if (at < 0) return null
50
+ let i = body.indexOf(':', at) + 1, depth = 0, quote = null, out = ''
51
+ for (; i < body.length; i++) {
52
+ const c = body[i]
53
+ if (quote) { out += c; if (c === quote && body[i - 1] !== '\\') quote = null; continue }
54
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; continue }
55
+ if (c === '(' || c === '[') depth++
56
+ if (c === ')' || c === ']') depth--
57
+ if (c === ',' && depth === 0) break
58
+ out += c
59
+ }
60
+ return out.trim()
61
+ }
62
+
63
+ const findings = []
64
+ const flag = (file, line, rule, detail, fix) =>
65
+ findings.push({ file, line, rule, detail, fix })
66
+
67
+ // ── the rules ────────────────────────────────────────────────────────────
68
+ function lintFile(abs, root) {
69
+ const rel = relative(root, abs).split(sep).join('/')
70
+ if (EXEMPT.test('/' + rel)) return
71
+ const raw = readFileSync(abs, 'utf8')
72
+ const src = strip(raw)
73
+ const isStyle = /\.(css|scss)$/.test(rel)
74
+
75
+ // 1. var() must resolve — against the design tokens or a local declaration.
76
+ // This is the menu that painted with undefined tokens.
77
+ const local = new Set([...src.matchAll(/--([A-Za-z0-9-]+)\s*:/g)].map((m) => m[1]))
78
+ for (const m of src.matchAll(/var\(\s*--([A-Za-z0-9-]+)\s*(\)|,)/g)) {
79
+ const [, name, close] = m
80
+ if (TOKENS.has(name) || local.has(name)) continue
81
+ if (close === ',') continue // an explicit fallback is a deliberate choice
82
+ flag(rel, lineOf(src, m.index), 'unresolved-token',
83
+ `var(--${name}) — nothing declares it`,
84
+ 'use a token from @hanzo/design or declare it locally')
85
+ }
86
+
87
+ // 2. no raw colour in a surface. The theme cannot reach a literal.
88
+ for (const m of src.matchAll(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b|\b(?:rgba?|hsla?)\(/g)) {
89
+ const line = lineOf(src, m.index)
90
+ const text = src.split('\n')[line - 1] ?? ''
91
+ if (/currentColor|transparent|url\(|\.svg|<svg|xmlns|viewBox|stopColor|fill=/.test(text)) continue
92
+ flag(rel, line, 'raw-color', m[0].startsWith('#') ? m[0] : m[0] + '…)',
93
+ 'use --foreground / --surface-card / the --white-* ladder')
94
+ }
95
+
96
+ // 3. no bare z-index. The ladder is --z-base … --z-notification.
97
+ for (const m of src.matchAll(/z-?[Ii]ndex\s*[:=]\s*['"{ ]*(-?\d+)/g))
98
+ flag(rel, lineOf(src, m.index), 'raw-z-index', `z-index: ${m[1]}`,
99
+ 'use --z-dropdown / --z-modal / --z-toast …')
100
+ for (const m of src.matchAll(/\bz-\[(-?\d+)\]/g))
101
+ flag(rel, lineOf(src, m.index), 'raw-z-index', `z-[${m[1]}]`,
102
+ 'use var(--z-…) via an arbitrary property, not a magic number')
103
+
104
+ // 4. no raw font-size. The scale is --type-* / --text-*.
105
+ for (const m of src.matchAll(/font-?[Ss]ize\s*[:=]\s*['"{ ]*(\d+(?:\.\d+)?)(px|pt)/g))
106
+ flag(rel, lineOf(src, m.index), 'raw-font-size', `${m[1]}${m[2]}`,
107
+ 'use --type-body / --type-h2 / the --text-* scale')
108
+
109
+ // 5. sentence case. ALL CAPS is a different brand — eyebrows excepted, and
110
+ // an eyebrow says so by using --type-eyebrow / the eyebrow class.
111
+ if (isStyle) {
112
+ for (const m of src.matchAll(/text-transform\s*:\s*uppercase/g)) {
113
+ const around = src.slice(Math.max(0, m.index - 400), m.index)
114
+ if (/eyebrow|--type-eyebrow|tracking-widest/i.test(around)) continue
115
+ flag(rel, lineOf(src, m.index), 'all-caps', 'text-transform: uppercase',
116
+ 'sentence case; use --type-eyebrow if it is genuinely an eyebrow')
117
+ }
118
+ } else {
119
+ for (const m of src.matchAll(/>\s*([A-Z][A-Z0-9 &/'-]{5,40})\s*</g)) {
120
+ const t = m[1].trim()
121
+ if (!/[A-Z]{2}/.test(t) || /^[A-Z0-9 &/'-]+$/.test(t) === false) continue
122
+ if (t.split(/\s+/).every((w) => w.length <= 3)) continue // AI, API, GPU…
123
+ const line = lineOf(src, m.index)
124
+ const around = src.split('\n').slice(Math.max(0, line - 4), line).join(' ')
125
+ if (/eyebrow|uppercase|tracking-widest/i.test(around)) continue
126
+ flag(rel, line, 'all-caps', `"${t}"`, 'sentence case for labels and headings')
127
+ }
128
+ }
129
+
130
+ // 6. no inline style carrying design decisions. It outranks every token and
131
+ // every theme, which is exactly why it keeps being reached for.
132
+ // Only a LITERAL is a violation: `color:'#fff'`, `fontSize:13`. An
133
+ // identifier (`background:FILL[variant]`) is indirection we cannot read,
134
+ // and guessing there would train people to ignore the linter.
135
+ for (const m of src.matchAll(/style=\{\{([^}]*)\}\}/g)) {
136
+ const body = m[1]
137
+ const bad = ['color', 'background', 'backgroundColor', 'fontSize', 'boxShadow', 'borderColor']
138
+ .filter((k) => {
139
+ const val = styleValue(body, k)
140
+ if (val === null) return false
141
+ if (/var\(/.test(val)) return false
142
+ if (/^['"`]?(transparent|none|inherit|currentColor|unset|initial|auto)['"`]?$/i.test(val)) return false
143
+ return /^['"`]/.test(val) || /^-?\d/.test(val)
144
+ })
145
+ if (bad.length)
146
+ flag(rel, lineOf(src, m.index), 'inline-style', `style={{ ${bad.join(', ')} }}`,
147
+ 'move to a class; if it must be inline, the value must be var(--token)')
148
+ }
149
+
150
+ // 7. one icon set.
151
+ for (const m of src.matchAll(/from\s+['"](@?[\w./-]*(?:react-icons|heroicons|font-awesome|@mui\/icons|feather-icons|phosphor)[\w./-]*)['"]/g))
152
+ flag(rel, lineOf(src, m.index), 'icon-set', m[1], 'lucide-react, one set, no other')
153
+
154
+ // 8. the import that makes all of the above resolvable.
155
+ for (const m of src.matchAll(/['"]@hanzoai\/design/g))
156
+ flag(rel, lineOf(src, m.index), 'wrong-package', '@hanzoai/design',
157
+ 'the package is @hanzo/design — @hanzoai/design is a 404 and resolves nothing')
158
+ }
159
+
160
+ // ── run ──────────────────────────────────────────────────────────────────
161
+ const targets = process.argv.slice(2).filter((a) => !a.startsWith('-'))
162
+ const roots = targets.length ? targets : [process.cwd()]
163
+ let scanned = 0
164
+ for (const r of roots) for (const f of walk(r)) { scanned++; lintFile(f, r === f ? dirname(r) : r) }
165
+
166
+ const RULES = ['unresolved-token', 'raw-color', 'raw-z-index', 'raw-font-size', 'all-caps', 'inline-style', 'icon-set', 'wrong-package']
167
+ if (!findings.length) {
168
+ console.log(`hanzo-design-lint: ${scanned} files, clean`)
169
+ process.exit(0)
170
+ }
171
+ for (const rule of RULES) {
172
+ const hits = findings.filter((f) => f.rule === rule)
173
+ if (!hits.length) continue
174
+ console.log(`\n${rule} (${hits.length}) — ${hits[0].fix}`)
175
+ for (const h of hits.slice(0, 20)) console.log(` ${h.file}:${h.line} ${h.detail}`)
176
+ if (hits.length > 20) console.log(` … ${hits.length - 20} more`)
177
+ }
178
+ console.log(`\n${findings.length} violation(s) across ${scanned} files`)
179
+ process.exit(1)
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: design-system
3
+ description: "Use whenever you generate, edit, or review a Hanzo user interface — a page, component, screen, email, or any code change touching colour, type, spacing, elevation, motion, or stacking order. Use it when PLANNING UI work too, so the plan names tokens rather than values. Teaches the Hanzo token layer (@hanzo/design, derived from @hanzo/brand plus @hanzo/logo) and runs the linter that proves generated code actually reaches it. Triggers — build a page, add a component, style, theme, dark mode, colour, hex, palette, font size, spacing, padding, z-index, modal, dropdown, toast, button, card, dialog, icon, make it look Hanzo, brand, design review."
4
+ license: BSD-3-Clause
5
+ ---
6
+
7
+ # The Hanzo design system
8
+
9
+ One atom, and everything else follows from it:
10
+
11
+ **Monochrome. True black. White type. Colour only as state, never as decoration.**
12
+
13
+ ## The derivation chain — never reach past it
14
+
15
+ ```
16
+ @hanzo/brand (brand.json — the identity: marks, palette source, motion intent)
17
+ @hanzo/logo (the mark itself, as SVG + React)
18
+ │ derive
19
+
20
+ @hanzo/design (tokens/*.css — 221 CSS custom properties, the ONLY vocabulary)
21
+ │ consume
22
+
23
+ your component
24
+ ```
25
+
26
+ A component reads `@hanzo/design`. It does **not** reach past it to `@hanzo/brand`,
27
+ to `@hanzo/logo`'s raw SVG, or to a literal value. If a component needs something
28
+ the token layer does not express, the fix is a **new token**, not a local
29
+ constant — otherwise the next surface invents a second one and the two drift.
30
+ (That is how one product ended up with 25 different z-index values.)
31
+
32
+ The package is **`@hanzo/design`**. `@hanzoai/design` does not exist on npm; an
33
+ import of it resolves to nothing, and an unresolved token layer paints nothing.
34
+
35
+ ## Start here
36
+
37
+ ```css
38
+ @import "@hanzo/design/styles.css"; /* every token, as a CSS custom property */
39
+ ```
40
+
41
+ Then reach for a **name**, never a value:
42
+
43
+ | You want | Use | Never |
44
+ |---|---|---|
45
+ | a page / text | `--background`, `--foreground`, `--text-secondary` | `#000`, `#fff`, `rgb(…)` |
46
+ | a card, a panel | `--surface-card`, `--border-hairline` | a hand-mixed grey |
47
+ | rank / emphasis | the ladder `--white-05 … --white-80` | an off-ladder 12% or 37% |
48
+ | a size | `--type-body`, `--type-h2`, `--text-sm` | `font-size: 13px` |
49
+ | space | `--space-*`, `--gutter*`, `--container-max` | `padding: 13px` |
50
+ | a layer | `--z-dropdown`, `--z-modal`, `--z-toast` | `z-index: 9999` |
51
+ | a scrim | `--surface-scrim` | `rgba(0,0,0,.8)` |
52
+ | an icon | `lucide-react` | react-icons, heroicons, MUI |
53
+
54
+ The **only** coloured pixels permitted: `--state-error`, `--state-online`,
55
+ `--state-success`, the macOS chrome dot trio, and third-party brand logos. A blue
56
+ button or a purple gradient is not Hanzo — make it white on black.
57
+
58
+ Sentence case for headings and buttons. ALL CAPS is only an eyebrow, and an
59
+ eyebrow says so with `--type-eyebrow`.
60
+
61
+ ## Read before you write
62
+
63
+ Load these from the installed package (`node_modules/@hanzo/design/`) or the repo:
64
+
65
+ | File | When |
66
+ |---|---|
67
+ | `prompts/system.md` | Always. The whole language, one screen. Paste as system prompt for a sub-agent. |
68
+ | `prompts/rules.md` | The do/don't table. Walk it before you return code. |
69
+ | `components/**/*.prompt.md` | Per component, when you place one. |
70
+ | `guidelines/DESIGN.md` | The reasoning, when a rule seems to be in your way. |
71
+ | `tokens/*.css` | The authoritative list. Grep it rather than guessing a name. |
72
+
73
+ ## Finish by proving it — this step is not optional
74
+
75
+ Generated code that violates the token layer must be **caught, not shipped**.
76
+ Nothing here fails loudly on its own: an undefined `var()` paints nothing, a raw
77
+ hex silently ignores the theme, a class with no rule paints nothing. So run the
78
+ gate on every file you touched:
79
+
80
+ ```sh
81
+ npx hanzo-design-lint <paths…> # exit 0 = clean, exit 1 = violations
82
+ ```
83
+
84
+ It checks eight things, each of which has shipped to production at least once:
85
+
86
+ 1. `unresolved-token` — a `var(--x)` nothing declares
87
+ 2. `raw-color` — a hex / `rgb()` / `hsl()` literal in a surface
88
+ 3. `raw-z-index` — a magic number instead of the ladder
89
+ 4. `raw-font-size` — `px`/`pt` instead of the scale
90
+ 5. `all-caps` — an uppercase label that is not an eyebrow
91
+ 6. `inline-style` — a literal colour/size in `style={{…}}` (a `var()` is fine)
92
+ 7. `icon-set` — an icon library that is not lucide
93
+ 8. `wrong-package` — `@hanzoai/design`, which resolves to nothing
94
+
95
+ Fix every finding, then re-run until clean. **Do not** silence a rule, and do not
96
+ report the work as done on a red gate. If a rule is genuinely wrong for a case,
97
+ say so explicitly in your summary rather than working around it.
98
+
99
+ ## The one test
100
+
101
+ Monochrome, true black, white type, colour only as state. If the screenshot would
102
+ look at home on hanzo.ai, it passes. If it looks like a generic SaaS template, it
103
+ fails — and the fix is your work, never the atom.
package/src/tokens.gen.ts CHANGED
@@ -57,6 +57,7 @@ export const colors = {
57
57
  'surface-card-quiet': 'rgb(23 23 23 / .4)',
58
58
  'surface-overlay': 'rgb(10 10 10 / .95)',
59
59
  'surface-header': 'rgb(0 0 0 / .7)',
60
+ 'surface-scrim': 'rgb(0 0 0 / .8)',
60
61
  'border-hairline': 'var(--neutral-800)',
61
62
  'border-card': 'var(--white-10)',
62
63
  'border-strong': 'var(--neutral-500)',
@@ -314,6 +315,7 @@ export const cssVars = {
314
315
  '--surface-card-quiet': 'rgb(23 23 23 / .4)',
315
316
  '--surface-overlay': 'rgb(10 10 10 / .95)',
316
317
  '--surface-header': 'rgb(0 0 0 / .7)',
318
+ '--surface-scrim': 'rgb(0 0 0 / .8)',
317
319
  '--border-hairline': 'var(--neutral-800)',
318
320
  '--border-card': 'var(--white-10)',
319
321
  '--border-strong': 'var(--neutral-500)',
package/tokens/colors.css CHANGED
@@ -70,6 +70,7 @@
70
70
  --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
71
71
  --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
72
72
  --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
73
+ --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
73
74
  /* Boundaries come in two kinds and they are NOT interchangeable.
74
75
  DECORATIVE (--border, --border-hairline, --border-card): separates content;
75
76
  WCAG imposes no ratio. Keep them quiet.
@@ -138,6 +139,7 @@
138
139
  --surface-card-quiet:#fafafa;
139
140
  --surface-overlay:rgb(255 255 255 / .95);
140
141
  --surface-header:rgb(255 255 255 / .8);
142
+ --surface-scrim:rgb(0 0 0 / .5);
141
143
  --border-hairline:var(--neutral-200);
142
144
  --border-card:rgb(0 0 0 / .1);
143
145
  --border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */