@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.
@@ -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/index.ts CHANGED
@@ -12,17 +12,30 @@
12
12
  export * from './tokens.gen.js'
13
13
  import { cssVars, type CssVarName } from './tokens.gen.js'
14
14
 
15
+ /** A token name with the leading `--` omitted: `'background'` for `'--background'`. */
16
+ export type TokenName = CssVarName extends `--${infer N}` ? N : never
17
+
15
18
  /**
16
- * A `var(--name[, fallback])` reference to a token — the ONE way code should
17
- * reach a token so it resolves through the live CSS cascade (honoring the
18
- * viewer's light/dark theme) rather than baking a fixed value.
19
+ * A `var(--name, <authored literal>)` reference to a token — the ONE way code
20
+ * should reach a token, so it resolves through the live CSS cascade (honoring
21
+ * the viewer's light/dark theme and any brand fork) rather than baking a value.
22
+ *
23
+ * background: cssVar('--background') // → "var(--background, #000000)"
24
+ * color: cssVar('foreground', '#fff')// → "var(--foreground, #fff)"
19
25
  *
20
- * background: cssVar('--background') // "var(--background)"
21
- * color: cssVar('--foreground', '#fff')
26
+ * The name is checked AGAINST THE STYLESHEET at compile time. That check used to
27
+ * be opted out of with `| (string & {})`, which is how `cssVar('surface-1')`
28
+ * shipped: the token did not exist, `var(--surface-1)` resolved to nothing, and
29
+ * a menu painted transparent with no error anywhere. An undefined custom
30
+ * property fails SILENTLY, so the type is the only place it can be caught.
31
+ *
32
+ * When no explicit fallback is given the token's own authored literal is used,
33
+ * so the reference still paints on a host that has not loaded the CSS layer.
22
34
  */
