@hanzo/design 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/check-tokens.mjs +37 -6
- package/scripts/gen-tokens.mjs +48 -0
- package/styles.css +497 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/design",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"packageManager": "pnpm@11.17.0",
|
|
5
5
|
"description": "Hanzo Design System \u2014 monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -68,4 +68,4 @@
|
|
|
68
68
|
"bin": {
|
|
69
69
|
"hanzo-design-lint": "./scripts/lint.mjs"
|
|
70
70
|
}
|
|
71
|
-
}
|
|
71
|
+
}
|
package/scripts/check-tokens.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// unimported token file resolves nothing, and a 1.66:1 focus ring looks fine to
|
|
4
4
|
// whoever shipped it. None of them can fail loudly on their own, so they fail
|
|
5
5
|
// here. Run via `npm test` (part of `build`).
|
|
6
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
9
|
|
|
@@ -16,16 +16,47 @@ let failures = 0
|
|
|
16
16
|
const fail = (msg) => { console.error(` FAIL ${msg}`); failures++ }
|
|
17
17
|
const pass = (msg) => console.log(` ok ${msg}`)
|
|
18
18
|
|
|
19
|
-
// ── 1. styles.css must
|
|
19
|
+
// ── 1. styles.css must CARRY every token file ────────────────────────────
|
|
20
20
|
// tokens/z.css was authored, exported and documented — and left out of
|
|
21
21
|
// styles.css, so the whole ladder resolved to nothing on every consumer.
|
|
22
|
+
//
|
|
23
|
+
// The entry point is now generated and FLATTENED, so this checks for the
|
|
24
|
+
// declarations themselves rather than for an @import naming the file. That is
|
|
25
|
+
// the stronger test and it is the one that was actually needed: the old shape
|
|
26
|
+
// passed for years while no bundler could resolve a single token, because a
|
|
27
|
+
// relative url() resolves against the CONSUMER's directory. Presence of the
|
|
28
|
+
// import proved nothing about presence of the tokens.
|
|
22
29
|
{
|
|
23
|
-
const entry = read(join(root, 'styles.css'))
|
|
30
|
+
const entry = strip(read(join(root, 'styles.css')))
|
|
24
31
|
const files = readdirSync(tokensDir).filter((f) => f.endsWith('.css'))
|
|
25
|
-
const missing = files.filter((f) =>
|
|
32
|
+
const missing = files.filter((f) => {
|
|
33
|
+
const decls = [...strip(read(join(tokensDir, f))).matchAll(/--([A-Za-z0-9-]+)\s*:/g)].map((m) => m[1])
|
|
34
|
+
// A file with no custom properties (pure element rules, e.g. base.css) is
|
|
35
|
+
// carried if one of its selectors made it across.
|
|
36
|
+
if (!decls.length) return !entry.includes(strip(read(join(tokensDir, f))).trim().split('\n')[0].trim())
|
|
37
|
+
return !decls.every((d) => entry.includes(`--${d}`))
|
|
38
|
+
})
|
|
26
39
|
missing.length
|
|
27
|
-
? fail(`styles.css does not
|
|
28
|
-
: pass(`styles.css
|
|
40
|
+
? fail(`styles.css does not carry the tokens from: ${missing.join(', ')}`)
|
|
41
|
+
: pass(`styles.css carries all ${files.length} token files, flattened`)
|
|
42
|
+
|
|
43
|
+
// And it must stay resolvable: an @import here is the exact defect above.
|
|
44
|
+
entry.includes('@import')
|
|
45
|
+
? fail('styles.css contains an @import — a consumer cannot resolve it (see gen-tokens.mjs)')
|
|
46
|
+
: pass('styles.css has no @import to resolve')
|
|
47
|
+
|
|
48
|
+
// Every asset it names must EXIST at the path it names, from the root the
|
|
49
|
+
// bundle now sits at. tokens/fonts.css points one directory up because it is
|
|
50
|
+
// authored one directory down; flattening rebases that, and a rebase that
|
|
51
|
+
// silently stopped happening would put the faces outside the package and fail
|
|
52
|
+
// every consumer's build on a missing module.
|
|
53
|
+
const urls = [...entry.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)]
|
|
54
|
+
.map((m) => m[1])
|
|
55
|
+
.filter((u) => !/^(data:|https?:|\/\/)/.test(u))
|
|
56
|
+
const broken = urls.filter((u) => !existsSync(join(root, u)))
|
|
57
|
+
broken.length
|
|
58
|
+
? broken.forEach((u) => fail(`styles.css references ${u}, which does not exist at the package root`))
|
|
59
|
+
: pass(`all ${urls.length} asset url()s resolve from the package root`)
|
|
29
60
|
}
|
|
30
61
|
|
|
31
62
|
// ── 2. every var() used inside the token layer must resolve ──────────────
|
package/scripts/gen-tokens.mjs
CHANGED
|
@@ -76,3 +76,51 @@ ts += `export type CssVarName = keyof typeof cssVars\n`
|
|
|
76
76
|
|
|
77
77
|
writeFileSync(join(root, 'src', 'tokens.gen.ts'), ts)
|
|
78
78
|
console.log(`gen-tokens: wrote src/tokens.gen.ts — ${flat.length} tokens across ${Object.keys(groups).length} groups`)
|
|
79
|
+
|
|
80
|
+
// ── styles.css, FLATTENED ────────────────────────────────────────────────
|
|
81
|
+
// The entry point says "import THIS one file", and for a long time no bundler
|
|
82
|
+
// could. It was a list of `@import url("tokens/*.css")`, and a relative url
|
|
83
|
+
// resolves against the file doing the importing — so a consumer's
|
|
84
|
+
// `@import "@hanzo/design/styles.css"` sent webpack looking for
|
|
85
|
+
// `<their app>/tokens/fonts.css` and the build died on a module it never had.
|
|
86
|
+
// Nested imports also have to precede every other rule, which the spec then
|
|
87
|
+
// invalidates the moment the consumer imports anything before them; browsers
|
|
88
|
+
// drop them silently, every token resolves to nothing, and `border-border`
|
|
89
|
+
// falls back to currentColor — a stark white hairline on black.
|
|
90
|
+
//
|
|
91
|
+
// Both failures are the same fact: an @import list is not a stylesheet a
|
|
92
|
+
// consumer can use. So the entry point is now the CONTENT, concatenated in the
|
|
93
|
+
// same order, with no imports to resolve and no order to get wrong. That is why
|
|
94
|
+
// nothing consumed this package and every app hand-rolled its own palette.
|
|
95
|
+
//
|
|
96
|
+
// Authoring does not change: tokens/*.css stays the source of truth and this is
|
|
97
|
+
// its build product, exactly like tokens.gen.ts.
|
|
98
|
+
const banner = `/* Hanzo Design System — the entry point. Import THIS one file.
|
|
99
|
+
*
|
|
100
|
+
* AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
|
|
101
|
+
* Edit the token in tokens/<group>.css and re-run "npm run gen".
|
|
102
|
+
*
|
|
103
|
+
* Flattened deliberately: a consumer's bundler resolves a nested url() against
|
|
104
|
+
* ITS OWN directory, not ours, so an @import list here is a build error in
|
|
105
|
+
* every app that follows the instruction above. Nested imports must also
|
|
106
|
+
* precede all other rules, which the spec invalidates the moment the consumer
|
|
107
|
+
* imports anything first — browsers then drop them silently and every token
|
|
108
|
+
* resolves to nothing. The content is inlined in the order the groups were
|
|
109
|
+
* always imported in.
|
|
110
|
+
*/
|
|
111
|
+
`
|
|
112
|
+
// Hoisting a file from tokens/ to the package root moves what its relative
|
|
113
|
+
// url()s point at. tokens/fonts.css says `../assets/fonts/Geist-Variable.woff2`
|
|
114
|
+
// — correct from tokens/, one directory too high from the root, where it lands
|
|
115
|
+
// outside the package entirely and every consumer's build fails on a missing
|
|
116
|
+
// module. Rebasing is therefore part of flattening, not an afterthought: the
|
|
117
|
+
// bundle is one level shallower, so `../` becomes `./`.
|
|
118
|
+
const rebase = (css) =>
|
|
119
|
+
css.replace(/url\((\s*['"]?)\.\.\/(?!\.)/g, 'url($1./')
|
|
120
|
+
|
|
121
|
+
const bundle = banner + FILES.map((f) => {
|
|
122
|
+
const css = rebase(readFileSync(join(tokensDir, `${f}.css`), 'utf8').trim())
|
|
123
|
+
return `\n/* ── tokens/${f}.css ─────────────────────────────────────── */\n${css}\n`
|
|
124
|
+
}).join('')
|
|
125
|
+
writeFileSync(join(root, 'styles.css'), bundle)
|
|
126
|
+
console.log(`gen-tokens: wrote styles.css — ${FILES.length} token groups, flattened`)
|
package/styles.css
CHANGED
|
@@ -1,15 +1,497 @@
|
|
|
1
|
-
/* Hanzo Design System —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
/* Hanzo Design System — the entry point. Import THIS one file.
|
|
2
|
+
*
|
|
3
|
+
* AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
|
|
4
|
+
* Edit the token in tokens/<group>.css and re-run "npm run gen".
|
|
5
|
+
*
|
|
6
|
+
* Flattened deliberately: a consumer's bundler resolves a nested url() against
|
|
7
|
+
* ITS OWN directory, not ours, so an @import list here is a build error in
|
|
8
|
+
* every app that follows the instruction above. Nested imports must also
|
|
9
|
+
* precede all other rules, which the spec invalidates the moment the consumer
|
|
10
|
+
* imports anything first — browsers then drop them silently and every token
|
|
11
|
+
* resolves to nothing. The content is inlined in the order the groups were
|
|
12
|
+
* always imported in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* ── tokens/colors.css ─────────────────────────────────────── */
|
|
16
|
+
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
|
|
17
|
+
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
|
|
18
|
+
hanzo.ai's CSS variables exactly, so code copies over 1:1.
|
|
19
|
+
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
|
|
20
|
+
|
|
21
|
+
:root{
|
|
22
|
+
/* ——— base neutral ladder ——— */
|
|
23
|
+
--neutral-50:#FAFAFA;
|
|
24
|
+
--neutral-100:#F5F5F5;
|
|
25
|
+
--neutral-200:#E5E5E5;
|
|
26
|
+
--neutral-300:#D4D4D4;
|
|
27
|
+
--neutral-400:#A3A3A3;
|
|
28
|
+
--neutral-500:#737373;
|
|
29
|
+
--neutral-600:#525252;
|
|
30
|
+
--neutral-700:#404040;
|
|
31
|
+
--neutral-800:#262626;
|
|
32
|
+
--neutral-900:#171717;
|
|
33
|
+
--neutral-950:#0A0A0A;
|
|
34
|
+
--pure-black:#000000;
|
|
35
|
+
--pure-white:#FFFFFF;
|
|
36
|
+
/* Press-kit brand constants (public/press/hanzo/README.md). */
|
|
37
|
+
--hanzo-black:#0A0A0B;
|
|
38
|
+
--hanzo-white:#FFFFFF;
|
|
39
|
+
|
|
40
|
+
/* ——— the opacity ladder: the real palette ——— */
|
|
41
|
+
--white-05:rgb(255 255 255 / .05);
|
|
42
|
+
--white-10:rgb(255 255 255 / .10);
|
|
43
|
+
--white-15:rgb(255 255 255 / .15);
|
|
44
|
+
--white-20:rgb(255 255 255 / .20);
|
|
45
|
+
--white-30:rgb(255 255 255 / .30);
|
|
46
|
+
--white-40:rgb(255 255 255 / .40);
|
|
47
|
+
--white-60:rgb(255 255 255 / .60);
|
|
48
|
+
--white-80:rgb(255 255 255 / .80);
|
|
49
|
+
|
|
50
|
+
/* ——— semantic aliases (dark, the default) ——— */
|
|
51
|
+
--background:#000000;
|
|
52
|
+
--foreground:#ededed;
|
|
53
|
+
--card:#0a0a0a;
|
|
54
|
+
--card-foreground:#f5f5f5;
|
|
55
|
+
--popover:#0a0a0a;
|
|
56
|
+
--popover-foreground:#f5f5f5;
|
|
57
|
+
--primary:#ffffff;
|
|
58
|
+
--primary-foreground:#000000;
|
|
59
|
+
--secondary:#1a1a1a;
|
|
60
|
+
--secondary-foreground:#f5f5f5;
|
|
61
|
+
--muted:#101010;
|
|
62
|
+
--muted-foreground:#888888;
|
|
63
|
+
--accent:#1a1a1a;
|
|
64
|
+
--accent-foreground:#f5f5f5;
|
|
65
|
+
--destructive:#666666;
|
|
66
|
+
--destructive-foreground:#f5f5f5;
|
|
67
|
+
--border:#1f1f1f;
|
|
68
|
+
--input:#1f1f1f;
|
|
69
|
+
/* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
|
|
70
|
+
3:1 against every surface it can land on. --neutral-500 is the only rung on
|
|
71
|
+
this ladder that clears 3:1 on all of them — #000000, #0a0a0a, #101010,
|
|
72
|
+
#1a1a1a, AND the light theme's #ffffff/#f5f5f5 — so one value serves both
|
|
73
|
+
themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
|
|
74
|
+
--ring:var(--neutral-500);
|
|
75
|
+
--brand:#e4e4e7;
|
|
76
|
+
--brand-foreground:#09090b;
|
|
77
|
+
--brand-muted:#a3a3a3;
|
|
78
|
+
--black:#000000;
|
|
79
|
+
--white:#f5f5f5;
|
|
80
|
+
|
|
81
|
+
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
|
|
82
|
+
--surface-page:var(--background);
|
|
83
|
+
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
|
|
84
|
+
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
|
|
85
|
+
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
|
|
86
|
+
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
|
|
87
|
+
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
|
|
88
|
+
--surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
|
|
89
|
+
/* Boundaries come in two kinds and they are NOT interchangeable.
|
|
90
|
+
DECORATIVE (--border, --border-hairline, --border-card): separates content;
|
|
91
|
+
WCAG imposes no ratio. Keep them quiet.
|
|
92
|
+
PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
|
|
93
|
+
switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
|
|
94
|
+
Reach for --border-strong whenever the boundary IS the affordance. */
|
|
95
|
+
--border-hairline:var(--neutral-800);
|
|
96
|
+
--border-card:var(--white-10);
|
|
97
|
+
--border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
|
|
98
|
+
|
|
99
|
+
/* ——— the numeric surface ladder ——— */
|
|
100
|
+
/* Aliases onto the semantic canvases above, so a brand fork that retunes
|
|
101
|
+
--card/--muted/--secondary retunes the ladder with it and the light theme
|
|
102
|
+
inverts for free. Ascending lift: 0 is the page, 3 is a hovered control. */
|
|
103
|
+
--surface-0:var(--background);
|
|
104
|
+
--surface-1:var(--card);
|
|
105
|
+
--surface-2:var(--muted);
|
|
106
|
+
--surface-3:var(--secondary);
|
|
107
|
+
|
|
108
|
+
/* ——— text ranks ——— */
|
|
109
|
+
--text-primary:var(--pure-white);
|
|
110
|
+
--text-secondary:var(--white-80);
|
|
111
|
+
--text-tertiary:var(--white-60);
|
|
112
|
+
--text-helper:var(--muted-foreground);
|
|
113
|
+
--text-disabled:var(--white-30);
|
|
114
|
+
|
|
115
|
+
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
|
|
116
|
+
--state-error:#ef4444; /* red-500 — destructive / blocking error */
|
|
117
|
+
--state-error-text:#fca5a5; /* red-300 */
|
|
118
|
+
--state-error-bg:rgb(239 68 68 / .1);
|
|
119
|
+
--state-online:#4ade80; /* green-400 — live status dot */
|
|
120
|
+
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
|
|
121
|
+
--chrome-dot-red:rgb(239 68 68 / .6);
|
|
122
|
+
--chrome-dot-yellow:rgb(234 179 8 / .6);
|
|
123
|
+
--chrome-dot-green:rgb(34 197 94 / .6);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
|
|
127
|
+
.light{
|
|
128
|
+
--background:#ffffff;
|
|
129
|
+
--foreground:#0a0a0a;
|
|
130
|
+
--card:#f5f5f5;
|
|
131
|
+
--card-foreground:#0a0a0a;
|
|
132
|
+
--popover:#ffffff;
|
|
133
|
+
--popover-foreground:#0a0a0a;
|
|
134
|
+
--primary:#0a0a0a;
|
|
135
|
+
--primary-foreground:#ffffff;
|
|
136
|
+
--secondary:#f5f5f5;
|
|
137
|
+
--secondary-foreground:#0a0a0a;
|
|
138
|
+
--muted:#f5f5f5;
|
|
139
|
+
--muted-foreground:#525252;
|
|
140
|
+
--accent:#f5f5f5;
|
|
141
|
+
--accent-foreground:#0a0a0a;
|
|
142
|
+
--destructive:#999999;
|
|
143
|
+
--destructive-foreground:#ffffff;
|
|
144
|
+
--border:#e5e5e5;
|
|
145
|
+
--input:#e5e5e5;
|
|
146
|
+
/* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
|
|
147
|
+
focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
|
|
148
|
+
#f5f5f5, so ONE value is conformant in both themes. */
|
|
149
|
+
--ring:var(--neutral-500);
|
|
150
|
+
--black:#0a0a0a;
|
|
151
|
+
--white:#ffffff;
|
|
152
|
+
--surface-card:#f5f5f5;
|
|
153
|
+
--surface-card-emphasis:#ffffff;
|
|
154
|
+
--surface-card-quiet:#fafafa;
|
|
155
|
+
--surface-overlay:rgb(255 255 255 / .95);
|
|
156
|
+
--surface-header:rgb(255 255 255 / .8);
|
|
157
|
+
--surface-scrim:rgb(0 0 0 / .5);
|
|
158
|
+
--border-hairline:var(--neutral-200);
|
|
159
|
+
--border-card:rgb(0 0 0 / .1);
|
|
160
|
+
--border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
|
|
161
|
+
/* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
|
|
162
|
+
here (1.00:1). Anything that needs a visible edge in BOTH themes must use
|
|
163
|
+
--border-strong, never a --white-* rung. */
|
|
164
|
+
--text-primary:var(--neutral-950);
|
|
165
|
+
--text-secondary:rgb(10 10 10 / .8);
|
|
166
|
+
--text-tertiary:rgb(10 10 10 / .6);
|
|
167
|
+
--text-disabled:rgb(10 10 10 / .3);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ── tokens/typography.css ─────────────────────────────────────── */
|
|
171
|
+
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
|
|
172
|
+
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
|
|
173
|
+
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
|
|
174
|
+
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
|
|
175
|
+
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
|
|
176
|
+
on :root to retune density on demand. */
|
|
177
|
+
:root{
|
|
178
|
+
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
|
|
179
|
+
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
|
|
180
|
+
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
|
|
181
|
+
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
|
|
182
|
+
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
|
|
183
|
+
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
|
|
184
|
+
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
|
|
185
|
+
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
|
|
186
|
+
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
|
|
187
|
+
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
|
|
188
|
+
--text-7xl:4rem; --leading-7xl:1; /* 64px */
|
|
189
|
+
--text-8xl:5.25rem; --leading-8xl:1; /* 84px */
|
|
190
|
+
--text-9xl:7rem; --leading-9xl:1; /* 112px */
|
|
191
|
+
|
|
192
|
+
/* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
|
|
193
|
+
Hanzo Studio address the ramp as --font-size-*; both names are one value, so
|
|
194
|
+
a component written against either resolves here. --text-* is canonical. */
|
|
195
|
+
--font-size-xs:var(--text-xs);
|
|
196
|
+
--font-size-sm:var(--text-sm);
|
|
197
|
+
--font-size-base:var(--text-base);
|
|
198
|
+
--font-size-lg:var(--text-lg);
|
|
199
|
+
--font-size-xl:var(--text-xl);
|
|
200
|
+
--font-size-2xl:var(--text-2xl);
|
|
201
|
+
--font-size-3xl:var(--text-3xl);
|
|
202
|
+
--font-size-4xl:var(--text-4xl);
|
|
203
|
+
--font-size-5xl:var(--text-5xl);
|
|
204
|
+
--font-size-6xl:var(--text-6xl);
|
|
205
|
+
--font-size-7xl:var(--text-7xl);
|
|
206
|
+
--font-size-8xl:var(--text-8xl);
|
|
207
|
+
--font-size-9xl:var(--text-9xl);
|
|
208
|
+
|
|
209
|
+
--weight-normal:400;
|
|
210
|
+
--weight-medium:500;
|
|
211
|
+
--weight-semibold:600;
|
|
212
|
+
--weight-bold:700;
|
|
213
|
+
|
|
214
|
+
--tracking-tight:-0.025em;
|
|
215
|
+
--tracking-normal:0em;
|
|
216
|
+
--tracking-wide:0.025em;
|
|
217
|
+
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
|
|
218
|
+
|
|
219
|
+
--leading-none:1;
|
|
220
|
+
--leading-tight:1.25;
|
|
221
|
+
--leading-snug:1.375;
|
|
222
|
+
--leading-normal:1.5;
|
|
223
|
+
--leading-relaxed:1.625;
|
|
224
|
+
--leading-golden:1.618;
|
|
225
|
+
|
|
226
|
+
/* named roles */
|
|
227
|
+
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
|
|
228
|
+
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
|
|
229
|
+
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
|
|
230
|
+
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
|
|
231
|
+
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
|
|
232
|
+
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
|
|
233
|
+
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
|
|
234
|
+
--type-eyebrow:600 0.625rem/1 var(--font-sans);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ── tokens/spacing.css ─────────────────────────────────────── */
|
|
238
|
+
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
|
|
239
|
+
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
|
|
240
|
+
reference) — use it for editorial layouts, not for component padding. */
|
|
241
|
+
:root{
|
|
242
|
+
--space-0:0;
|
|
243
|
+
--space-1:0.25rem;
|
|
244
|
+
--space-2:0.5rem;
|
|
245
|
+
--space-3:0.75rem;
|
|
246
|
+
--space-4:1rem;
|
|
247
|
+
--space-5:1.25rem;
|
|
248
|
+
--space-6:1.5rem;
|
|
249
|
+
--space-8:2rem;
|
|
250
|
+
--space-10:2.5rem;
|
|
251
|
+
--space-12:3rem;
|
|
252
|
+
--space-14:3.5rem;
|
|
253
|
+
--space-16:4rem;
|
|
254
|
+
--space-20:5rem;
|
|
255
|
+
--space-24:6rem;
|
|
256
|
+
--space-32:8rem;
|
|
257
|
+
|
|
258
|
+
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
|
|
259
|
+
--golden-1:0.25rem;
|
|
260
|
+
--golden-2:0.405rem;
|
|
261
|
+
--golden-3:0.654rem;
|
|
262
|
+
--golden-4:1.059rem;
|
|
263
|
+
--golden-5:1.713rem;
|
|
264
|
+
--golden-6:2.772rem;
|
|
265
|
+
--golden-7:4.487rem;
|
|
266
|
+
--golden-8:7.26rem;
|
|
267
|
+
--golden-9:11.749rem;
|
|
268
|
+
--golden-split:38.2% 61.8%; /* @kind other */
|
|
269
|
+
|
|
270
|
+
/* layout rules (DESIGN.md §1.3) */
|
|
271
|
+
--container-max:80rem; /* max-w-7xl — grids */
|
|
272
|
+
--container-prose:48rem; /* max-w-3xl — centered text */
|
|
273
|
+
--container-wide:72rem; /* max-w-6xl — landing sections */
|
|
274
|
+
--gutter:1rem; /* px-4 */
|
|
275
|
+
--gutter-sm:1.5rem; /* sm:px-6 */
|
|
276
|
+
--gutter-lg:2rem; /* lg:px-8 */
|
|
277
|
+
--section-y:4rem; /* py-16 — content sections */
|
|
278
|
+
--section-y-lg:6rem; /* py-24 — landing sections */
|
|
279
|
+
--hero-y:5rem; /* py-20 … */
|
|
280
|
+
--hero-y-lg:8rem; /* … lg:py-32 */
|
|
281
|
+
--header-height:4rem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ── tokens/grid.css ─────────────────────────────────────── */
|
|
285
|
+
/* Grid — one definition of the field every Hanzo surface lays out on.
|
|
286
|
+
|
|
287
|
+
Derived, not invented. Across hanzo.ai and hanzo.app the grid written by hand
|
|
288
|
+
is overwhelmingly ONE grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` with a
|
|
289
|
+
1.5rem gutter (240 / 197 / 127 / 185 occurrences on hanzo.ai alone, and the
|
|
290
|
+
same shape leads on app). 1, 2, 3 and 4 columns account for 97% of every
|
|
291
|
+
grid-cols- in both repos, and all four divide 12 cleanly — so 12 is the base
|
|
292
|
+
the spans are cut from, and the long tail (5, 6, 7, 8, 12) still lands on it.
|
|
293
|
+
|
|
294
|
+
Containers, gutters and section rhythm are NOT restated here: spacing.css
|
|
295
|
+
already owns --container-max / --container-prose / --container-wide /
|
|
296
|
+
--gutter* / --section-y*, and a grid that redeclared them would be a second
|
|
297
|
+
answer to a settled question. This file adds only what a grid knows that
|
|
298
|
+
spacing does not — how many columns, how far apart, and where it reflows. */
|
|
299
|
+
:root{
|
|
300
|
+
/* The base. Spans are cut from twelve because 1/2/3/4/6 all divide it. */
|
|
301
|
+
--grid-columns:12;
|
|
302
|
+
|
|
303
|
+
/* Gutter. --grid-gap is the measured card-grid default (gap-6 leads hanzo.ai
|
|
304
|
+
at 185 uses); tight and loose are the neighbouring steps that cover app's
|
|
305
|
+
gap-4 (45) and the wide editorial grids (gap-8, 75). All three reference the
|
|
306
|
+
space ramp — the grid does not get its own private set of distances. */
|
|
307
|
+
--grid-gap:var(--space-6);
|
|
308
|
+
--grid-gap-tight:var(--space-4);
|
|
309
|
+
--grid-gap-loose:var(--space-8);
|
|
310
|
+
|
|
311
|
+
/* Reflow points, byte-identical to the Tailwind v4 defaults these surfaces
|
|
312
|
+
already compile against. Same NAMES and same VALUES on purpose: the utility
|
|
313
|
+
layer and anything reading tokens then resolve one value, not two that agree
|
|
314
|
+
by luck. md and lg carry ~85% of every breakpoint prefix in use; xl is 44
|
|
315
|
+
occurrences across both repos and 2xl is 1. */
|
|
316
|
+
--breakpoint-sm:40rem;
|
|
317
|
+
--breakpoint-md:48rem;
|
|
318
|
+
--breakpoint-lg:64rem;
|
|
319
|
+
--breakpoint-xl:80rem;
|
|
320
|
+
--breakpoint-2xl:96rem;
|
|
321
|
+
|
|
322
|
+
/* Intrinsic card grids: state ONE track minimum instead of three column counts
|
|
323
|
+
at three breakpoints. `repeat(auto-fit, minmax(var(--grid-card-min), 1fr))`
|
|
324
|
+
reflows 1 -> 2 -> 3 at the same widths the explicit recipe does, and it does
|
|
325
|
+
it from the space available rather than from the viewport — so a card grid
|
|
326
|
+
inside a sidebar behaves correctly, which the breakpoint version cannot.
|
|
327
|
+
|
|
328
|
+
18rem is chosen, not rounded to: inside --container-max less --gutter-lg on
|
|
329
|
+
both sides, lg (64rem) leaves ~60rem, where 3 tracks need 3x18 + 2x1.5 =
|
|
330
|
+
57rem and 4 would need 76.5rem; md (48rem) leaves ~44rem, where 2 tracks
|
|
331
|
+
need 37.5rem and 3 would need 57rem. The measured 1/2/3 ladder falls out. */
|
|
332
|
+
--grid-card-min:18rem;
|
|
333
|
+
--grid-card-min-wide:24rem;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ── tokens/radius.css ─────────────────────────────────────── */
|
|
337
|
+
:root{
|
|
338
|
+
--radius:0.5rem; /* the base token (globals.css) */
|
|
339
|
+
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
|
|
340
|
+
--radius-md:0.5rem;
|
|
341
|
+
--radius-lg:0.75rem; /* rounded-xl — cards */
|
|
342
|
+
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
|
|
343
|
+
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
|
|
344
|
+
--radius-composer:28px; /* the chat composer, exactly 28px */
|
|
345
|
+
--radius-full:9999px; /* pills, CTAs, avatars, badges */
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ── tokens/elevation.css ─────────────────────────────────────── */
|
|
349
|
+
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
|
|
350
|
+
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
|
|
351
|
+
floating surfaces) plus the ambient radial glow used behind heroes. */
|
|
352
|
+
:root{
|
|
353
|
+
--shadow-none:none;
|
|
354
|
+
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
|
|
355
|
+
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
|
|
356
|
+
--ring-focus:0 0 0 2px var(--ring);
|
|
357
|
+
|
|
358
|
+
/* The t-shirt ramp. Named by size rather than by role, because that is how
|
|
359
|
+
every component library already asks for a shadow (and how @hanzo/brand
|
|
360
|
+
spells it). Alphas are heavier than Tailwind's defaults: on a near-black
|
|
361
|
+
ground a 10% black drop is invisible, so each rung is tuned to read on
|
|
362
|
+
--background. --shadow-2xl and --shadow-floating are the same rung. */
|
|
363
|
+
--shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
|
|
364
|
+
--shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
|
|
365
|
+
--shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
|
|
366
|
+
--shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
|
|
367
|
+
--shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
|
|
368
|
+
--shadow-2xl:var(--shadow-floating);
|
|
369
|
+
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
|
|
370
|
+
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
|
|
371
|
+
--glow-hero-blur:120px;
|
|
372
|
+
/* Card top-corner sheen used on the story cards. */
|
|
373
|
+
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
|
|
374
|
+
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
|
|
375
|
+
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
|
|
376
|
+
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
|
|
377
|
+
/* Section-top protection gradient (hero overlays). */
|
|
378
|
+
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* ── tokens/motion.css ─────────────────────────────────────── */
|
|
382
|
+
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
|
|
383
|
+
No springs, no bounce, no parallax, no autoplay carousels. */
|
|
384
|
+
:root{
|
|
385
|
+
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
|
|
386
|
+
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
|
|
387
|
+
--duration-slow:400ms; /* @kind other */ /* hero element entry */
|
|
388
|
+
--duration-slower:500ms; /* @kind other */ /* section entry */
|
|
389
|
+
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
|
|
390
|
+
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
|
|
391
|
+
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
|
|
392
|
+
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
|
|
393
|
+
--entry-rise:16px; /* hero y-offset */
|
|
394
|
+
--entry-rise-lg:24px; /* card y-offset */
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
398
|
+
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
|
399
|
+
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
|
|
400
|
+
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
|
|
401
|
+
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
|
|
402
|
+
|
|
403
|
+
@media (prefers-reduced-motion:reduce){
|
|
404
|
+
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ── tokens/z.css ─────────────────────────────────────── */
|
|
408
|
+
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
|
|
409
|
+
magic number, so a dropdown opened from the fixed header always sits above it
|
|
410
|
+
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow.
|
|
411
|
+
|
|
412
|
+
Rungs are 100 apart so a surface can slot a one-off BETWEEN two roles
|
|
413
|
+
(calc(var(--z-modal) + 1)) without inventing a new decade. */
|
|
414
|
+
:root{
|
|
415
|
+
--z-base:0;
|
|
416
|
+
--z-raised:10; /* hover-lifted cards, sticky table headers */
|
|
417
|
+
--z-sticky:200; /* pinned section rails */
|
|
418
|
+
--z-header:300; /* the fixed site header */
|
|
419
|
+
--z-dropdown:400; /* menus, selects, comboboxes */
|
|
420
|
+
--z-overlay:500; /* dialog / sheet scrim */
|
|
421
|
+
--z-modal:600; /* dialogs, sheets, command palette */
|
|
422
|
+
--z-popover:700; /* popovers, tooltips (also when anchored in modals) */
|
|
423
|
+
--z-toast:800; /* toasts / notifications — always on top */
|
|
424
|
+
|
|
425
|
+
/* @hanzo/brand spells the top two rungs --z-tooltip and --z-notification.
|
|
426
|
+
Same rungs, so a component written against either vocabulary stacks
|
|
427
|
+
identically. --z-popover / --z-toast are canonical. */
|
|
428
|
+
--z-tooltip:var(--z-popover);
|
|
429
|
+
--z-notification:var(--z-toast);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* ── tokens/fonts.css ─────────────────────────────────────── */
|
|
433
|
+
/* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
|
|
434
|
+
SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
|
|
435
|
+
variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Geist.txt).
|
|
436
|
+
|
|
437
|
+
Why self-hosted rather than @import from fonts.googleapis.com:
|
|
438
|
+
- A sign-in page must not make a third-party request. hanzoai/id refused to
|
|
439
|
+
import this file for exactly that reason, which split the token layer: id
|
|
440
|
+
took the colours and not the typeface. Self-hosting removes the reason, so
|
|
441
|
+
every surface can import styles.css unchanged.
|
|
442
|
+
- The @import was a render-blocking request to a host we do not control, on
|
|
443
|
+
the critical path of every surface, and it broke offline/air-gapped dev.
|
|
444
|
+
- One variable file per family replaces nine static weights, and it is fewer
|
|
445
|
+
bytes than the CSS-then-woff2 round trip Google served.
|
|
446
|
+
|
|
447
|
+
The url()s are relative to THIS file, so they resolve wherever the package is
|
|
448
|
+
mounted — node_modules, a CDN, a copied dist — with no configuration. */
|
|
449
|
+
|
|
450
|
+
@font-face{
|
|
451
|
+
font-family:"Geist";
|
|
452
|
+
src:url("./assets/fonts/Geist-Variable.woff2") format("woff2");
|
|
453
|
+
font-weight:100 900;
|
|
454
|
+
font-style:normal;
|
|
455
|
+
font-display:swap;
|
|
456
|
+
}
|
|
457
|
+
@font-face{
|
|
458
|
+
font-family:"Geist Mono";
|
|
459
|
+
src:url("./assets/fonts/GeistMono-Variable.woff2") format("woff2");
|
|
460
|
+
font-weight:100 900;
|
|
461
|
+
font-style:normal;
|
|
462
|
+
font-display:swap;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
:root{
|
|
466
|
+
--font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;
|
|
467
|
+
--font-display:var(--font-sans);
|
|
468
|
+
--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
|
|
469
|
+
--font-serif:Georgia,serif;
|
|
470
|
+
/* hanzo.ai sets these OpenType features on <body>. */
|
|
471
|
+
--font-feature-settings:"ss01","ss02","cv01","cv02","cv03";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* ── tokens/base.css ─────────────────────────────────────── */
|
|
475
|
+
/* Minimal element defaults so specimen cards and kits inherit the brand without
|
|
476
|
+
a utility framework. Components carry their own styles inline. */
|
|
477
|
+
*{box-sizing:border-box;border-color:var(--border)}
|
|
478
|
+
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
|
|
479
|
+
/* Each font-family carries the stack as a literal fallback. --font-sans lives in
|
|
480
|
+
tokens/fonts.css, which a surface may legitimately import separately; without
|
|
481
|
+
the fallback an unresolved var() makes font-family invalid and the UA drops to
|
|
482
|
+
its SERIF default — the whole console silently rendered in Times. */
|
|
483
|
+
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)}
|
|
484
|
+
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)}
|
|
485
|
+
p{margin:0;text-wrap:pretty}
|
|
486
|
+
code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
|
|
487
|
+
a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
|
|
488
|
+
/* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
|
|
489
|
+
bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
|
|
490
|
+
blanket `a:hover` underlined every one of them on every surface that imports
|
|
491
|
+
these tokens. Scope it to the elements that actually carry prose; a link that
|
|
492
|
+
is a component states its own hover. The old rule also re-set `color` to the
|
|
493
|
+
value `a` already has — a no-op that only served to outrank a component's own
|
|
494
|
+
hover colour. */
|
|
495
|
+
:is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
|
|
496
|
+
:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
|
|
497
|
+
::selection{background:var(--white-20);color:#fff}
|