@hanzo/design 0.3.5 → 0.4.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/tailwind.css ADDED
@@ -0,0 +1,787 @@
1
+ /* Hanzo Design System — Tailwind v4 bridge. The ONE import a Tailwind app adds.
2
+ *
3
+ * AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
4
+ *
5
+ * @import "tailwindcss";
6
+ * @import "@hanzo/design/tailwind.css";
7
+ *
8
+ * It carries the tokens AND maps them onto Tailwind's utility namespace, so
9
+ * `bg-background`, `text-muted-foreground` and `border-border` resolve to the
10
+ * system's values with nothing to wire up by hand.
11
+ *
12
+ * Import it AFTER tailwindcss: @theme has to come after the framework it
13
+ * extends, and the element defaults are layered so your utilities still win.
14
+ *
15
+ * "inline" is deliberate — it compiles `border-border` to var(--border) rather
16
+ * than to that variable's build-time value, so switching .light at runtime moves
17
+ * the UI and not merely the variables.
18
+ *
19
+ * The tokens are inlined below rather than @imported for the reason styles.css
20
+ * is flattened: an @import must precede every other rule, so importing this
21
+ * after tailwindcss would invalidate it and the browser would drop the whole
22
+ * token layer without a word.
23
+ */
24
+ /* Hanzo Design System — the entry point. Import THIS one file.
25
+ *
26
+ * AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
27
+ * Edit the token in tokens/<group>.css and re-run "npm run gen".
28
+ *
29
+ * Flattened deliberately: a consumer's bundler resolves a nested url() against
30
+ * ITS OWN directory, not ours, so an @import list here is a build error in
31
+ * every app that follows the instruction above. Nested imports must also
32
+ * precede all other rules, which the spec invalidates the moment the consumer
33
+ * imports anything first — browsers then drop them silently and every token
34
+ * resolves to nothing. The content is inlined in the order the groups were
35
+ * always imported in.
36
+ */
37
+
38
+ /* ── tokens/colors.css ─────────────────────────────────────── */
39
+ /* Hanzo is monochrome. One hue rendered through an opacity ladder.
40
+ Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
41
+ hanzo.ai's CSS variables exactly, so code copies over 1:1.
42
+ DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark").
43
+
44
+ The dark palette is derived from the chat surface in hanzoai/extension
45
+ (packages/browser/src/sidebar.css), which is the reference aesthetic: a
46
+ #0a0a0a ground, #fafafa ink, and boundaries drawn as LOW-ALPHA HAIRLINES.
47
+ Two properties of that reference are load-bearing and easy to lose:
48
+
49
+ 1. Borders are alpha, not hex. rgb(255 255 255 / .10) composites correctly on
50
+ #0a0a0a AND on #262626; a solid #1f1f1f reads on the page and vanishes the
51
+ moment it lands on a lifted surface — which is why anyone who needed a
52
+ visible edge reached past it for the CONTROL rung and got a wireframe.
53
+ 2. Surfaces lift by tiny steps (0a -> 0f -> 17 -> 26), and ink is a graded
54
+ ramp (#fafafa -> .78 -> .55 -> #a3a3a3), not one flat white. Pure #ffffff
55
+ on pure #000000 is 21:1 — the maximum-contrast pair that exists, and it
56
+ halates. #fafafa on #0a0a0a is 18.1:1: still far past AAA, visibly softer. */
57
+
58
+ :root{
59
+ color-scheme:dark;
60
+
61
+ /* ——— base neutral ladder ——— */
62
+ --neutral-50:#FAFAFA;
63
+ --neutral-100:#F5F5F5;
64
+ --neutral-200:#E5E5E5;
65
+ --neutral-300:#D4D4D4;
66
+ --neutral-400:#A3A3A3;
67
+ --neutral-500:#737373;
68
+ --neutral-600:#525252;
69
+ --neutral-700:#404040;
70
+ --neutral-800:#262626;
71
+ --neutral-900:#171717;
72
+ --neutral-950:#0A0A0A;
73
+ --pure-black:#000000;
74
+ --pure-white:#FFFFFF;
75
+ /* Press-kit brand constants (public/press/hanzo/README.md). */
76
+ --hanzo-black:#0A0A0B;
77
+ --hanzo-white:#FFFFFF;
78
+
79
+ /* ——— the opacity ladder: the real palette ——— */
80
+ /* Every rung below .30 is a HAIRLINE or a LIFT — the two things this system
81
+ draws with. The rungs are the reference's own values, not a rounded ramp. */
82
+ --white-05:rgb(255 255 255 / .05);
83
+ --white-06:rgb(255 255 255 / .06);
84
+ --white-08:rgb(255 255 255 / .08);
85
+ --white-10:rgb(255 255 255 / .10);
86
+ --white-15:rgb(255 255 255 / .15);
87
+ --white-16:rgb(255 255 255 / .16);
88
+ --white-20:rgb(255 255 255 / .20);
89
+ --white-22:rgb(255 255 255 / .22);
90
+ --white-30:rgb(255 255 255 / .30);
91
+ --white-40:rgb(255 255 255 / .40);
92
+ --white-55:rgb(255 255 255 / .55);
93
+ --white-60:rgb(255 255 255 / .60);
94
+ --white-78:rgb(255 255 255 / .78);
95
+ --white-80:rgb(255 255 255 / .80);
96
+
97
+ /* ——— semantic aliases (dark, the default) ——— */
98
+ --background:#0a0a0a;
99
+ --foreground:#fafafa;
100
+ --card:#0f0f0f;
101
+ --card-foreground:#fafafa;
102
+ --popover:#0f0f0f;
103
+ --popover-foreground:#fafafa;
104
+ --primary:#fafafa;
105
+ --primary-hover:#d4d4d4;
106
+ --primary-foreground:#0a0a0a;
107
+ --secondary:#262626;
108
+ --secondary-foreground:#fafafa;
109
+ --muted:#171717;
110
+ --muted-foreground:#a3a3a3;
111
+ --accent:#262626;
112
+ --accent-foreground:#fafafa;
113
+ --destructive:#666666;
114
+ --destructive-foreground:#fafafa;
115
+
116
+ /* ——— boundaries ———
117
+ THREE tiers, and the split is the whole point. The first two are DECORATIVE:
118
+ they separate content, WCAG imposes no ratio on them, and they must stay
119
+ quiet. The third is PERCEIVABLE: it is the edge that IS the affordance.
120
+
121
+ --border the hairline. Every card, panel, divider, list row.
122
+ --border-strong the SAME hairline, one step up. Hover, active, emphasis.
123
+ --border-control a control's edge — input, select, textarea, checkbox,
124
+ switch. Must clear 3:1 on every surface in both themes
125
+ (WCAG 1.4.11); check-tokens.mjs pins it.
126
+
127
+ Before this split there were only two rungs and they were an octave apart:
128
+ --border at #1f1f1f (1.27:1, invisible on anything lifted) and
129
+ --border-strong at --neutral-500 (4.43:1). Nothing sat between, so every
130
+ surface that wanted a border it could actually SEE reached for the control
131
+ rung — and a 1px mid-grey box around a card on near-black is a wireframe.
132
+ That is the "garish outlines" complaint, and it is fixed by having a middle.
133
+
134
+ A boundary that is WCAG-perceivable on a near-black ground IS a mid-grey;
135
+ that cannot be tuned away. So it is not tuned away — it is NAMED, so it can
136
+ only be spent where it is owed. "strong" is a degree word and will always be
137
+ read as one; "control" is a duty.
138
+
139
+ Which ladder a boundary is cut from follows from that, and is the whole
140
+ rule: DECORATIVE edges come off the ALPHA ladder, because they owe quiet
141
+ and they must composite over whatever surface they land on. CONTROL edges
142
+ come off the NEUTRAL ladder, because they owe a contrast ratio, and a ratio
143
+ you can only meet at a fixed value is not something to express as alpha.
144
+ --border-focus is therefore one NEUTRAL rung above --border-control, not an
145
+ alpha step: a focused field has to read brighter than a resting one, and
146
+ the resting one already sits at the WCAG floor. */
147
+ --border:var(--white-10);
148
+ --border-strong:var(--white-16);
149
+ --border-control:var(--neutral-500); /* 4.43:1 worst case — see --ring */
150
+ --border-focus:var(--neutral-400); /* one rung up: the field is focused */
151
+ --input:var(--border-control); /* shadcn `border-input` — a control */
152
+ /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
153
+ 3:1 against every surface it can land on. --neutral-500 is the only rung on
154
+ this ladder that clears 3:1 on all of them — #0a0a0a, #0f0f0f, #171717,
155
+ #262626, AND the light theme's #ffffff/#fafafa — so one value serves both
156
+ themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
157
+ --ring:var(--neutral-500);
158
+ --brand:#e4e4e7;
159
+ --brand-foreground:#09090b;
160
+ --brand-muted:#a3a3a3;
161
+ --black:#000000;
162
+ --white:#fafafa;
163
+ --selection:var(--white-20);
164
+
165
+ /* ——— glass: the lift ———
166
+ How a surface rises WITHOUT a block fill. An alpha-white wash composites
167
+ over whatever it lands on, so one value works on the page, on a card and
168
+ inside a popover. This is the mechanism behind the reference's depth. */
169
+ --glass:var(--white-05);
170
+ --glass-strong:var(--white-08);
171
+
172
+ /* ——— surface recipes (card fills used across hanzo.ai) ——— */
173
+ --surface-page:var(--background);
174
+ --surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
175
+ --surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
176
+ --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
177
+ --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
178
+ --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
179
+ --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
180
+
181
+ /* ——— the numeric surface ladder ——— */
182
+ /* Aliases onto the semantic canvases above, so a brand fork that retunes
183
+ --card/--muted/--secondary retunes the ladder with it and the light theme
184
+ inverts for free. Ascending lift: 0 is the page, 3 is a hovered control.
185
+ Dark resolves to the reference's own ladder — 0a / 0f / 17 / 26. */
186
+ --surface-0:var(--background);
187
+ --surface-1:var(--card);
188
+ --surface-2:var(--muted);
189
+ --surface-3:var(--secondary);
190
+
191
+ /* ——— text ranks ———
192
+ A graded ramp, not one flat white. The steps are the reference's. */
193
+ --text-primary:#fafafa;
194
+ --text-secondary:var(--white-78);
195
+ --text-tertiary:var(--white-55);
196
+ --text-helper:var(--muted-foreground);
197
+ --text-disabled:var(--white-30);
198
+
199
+ /* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
200
+ --state-error:#ef4444; /* red-500 — destructive / blocking error */
201
+ --state-error-text:#fca5a5; /* red-300 */
202
+ --state-error-bg:rgb(239 68 68 / .1);
203
+ --state-online:#4ade80; /* green-400 — live status dot */
204
+ --state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
205
+ --chrome-dot-red:rgb(239 68 68 / .6);
206
+ --chrome-dot-yellow:rgb(234 179 8 / .6);
207
+ --chrome-dot-green:rgb(34 197 94 / .6);
208
+ }
209
+
210
+ /* Light theme — the same tokens, inverted.
211
+ The white-opacity ladder does NOT invert: --white-16 is white-on-white here.
212
+ So EVERY token whose dark value is a --white-* rung must be restated below,
213
+ and check-tokens.mjs fails the build if one is missed — that class of bug is
214
+ silent (the border simply stops existing) and has shipped before. */
215
+ .light{
216
+ color-scheme:light;
217
+ --background:#ffffff;
218
+ --foreground:#0a0a0a;
219
+ /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
220
+ dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
221
+ --card:#fafafa;
222
+ --card-foreground:#0a0a0a;
223
+ --popover:#ffffff;
224
+ --popover-foreground:#0a0a0a;
225
+ --primary:#0a0a0a;
226
+ --primary-hover:#262626;
227
+ --primary-foreground:#fafafa;
228
+ --secondary:#ededed;
229
+ --secondary-foreground:#0a0a0a;
230
+ --muted:#f5f5f5;
231
+ --muted-foreground:#525252;
232
+ --accent:#ededed;
233
+ --accent-foreground:#0a0a0a;
234
+ --destructive:#999999;
235
+ --destructive-foreground:#ffffff;
236
+ --border:rgb(0 0 0 / .10);
237
+ --border-strong:rgb(0 0 0 / .16);
238
+ --border-control:var(--neutral-500); /* 4.74:1 on #ffffff — conformant here too */
239
+ --border-focus:var(--neutral-600); /* one rung DOWN — darker reads as brighter here */
240
+ --input:var(--border-control);
241
+ /* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
242
+ focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
243
+ #f5f5f5, so ONE value is conformant in both themes. */
244
+ --ring:var(--neutral-500);
245
+ --black:#0a0a0a;
246
+ --white:#ffffff;
247
+ --selection:rgb(0 0 0 / .16);
248
+ --glass:rgb(0 0 0 / .04);
249
+ --glass-strong:rgb(0 0 0 / .07);
250
+ --surface-card:#fafafa;
251
+ --surface-card-emphasis:#ffffff;
252
+ --surface-card-quiet:#fcfcfc;
253
+ --surface-overlay:rgb(255 255 255 / .95);
254
+ --surface-header:rgb(255 255 255 / .8);
255
+ --surface-scrim:rgb(0 0 0 / .5);
256
+ --text-primary:var(--neutral-950);
257
+ --text-secondary:rgb(10 10 10 / .78);
258
+ --text-tertiary:rgb(10 10 10 / .55);
259
+ --text-disabled:rgb(10 10 10 / .3);
260
+ }
261
+
262
+ /* ── tokens/typography.css ─────────────────────────────────────── */
263
+ /* TIGHT app-first type scale — the compact developer-app register (linear.app /
264
+ vercel.com / the Codex desktop look), the Hanzo default across chat / app /
265
+ desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
266
+ lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
267
+ — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
268
+ on :root to retune density on demand. */
269
+ :root{
270
+ --text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
271
+ --text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
272
+ --text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
273
+ --text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
274
+ --text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
275
+ --text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
276
+ --text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
277
+ --text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
278
+ --text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
279
+ --text-6xl:3.25rem; --leading-6xl:1; /* 52px */
280
+ --text-7xl:4rem; --leading-7xl:1; /* 64px */
281
+ --text-8xl:5.25rem; --leading-8xl:1; /* 84px */
282
+ --text-9xl:7rem; --leading-9xl:1; /* 112px */
283
+
284
+ /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
285
+ Hanzo Studio address the ramp as --font-size-*; both names are one value, so
286
+ a component written against either resolves here. --text-* is canonical. */
287
+ --font-size-xs:var(--text-xs);
288
+ --font-size-sm:var(--text-sm);
289
+ --font-size-base:var(--text-base);
290
+ --font-size-lg:var(--text-lg);
291
+ --font-size-xl:var(--text-xl);
292
+ --font-size-2xl:var(--text-2xl);
293
+ --font-size-3xl:var(--text-3xl);
294
+ --font-size-4xl:var(--text-4xl);
295
+ --font-size-5xl:var(--text-5xl);
296
+ --font-size-6xl:var(--text-6xl);
297
+ --font-size-7xl:var(--text-7xl);
298
+ --font-size-8xl:var(--text-8xl);
299
+ --font-size-9xl:var(--text-9xl);
300
+
301
+ /* The size a FORM CONTROL renders at — and the ONE step in this scale that is
302
+ not a constant. iOS Safari zooms the viewport whenever a focused input
303
+ computes below 16px, and this scale's base is 14px, so every surface that
304
+ sized a field from --text-sm or --text-base bought the zoom.
305
+
306
+ It has to be a TOKEN rather than a rule because a control's size is almost
307
+ always set inline (every component in components/forms does), and an inline
308
+ style outranks any stylesheet — including a media query. A var() resolves
309
+ per-device inside that inline style, so this is the only construction that
310
+ actually reaches the control. Fields ask for --text-control; nothing else
311
+ should. */
312
+ --text-control:var(--text-sm);
313
+
314
+ --weight-normal:400;
315
+ --weight-medium:500;
316
+ --weight-semibold:600;
317
+ --weight-bold:700;
318
+
319
+ --tracking-tight:-0.025em;
320
+ --tracking-normal:0em;
321
+ --tracking-wide:0.025em;
322
+ --tracking-widest:0.1em; /* eyebrows / uppercase category labels */
323
+
324
+ --leading-none:1;
325
+ --leading-tight:1.25;
326
+ --leading-snug:1.375;
327
+ --leading-normal:1.5;
328
+ --leading-relaxed:1.625;
329
+ --leading-golden:1.618;
330
+
331
+ /* named roles */
332
+ --type-hero:600 var(--text-5xl)/1.05 var(--font-display);
333
+ --type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
334
+ --type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
335
+ --type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
336
+ --type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
337
+ --type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
338
+ --type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
339
+ --type-eyebrow:600 0.625rem/1 var(--font-sans);
340
+ }
341
+
342
+ /* On touch, a control goes to 16px. Below that iOS Safari zooms the viewport on
343
+ focus and never zooms back out, which is the single most common way a mobile
344
+ form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
345
+ the compact 13px field. */
346
+ @media (pointer:coarse){
347
+ :root{--text-control:1rem}
348
+ }
349
+
350
+ /* ── tokens/spacing.css ─────────────────────────────────────── */
351
+ /* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
352
+ declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
353
+ reference) — use it for editorial layouts, not for component padding. */
354
+ :root{
355
+ --space-0:0;
356
+ --space-1:0.25rem;
357
+ --space-2:0.5rem;
358
+ --space-3:0.75rem;
359
+ --space-4:1rem;
360
+ --space-5:1.25rem;
361
+ --space-6:1.5rem;
362
+ --space-8:2rem;
363
+ --space-10:2.5rem;
364
+ --space-12:3rem;
365
+ --space-14:3.5rem;
366
+ --space-16:4rem;
367
+ --space-20:5rem;
368
+ --space-24:6rem;
369
+ --space-32:8rem;
370
+
371
+ /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
372
+ --golden-1:0.25rem;
373
+ --golden-2:0.405rem;
374
+ --golden-3:0.654rem;
375
+ --golden-4:1.059rem;
376
+ --golden-5:1.713rem;
377
+ --golden-6:2.772rem;
378
+ --golden-7:4.487rem;
379
+ --golden-8:7.26rem;
380
+ --golden-9:11.749rem;
381
+ --golden-split:38.2% 61.8%; /* @kind other */
382
+
383
+ /* layout rules (DESIGN.md §1.3) —
384
+ MOBILE-FIRST: the values authored here are the PHONE values, and the
385
+ min-width block at the bottom of this file scales them up. A surface that
386
+ uses the raw token therefore gets a layout that already breathes correctly
387
+ at 390px, with no media query of its own. (This is also the value the
388
+ generator captures for tokens.gen.ts — first occurrence wins, and the
389
+ authored default is the small one.) */
390
+ --container-max:80rem; /* max-w-7xl — grids */
391
+ --container-prose:48rem; /* max-w-3xl — centered text */
392
+ --container-wide:72rem; /* max-w-6xl — landing sections */
393
+ /* The page gutter also clears a notch. env() is 0px on every device without
394
+ one, so this is exactly `1rem` in the ordinary case and the safe inset when
395
+ there is something to avoid — no per-app work, no landscape clipping.
396
+ Requires `<meta name="viewport" content="…,viewport-fit=cover">` on the
397
+ host page; without it the UA reports 0 and the max() is inert, not wrong. */
398
+ --gutter:max(1rem,var(--safe-left),var(--safe-right));
399
+ --gutter-sm:1.5rem; /* sm:px-6 */
400
+ --gutter-lg:2rem; /* lg:px-8 */
401
+ --section-y:2.5rem; /* py-10 on a phone → py-16 at md */
402
+ --section-y-lg:3.5rem; /* py-14 on a phone → py-24 at md */
403
+ --hero-y:3rem; /* py-12 on a phone → py-20 at md */
404
+ --hero-y-lg:4.5rem; /* py-18 on a phone → py-32 at md */
405
+ --header-height:3.5rem; /* 56px is the phone bar; 64px from md */
406
+
407
+ /* ——— touch ——— */
408
+ /* 44px is the floor a pointer-coarse target may render at (Apple HIG 44pt /
409
+ WCAG 2.5.5 AAA / 2.5.8 AA's 24px, taken at the higher bar). base.css spends
410
+ it automatically under `@media (pointer:coarse)`, so a button does not have
411
+ to opt in. It was previously a bare `min-height:44px` literal in
412
+ hanzoai/id's stylesheet — one app knowing something the system did not. */
413
+ --tap-target:44px;
414
+
415
+ /* ——— safe areas ——— */
416
+ /* The notch/home-indicator insets, named once. Anything pinned to a viewport
417
+ edge — a fixed header, a bottom bar, a sheet — adds the matching one. */
418
+ --safe-top:env(safe-area-inset-top,0px);
419
+ --safe-right:env(safe-area-inset-right,0px);
420
+ --safe-bottom:env(safe-area-inset-bottom,0px);
421
+ --safe-left:env(safe-area-inset-left,0px);
422
+ }
423
+
424
+ /* Scale up from the phone. `md` (48rem) is the same breakpoint grid.css
425
+ declares and Tailwind compiles against — one value, not two that agree. */
426
+ @media (min-width:48rem){
427
+ :root{
428
+ --section-y:4rem; /* py-16 — content sections */
429
+ --section-y-lg:6rem; /* py-24 — landing sections */
430
+ --hero-y:5rem; /* py-20 … */
431
+ --hero-y-lg:8rem; /* … lg:py-32 */
432
+ --header-height:4rem;
433
+ }
434
+ }
435
+
436
+ /* ── tokens/grid.css ─────────────────────────────────────── */
437
+ /* Grid — one definition of the field every Hanzo surface lays out on.
438
+
439
+ Derived, not invented. Across hanzo.ai and hanzo.app the grid written by hand
440
+ is overwhelmingly ONE grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` with a
441
+ 1.5rem gutter (240 / 197 / 127 / 185 occurrences on hanzo.ai alone, and the
442
+ same shape leads on app). 1, 2, 3 and 4 columns account for 97% of every
443
+ grid-cols- in both repos, and all four divide 12 cleanly — so 12 is the base
444
+ the spans are cut from, and the long tail (5, 6, 7, 8, 12) still lands on it.
445
+
446
+ Containers, gutters and section rhythm are NOT restated here: spacing.css
447
+ already owns --container-max / --container-prose / --container-wide /
448
+ --gutter* / --section-y*, and a grid that redeclared them would be a second
449
+ answer to a settled question. This file adds only what a grid knows that
450
+ spacing does not — how many columns, how far apart, and where it reflows. */
451
+ :root{
452
+ /* The base. Spans are cut from twelve because 1/2/3/4/6 all divide it. */
453
+ --grid-columns:12;
454
+
455
+ /* Gutter. --grid-gap is the measured card-grid default (gap-6 leads hanzo.ai
456
+ at 185 uses); tight and loose are the neighbouring steps that cover app's
457
+ gap-4 (45) and the wide editorial grids (gap-8, 75). All three reference the
458
+ space ramp — the grid does not get its own private set of distances. */
459
+ --grid-gap:var(--space-6);
460
+ --grid-gap-tight:var(--space-4);
461
+ --grid-gap-loose:var(--space-8);
462
+
463
+ /* Reflow points, byte-identical to the Tailwind v4 defaults these surfaces
464
+ already compile against. Same NAMES and same VALUES on purpose: the utility
465
+ layer and anything reading tokens then resolve one value, not two that agree
466
+ by luck. md and lg carry ~85% of every breakpoint prefix in use; xl is 44
467
+ occurrences across both repos and 2xl is 1. */
468
+ --breakpoint-sm:40rem;
469
+ --breakpoint-md:48rem;
470
+ --breakpoint-lg:64rem;
471
+ --breakpoint-xl:80rem;
472
+ --breakpoint-2xl:96rem;
473
+
474
+ /* Intrinsic card grids: state ONE track minimum instead of three column counts
475
+ at three breakpoints. `repeat(auto-fit, minmax(var(--grid-card-min), 1fr))`
476
+ reflows 1 -> 2 -> 3 at the same widths the explicit recipe does, and it does
477
+ it from the space available rather than from the viewport — so a card grid
478
+ inside a sidebar behaves correctly, which the breakpoint version cannot.
479
+
480
+ 18rem is chosen, not rounded to: inside --container-max less --gutter-lg on
481
+ both sides, lg (64rem) leaves ~60rem, where 3 tracks need 3x18 + 2x1.5 =
482
+ 57rem and 4 would need 76.5rem; md (48rem) leaves ~44rem, where 2 tracks
483
+ need 37.5rem and 3 would need 57rem. The measured 1/2/3 ladder falls out. */
484
+ --grid-card-min:18rem;
485
+ --grid-card-min-wide:24rem;
486
+ }
487
+
488
+ /* ── tokens/radius.css ─────────────────────────────────────── */
489
+ :root{
490
+ --radius:0.5rem; /* the base token (globals.css) */
491
+ --radius-sm:0.375rem; /* rounded-md — buttons, inputs */
492
+ --radius-md:0.5rem;
493
+ --radius-lg:0.75rem; /* rounded-xl — cards */
494
+ --radius-xl:1rem; /* rounded-2xl — dropdown panels */
495
+ --radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
496
+ --radius-composer:28px; /* the chat composer, exactly 28px */
497
+ --radius-full:9999px; /* pills, CTAs, avatars, badges */
498
+ }
499
+
500
+ /* ── tokens/elevation.css ─────────────────────────────────────── */
501
+ /* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
502
+ a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
503
+ floating surfaces) plus the ambient radial glow used behind heroes. */
504
+ :root{
505
+ --shadow-none:none;
506
+ --shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
507
+ --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
508
+ --ring-focus:0 0 0 2px var(--ring);
509
+
510
+ /* The t-shirt ramp. Named by size rather than by role, because that is how
511
+ every component library already asks for a shadow (and how @hanzo/brand
512
+ spells it). Alphas are heavier than Tailwind's defaults: on a near-black
513
+ ground a 10% black drop is invisible, so each rung is tuned to read on
514
+ --background. --shadow-2xl and --shadow-floating are the same rung. */
515
+ --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
516
+ --shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
517
+ --shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
518
+ --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
519
+ --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
520
+ --shadow-2xl:var(--shadow-floating);
521
+ /* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
522
+ --glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
523
+ --glow-hero-blur:120px;
524
+ /* Card top-corner sheen used on the story cards. */
525
+ --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
526
+ /* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
527
+ --gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
528
+ --gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
529
+ /* Section-top protection gradient (hero overlays). */
530
+ --gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
531
+ }
532
+
533
+ /* ── tokens/motion.css ─────────────────────────────────────── */
534
+ /* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
535
+ No springs, no bounce, no parallax, no autoplay carousels. */
536
+ :root{
537
+ --duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
538
+ --duration-base:300ms; /* @kind other */ /* slide-up-fade */
539
+ --duration-slow:400ms; /* @kind other */ /* hero element entry */
540
+ --duration-slower:500ms; /* @kind other */ /* section entry */
541
+ --duration-glow:9s; /* @kind other */ /* ambient radial breathe */
542
+ --ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
543
+ --ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
544
+ --stagger:60ms; /* @kind other */ /* per-element delay in a group */
545
+ --entry-rise:16px; /* hero y-offset */
546
+ --entry-rise-lg:24px; /* card y-offset */
547
+ }
548
+
549
+ @keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
550
+ @keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
551
+ @keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
552
+ @keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
553
+ @keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
554
+
555
+ @media (prefers-reduced-motion:reduce){
556
+ *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
557
+ }
558
+
559
+ /* ── tokens/z.css ─────────────────────────────────────── */
560
+ /* Stacking order — the one z-index ladder. Layers are named by role, never by a
561
+ magic number, so a dropdown opened from the fixed header always sits above it
562
+ and nothing ever reaches for 9999. Below --z-raised is ordinary document flow.
563
+
564
+ Rungs are 100 apart so a surface can slot a one-off BETWEEN two roles
565
+ (calc(var(--z-modal) + 1)) without inventing a new decade. */
566
+ :root{
567
+ --z-base:0;
568
+ --z-raised:10; /* hover-lifted cards, sticky table headers */
569
+ --z-sticky:200; /* pinned section rails */
570
+ --z-header:300; /* the fixed site header */
571
+ --z-dropdown:400; /* menus, selects, comboboxes */
572
+ --z-overlay:500; /* dialog / sheet scrim */
573
+ --z-modal:600; /* dialogs, sheets, command palette */
574
+ --z-popover:700; /* popovers, tooltips (also when anchored in modals) */
575
+ --z-toast:800; /* toasts / notifications — always on top */
576
+
577
+ /* @hanzo/brand spells the top two rungs --z-tooltip and --z-notification.
578
+ Same rungs, so a component written against either vocabulary stacks
579
+ identically. --z-popover / --z-toast are canonical. */
580
+ --z-tooltip:var(--z-popover);
581
+ --z-notification:var(--z-toast);
582
+ }
583
+
584
+ /* ── tokens/fonts.css ─────────────────────────────────────── */
585
+ /* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
586
+ SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
587
+ variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Geist.txt).
588
+
589
+ Why self-hosted rather than @import from fonts.googleapis.com:
590
+ - A sign-in page must not make a third-party request. hanzoai/id refused to
591
+ import this file for exactly that reason, which split the token layer: id
592
+ took the colours and not the typeface. Self-hosting removes the reason, so
593
+ every surface can import styles.css unchanged.
594
+ - The @import was a render-blocking request to a host we do not control, on
595
+ the critical path of every surface, and it broke offline/air-gapped dev.
596
+ - One variable file per family replaces nine static weights, and it is fewer
597
+ bytes than the CSS-then-woff2 round trip Google served.
598
+
599
+ The url()s are relative to THIS file, so they resolve wherever the package is
600
+ mounted — node_modules, a CDN, a copied dist — with no configuration. */
601
+
602
+ @font-face{
603
+ font-family:"Geist";
604
+ src:url("./assets/fonts/Geist-Variable.woff2") format("woff2");
605
+ font-weight:100 900;
606
+ font-style:normal;
607
+ font-display:swap;
608
+ }
609
+ @font-face{
610
+ font-family:"Geist Mono";
611
+ src:url("./assets/fonts/GeistMono-Variable.woff2") format("woff2");
612
+ font-weight:100 900;
613
+ font-style:normal;
614
+ font-display:swap;
615
+ }
616
+
617
+ :root{
618
+ --font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;
619
+ --font-display:var(--font-sans);
620
+ --font-mono:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
621
+ --font-serif:Georgia,serif;
622
+ /* hanzo.ai sets these OpenType features on <body>. */
623
+ --font-feature-settings:"ss01","ss02","cv01","cv02","cv03";
624
+ }
625
+
626
+ /* ── tokens/base.css ─────────────────────────────────────── */
627
+ /* Minimal element defaults so specimen cards and kits inherit the brand without
628
+ a utility framework. Components carry their own styles inline.
629
+
630
+ EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
631
+ A rule outside a cascade layer beats a rule inside one no matter how specific
632
+ the loser is — so an unlayered `a{color:...}` here outranked EVERY Tailwind
633
+ text utility on every anchor, on every surface that imports these tokens. A
634
+ white-on-white primary button is what that looks like: `text-neutral-950` on
635
+ an anchor, silently overridden to --text-primary, invisible on its own white
636
+ fill, and reading as a rendering glitch rather than a cascade bug.
637
+
638
+ The same trap already bit `text-decoration` here once (see the a:hover note
639
+ below). It was fixed by deleting that one declaration, which left the identical
640
+ defect in `color` untouched. Layering fixes the whole class: these are
641
+ DEFAULTS, and a default must lose to anything an app states deliberately.
642
+
643
+ The rules added for controls, media and touch go one step further and wrap
644
+ their selectors in :where(), which has ZERO specificity. @layer decides who
645
+ wins BETWEEN layers; :where() decides who wins INSIDE this one. An app that
646
+ ships its own reset — `input { … }`, `img { … }` — very often lands in the
647
+ same base layer, and these defaults have to lose that collision too. */
648
+ @layer base {
649
+ *{box-sizing:border-box;border-color:var(--border)}
650
+ html{
651
+ -webkit-font-smoothing:antialiased;
652
+ text-rendering:optimizeLegibility;
653
+ scroll-behavior:smooth;
654
+ /* iOS inflates text when a phone is rotated to landscape unless this is
655
+ pinned. It is the single most common reason a mobile layout that was
656
+ verified in portrait comes apart on its side. */
657
+ -webkit-text-size-adjust:100%;
658
+ }
659
+ /* Each font-family carries the stack as a literal fallback. --font-sans lives in
660
+ tokens/fonts.css, which a surface may legitimately import separately; without
661
+ the fallback an unresolved var() makes font-family invalid and the UA drops to
662
+ its SERIF default — the whole console silently rendered in Times. */
663
+ 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);overflow-wrap:break-word}
664
+ 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)}
665
+ p{margin:0;text-wrap:pretty}
666
+ code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
667
+ /* No `transition` here: the shared interactive rule below states one, and an
668
+ `a` selector (0,0,1) would outrank that :where() (0,0,0) and replace the
669
+ whole shorthand — anchors would transition colour and nothing else. */
670
+ a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px}
671
+ /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
672
+ bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
673
+ blanket `a:hover` underlined every one of them on every surface that imports
674
+ these tokens. Scope it to the elements that actually carry prose; a link that
675
+ is a component states its own hover. The old rule also re-set `color` to the
676
+ value `a` already has — a no-op that only served to outrank a component's own
677
+ hover colour. */
678
+ :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
679
+
680
+ /* ——— the smooth ———
681
+ Every interactive element in the reference transitions its colour, its fill
682
+ and its EDGE. That is most of what separates "classy and smooth" from a
683
+ correct set of colours: a hairline that steps from .10 to .16 instantly
684
+ reads as a flicker, and over 150ms reads as a response. Granted once, here,
685
+ rather than restated on every component. tokens/motion.css already zeroes
686
+ all of it under prefers-reduced-motion. */
687
+ :where(a,button,[role=button],input,select,textarea,summary,[tabindex]){
688
+ transition:color var(--duration-fast) var(--ease-out),
689
+ background-color var(--duration-fast) var(--ease-out),
690
+ border-color var(--duration-fast) var(--ease-out),
691
+ box-shadow var(--duration-fast) var(--ease-out),
692
+ opacity var(--duration-fast) var(--ease-out);
693
+ }
694
+
695
+ /* ——— controls ———
696
+ A bare <input> renders in the UA's face (Arial) at the UA's size with the
697
+ UA's border, which puts two typefaces and a foreign blue focus ring inside
698
+ Hanzo cards on every surface that has not styled its own fields yet. The
699
+ rules below are what a control looks like here. --border-control, NOT
700
+ --border: the boundary IS the affordance, so it is the rung that owes 3:1. */
701
+ :where(input,select,textarea,button){font:inherit;color:inherit}
702
+ :where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
703
+ background:var(--surface-2);
704
+ border:1px solid var(--border-control);
705
+ border-radius:var(--radius-sm);
706
+ padding:0 var(--space-3);
707
+ }
708
+ :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
709
+ :where(input,select,textarea):focus-visible{border-color:var(--border-focus)}
710
+ :where(input,textarea)::placeholder{color:var(--text-disabled)}
711
+ :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
712
+ :where(button,[role=button],summary,label,select){cursor:pointer}
713
+
714
+ /* ——— media ———
715
+ An image, a video or an embed at its intrinsic width is the usual cause of a
716
+ 390px page that scrolls sideways. */
717
+ :where(img,svg,video,canvas,iframe,picture,object){max-width:100%}
718
+ :where(img,video){height:auto}
719
+ /* A code block scrolls ITSELF rather than widening the document. */
720
+ :where(pre){overflow-x:auto}
721
+
722
+ /* ——— touch ———
723
+ Everything a finger aims at clears 44px, and every field renders at 16px so
724
+ iOS does not zoom the viewport on focus. Both are keyed on pointer:coarse —
725
+ the actual signal — so a desktop mouse still gets compact controls. This is
726
+ the whole of "mobile-first without per-app work": an app that never thought
727
+ about phones still has hittable buttons and a viewport that stays put. */
728
+ @media (pointer:coarse){
729
+ :where(button,[role=button],a[role=button],summary,select,textarea,
730
+ input:not([type=checkbox]):not([type=radio]):not([type=hidden])){
731
+ min-height:var(--tap-target);
732
+ }
733
+ :where(input,select,textarea){font-size:var(--text-control)}
734
+ /* A checkbox, radio or switch must NOT grow to 44px — the box is 16px
735
+ because that is what a checkbox looks like. What has to reach 44px is the
736
+ area a finger may land in, so the target is expanded with a centred
737
+ pseudo-element that changes nothing about layout or paint. WCAG 2.5.8 puts
738
+ the floor at 24px; a 16px box misses it, and every one of these ships at
739
+ 16px. */
740
+ :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio]){position:relative}
741
+ :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio])::after{
742
+ content:'';position:absolute;top:50%;left:50%;
743
+ width:var(--tap-target);height:var(--tap-target);
744
+ transform:translate(-50%,-50%);
745
+ }
746
+ }
747
+
748
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
749
+ /* --white-20 is white-on-white in the light theme, so selection reads through
750
+ --selection, which BOTH themes define. */
751
+ ::selection{background:var(--selection);color:var(--text-primary)}
752
+ }
753
+
754
+
755
+ @theme inline {
756
+ --color-background: var(--background);
757
+ --color-foreground: var(--foreground);
758
+ --color-card: var(--card);
759
+ --color-card-foreground: var(--card-foreground);
760
+ --color-popover: var(--popover);
761
+ --color-popover-foreground: var(--popover-foreground);
762
+ --color-primary: var(--primary);
763
+ --color-primary-foreground: var(--primary-foreground);
764
+ --color-secondary: var(--secondary);
765
+ --color-secondary-foreground: var(--secondary-foreground);
766
+ --color-muted: var(--muted);
767
+ --color-muted-foreground: var(--muted-foreground);
768
+ --color-accent: var(--accent);
769
+ --color-accent-foreground: var(--accent-foreground);
770
+ --color-destructive: var(--destructive);
771
+ --color-destructive-foreground: var(--destructive-foreground);
772
+ --color-border: var(--border);
773
+ --color-input: var(--input);
774
+ --color-ring: var(--ring);
775
+
776
+ --color-error: var(--state-error);
777
+ --color-success: var(--state-success);
778
+ --color-online: var(--state-online);
779
+
780
+ --radius-sm: var(--radius-sm);
781
+ --radius-md: var(--radius-md);
782
+ --radius-lg: var(--radius-lg);
783
+ --radius-xl: var(--radius-xl);
784
+
785
+ --font-sans: var(--font-sans);
786
+ --font-mono: var(--font-mono);
787
+ }