@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 +18 -3
- package/components/core/HanzoLogo.jsx +1 -1
- package/components/overlays/Dialog.jsx +1 -1
- package/components/overlays/DropdownMenu.jsx +1 -1
- package/components/overlays/Sheet.jsx +1 -1
- package/components/overlays/Toaster.jsx +1 -1
- package/dist/tokens.gen.d.ts +2 -0
- package/dist/tokens.gen.d.ts.map +1 -1
- package/dist/tokens.gen.js +2 -0
- package/docs/README.md +1 -1
- package/docs/integrate.md +4 -4
- package/package.json +13 -6
- package/prompts/README.md +6 -1
- package/prompts/system.md +1 -1
- package/scripts/check-tokens.mjs +93 -0
- package/scripts/gen-tokens.mjs +78 -0
- package/scripts/lint.mjs +179 -0
- package/skills/design-system/SKILL.md +103 -0
- package/src/tokens.gen.ts +2 -0
- package/tokens/colors.css +2 -0
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
|
-
`@
|
|
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 "@
|
|
53
|
-
import { HanzoLogo } from "@
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
package/dist/tokens.gen.d.ts
CHANGED
|
@@ -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)";
|
package/dist/tokens.gen.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.gen.d.ts","sourceRoot":"","sources":["../src/tokens.gen.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,eAAO,MAAM,MAAM
|
|
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"}
|
package/dist/tokens.gen.js
CHANGED
|
@@ -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
package/docs/integrate.md
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
## 1. Install
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
pnpm add @
|
|
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 "@
|
|
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 "@
|
|
58
|
-
import { HanzoLogo } from "@
|
|
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.
|
|
4
|
-
"description": "Hanzo Design System
|
|
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 "@
|
|
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 "@
|
|
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`)
|
package/scripts/lint.mjs
ADDED
|
@@ -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 */
|