23
- export function cssVar(name: CssVarName | (string & {}), fallback?: string): string {
24
- const n = name.startsWith('--') ? name : `--${name}`
25
- return fallback ? `var(${n}, ${fallback})` : `var(${n})`
35
+ export function cssVar(name: CssVarName | TokenName, fallback?: string): string {
36
+ const n = (name.startsWith('--') ? name : `--${name}`) as CssVarName
37
+ const lit = fallback ?? (cssVars as Record<string, string>)[n]
38
+ return lit ? `var(${n}, ${lit})` : `var(${n})`
26
39
  }
27
40
 
28
41
  /** The raw authored value of a token (the literal from the CSS), or `undefined`. */
@@ -35,8 +48,12 @@ export function tokenValue(name: CssVarName): string | undefined {
35
48
  * cannot use a bundler CSS import (e.g. a runtime-mounted island). Prefer the
36
49
  * static `import '@hanzo/design/styles.css'` where a bundler is available.
37
50
  * No-op outside the browser.
51
+ *
52
+ * `href` is REQUIRED: this used to default to esm.sh, which silently made a
53
+ * third-party CDN the origin of the entire token layer for anyone who called it
54
+ * bare. Pass a URL you serve.
38
55
  */
39
- export function injectDesignCss(href = 'https://esm.sh/@hanzo/design/styles.css'): void {
56
+ export function injectDesignCss(href: string): void {
40
57
  if (typeof document === 'undefined') return
41
58
  if (document.querySelector('link[data-hanzo-design]')) return
42
59
  const l = document.createElement('link')
package/src/tokens.gen.ts CHANGED
@@ -45,7 +45,7 @@ export const colors = {
45
45
  'destructive-foreground': '#f5f5f5',
46
46
  'border': '#1f1f1f',
47
47
  'input': '#1f1f1f',
48
- 'ring': '#333333',
48
+ 'ring': 'var(--neutral-500)',
49
49
  'brand': '#e4e4e7',
50
50
  'brand-foreground': '#09090b',
51
51
  'brand-muted': '#a3a3a3',
@@ -57,9 +57,14 @@ 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
- 'border-strong': 'var(--neutral-700)',
63
+ 'border-strong': 'var(--neutral-500)',
64
+ 'surface-0': 'var(--background)',
65
+ 'surface-1': 'var(--card)',
66
+ 'surface-2': 'var(--muted)',
67
+ 'surface-3': 'var(--secondary)',
63
68
  'text-primary': 'var(--pure-white)',
64
69
  'text-secondary': 'var(--white-80)',
65
70
  'text-tertiary': 'var(--white-60)',
@@ -99,6 +104,23 @@ export const typography = {
99
104
  'leading-6xl': '1',
100
105
  'text-7xl': '4rem',
101
106
  'leading-7xl': '1',
107
+ 'text-8xl': '5.25rem',
108
+ 'leading-8xl': '1',
109
+ 'text-9xl': '7rem',
110
+ 'leading-9xl': '1',
111
+ 'font-size-xs': 'var(--text-xs)',
112
+ 'font-size-sm': 'var(--text-sm)',
113
+ 'font-size-base': 'var(--text-base)',
114
+ 'font-size-lg': 'var(--text-lg)',
115
+ 'font-size-xl': 'var(--text-xl)',
116
+ 'font-size-2xl': 'var(--text-2xl)',
117
+ 'font-size-3xl': 'var(--text-3xl)',
118
+ 'font-size-4xl': 'var(--text-4xl)',
119
+ 'font-size-5xl': 'var(--text-5xl)',
120
+ 'font-size-6xl': 'var(--text-6xl)',
121
+ 'font-size-7xl': 'var(--text-7xl)',
122
+ 'font-size-8xl': 'var(--text-8xl)',
123
+ 'font-size-9xl': 'var(--text-9xl)',
102
124
  'weight-normal': '400',
103
125
  'weight-medium': '500',
104
126
  'weight-semibold': '600',
@@ -181,6 +203,12 @@ export const elevation = {
181
203
  'shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
182
204
  'shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
183
205
  'ring-focus': '0 0 0 2px var(--ring)',
206
+ 'shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
207
+ 'shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
208
+ 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
209
+ 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
210
+ 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
211
+ 'shadow-2xl': 'var(--shadow-floating)',
184
212
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
185
213
  'glow-hero-blur': '120px',
186
214
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -214,6 +242,8 @@ export const zIndex = {
214
242
  'z-modal': '600',
215
243
  'z-popover': '700',
216
244
  'z-toast': '800',
245
+ 'z-tooltip': 'var(--z-popover)',
246
+ 'z-notification': 'var(--z-toast)',
217
247
  } as const
218
248
 
219
249
  /** fonts tokens (from tokens/fonts.css). Values are raw CSS. */
@@ -273,7 +303,7 @@ export const cssVars = {
273
303
  '--destructive-foreground': '#f5f5f5',
274
304
  '--border': '#1f1f1f',
275
305
  '--input': '#1f1f1f',
276
- '--ring': '#333333',
306
+ '--ring': 'var(--neutral-500)',
277
307
  '--brand': '#e4e4e7',
278
308
  '--brand-foreground': '#09090b',
279
309
  '--brand-muted': '#a3a3a3',
@@ -285,9 +315,14 @@ export const cssVars = {
285
315
  '--surface-card-quiet': 'rgb(23 23 23 / .4)',
286
316
  '--surface-overlay': 'rgb(10 10 10 / .95)',
287
317
  '--surface-header': 'rgb(0 0 0 / .7)',
318
+ '--surface-scrim': 'rgb(0 0 0 / .8)',
288
319
  '--border-hairline': 'var(--neutral-800)',
289
320
  '--border-card': 'var(--white-10)',
290
- '--border-strong': 'var(--neutral-700)',
321
+ '--border-strong': 'var(--neutral-500)',
322
+ '--surface-0': 'var(--background)',
323
+ '--surface-1': 'var(--card)',
324
+ '--surface-2': 'var(--muted)',
325
+ '--surface-3': 'var(--secondary)',
291
326
  '--text-primary': 'var(--pure-white)',
292
327
  '--text-secondary': 'var(--white-80)',
293
328
  '--text-tertiary': 'var(--white-60)',
@@ -323,6 +358,23 @@ export const cssVars = {
323
358
  '--leading-6xl': '1',
324
359
  '--text-7xl': '4rem',
325
360
  '--leading-7xl': '1',
361
+ '--text-8xl': '5.25rem',
362
+ '--leading-8xl': '1',
363
+ '--text-9xl': '7rem',
364
+ '--leading-9xl': '1',
365
+ '--font-size-xs': 'var(--text-xs)',
366
+ '--font-size-sm': 'var(--text-sm)',
367
+ '--font-size-base': 'var(--text-base)',
368
+ '--font-size-lg': 'var(--text-lg)',
369
+ '--font-size-xl': 'var(--text-xl)',
370
+ '--font-size-2xl': 'var(--text-2xl)',
371
+ '--font-size-3xl': 'var(--text-3xl)',
372
+ '--font-size-4xl': 'var(--text-4xl)',
373
+ '--font-size-5xl': 'var(--text-5xl)',
374
+ '--font-size-6xl': 'var(--text-6xl)',
375
+ '--font-size-7xl': 'var(--text-7xl)',
376
+ '--font-size-8xl': 'var(--text-8xl)',
377
+ '--font-size-9xl': 'var(--text-9xl)',
326
378
  '--weight-normal': '400',
327
379
  '--weight-medium': '500',
328
380
  '--weight-semibold': '600',
@@ -393,6 +445,12 @@ export const cssVars = {
393
445
  '--shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
394
446
  '--shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
395
447
  '--ring-focus': '0 0 0 2px var(--ring)',
448
+ '--shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
449
+ '--shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
450
+ '--shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
451
+ '--shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
452
+ '--shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
453
+ '--shadow-2xl': 'var(--shadow-floating)',
396
454
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
397
455
  '--glow-hero-blur': '120px',
398
456
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -418,6 +476,8 @@ export const cssVars = {
418
476
  '--z-modal': '600',
419
477
  '--z-popover': '700',
420
478
  '--z-toast': '800',
479
+ '--z-tooltip': 'var(--z-popover)',
480
+ '--z-notification': 'var(--z-toast)',
421
481
  '--font-sans': '"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif',
422
482
  '--font-display': 'var(--font-sans)',
423
483
  '--font-mono': '"Geist Mono",ui-monospace,SFMono-Regular,monospace',
package/styles.css CHANGED
@@ -1,5 +1,8 @@
1
1
  /* Hanzo Design System — global entry point. Import THIS one file.
2
- Source of truth: hanzo-apps/hanzo.ai (app/globals.css, tailwind.config.ts, DESIGN.md). */
2
+ Every token group is served here; nothing is authored but left unimported.
3
+ (`tokens/z.css` went missing from this list once and the whole ladder stopped
4
+ resolving while still type-checking — see scripts/check-tokens.mjs, which now
5
+ fails the build if any tokens/*.css is not imported below.) */
3
6
  @import url("tokens/fonts.css");
4
7
  @import url("tokens/colors.css");
5
8
  @import url("tokens/typography.css");
package/tokens/base.css CHANGED
@@ -2,10 +2,14 @@
2
2
  a utility framework. Components carry their own styles inline. */
3
3
  *{box-sizing:border-box;border-color:var(--border)}
4
4
  html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
5
- body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
6
- h1,h2,h3,h4{margin:0;font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-primary)}
5
+ /* Each font-family carries the stack as a literal fallback. --font-sans lives in
6
+ tokens/fonts.css, which a surface may legitimately import separately; without
7
+ the fallback an unresolved var() makes font-family invalid and the UA drops to
8
+ its SERIF default — the whole console silently rendered in Times. */
9
+ body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
10
+ h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
7
11
  p{margin:0;text-wrap:pretty}
8
- code,pre,kbd{font-family:var(--font-mono)}
12
+ code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
9
13
  a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
10
14
  a:hover{color:var(--text-primary);text-decoration:underline}
11
15
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
package/tokens/colors.css CHANGED
@@ -51,7 +51,12 @@
51
51
  --destructive-foreground:#f5f5f5;
52
52
  --border:#1f1f1f;
53
53
  --input:#1f1f1f;
54
- --ring:#333333;
54
+ /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
55
+ 3:1 against every surface it can land on. --neutral-500 is the only rung on
56
+ this ladder that clears 3:1 on all of them — #000000, #0a0a0a, #101010,
57
+ #1a1a1a, AND the light theme's #ffffff/#f5f5f5 — so one value serves both
58
+ themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
59
+ --ring:var(--neutral-500);
55
60
  --brand:#e4e4e7;
56
61
  --brand-foreground:#09090b;
57
62
  --brand-muted:#a3a3a3;
@@ -65,9 +70,25 @@
65
70
  --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
66
71
  --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
67
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 */
74
+ /* Boundaries come in two kinds and they are NOT interchangeable.
75
+ DECORATIVE (--border, --border-hairline, --border-card): separates content;
76
+ WCAG imposes no ratio. Keep them quiet.
77
+ PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
78
+ switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
79
+ Reach for --border-strong whenever the boundary IS the affordance. */
68
80
  --border-hairline:var(--neutral-800);
69
81
  --border-card:var(--white-10);
70
- --border-strong:var(--neutral-700);
82
+ --border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
83
+
84
+ /* ——— the numeric surface ladder ——— */
85
+ /* Aliases onto the semantic canvases above, so a brand fork that retunes
86
+ --card/--muted/--secondary retunes the ladder with it and the light theme
87
+ inverts for free. Ascending lift: 0 is the page, 3 is a hovered control. */
88
+ --surface-0:var(--background);
89
+ --surface-1:var(--card);
90
+ --surface-2:var(--muted);
91
+ --surface-3:var(--secondary);
71
92
 
72
93
  /* ——— text ranks ——— */
73
94
  --text-primary:var(--pure-white);
@@ -107,7 +128,10 @@
107
128
  --destructive-foreground:#ffffff;
108
129
  --border:#e5e5e5;
109
130
  --input:#e5e5e5;
110
- --ring:#d4d4d4;
131
+ /* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
132
+ focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
133
+ #f5f5f5, so ONE value is conformant in both themes. */
134
+ --ring:var(--neutral-500);
111
135
  --black:#0a0a0a;
112
136
  --white:#ffffff;
113
137
  --surface-card:#f5f5f5;
@@ -115,9 +139,13 @@
115
139
  --surface-card-quiet:#fafafa;
116
140
  --surface-overlay:rgb(255 255 255 / .95);
117
141
  --surface-header:rgb(255 255 255 / .8);
142
+ --surface-scrim:rgb(0 0 0 / .5);
118
143
  --border-hairline:var(--neutral-200);
119
144
  --border-card:rgb(0 0 0 / .1);
120
- --border-strong:var(--neutral-300);
145
+ --border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
146
+ /* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
147
+ here (1.00:1). Anything that needs a visible edge in BOTH themes must use
148
+ --border-strong, never a --white-* rung. */
121
149
  --text-primary:var(--neutral-950);
122
150
  --text-secondary:rgb(10 10 10 / .8);
123
151
  --text-tertiary:rgb(10 10 10 / .6);
@@ -6,6 +6,18 @@
6
6
  --shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
7
7
  --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
8
8
  --ring-focus:0 0 0 2px var(--ring);
9
+
10
+ /* The t-shirt ramp. Named by size rather than by role, because that is how
11
+ every component library already asks for a shadow (and how @hanzo/brand
12
+ spells it). Alphas are heavier than Tailwind's defaults: on a near-black
13
+ ground a 10% black drop is invisible, so each rung is tuned to read on
14
+ --background. --shadow-2xl and --shadow-floating are the same rung. */
15
+ --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
16
+ --shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
17
+ --shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
18
+ --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
19
+ --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
20
+ --shadow-2xl:var(--shadow-floating);
9
21
  /* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
10
22
  --glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
11
23
  --glow-hero-blur:120px;
package/tokens/fonts.css CHANGED
@@ -1,7 +1,34 @@
1
1
  /* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
2
- hanzo.ai loads them via next/font/google (Geist, Geist_Mono); here they come
3
- from the same Google Fonts source, so the rendered face is identical. */
4
- @import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap");
2
+ SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
3
+ variable files, 141 KB total, SIL OFL-1.1 see assets/fonts/LICENSE-Geist.txt).
4
+
5
+ Why self-hosted rather than @import from fonts.googleapis.com:
6
+ - A sign-in page must not make a third-party request. hanzoai/id refused to
7
+ import this file for exactly that reason, which split the token layer: id
8
+ took the colours and not the typeface. Self-hosting removes the reason, so
9
+ every surface can import styles.css unchanged.
10
+ - The @import was a render-blocking request to a host we do not control, on
11
+ the critical path of every surface, and it broke offline/air-gapped dev.
12
+ - One variable file per family replaces nine static weights, and it is fewer
13
+ bytes than the CSS-then-woff2 round trip Google served.
14
+
15
+ The url()s are relative to THIS file, so they resolve wherever the package is
16
+ mounted — node_modules, a CDN, a copied dist — with no configuration. */
17
+
18
+ @font-face{
19
+ font-family:"Geist";
20
+ src:url("../assets/fonts/Geist-Variable.woff2") format("woff2");
21
+ font-weight:100 900;
22
+ font-style:normal;
23
+ font-display:swap;
24
+ }
25
+ @font-face{
26
+ font-family:"Geist Mono";
27
+ src:url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
28
+ font-weight:100 900;
29
+ font-style:normal;
30
+ font-display:swap;
31
+ }
5
32
 
6
33
  :root{
7
34
  --font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;