@hanzo/design 0.5.17 → 0.5.18

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 DELETED
@@ -1,1162 +0,0 @@
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
- /* The five rungs marked (chrome) below were being retyped as raw rgba in
83
- @hanzogui/shell's theme.ts, which is the shared bar every Hanzo property
84
- mounts. That is the same defect this ladder exists to end — a value in two
85
- places drifts, and this pair already had: the shell's resting hairline sat
86
- at .06 against this file's .10 for long enough that the bar and the page
87
- under it drew different hairlines. They are rungs here now, and the shell
88
- references them, so the chrome has no colour of its own left to drift.
89
-
90
- They are the shell's OWN measured values, added at the alpha it already
91
- ships. Snapping them to neighbouring rungs instead would have been a
92
- silent redesign of the estate's chrome, and two of them carry decisions
93
- that a round number would have destroyed: .92 is ink deliberately short of
94
- the .98 hover so a hover still reads as a response, and .03 is the one
95
- lift that must stay below the .05 hairline it sits inside. */
96
- --white-03:rgb(255 255 255 / .03); /* (chrome) the raised fill */
97
- --white-05:rgb(255 255 255 / .05);
98
- --white-06:rgb(255 255 255 / .06);
99
- --white-08:rgb(255 255 255 / .08);
100
- --white-10:rgb(255 255 255 / .10);
101
- --white-14:rgb(255 255 255 / .14); /* (chrome) selection wash */
102
- --white-15:rgb(255 255 255 / .15);
103
- --white-16:rgb(255 255 255 / .16);
104
- --white-18:rgb(255 255 255 / .18); /* (chrome) selection tint */
105
- --white-20:rgb(255 255 255 / .20);
106
- --white-22:rgb(255 255 255 / .22);
107
- --white-30:rgb(255 255 255 / .30);
108
- --white-40:rgb(255 255 255 / .40);
109
- --white-45:rgb(255 255 255 / .45); /* (chrome) the dimmest ink */
110
- --white-55:rgb(255 255 255 / .55);
111
- --white-60:rgb(255 255 255 / .60);
112
- --white-78:rgb(255 255 255 / .78);
113
- --white-80:rgb(255 255 255 / .80);
114
- --white-92:rgb(255 255 255 / .92); /* (chrome) body ink */
115
-
116
- /* ——— semantic aliases (dark, the default) ——— */
117
- --background:#0a0a0a;
118
- /* READING ink, one rung below the top of the ramp. The note at the head of this
119
- file already made this argument once — #ffffff on #000000 halates, so #fafafa
120
- — and the argument does not stop being true at 19.0:1. Every heading and
121
- paragraph on every surface was set in the brightest ink the system has, while
122
- the CHROME beside them draws body text at --white-92; a page read as glare
123
- against its own header. #e5e5e5 is the neutral-200 rung this file already
124
- spends on --primary-hover, so the ramp gains no new value: 15.7:1 on the page,
125
- 15.2:1 on a card, both more than twice the 7:1 AAA asks of body text.
126
- Something that genuinely needs the brightest ink names --primary. */
127
- --foreground:#e5e5e5;
128
- --card:#0f0f0f;
129
- --card-foreground:#e5e5e5;
130
- --popover:#0f0f0f;
131
- --popover-foreground:#e5e5e5;
132
- --primary:#fafafa;
133
- /* A 5% step, not the 16% this used to be. A filled button's fill is already
134
- the top of the ink ramp, so the only directions available on hover are
135
- "dimmer" and "brighter-around-the-edges" — and a 16% drop reads as the
136
- button going DISABLED under the cursor, which is the one thing a hover must
137
- never look like. The step is now small enough to register as pressure while
138
- --bloom does the actual work of lighting it up. */
139
- --primary-hover:#e5e5e5;
140
- --primary-foreground:#0a0a0a;
141
- --secondary:#262626;
142
- /* One rung up, so a secondary button HAS a hover. It previously resolved to
143
- --surface-3, which is --secondary — the same value it starts at. */
144
- --secondary-hover:#333333;
145
- --secondary-foreground:#fafafa;
146
- --muted:#171717;
147
- --muted-foreground:#a3a3a3;
148
- --accent:#262626;
149
- --accent-foreground:#fafafa;
150
- /* Destructive is the ONE place the monochrome rule already yields, and it was
151
- yielding in only half the system: a field that fails validation draws
152
- --state-error (#ef4444, which DESIGN.md §2.4 lists as permitted precisely
153
- for "destructive / blocking error"), while the button that performs the
154
- destruction drew #666666 — a mid-grey that is indistinguishable from a
155
- disabled control. "Delete everything" and "you cannot click this" must not
156
- be the same colour. So the two converge on the one token that already
157
- names this meaning, and the ramp stays monochrome everywhere else. */
158
- --destructive:var(--state-error);
159
- --destructive-hover:#dc2626; /* red-600 — deepens under the cursor */
160
- --destructive-foreground:#fafafa;
161
-
162
- /* ——— boundaries ———
163
- ONE ladder, cut from alpha, graded by duty. Every rung is a rung of the
164
- opacity ladder above, so every boundary composites correctly on the page,
165
- on a card, and inside a popover — and every rung is the reference's own
166
- measured value, not a rounded ramp:
167
-
168
- --border .10 the hairline. Cards, panels, dividers, list rows.
169
- --border-strong .16 the same hairline, one step up. Hover, emphasis.
170
- --border-control .15 a control's resting edge — input, select, textarea.
171
- --border-focus .22 that control, focused.
172
- --border-selected .30 the thing that is currently CHOSEN — the active
173
- tab, the current tile, the featured plan.
174
-
175
- The previous release cut the last three from the NEUTRAL ladder instead,
176
- because a boundary that clears WCAG 1.4.11's 3:1 on a near-black ground IS
177
- a mid-grey and no amount of tuning changes that. It is true, and it is also
178
- what a wireframe looks like: a 1px #737373 box around every field, four
179
- times heavier than the hairline beside it, so a form read as a debug
180
- overlay rather than a surface. Aesthetics decided it — the reference draws
181
- an input at rgb(255 255 255 / .15) and a focused one at .22 (sidebar.css
182
- --input-border / .composer-box:focus-within), and that is what ships here.
183
-
184
- What replaces the ratio is the FOCUS INDICATOR, which is the thing a
185
- keyboard user actually navigates by and the one boundary still pinned at
186
- 3:1 (--ring, below). A resting edge is an affordance; a focus ring is a
187
- position. Only the second one is load-bearing when you cannot see well, and
188
- spending the contrast budget there rather than on all four edges of every
189
- idle field is what lets the system be quiet AND findable at once. */
190
- --border:var(--white-10);
191
- --border-strong:var(--white-16);
192
- --border-control:var(--white-15); /* sidebar.css --input-border */
193
- --border-focus:var(--white-22); /* .composer-box:focus-within */
194
- --border-selected:var(--white-30); /* the current tab / tile / plan */
195
- --input:var(--border-control); /* shadcn `border-input` — a control */
196
- /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
197
- 3:1 against every surface it can land on, and unlike a resting edge it is
198
- worth the loudness — it is transient, it only appears on keyboard focus,
199
- and it is the whole of how a keyboard user knows where they are.
200
- --white-40 clears it on every canvas in both themes precisely BECAUSE it is
201
- alpha: on a lifted surface the ring lifts with it (3.77:1 on #0a0a0a,
202
- 3.66:1 on #262626), which a fixed grey cannot do. */
203
- --ring:var(--white-40);
204
- /* The soft halo that sits outside the ring — the reference's
205
- `box-shadow: 0 0 0 2px rgb(255 255 255 / .06)` on a focused composer. It
206
- carries no ratio; it is what makes focus read as designed rather than as a
207
- browser default. Spent through --ring-focus (tokens/elevation.css). */
208
- --ring-halo:var(--white-10);
209
- --brand:#e4e4e7;
210
- --brand-foreground:#09090b;
211
- --brand-muted:#a3a3a3;
212
- --black:#000000;
213
- --white:#fafafa;
214
- --selection:var(--white-20);
215
-
216
- /* ——— glass: the lift ———
217
- How a surface rises WITHOUT a block fill. An alpha-white wash composites
218
- over whatever it lands on, so one value works on the page, on a card and
219
- inside a popover. This is the mechanism behind the reference's depth. */
220
- --glass:var(--white-05);
221
- --glass-strong:var(--white-08);
222
-
223
- /* ——— surface recipes ———
224
- A card has to be a SURFACE, which means you have to be able to see that it
225
- is not the page. These were cut from neutral-900 (#171717) at .4/.5/.8, and
226
- at .5 that composites to #101010 on a #0a0a0a ground — six levels above the
227
- page, which is not a surface, it is a very slightly different black. Three
228
- cards side by side then read as one flat field with hairlines ruled across
229
- it, and no amount of shadow fixes it, because you cannot cast a shadow onto
230
- black.
231
-
232
- Cut from neutral-800 (#262626) instead, they land on 14 / 18 / 1f — the
233
- reference's own panel (#171717 on a #0f0f0f container) with room either
234
- side of it. They stay ALPHA so a card nested in a popover still lifts off
235
- its parent rather than punching a hole in it; that is the whole reason
236
- these are recipes and not just --surface-* rungs. */
237
- --surface-page:var(--background);
238
- --surface-card:rgb(38 38 38 / .5); /* -> #181818 on the page */
239
- --surface-card-emphasis:rgb(38 38 38 / .75);/* -> #1f1f1f — featured */
240
- --surface-card-quiet:rgb(38 38 38 / .35); /* -> #141414 — story cards */
241
- /* A floating panel that is the SAME colour as the page is not floating. This
242
- was rgb(10 10 10 / .95) — the page, at 95% of the page. */
243
- --surface-overlay:rgb(23 23 23 / .92); /* dropdown / popover panels */
244
- --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
245
- --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
246
-
247
- /* ——— the numeric surface ladder ——— */
248
- /* Aliases onto the semantic canvases above, so a brand fork that retunes
249
- --card/--muted/--secondary retunes the ladder with it and the light theme
250
- inverts for free. Ascending lift: 0 is the page, 3 is a hovered control.
251
- Dark resolves to the reference's own ladder — 0a / 0f / 17 / 26. */
252
- --surface-0:var(--background);
253
- --surface-1:var(--card);
254
- --surface-2:var(--muted);
255
- --surface-3:var(--secondary);
256
-
257
- /* ——— text ranks ———
258
- A graded ramp, not one flat white. The steps are the reference's. */
259
- --text-primary:#fafafa;
260
- --text-secondary:var(--white-78);
261
- --text-tertiary:var(--white-55);
262
- --text-helper:var(--muted-foreground);
263
- --text-disabled:var(--white-30);
264
-
265
- /* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
266
- --state-error:#ef4444; /* red-500 — destructive / blocking error */
267
- --state-error-text:#fca5a5; /* red-300 */
268
- --state-error-bg:rgb(239 68 68 / .1);
269
- --state-online:#4ade80; /* green-400 — live status dot */
270
- --state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
271
- --chrome-dot-red:rgb(239 68 68 / .6);
272
- --chrome-dot-yellow:rgb(234 179 8 / .6);
273
- --chrome-dot-green:rgb(34 197 94 / .6);
274
- }
275
-
276
- /* Light theme — the same tokens, inverted.
277
- The white-opacity ladder does NOT invert: --white-16 is white-on-white here.
278
- So EVERY token whose dark value is a --white-* rung must be restated below,
279
- and check-tokens.mjs fails the build if one is missed — that class of bug is
280
- silent (the border simply stops existing) and has shipped before. */
281
- .light{
282
- color-scheme:light;
283
- --background:#f7f7f7;
284
- --foreground:#0a0a0a;
285
- /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
286
- dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
287
- --card:#f2f2f2;
288
- --card-foreground:#0a0a0a;
289
- --popover:#fbfbfb;
290
- --popover-foreground:#0a0a0a;
291
- --primary:#0a0a0a;
292
- --primary-hover:#262626;
293
- --primary-foreground:#fafafa;
294
- --secondary:#e4e4e4;
295
- --secondary-hover:#e0e0e0;
296
- --secondary-foreground:#0a0a0a;
297
- --muted:#ededed;
298
- --muted-foreground:#525252;
299
- --accent:#e4e4e4;
300
- --accent-foreground:#0a0a0a;
301
- --destructive:var(--state-error);
302
- --destructive-hover:#dc2626;
303
- --destructive-foreground:#ffffff;
304
- --border:rgb(0 0 0 / .10);
305
- --border-strong:rgb(0 0 0 / .16);
306
- --border-control:rgb(0 0 0 / .15);
307
- /* Focus and selection are pushed further here than the .22/.30 they sit at in
308
- dark. Black-on-white at a given alpha reads FAINTER than white-on-black at
309
- the same alpha — .22 black on #ffffff measures 1.69:1 where .22 white on
310
- #0a0a0a measures 1.97:1 — so mirroring the number would quietly make the
311
- light theme's focus state the weakest state in the system. */
312
- --border-focus:rgb(0 0 0 / .32);
313
- --border-selected:rgb(0 0 0 / .42);
314
- --input:var(--border-control);
315
- /* Same duty, same 3:1 floor, opposite direction: .50 black is 3.98:1 on
316
- #ffffff and 3.67:1 on #f5f5f5. */
317
- --ring:rgb(0 0 0 / .5);
318
- --ring-halo:rgb(0 0 0 / .07);
319
- --black:#0a0a0a;
320
- --white:#ffffff;
321
- --selection:rgb(0 0 0 / .16);
322
- --glass:rgb(0 0 0 / .04);
323
- --glass-strong:rgb(0 0 0 / .07);
324
- --surface-card:#f2f2f2;
325
- --surface-card-emphasis:#fdfdfd;
326
- --surface-card-quiet:#f5f5f5;
327
- --surface-overlay:rgb(255 255 255 / .95);
328
- --surface-header:rgb(255 255 255 / .8);
329
- --surface-scrim:rgb(0 0 0 / .5);
330
- --text-primary:var(--neutral-950);
331
- --text-secondary:rgb(10 10 10 / .78);
332
- --text-tertiary:rgb(10 10 10 / .55);
333
- --text-disabled:rgb(10 10 10 / .3);
334
- }
335
-
336
- /* ── tokens/typography.css ─────────────────────────────────────── */
337
- /* TIGHT app-first type scale — the compact developer-app register (linear.app /
338
- vercel.com / the Codex desktop look), the Hanzo default across chat / app /
339
- desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
340
- lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
341
- — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
342
- on :root to retune density on demand. */
343
- :root{
344
- /* The type knob. 1 is the published scale; every rung below multiplies by
345
- it, so a surface or a person retunes the WHOLE ramp by setting one
346
- number and no rung can drift out of relation with the others.
347
- @hanzo/design's `vars({type})` emits exactly this name. */
348
- --type-scale:1;
349
-
350
- /* The CONTRAST knob — how far apart the rungs sit, where --type-scale is how
351
- big they all are. Two different questions, and only the first one was
352
- askable: a surface could take the whole ramp up 15% but could not flatten a
353
- dense table's steps or open up a marketing page's, so both settled for the
354
- app register's tight default.
355
-
356
- It is authored as a TRANSFORM and not as a generated ramp, which is the
357
- whole point. `base + (authored - base) * ratio` keeps each rung's authored
358
- value as the INPUT, so ratio 1 reduces to exactly the published number by
359
- algebra rather than by care — the deltas below are just `authored - 0.875`.
360
- The alternative, deriving each rung as base * ratio^n, would restate the
361
- ramp in a second place, and this scale is not geometric anyway: the app
362
- register (11/13/14/15/17) is near-linear on purpose and a ratio applied to
363
- it would erase the 1px steps the compact register is FOR. That drift is not
364
- hypothetical here — see src/preference.ts, where a recomputed copy of this
365
- ramp shipped `lg: 16px` against this file's 15px.
366
-
367
- A rung is one place, so a rung added later needs a delta and nothing else. */
368
- --type-ratio:1;
369
-
370
- /* EVERY rung is bounded at BOTH ends, by one rule, because the knobs COMPOUND
371
- and they compound in both directions. Clamping each knob's range separately
372
- cannot fix either end — 0.85 x 0.9 is inside both bounds and lands at 8.5px
373
- — so the RESULT is what carries the bound:
374
-
375
- --text-floor 10px below this a label is not small, it is unreadable
376
- --text-ceiling 30vw above this a heading is not type, it is a texture
377
-
378
- The ceiling is in the VIEWPORT's units because that is what "too big" means:
379
- measured, the tallest rung (--text-9xl, 112px) is 28.7vw on a 390px phone
380
- and 7.8vw at 1440, so 30vw binds no published value at any real screen and
381
- catches only a scale that has run away. Both bounds are stated once here and
382
- `vars()` emits the same clamp for a modular scale, so a regenerated rung and
383
- an authored one are bounded identically — the rule does not depend on which
384
- produced the value. */
385
- --text-floor:0.625rem;
386
- --text-ceiling:30vw;
387
- --text-xs:clamp(var(--text-floor), calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
388
- --text-sm:clamp(var(--text-floor), calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px — nav labels, dense body */
389
- --text-base:clamp(var(--text-floor), calc(0.875rem * var(--type-scale, 1)), var(--text-ceiling)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
390
- --text-lg:clamp(var(--text-floor), calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
391
- --text-xl:clamp(var(--text-floor), calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
392
- --text-2xl:clamp(var(--text-floor), calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
393
- --text-3xl:clamp(var(--text-floor), calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
394
- --text-4xl:clamp(var(--text-floor), calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
395
- --text-5xl:clamp(var(--text-floor), calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-5xl:1.05; /* 40px — display leading is unitless, so it tracks its own size */
396
- --text-6xl:clamp(var(--text-floor), calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-6xl:1; /* 52px */
397
- --text-7xl:clamp(var(--text-floor), calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-7xl:1; /* 64px */
398
- --text-8xl:clamp(var(--text-floor), calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-8xl:1; /* 84px */
399
- --text-9xl:clamp(var(--text-floor), calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-9xl:1; /* 112px */
400
-
401
- /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
402
- Hanzo Studio address the ramp as --font-size-*; both names are one value, so
403
- a component written against either resolves here. --text-* is canonical. */
404
- --font-size-xs:var(--text-xs);
405
- --font-size-sm:var(--text-sm);
406
- --font-size-base:var(--text-base);
407
- --font-size-lg:var(--text-lg);
408
- --font-size-xl:var(--text-xl);
409
- --font-size-2xl:var(--text-2xl);
410
- --font-size-3xl:var(--text-3xl);
411
- --font-size-4xl:var(--text-4xl);
412
- --font-size-5xl:var(--text-5xl);
413
- --font-size-6xl:var(--text-6xl);
414
- --font-size-7xl:var(--text-7xl);
415
- --font-size-8xl:var(--text-8xl);
416
- --font-size-9xl:var(--text-9xl);
417
-
418
- /* The size a FORM CONTROL renders at — and the ONE step in this scale that is
419
- not a constant. iOS Safari zooms the viewport whenever a focused input
420
- computes below 16px, and this scale's base is 14px, so every surface that
421
- sized a field from --text-sm or --text-base bought the zoom.
422
-
423
- It has to be a TOKEN rather than a rule because a control's size is almost
424
- always set inline (every component in components/forms does), and an inline
425
- style outranks any stylesheet — including a media query. A var() resolves
426
- per-device inside that inline style, so this is the only construction that
427
- actually reaches the control. Fields ask for --text-control; nothing else
428
- should. */
429
- --text-control:var(--text-sm);
430
-
431
- --weight-normal:400;
432
- --weight-medium:500;
433
- --weight-semibold:600;
434
- --weight-bold:700;
435
-
436
- --tracking-tight:-0.025em;
437
- --tracking-normal:0em;
438
- --tracking-wide:0.025em;
439
- --tracking-widest:0.1em; /* eyebrows / uppercase category labels */
440
-
441
- --leading-none:1;
442
- --leading-tight:1.25;
443
- --leading-snug:1.375;
444
- --leading-normal:1.5;
445
- --leading-relaxed:1.625;
446
- --leading-golden:1.618;
447
-
448
- /* named roles */
449
- --type-hero:600 var(--text-5xl)/1.05 var(--font-display);
450
- --type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
451
- --type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
452
- --type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
453
- --type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
454
- --type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
455
- --type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
456
- --type-eyebrow:600 0.625rem/1 var(--font-sans);
457
- }
458
-
459
- /* On touch, a control goes to 16px. Below that iOS Safari zooms the viewport on
460
- focus and never zooms back out, which is the single most common way a mobile
461
- form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
462
- the compact 13px field. */
463
- @media (pointer:coarse){
464
- :root{--text-control:calc(1rem * var(--type-scale, 1))}
465
- }
466
-
467
- /* ── tokens/spacing.css ─────────────────────────────────────── */
468
- /* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
469
- declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
470
- reference) — use it for editorial layouts, not for component padding. */
471
- :root{
472
- /* The density knob. 1 is the published spacing; every --space-* rung below
473
- multiplies by it, and --grid-gap-* reference those rungs rather than
474
- restating them, so gaps, padding and section rhythm move together.
475
- @hanzo/design's `vars({density})` emits exactly this name. */
476
- --density:1;
477
-
478
- --space-0:0;
479
- --space-1:calc(0.25rem * var(--density, 1));
480
- --space-2:calc(0.5rem * var(--density, 1));
481
- --space-3:calc(0.75rem * var(--density, 1));
482
- --space-4:calc(1rem * var(--density, 1));
483
- --space-5:calc(1.25rem * var(--density, 1));
484
- --space-6:calc(1.5rem * var(--density, 1));
485
- --space-8:calc(2rem * var(--density, 1));
486
- --space-10:calc(2.5rem * var(--density, 1));
487
- --space-12:calc(3rem * var(--density, 1));
488
- --space-14:calc(3.5rem * var(--density, 1));
489
- --space-16:calc(4rem * var(--density, 1));
490
- --space-20:calc(5rem * var(--density, 1));
491
- --space-24:calc(6rem * var(--density, 1));
492
- --space-32:calc(8rem * var(--density, 1));
493
-
494
- /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
495
- --golden-1:0.25rem;
496
- --golden-2:0.405rem;
497
- --golden-3:0.654rem;
498
- --golden-4:1.059rem;
499
- --golden-5:1.713rem;
500
- --golden-6:2.772rem;
501
- --golden-7:4.487rem;
502
- --golden-8:7.26rem;
503
- --golden-9:11.749rem;
504
- --golden-split:38.2% 61.8%; /* @kind other */
505
-
506
- /* layout rules (DESIGN.md §1.3) —
507
- MOBILE-FIRST: the values authored here are the PHONE values, and the
508
- min-width block at the bottom of this file scales them up. A surface that
509
- uses the raw token therefore gets a layout that already breathes correctly
510
- at 390px, with no media query of its own. (This is also the value the
511
- generator captures for tokens.gen.ts — first occurrence wins, and the
512
- authored default is the small one.) */
513
- --container-max:80rem; /* max-w-7xl — grids */
514
- --container-prose:48rem; /* max-w-3xl — centered text */
515
- --container-wide:72rem; /* max-w-6xl — landing sections */
516
- /* The page gutter also clears a notch. env() is 0px on every device without
517
- one, so this is exactly `1rem` in the ordinary case and the safe inset when
518
- there is something to avoid — no per-app work, no landscape clipping.
519
- Requires `<meta name="viewport" content="…,viewport-fit=cover">` on the
520
- host page; without it the UA reports 0 and the max() is inert, not wrong. */
521
- --gutter:max(1rem,var(--safe-left),var(--safe-right));
522
- --gutter-sm:1.5rem; /* sm:px-6 */
523
- --gutter-lg:2rem; /* lg:px-8 */
524
- --section-y:2.5rem; /* py-10 on a phone → py-16 at md */
525
- --section-y-lg:3.5rem; /* py-14 on a phone → py-24 at md */
526
- --hero-y:3rem; /* py-12 on a phone → py-20 at md */
527
- --hero-y-lg:4.5rem; /* py-18 on a phone → py-32 at md */
528
- --header-height:3.5rem; /* 56px is the phone bar; 64px from md */
529
-
530
- /* ——— touch ——— */
531
- /* 44px is the floor a pointer-coarse target may render at (Apple HIG 44pt /
532
- WCAG 2.5.5 AAA / 2.5.8 AA's 24px, taken at the higher bar). base.css spends
533
- it automatically under `@media (pointer:coarse)`, so a button does not have
534
- to opt in. It was previously a bare `min-height:44px` literal in
535
- hanzoai/id's stylesheet — one app knowing something the system did not. */
536
- --tap-target:44px;
537
-
538
- /* ——— controls ——— */
539
- /* A control's OWN height, which the floor above does not answer: 44px is the
540
- smallest a finger may aim at, not the size a thing should be. Nothing named
541
- the second question, so each surface answered it privately and they drifted
542
- — @hanzo/ui keeps `CONTROL_H = 36` in TypeScript, hanzoai/app pins a 30px
543
- `--control-h` over that same 36px library, and hanzoai/id spelled
544
- `min-height:52px` into three separate rules.
545
- The first three rungs ARE @hanzo/ui's size table (default/sm/lg), moved
546
- down to the layer that holds values so a host can answer it once instead of
547
- each library carrying a private copy. They sit under the tap floor on
548
- purpose: base.css lifts whichever one is spent to 44px under
549
- `pointer:coarse`, so one declaration serves a compact pointer control and a
550
- hittable touch one. -xl is what a surface earns when the control IS the
551
- content — one field, one button, nothing competing — where the default
552
- reads as one of a stack rather than as the thing the screen is for. */
553
- --control-height-sm:32px;
554
- --control-height:36px;
555
- --control-height-lg:40px;
556
- --control-height-xl:52px;
557
-
558
- /* ——— safe areas ——— */
559
- /* The notch/home-indicator insets, named once. Anything pinned to a viewport
560
- edge — a fixed header, a bottom bar, a sheet — adds the matching one. */
561
- --safe-top:env(safe-area-inset-top,0px);
562
- --safe-right:env(safe-area-inset-right,0px);
563
- --safe-bottom:env(safe-area-inset-bottom,0px);
564
- --safe-left:env(safe-area-inset-left,0px);
565
- }
566
-
567
- /* Scale up from the phone. `md` (48rem) is the same breakpoint grid.css
568
- declares and Tailwind compiles against — one value, not two that agree. */
569
- @media (min-width:48rem){
570
- :root{
571
- --section-y:4rem; /* py-16 — content sections */
572
- --section-y-lg:6rem; /* py-24 — landing sections */
573
- --hero-y:5rem; /* py-20 … */
574
- --hero-y-lg:8rem; /* … lg:py-32 */
575
- --header-height:4rem;
576
- }
577
- }
578
-
579
- /* ── tokens/grid.css ─────────────────────────────────────── */
580
- /* Grid — one definition of the field every Hanzo surface lays out on.
581
-
582
- Derived, not invented. Across hanzo.ai and hanzo.app the grid written by hand
583
- is overwhelmingly ONE grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` with a
584
- 1.5rem gutter (240 / 197 / 127 / 185 occurrences on hanzo.ai alone, and the
585
- same shape leads on app). 1, 2, 3 and 4 columns account for 97% of every
586
- grid-cols- in both repos, and all four divide 12 cleanly — so 12 is the base
587
- the spans are cut from, and the long tail (5, 6, 7, 8, 12) still lands on it.
588
-
589
- Containers, gutters and section rhythm are NOT restated here: spacing.css
590
- already owns --container-max / --container-prose / --container-wide /
591
- --gutter* / --section-y*, and a grid that redeclared them would be a second
592
- answer to a settled question. This file adds only what a grid knows that
593
- spacing does not — how many columns, how far apart, and where it reflows. */
594
- :root{
595
- /* The base. Spans are cut from twelve because 1/2/3/4/6 all divide it. */
596
- --grid-columns:12;
597
-
598
- /* Gutter. --grid-gap is the measured card-grid default (gap-6 leads hanzo.ai
599
- at 185 uses); tight and loose are the neighbouring steps that cover app's
600
- gap-4 (45) and the wide editorial grids (gap-8, 75). All three reference the
601
- space ramp — the grid does not get its own private set of distances. */
602
- --grid-gap:var(--space-6);
603
- --grid-gap-tight:var(--space-4);
604
- --grid-gap-loose:var(--space-8);
605
-
606
- /* Reflow points, byte-identical to the Tailwind v4 defaults these surfaces
607
- already compile against. Same NAMES and same VALUES on purpose: the utility
608
- layer and anything reading tokens then resolve one value, not two that agree
609
- by luck. md and lg carry ~85% of every breakpoint prefix in use; xl is 44
610
- occurrences across both repos and 2xl is 1. */
611
- --breakpoint-sm:40rem;
612
- --breakpoint-md:48rem;
613
- --breakpoint-lg:64rem;
614
- --breakpoint-xl:80rem;
615
- --breakpoint-2xl:96rem;
616
-
617
- /* Intrinsic card grids: state ONE track minimum instead of three column counts
618
- at three breakpoints. `repeat(auto-fit, minmax(var(--grid-card-min), 1fr))`
619
- reflows 1 -> 2 -> 3 at the same widths the explicit recipe does, and it does
620
- it from the space available rather than from the viewport — so a card grid
621
- inside a sidebar behaves correctly, which the breakpoint version cannot.
622
-
623
- 18rem is chosen, not rounded to: inside --container-max less --gutter-lg on
624
- both sides, lg (64rem) leaves ~60rem, where 3 tracks need 3x18 + 2x1.5 =
625
- 57rem and 4 would need 76.5rem; md (48rem) leaves ~44rem, where 2 tracks
626
- need 37.5rem and 3 would need 57rem. The measured 1/2/3 ladder falls out. */
627
- --grid-card-min:18rem;
628
- --grid-card-min-wide:24rem;
629
- }
630
-
631
- /* ── tokens/radius.css ─────────────────────────────────────── */
632
- :root{
633
- --radius:0.5rem; /* the base token (globals.css) */
634
- --radius-sm:0.375rem; /* rounded-md — buttons, inputs */
635
- --radius-md:0.5rem;
636
- --radius-lg:0.75rem; /* rounded-xl — cards */
637
- --radius-xl:1rem; /* rounded-2xl — dropdown panels */
638
- --radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
639
- --radius-composer:28px; /* the chat composer, exactly 28px */
640
- --radius-full:9999px; /* pills, CTAs, avatars, badges */
641
- }
642
-
643
- /* ── tokens/elevation.css ─────────────────────────────────────── */
644
- /* Elevation on a near-black ground is NOT a drop shadow. A dark blur on a dark
645
- page is nothing — you cannot cast a shadow onto black — which is why a system
646
- that ships only `box-shadow` reads flat no matter how heavy the alphas get.
647
-
648
- What actually lifts a surface here is LIGHT, and it arrives in three parts:
649
-
650
- 1. a hairline EDGE (tokens/colors.css --border)
651
- 2. an inset top HIGHLIGHT (--edge-highlight, below)
652
- 3. a wide, very dark DROP (--shadow-*)
653
-
654
- (2) is the one that was missing and the one that does most of the work: a 1px
655
- inset white line along the top edge reads as the surface catching the light
656
- from above, and it is the whole difference between a panel that sits ON the
657
- page and a rectangle that is merely a different colour than the page. The
658
- reference spends it on its login card (`inset 0 1px 0 rgb(255 255 255 / .05)`)
659
- and it is the single cheapest premium signal available on dark UI.
660
-
661
- They are separate tokens rather than one baked shadow because a component
662
- composes the ones it needs — `box-shadow: var(--edge-highlight), var(--shadow-lg)`
663
- — and a card that wants the highlight without the drop should not have to
664
- accept both. */
665
- :root{
666
- --shadow-none:none;
667
- /* The floating surface: dropdowns, dialogs, sheets, the composer. Wide, soft,
668
- and pushed well down. It was Tailwind's `shadow-2xl` at .25 alpha, which is
669
- invisible on #0a0a0a — so the surfaces that float highest had the weakest
670
- drop in the system while the t-shirt ramp below was correctly tuned. */
671
- --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .75);
672
- --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
673
-
674
- /* ——— the light ——— */
675
- /* The top edge, catching light. Add it to any raised surface. */
676
- --edge-highlight:inset 0 1px 0 0 rgb(255 255 255 / .10);
677
- /* The bloom around a LIT action on hover — a filled primary button, the send
678
- key. Monochrome brand: this is where "alive" comes from instead of a hue,
679
- and it is the whole hover signal on a fill that is already at the top of
680
- the ink ramp. It was .10, which is invisible: a white glow has to compete
681
- with the white object casting it, so the alpha that reads around a WHITE
682
- button is far higher than the one that reads around a dark one. */
683
- --bloom:0 0 24px -2px rgb(255 255 255 / .28);
684
- /* The focus halo: soft, outside the ring, carrying no ratio of its own. This
685
- was `0 0 0 2px var(--ring)` — a second hard ring around the hard ring. */
686
- --ring-focus:0 0 0 3px var(--ring-halo);
687
-
688
- /* The t-shirt ramp. Named by size rather than by role, because that is how
689
- every component library already asks for a shadow (and how @hanzo/brand
690
- spells it). Alphas are heavier than Tailwind's defaults: on a near-black
691
- ground a 10% black drop is invisible, so each rung is tuned to read on
692
- --background. --shadow-2xl and --shadow-floating are the same rung. */
693
- --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
694
- --shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
695
- --shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
696
- --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
697
- --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
698
- --shadow-2xl:var(--shadow-floating);
699
-
700
- /* ——— the paper ——— */
701
- /* Depth carries STATE, not decoration: which sheet is the workspace, which is
702
- the tool in use, which one opened. Reach for a level before a border — if
703
- two surfaces need separating, RAISE one rather than outline both, and a
704
- surface that is already raised does not also need a box drawn round it.
705
-
706
- A level is a tint AND the light on it, published as a pair, because the
707
- alternative — every component composing its own `--edge-highlight,
708
- --shadow-*` — is how two panels on one screen end up lit from two
709
- directions. Every rung here is pure vertical: a 1px inset line along the
710
- top edge, a drop straight down. One origin, above and slightly in front.
711
-
712
- The tints are the surface recipes rather than new values, so sheets stack
713
- the way paper does: each is an alpha-white wash, one laid on another lands
714
- a step lighter, and the stack converges on #262626 instead of blowing out.
715
-
716
- Nothing here is restated in `.light`, deliberately: every part defers to a
717
- token that already flips, so the ramp inverts with the theme for free. */
718
- --sheet-0:var(--surface-page); /* the workspace ground */
719
- --sheet-1:var(--surface-card); /* an active tool */
720
- --sheet-2:var(--surface-card-emphasis); /* what opened, or what is inside */
721
- /* The drops are spelled out rather than read from the t-shirt ramp above, and
722
- that is the whole reason this ladder can be shared. `--shadow-sm` is a name
723
- this package does not own — @hanzo/brand declares the same names at :root
724
- with white-canvas alphas, and load order decides who wins — so a rung built
725
- on one is a rung somebody else can flatten. `--shadow-sheet-*` is ours
726
- alone, which is what lets @hanzo/ui's `.elevation-*` read it instead of
727
- keeping a private copy. The numbers ARE rung 1 and rung 2 of the ramp; if
728
- one moves, move both. `--edge-highlight` stays deferred: it is role-named,
729
- uncollided, and its `.light` zeroing is what makes one value work on both
730
- canvases. */
731
- --shadow-sheet-1:var(--edge-highlight),0 1px 2px 0 rgb(0 0 0 / .40);
732
- --shadow-sheet-2:var(--edge-highlight),0 10px 15px -3px rgb(0 0 0 / .55),0 4px 6px -4px rgb(0 0 0 / .55);
733
-
734
- /* The fold — a corner turned back, and the one mark that means THIS OPENS.
735
- Ornament nowhere: a sheet that does not expand does not wear one.
736
- It is a background-image, so it costs no element and no pseudo-element —
737
- paint it on the sheet itself and square that corner
738
- (`border-bottom-right-radius:0`), because a folded corner is not round.
739
- The flap catches the light like every other surface; the crease is the
740
- hairline; both defer, so both invert. */
741
- --fold:12px;
742
- --fold-face:linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold)); /* @kind color */
743
-
744
- /* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
745
- --glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
746
- --glow-hero-blur:120px;
747
- /* Card top-corner sheen used on the story cards. */
748
- --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
749
- /* The panel's top hairline, brightest at the centre and dissolving before it
750
- reaches either corner — so an edge never terminates in a hard stop. One of
751
- the two details that make the reference's panels look machined rather than
752
- drawn (sidebar.css .panel::before). Paint it on a 1px-tall ::before. */
753
- --sheen-edge:linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent); /* @kind color */
754
- /* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
755
- --gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
756
- --gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
757
- /* Section-top protection gradient (hero overlays). */
758
- --gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
759
- }
760
-
761
- /* Light theme — the same three parts, redistributed.
762
- On white the order of work inverts: the DROP does almost all of it and the
763
- inset highlight does none, because a white line on a white card is nothing.
764
- Every token below is one whose dark value is white — i.e. one that silently
765
- stops existing in `.light` unless it is restated here. check-tokens.mjs
766
- scans this file for exactly that omission.
767
-
768
- --edge-highlight is `inset 0 0 0 0 transparent` rather than `none` on
769
- purpose: components compose these — `box-shadow: var(--edge-highlight),
770
- var(--shadow-lg)` — and `none` is not a legal layer in a comma list, so the
771
- obvious spelling would invalidate the whole declaration and take the drop
772
- shadow down with it. A zero-size transparent shadow composes to nothing and
773
- costs nothing. */
774
- .light{
775
- --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .18);
776
- /* On white the drop does all of it, so the paper rungs are restated here even
777
- though check 4 does not demand it — their white is inside --edge-highlight,
778
- which flips underneath. Rung 1 and rung 2 of the light ramp, same as dark. */
779
- --shadow-sheet-1:var(--edge-highlight),0 1px 2px 0 rgb(0 0 0 / .06);
780
- --shadow-sheet-2:var(--edge-highlight),0 10px 15px -3px rgb(0 0 0 / .09),0 4px 6px -4px rgb(0 0 0 / .07);
781
- --shadow-inset-hairline:inset 0 0 0 1px rgb(0 0 0 / .10);
782
- --edge-highlight:inset 0 0 0 0 transparent;
783
- --bloom:0 6px 20px -4px rgb(0 0 0 / .18);
784
- --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .06);
785
- --shadow:0 1px 3px 0 rgb(0 0 0 / .08), 0 1px 2px -1px rgb(0 0 0 / .06);
786
- --shadow-md:0 4px 6px -1px rgb(0 0 0 / .08), 0 2px 4px -2px rgb(0 0 0 / .06);
787
- --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .09), 0 4px 6px -4px rgb(0 0 0 / .07);
788
- --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .10), 0 8px 10px -6px rgb(0 0 0 / .08);
789
- --glow-hero:radial-gradient(circle,rgb(0 0 0 / .06) 0%,transparent 68%);
790
- --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(0 0 0 / .04) 0%,transparent 55%);
791
- --sheen-edge:linear-gradient(90deg,transparent,rgb(0 0 0 / .08) 50%,transparent);
792
- --gradient-chrome:linear-gradient(to right,#0a0a0a,rgb(10 10 10 / .8),rgb(10 10 10 / .6));
793
- --gradient-chrome-2:linear-gradient(to right,#0a0a0a,var(--neutral-500));
794
- --gradient-protect:linear-gradient(to bottom,rgb(0 0 0 / .10),transparent);
795
- }
796
-
797
- /* ── tokens/motion.css ─────────────────────────────────────── */
798
- /* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
799
- No springs, no bounce, no parallax, no autoplay carousels. */
800
- :root{
801
- --duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
802
- --duration-base:300ms; /* @kind other */ /* slide-up-fade */
803
- --duration-slow:400ms; /* @kind other */ /* hero element entry */
804
- --duration-slower:500ms; /* @kind other */ /* section entry */
805
- --duration-glow:9s; /* @kind other */ /* ambient radial breathe */
806
- --duration-press:90ms; /* @kind other */ /* the press-down on :active */
807
- --ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
808
- --ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
809
- /* The entrance curve. Nearly all of the distance is covered in the first
810
- third and it settles without overshooting, so a panel arrives DECIDED
811
- rather than drifting in — the difference between 300ms that feels
812
- immediate and 300ms that feels slow. Use it for anything that appears
813
- (menus, dialogs, sheets, toasts); --ease-out stays the curve for a state
814
- change on something already on screen. It is not a spring: this system has
815
- no bounce anywhere. */
816
- --ease-emphasis:cubic-bezier(0.16,1,0.3,1); /* @kind other */
817
- --stagger:60ms; /* @kind other */ /* per-element delay in a group */
818
- --entry-rise:16px; /* hero y-offset */
819
- --entry-rise-lg:24px; /* card y-offset */
820
- }
821
-
822
- @keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
823
- @keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
824
- @keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
825
- /* The entrance for anything that appears OVER the page — dialog, sheet, command
826
- palette. It arrives from very slightly small and very slightly low, which is
827
- what reads as "came forward" rather than "was toggled on". 3% and 8px: any
828
- more and it is an animation you notice, which is the failure mode. Pair it
829
- with --ease-emphasis. */
830
- @keyframes hanzo-zoom-in{from{opacity:0;transform:scale(.97) translateY(8px)}to{opacity:1;transform:none}}
831
- @keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
832
- @keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
833
-
834
- @media (prefers-reduced-motion:reduce){
835
- *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
836
- }
837
-
838
- /* ── tokens/z.css ─────────────────────────────────────── */
839
- /* Stacking order — the one z-index ladder. Layers are named by role, never by a
840
- magic number, so a dropdown opened from the fixed header always sits above it
841
- and nothing ever reaches for 9999. Below --z-raised is ordinary document flow.
842
-
843
- Rungs are 100 apart so a surface can slot a one-off BETWEEN two roles
844
- (calc(var(--z-modal) + 1)) without inventing a new decade. */
845
- :root{
846
- --z-base:0;
847
- --z-raised:10; /* hover-lifted cards, sticky table headers */
848
- --z-sticky:200; /* pinned section rails */
849
- --z-header:300; /* the fixed site header */
850
- --z-dropdown:400; /* menus, selects, comboboxes */
851
- --z-overlay:500; /* dialog / sheet scrim */
852
- --z-modal:600; /* dialogs, sheets, command palette */
853
- --z-popover:700; /* popovers, tooltips (also when anchored in modals) */
854
- --z-toast:800; /* toasts / notifications — always on top */
855
-
856
- /* @hanzo/brand spells the top two rungs --z-tooltip and --z-notification.
857
- Same rungs, so a component written against either vocabulary stacks
858
- identically. --z-popover / --z-toast are canonical. */
859
- --z-tooltip:var(--z-popover);
860
- --z-notification:var(--z-toast);
861
- }
862
-
863
- /* ── tokens/fonts.css ─────────────────────────────────────── */
864
- /* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
865
- SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
866
- variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Geist.txt).
867
-
868
- Why self-hosted rather than @import from fonts.googleapis.com:
869
- - A sign-in page must not make a third-party request. hanzoai/id refused to
870
- import this file for exactly that reason, which split the token layer: id
871
- took the colours and not the typeface. Self-hosting removes the reason, so
872
- every surface can import styles.css unchanged.
873
- - The @import was a render-blocking request to a host we do not control, on
874
- the critical path of every surface, and it broke offline/air-gapped dev.
875
- - One variable file per family replaces nine static weights, and it is fewer
876
- bytes than the CSS-then-woff2 round trip Google served.
877
-
878
- The url()s are relative to THIS file, so they resolve wherever the package is
879
- mounted — node_modules, a CDN, a copied dist — with no configuration. */
880
-
881
- @font-face{
882
- font-family:"Geist";
883
- src:url("./assets/fonts/Geist-Variable.woff2") format("woff2");
884
- font-weight:100 900;
885
- font-style:normal;
886
- font-display:swap;
887
- }
888
- @font-face{
889
- font-family:"Geist Mono";
890
- src:url("./assets/fonts/GeistMono-Variable.woff2") format("woff2");
891
- font-weight:100 900;
892
- font-style:normal;
893
- font-display:swap;
894
- }
895
-
896
- :root{
897
- --font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;
898
- --font-display:var(--font-sans);
899
- --font-mono:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
900
- --font-serif:Georgia,serif;
901
- /* hanzo.ai sets these OpenType features on <body>. */
902
- --font-feature-settings:"ss01","ss02","cv01","cv02","cv03";
903
- }
904
-
905
- /* ── tokens/base.css ─────────────────────────────────────── */
906
- /* Minimal element defaults so specimen cards and kits inherit the brand without
907
- a utility framework. Components carry their own styles inline.
908
-
909
- EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
910
- A rule outside a cascade layer beats a rule inside one no matter how specific
911
- the loser is — so an unlayered `a{color:...}` here outranked EVERY Tailwind
912
- text utility on every anchor, on every surface that imports these tokens. A
913
- white-on-white primary button is what that looks like: `text-neutral-950` on
914
- an anchor, silently overridden to --text-primary, invisible on its own white
915
- fill, and reading as a rendering glitch rather than a cascade bug.
916
-
917
- The same trap already bit `text-decoration` here once (see the a:hover note
918
- below). It was fixed by deleting that one declaration, which left the identical
919
- defect in `color` untouched. Layering fixes the whole class: these are
920
- DEFAULTS, and a default must lose to anything an app states deliberately.
921
-
922
- The rules added for controls, media and touch go one step further and wrap
923
- their selectors in :where(), which has ZERO specificity. @layer decides who
924
- wins BETWEEN layers; :where() decides who wins INSIDE this one. An app that
925
- ships its own reset — `input { … }`, `img { … }` — very often lands in the
926
- same base layer, and these defaults have to lose that collision too. */
927
- @layer base {
928
- *{box-sizing:border-box;border-color:var(--border)}
929
- html{
930
- -webkit-font-smoothing:antialiased;
931
- text-rendering:optimizeLegibility;
932
- scroll-behavior:smooth;
933
- /* iOS inflates text when a phone is rotated to landscape unless this is
934
- pinned. It is the single most common reason a mobile layout that was
935
- verified in portrait comes apart on its side. */
936
- -webkit-text-size-adjust:100%;
937
- }
938
- /* Each font-family carries the stack as a literal fallback. --font-sans lives in
939
- tokens/fonts.css, which a surface may legitimately import separately; without
940
- the fallback an unresolved var() makes font-family invalid and the UA drops to
941
- its SERIF default — the whole console silently rendered in Times. */
942
- 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}
943
- 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)}
944
- p{margin:0;text-wrap:pretty}
945
- code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
946
- /* No `transition` here: the shared interactive rule below states one, and an
947
- `a` selector (0,0,1) would outrank that :where() (0,0,0) and replace the
948
- whole shorthand — anchors would transition colour and nothing else. */
949
- a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px}
950
- /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
951
- bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
952
- blanket `a:hover` underlined every one of them on every surface that imports
953
- these tokens. Scope it to the elements that actually carry prose; a link that
954
- is a component states its own hover. The old rule also re-set `color` to the
955
- value `a` already has — a no-op that only served to outrank a component's own
956
- hover colour. */
957
- :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
958
-
959
- /* ——— the smooth ———
960
- Every interactive element in the reference transitions its colour, its fill
961
- and its EDGE. That is most of what separates "classy and smooth" from a
962
- correct set of colours: a hairline that steps from .10 to .16 instantly
963
- reads as a flicker, and over 150ms reads as a response. Granted once, here,
964
- rather than restated on every component. tokens/motion.css already zeroes
965
- all of it under prefers-reduced-motion. */
966
- :where(a,button,[role=button],input,select,textarea,summary,[tabindex]){
967
- transition:color var(--duration-fast) var(--ease-out),
968
- background-color var(--duration-fast) var(--ease-out),
969
- border-color var(--duration-fast) var(--ease-out),
970
- box-shadow var(--duration-fast) var(--ease-out),
971
- opacity var(--duration-fast) var(--ease-out),
972
- transform var(--duration-press) var(--ease-out);
973
- }
974
-
975
- /* ——— the press ———
976
- A button that moves under the finger is the cheapest "this is a real
977
- control" signal there is, and its absence is most of why a correct-looking
978
- button still feels like a picture of a button. 2% over 90ms — small enough
979
- that it reads as pressure rather than as animation, and it is the
980
- reference's own value (.auth-signin-btn:active). Inputs are excluded: a
981
- text field does not depress, and scaling one mid-caret is a glitch. */
982
- :where(button,[role=button],a[role=button],summary):active:not(:disabled){
983
- transform:scale(.98);
984
- }
985
-
986
- /* ——— controls ———
987
- A bare <input> renders in the UA's face (Arial) at the UA's size with the
988
- UA's border, which puts two typefaces and a foreign blue focus ring inside
989
- Hanzo cards on every surface that has not styled its own fields yet. The
990
- rules below are what a control looks like here. --border-control, NOT
991
- --border: a field's edge is its own duty and its own rung, so a surface can
992
- retune fields without touching every divider on the page. It owes no
993
- contrast ratio — the whole budget goes to --ring, below. */
994
- :where(input,select,textarea,button){font:inherit;color:inherit}
995
- :where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
996
- background:var(--surface-2);
997
- border:1px solid var(--border-control);
998
- /* --radius-md (8px), not --radius-sm (6px). On a 36px control the two are
999
- one step apart and it is the step between "boxy" and "considered";
1000
- --radius-sm keeps its job on genuinely small parts — badges, chips, menu
1001
- rows — where 8px would look bubbly. The reference draws its composer and
1002
- its buttons at 8-10px and nothing at 6px except 22px chips. */
1003
- border-radius:var(--radius-md);
1004
- padding:0 var(--space-3);
1005
- /* A field's ring is drawn INSIDE its own box. The one focus rule below
1006
- reads this; nothing else about the indicator changes.
1007
-
1008
- A field lives in containers a button does not — composers, palettes and
1009
- panels that round their corners with `overflow:hidden`. An outline at a
1010
- POSITIVE offset is painted outside the border box, so any such ancestor
1011
- clips it, and the computed style still says `2px solid` while nothing is
1012
- drawn: a chat composer measured byte-identical screenshots focused and
1013
- unfocused, and a search pill kept only its left and right edges. A
1014
- negative offset cannot be clipped by anything, so a field needs to know
1015
- nothing about what contains it.
1016
-
1017
- -2 against a 2px ring lays the outline over the outermost 2px of the box,
1018
- so it REPLACES the 1px edge for as long as focus is there rather than
1019
- drawing a second line a pixel inside the first. */
1020
- --ring-offset:-2px;
1021
- }
1022
- :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
1023
- /* A field has NO focus rule of its own — see the ring at the bottom of this
1024
- layer, which is the one focus indicator for everything.
1025
-
1026
- There used to be one here: `outline:none` plus a brightened edge (.15 ->
1027
- .22) plus a soft halo, the composer look. It was removed because it could
1028
- not do the job in either of the two ways that matter.
1029
-
1030
- It was never VISIBLE ENOUGH. Composited on --background the brightened edge
1031
- measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
1032
- of a focus indicator and that this package already gates --ring on. The
1033
- budget was documented as being spent entirely on --ring "because that is
1034
- what a keyboard user navigates by" — true for a button, and false for a
1035
- field for exactly as long as this rule told fields not to use it.
1036
-
1037
- And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
1038
- app that states `border` on its own fields overrode it — @hanzo/id does,
1039
- unlayered, which beats this layer whatever its specificity, and its focused
1040
- fields sat at the resting .15 while both files read as correct. An outline
1041
- is not a border: nothing in an app's field styling reaches it, so the ring
1042
- paints whether or not the app has opinions about edges. */
1043
- :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
1044
- :where(input,textarea)::placeholder{color:var(--text-disabled)}
1045
- :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
1046
- :where(button,[role=button],summary,label,select){cursor:pointer}
1047
-
1048
- /* ——— media ———
1049
- An image, a video or an embed at its intrinsic width is the usual cause of a
1050
- 390px page that scrolls sideways. */
1051
- :where(img,svg,video,canvas,iframe,picture,object){max-width:100%}
1052
- :where(img,video){height:auto}
1053
- /* A code block scrolls ITSELF rather than widening the document. */
1054
- :where(pre){overflow-x:auto}
1055
-
1056
- /* ——— touch ———
1057
- Everything a finger aims at clears 44px, and every field renders at 16px so
1058
- iOS does not zoom the viewport on focus. Both are keyed on pointer:coarse —
1059
- the actual signal — so a desktop mouse still gets compact controls. This is
1060
- the whole of "mobile-first without per-app work": an app that never thought
1061
- about phones still has hittable buttons and a viewport that stays put. */
1062
- @media (pointer:coarse){
1063
- :where(button,[role=button],a[role=button],summary,select,textarea,
1064
- input:not([type=checkbox]):not([type=radio]):not([type=hidden])){
1065
- min-height:var(--tap-target);
1066
- }
1067
- :where(input,select,textarea){font-size:var(--text-control)}
1068
- /* A checkbox, radio or switch must NOT grow to 44px — the box is 16px
1069
- because that is what a checkbox looks like. What has to reach 44px is the
1070
- area a finger may land in, so the target is expanded with a centred
1071
- pseudo-element that changes nothing about layout or paint. WCAG 2.5.8 puts
1072
- the floor at 24px; a 16px box misses it, and every one of these ships at
1073
- 16px. */
1074
- :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio]){position:relative}
1075
- :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio])::after{
1076
- content:'';position:absolute;top:50%;left:50%;
1077
- width:var(--tap-target);height:var(--tap-target);
1078
- transform:translate(-50%,-50%);
1079
- }
1080
- }
1081
-
1082
- /* THE focus indicator. One rule, every focusable thing, no exceptions — a
1083
- button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
1084
- canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
1085
- check-tokens holds --ring to that and nothing else here may weaken it.
1086
-
1087
- WHERE the ring sits is the one thing that varies, and it varies as a VALUE
1088
- the element carries, never as a second rule: `--ring-offset` defaults to 2
1089
- and a field sets it to -2 (see the field block above). Two rules is what
1090
- 0.4.9 removed, and a second one here would be the same defect again.
1091
-
1092
- There were two rules until 0.4.9, and they collided invisibly. Both computed
1093
- to (0,1,0) — :where() zeroes whatever it wraps, leaving one pseudo-class on
1094
- each side — so the cascade fell through to SOURCE ORDER inside this layer,
1095
- this rule was written later, and it overrode the `outline:none` the field
1096
- rule stated expressly to prevent it. Every focused input on every consumer
1097
- drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
1098
- is why it survived review in both files; the defect existed only in their
1099
- order. One rule cannot disagree with itself. */
1100
- :focus-visible{outline:2px solid var(--ring);outline-offset:var(--ring-offset,2px)}
1101
- /* --white-20 is white-on-white in the light theme, so selection reads through
1102
- --selection, which BOTH themes define. */
1103
- ::selection{background:var(--selection);color:var(--text-primary)}
1104
-
1105
- /* ——— the scrollbar ———
1106
- The single largest piece of UNSTYLED chrome left on a dark surface: a
1107
- default 15px light-grey scrollbar down the side of a #0a0a0a panel is the
1108
- one element on the page that did not get the memo, and it is visible on
1109
- every scrolling surface in the product. 6px, no track, thumb on the same
1110
- hairline ladder as everything else. Both syntaxes ship because they are
1111
- disjoint — `scrollbar-*` is Firefox and the `::-webkit-*` pseudos are
1112
- Chromium/Safari; neither engine reads the other.
1113
- The thumb is a --white-* rung, so it does not invert; the `.light`
1114
- restatements sit here rather than in colors.css because a scrollbar is an
1115
- element default and this file is where element defaults live. Everything
1116
- stays INSIDE the layer — an unlayered rule here would outrank an app's own
1117
- utilities, which is the defect check 1c exists to catch. */
1118
- :where(html){scrollbar-color:var(--white-15) transparent;scrollbar-width:thin}
1119
- .light{scrollbar-color:rgb(0 0 0 / .18) transparent}
1120
- :where(*)::-webkit-scrollbar{width:6px;height:6px}
1121
- :where(*)::-webkit-scrollbar-track{background:transparent}
1122
- :where(*)::-webkit-scrollbar-thumb{background:var(--white-15);border-radius:var(--radius-full)}
1123
- :where(*)::-webkit-scrollbar-thumb:hover{background:var(--white-30)}
1124
- :where(*)::-webkit-scrollbar-corner{background:transparent}
1125
- .light :where(*)::-webkit-scrollbar-thumb{background:rgb(0 0 0 / .18)}
1126
- .light :where(*)::-webkit-scrollbar-thumb:hover{background:rgb(0 0 0 / .32)}
1127
- }
1128
-
1129
-
1130
- @theme inline {
1131
- --color-background: var(--background);
1132
- --color-foreground: var(--foreground);
1133
- --color-card: var(--card);
1134
- --color-card-foreground: var(--card-foreground);
1135
- --color-popover: var(--popover);
1136
- --color-popover-foreground: var(--popover-foreground);
1137
- --color-primary: var(--primary);
1138
- --color-primary-foreground: var(--primary-foreground);
1139
- --color-secondary: var(--secondary);
1140
- --color-secondary-foreground: var(--secondary-foreground);
1141
- --color-muted: var(--muted);
1142
- --color-muted-foreground: var(--muted-foreground);
1143
- --color-accent: var(--accent);
1144
- --color-accent-foreground: var(--accent-foreground);
1145
- --color-destructive: var(--destructive);
1146
- --color-destructive-foreground: var(--destructive-foreground);
1147
- --color-border: var(--border);
1148
- --color-input: var(--input);
1149
- --color-ring: var(--ring);
1150
-
1151
- --color-error: var(--state-error);
1152
- --color-success: var(--state-success);
1153
- --color-online: var(--state-online);
1154
-
1155
- --radius-sm: var(--radius-sm);
1156
- --radius-md: var(--radius-md);
1157
- --radius-lg: var(--radius-lg);
1158
- --radius-xl: var(--radius-xl);
1159
-
1160
- --font-sans: var(--font-sans);
1161
- --font-mono: var(--font-mono);
1162
- }