@hanzo/design 0.4.0 → 0.4.2

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 CHANGED
@@ -39,9 +39,25 @@
39
39
  /* Hanzo is monochrome. One hue rendered through an opacity ladder.
40
40
  Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
41
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"). */
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. */
43
57
 
44
58
  :root{
59
+ color-scheme:dark;
60
+
45
61
  /* ——— base neutral ladder ——— */
46
62
  --neutral-50:#FAFAFA;
47
63
  --neutral-100:#F5F5F5;
@@ -61,77 +77,160 @@
61
77
  --hanzo-white:#FFFFFF;
62
78
 
63
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. */
64
82
  --white-05:rgb(255 255 255 / .05);
83
+ --white-06:rgb(255 255 255 / .06);
84
+ --white-08:rgb(255 255 255 / .08);
65
85
  --white-10:rgb(255 255 255 / .10);
66
86
  --white-15:rgb(255 255 255 / .15);
87
+ --white-16:rgb(255 255 255 / .16);
67
88
  --white-20:rgb(255 255 255 / .20);
89
+ --white-22:rgb(255 255 255 / .22);
68
90
  --white-30:rgb(255 255 255 / .30);
69
91
  --white-40:rgb(255 255 255 / .40);
92
+ --white-55:rgb(255 255 255 / .55);
70
93
  --white-60:rgb(255 255 255 / .60);
94
+ --white-78:rgb(255 255 255 / .78);
71
95
  --white-80:rgb(255 255 255 / .80);
72
96
 
73
97
  /* ——— semantic aliases (dark, the default) ——— */
74
- --background:#000000;
75
- --foreground:#ededed;
76
- --card:#0a0a0a;
77
- --card-foreground:#f5f5f5;
78
- --popover:#0a0a0a;
79
- --popover-foreground:#f5f5f5;
80
- --primary:#ffffff;
81
- --primary-foreground:#000000;
82
- --secondary:#1a1a1a;
83
- --secondary-foreground:#f5f5f5;
84
- --muted:#101010;
85
- --muted-foreground:#888888;
86
- --accent:#1a1a1a;
87
- --accent-foreground:#f5f5f5;
88
- --destructive:#666666;
89
- --destructive-foreground:#f5f5f5;
90
- --border:#1f1f1f;
91
- --input:#1f1f1f;
98
+ --background:#0a0a0a;
99
+ --foreground:#fafafa;
100
+ --card:#0f0f0f;
101
+ --card-foreground:#fafafa;
102
+ --popover:#0f0f0f;
103
+ --popover-foreground:#fafafa;
104
+ --primary:#fafafa;
105
+ /* A 5% step, not the 16% this used to be. A filled button's fill is already
106
+ the top of the ink ramp, so the only directions available on hover are
107
+ "dimmer" and "brighter-around-the-edges" — and a 16% drop reads as the
108
+ button going DISABLED under the cursor, which is the one thing a hover must
109
+ never look like. The step is now small enough to register as pressure while
110
+ --bloom does the actual work of lighting it up. */
111
+ --primary-hover:#e5e5e5;
112
+ --primary-foreground:#0a0a0a;
113
+ --secondary:#262626;
114
+ /* One rung up, so a secondary button HAS a hover. It previously resolved to
115
+ --surface-3, which is --secondary — the same value it starts at. */
116
+ --secondary-hover:#333333;
117
+ --secondary-foreground:#fafafa;
118
+ --muted:#171717;
119
+ --muted-foreground:#a3a3a3;
120
+ --accent:#262626;
121
+ --accent-foreground:#fafafa;
122
+ /* Destructive is the ONE place the monochrome rule already yields, and it was
123
+ yielding in only half the system: a field that fails validation draws
124
+ --state-error (#ef4444, which DESIGN.md §2.4 lists as permitted precisely
125
+ for "destructive / blocking error"), while the button that performs the
126
+ destruction drew #666666 — a mid-grey that is indistinguishable from a
127
+ disabled control. "Delete everything" and "you cannot click this" must not
128
+ be the same colour. So the two converge on the one token that already
129
+ names this meaning, and the ramp stays monochrome everywhere else. */
130
+ --destructive:var(--state-error);
131
+ --destructive-hover:#dc2626; /* red-600 — deepens under the cursor */
132
+ --destructive-foreground:#fafafa;
133
+
134
+ /* ——— boundaries ———
135
+ ONE ladder, cut from alpha, graded by duty. Every rung is a rung of the
136
+ opacity ladder above, so every boundary composites correctly on the page,
137
+ on a card, and inside a popover — and every rung is the reference's own
138
+ measured value, not a rounded ramp:
139
+
140
+ --border .10 the hairline. Cards, panels, dividers, list rows.
141
+ --border-strong .16 the same hairline, one step up. Hover, emphasis.
142
+ --border-control .15 a control's resting edge — input, select, textarea.
143
+ --border-focus .22 that control, focused.
144
+ --border-selected .30 the thing that is currently CHOSEN — the active
145
+ tab, the current tile, the featured plan.
146
+
147
+ The previous release cut the last three from the NEUTRAL ladder instead,
148
+ because a boundary that clears WCAG 1.4.11's 3:1 on a near-black ground IS
149
+ a mid-grey and no amount of tuning changes that. It is true, and it is also
150
+ what a wireframe looks like: a 1px #737373 box around every field, four
151
+ times heavier than the hairline beside it, so a form read as a debug
152
+ overlay rather than a surface. Aesthetics decided it — the reference draws
153
+ an input at rgb(255 255 255 / .15) and a focused one at .22 (sidebar.css
154
+ --input-border / .composer-box:focus-within), and that is what ships here.
155
+
156
+ What replaces the ratio is the FOCUS INDICATOR, which is the thing a
157
+ keyboard user actually navigates by and the one boundary still pinned at
158
+ 3:1 (--ring, below). A resting edge is an affordance; a focus ring is a
159
+ position. Only the second one is load-bearing when you cannot see well, and
160
+ spending the contrast budget there rather than on all four edges of every
161
+ idle field is what lets the system be quiet AND findable at once. */
162
+ --border:var(--white-10);
163
+ --border-strong:var(--white-16);
164
+ --border-control:var(--white-15); /* sidebar.css --input-border */
165
+ --border-focus:var(--white-22); /* .composer-box:focus-within */
166
+ --border-selected:var(--white-30); /* the current tab / tile / plan */
167
+ --input:var(--border-control); /* shadcn `border-input` — a control */
92
168
  /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
93
- 3:1 against every surface it can land on. --neutral-500 is the only rung on
94
- this ladder that clears 3:1 on all of them #000000, #0a0a0a, #101010,
95
- #1a1a1a, AND the light theme's #ffffff/#f5f5f5 so one value serves both
96
- themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
97
- --ring:var(--neutral-500);
169
+ 3:1 against every surface it can land on, and unlike a resting edge it is
170
+ worth the loudness it is transient, it only appears on keyboard focus,
171
+ and it is the whole of how a keyboard user knows where they are.
172
+ --white-40 clears it on every canvas in both themes precisely BECAUSE it is
173
+ alpha: on a lifted surface the ring lifts with it (3.77:1 on #0a0a0a,
174
+ 3.66:1 on #262626), which a fixed grey cannot do. */
175
+ --ring:var(--white-40);
176
+ /* The soft halo that sits outside the ring — the reference's
177
+ `box-shadow: 0 0 0 2px rgb(255 255 255 / .06)` on a focused composer. It
178
+ carries no ratio; it is what makes focus read as designed rather than as a
179
+ browser default. Spent through --ring-focus (tokens/elevation.css). */
180
+ --ring-halo:var(--white-10);
98
181
  --brand:#e4e4e7;
99
182
  --brand-foreground:#09090b;
100
183
  --brand-muted:#a3a3a3;
101
184
  --black:#000000;
102
- --white:#f5f5f5;
103
-
104
- /* ——— surface recipes (card fills used across hanzo.ai) ——— */
185
+ --white:#fafafa;
186
+ --selection:var(--white-20);
187
+
188
+ /* ——— glass: the lift ———
189
+ How a surface rises WITHOUT a block fill. An alpha-white wash composites
190
+ over whatever it lands on, so one value works on the page, on a card and
191
+ inside a popover. This is the mechanism behind the reference's depth. */
192
+ --glass:var(--white-05);
193
+ --glass-strong:var(--white-08);
194
+
195
+ /* ——— surface recipes ———
196
+ A card has to be a SURFACE, which means you have to be able to see that it
197
+ is not the page. These were cut from neutral-900 (#171717) at .4/.5/.8, and
198
+ at .5 that composites to #101010 on a #0a0a0a ground — six levels above the
199
+ page, which is not a surface, it is a very slightly different black. Three
200
+ cards side by side then read as one flat field with hairlines ruled across
201
+ it, and no amount of shadow fixes it, because you cannot cast a shadow onto
202
+ black.
203
+
204
+ Cut from neutral-800 (#262626) instead, they land on 14 / 18 / 1f — the
205
+ reference's own panel (#171717 on a #0f0f0f container) with room either
206
+ side of it. They stay ALPHA so a card nested in a popover still lifts off
207
+ its parent rather than punching a hole in it; that is the whole reason
208
+ these are recipes and not just --surface-* rungs. */
105
209
  --surface-page:var(--background);
106
- --surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 grid tiles */
107
- --surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
108
- --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
109
- --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
210
+ --surface-card:rgb(38 38 38 / .5); /* -> #181818 on the page */
211
+ --surface-card-emphasis:rgb(38 38 38 / .75);/* -> #1f1f1f — featured */
212
+ --surface-card-quiet:rgb(38 38 38 / .35); /* -> #141414 — story cards */
213
+ /* A floating panel that is the SAME colour as the page is not floating. This
214
+ was rgb(10 10 10 / .95) — the page, at 95% of the page. */
215
+ --surface-overlay:rgb(23 23 23 / .92); /* dropdown / popover panels */
110
216
  --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
111
217
  --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
112
- /* Boundaries come in two kinds and they are NOT interchangeable.
113
- DECORATIVE (--border, --border-hairline, --border-card): separates content;
114
- WCAG imposes no ratio. Keep them quiet.
115
- PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
116
- switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
117
- Reach for --border-strong whenever the boundary IS the affordance. */
118
- --border-hairline:var(--neutral-800);
119
- --border-card:var(--white-10);
120
- --border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
121
218
 
122
219
  /* ——— the numeric surface ladder ——— */
123
220
  /* Aliases onto the semantic canvases above, so a brand fork that retunes
124
221
  --card/--muted/--secondary retunes the ladder with it and the light theme
125
- inverts for free. Ascending lift: 0 is the page, 3 is a hovered control. */
222
+ inverts for free. Ascending lift: 0 is the page, 3 is a hovered control.
223
+ Dark resolves to the reference's own ladder — 0a / 0f / 17 / 26. */
126
224
  --surface-0:var(--background);
127
225
  --surface-1:var(--card);
128
226
  --surface-2:var(--muted);
129
227
  --surface-3:var(--secondary);
130
228
 
131
- /* ——— text ranks ——— */
132
- --text-primary:var(--pure-white);
133
- --text-secondary:var(--white-80);
134
- --text-tertiary:var(--white-60);
229
+ /* ——— text ranks ———
230
+ A graded ramp, not one flat white. The steps are the reference's. */
231
+ --text-primary:#fafafa;
232
+ --text-secondary:var(--white-78);
233
+ --text-tertiary:var(--white-55);
135
234
  --text-helper:var(--muted-foreground);
136
235
  --text-disabled:var(--white-30);
137
236
 
@@ -146,47 +245,63 @@
146
245
  --chrome-dot-green:rgb(34 197 94 / .6);
147
246
  }
148
247
 
149
- /* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
248
+ /* Light theme — the same tokens, inverted.
249
+ The white-opacity ladder does NOT invert: --white-16 is white-on-white here.
250
+ So EVERY token whose dark value is a --white-* rung must be restated below,
251
+ and check-tokens.mjs fails the build if one is missed — that class of bug is
252
+ silent (the border simply stops existing) and has shipped before. */
150
253
  .light{
254
+ color-scheme:light;
151
255
  --background:#ffffff;
152
256
  --foreground:#0a0a0a;
153
- --card:#f5f5f5;
257
+ /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
258
+ dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
259
+ --card:#fafafa;
154
260
  --card-foreground:#0a0a0a;
155
261
  --popover:#ffffff;
156
262
  --popover-foreground:#0a0a0a;
157
263
  --primary:#0a0a0a;
158
- --primary-foreground:#ffffff;
159
- --secondary:#f5f5f5;
264
+ --primary-hover:#262626;
265
+ --primary-foreground:#fafafa;
266
+ --secondary:#ededed;
267
+ --secondary-hover:#e0e0e0;
160
268
  --secondary-foreground:#0a0a0a;
161
269
  --muted:#f5f5f5;
162
270
  --muted-foreground:#525252;
163
- --accent:#f5f5f5;
271
+ --accent:#ededed;
164
272
  --accent-foreground:#0a0a0a;
165
- --destructive:#999999;
273
+ --destructive:var(--state-error);
274
+ --destructive-hover:#dc2626;
166
275
  --destructive-foreground:#ffffff;
167
- --border:#e5e5e5;
168
- --input:#e5e5e5;
169
- /* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
170
- focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
171
- #f5f5f5, so ONE value is conformant in both themes. */
172
- --ring:var(--neutral-500);
276
+ --border:rgb(0 0 0 / .10);
277
+ --border-strong:rgb(0 0 0 / .16);
278
+ --border-control:rgb(0 0 0 / .15);
279
+ /* Focus and selection are pushed further here than the .22/.30 they sit at in
280
+ dark. Black-on-white at a given alpha reads FAINTER than white-on-black at
281
+ the same alpha — .22 black on #ffffff measures 1.69:1 where .22 white on
282
+ #0a0a0a measures 1.97:1 — so mirroring the number would quietly make the
283
+ light theme's focus state the weakest state in the system. */
284
+ --border-focus:rgb(0 0 0 / .32);
285
+ --border-selected:rgb(0 0 0 / .42);
286
+ --input:var(--border-control);
287
+ /* Same duty, same 3:1 floor, opposite direction: .50 black is 3.98:1 on
288
+ #ffffff and 3.67:1 on #f5f5f5. */
289
+ --ring:rgb(0 0 0 / .5);
290
+ --ring-halo:rgb(0 0 0 / .07);
173
291
  --black:#0a0a0a;
174
292
  --white:#ffffff;
175
- --surface-card:#f5f5f5;
293
+ --selection:rgb(0 0 0 / .16);
294
+ --glass:rgb(0 0 0 / .04);
295
+ --glass-strong:rgb(0 0 0 / .07);
296
+ --surface-card:#fafafa;
176
297
  --surface-card-emphasis:#ffffff;
177
- --surface-card-quiet:#fafafa;
298
+ --surface-card-quiet:#fcfcfc;
178
299
  --surface-overlay:rgb(255 255 255 / .95);
179
300
  --surface-header:rgb(255 255 255 / .8);
180
301
  --surface-scrim:rgb(0 0 0 / .5);
181
- --border-hairline:var(--neutral-200);
182
- --border-card:rgb(0 0 0 / .1);
183
- --border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
184
- /* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
185
- here (1.00:1). Anything that needs a visible edge in BOTH themes must use
186
- --border-strong, never a --white-* rung. */
187
302
  --text-primary:var(--neutral-950);
188
- --text-secondary:rgb(10 10 10 / .8);
189
- --text-tertiary:rgb(10 10 10 / .6);
303
+ --text-secondary:rgb(10 10 10 / .78);
304
+ --text-tertiary:rgb(10 10 10 / .55);
190
305
  --text-disabled:rgb(10 10 10 / .3);
191
306
  }
192
307
 
@@ -229,6 +344,19 @@
229
344
  --font-size-8xl:var(--text-8xl);
230
345
  --font-size-9xl:var(--text-9xl);
231
346
 
347
+ /* The size a FORM CONTROL renders at — and the ONE step in this scale that is
348
+ not a constant. iOS Safari zooms the viewport whenever a focused input
349
+ computes below 16px, and this scale's base is 14px, so every surface that
350
+ sized a field from --text-sm or --text-base bought the zoom.
351
+
352
+ It has to be a TOKEN rather than a rule because a control's size is almost
353
+ always set inline (every component in components/forms does), and an inline
354
+ style outranks any stylesheet — including a media query. A var() resolves
355
+ per-device inside that inline style, so this is the only construction that
356
+ actually reaches the control. Fields ask for --text-control; nothing else
357
+ should. */
358
+ --text-control:var(--text-sm);
359
+
232
360
  --weight-normal:400;
233
361
  --weight-medium:500;
234
362
  --weight-semibold:600;
@@ -257,6 +385,14 @@
257
385
  --type-eyebrow:600 0.625rem/1 var(--font-sans);
258
386
  }
259
387
 
388
+ /* On touch, a control goes to 16px. Below that iOS Safari zooms the viewport on
389
+ focus and never zooms back out, which is the single most common way a mobile
390
+ form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
391
+ the compact 13px field. */
392
+ @media (pointer:coarse){
393
+ :root{--text-control:1rem}
394
+ }
395
+
260
396
  /* ── tokens/spacing.css ─────────────────────────────────────── */
261
397
  /* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
262
398
  declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
@@ -290,18 +426,57 @@
290
426
  --golden-9:11.749rem;
291
427
  --golden-split:38.2% 61.8%; /* @kind other */
292
428
 
293
- /* layout rules (DESIGN.md §1.3) */
429
+ /* layout rules (DESIGN.md §1.3)
430
+ MOBILE-FIRST: the values authored here are the PHONE values, and the
431
+ min-width block at the bottom of this file scales them up. A surface that
432
+ uses the raw token therefore gets a layout that already breathes correctly
433
+ at 390px, with no media query of its own. (This is also the value the
434
+ generator captures for tokens.gen.ts — first occurrence wins, and the
435
+ authored default is the small one.) */
294
436
  --container-max:80rem; /* max-w-7xl — grids */
295
437
  --container-prose:48rem; /* max-w-3xl — centered text */
296
438
  --container-wide:72rem; /* max-w-6xl — landing sections */
297
- --gutter:1rem; /* px-4 */
439
+ /* The page gutter also clears a notch. env() is 0px on every device without
440
+ one, so this is exactly `1rem` in the ordinary case and the safe inset when
441
+ there is something to avoid — no per-app work, no landscape clipping.
442
+ Requires `<meta name="viewport" content="…,viewport-fit=cover">` on the
443
+ host page; without it the UA reports 0 and the max() is inert, not wrong. */
444
+ --gutter:max(1rem,var(--safe-left),var(--safe-right));
298
445
  --gutter-sm:1.5rem; /* sm:px-6 */
299
446
  --gutter-lg:2rem; /* lg:px-8 */
300
- --section-y:4rem; /* py-16 content sections */
301
- --section-y-lg:6rem; /* py-24 landing sections */
302
- --hero-y:5rem; /* py-20 */
303
- --hero-y-lg:8rem; /* lg:py-32 */
304
- --header-height:4rem;
447
+ --section-y:2.5rem; /* py-10 on a phone → py-16 at md */
448
+ --section-y-lg:3.5rem; /* py-14 on a phone → py-24 at md */
449
+ --hero-y:3rem; /* py-12 on a phone → py-20 at md */
450
+ --hero-y-lg:4.5rem; /* py-18 on a phone → py-32 at md */
451
+ --header-height:3.5rem; /* 56px is the phone bar; 64px from md */
452
+
453
+ /* ——— touch ——— */
454
+ /* 44px is the floor a pointer-coarse target may render at (Apple HIG 44pt /
455
+ WCAG 2.5.5 AAA / 2.5.8 AA's 24px, taken at the higher bar). base.css spends
456
+ it automatically under `@media (pointer:coarse)`, so a button does not have
457
+ to opt in. It was previously a bare `min-height:44px` literal in
458
+ hanzoai/id's stylesheet — one app knowing something the system did not. */
459
+ --tap-target:44px;
460
+
461
+ /* ——— safe areas ——— */
462
+ /* The notch/home-indicator insets, named once. Anything pinned to a viewport
463
+ edge — a fixed header, a bottom bar, a sheet — adds the matching one. */
464
+ --safe-top:env(safe-area-inset-top,0px);
465
+ --safe-right:env(safe-area-inset-right,0px);
466
+ --safe-bottom:env(safe-area-inset-bottom,0px);
467
+ --safe-left:env(safe-area-inset-left,0px);
468
+ }
469
+
470
+ /* Scale up from the phone. `md` (48rem) is the same breakpoint grid.css
471
+ declares and Tailwind compiles against — one value, not two that agree. */
472
+ @media (min-width:48rem){
473
+ :root{
474
+ --section-y:4rem; /* py-16 — content sections */
475
+ --section-y-lg:6rem; /* py-24 — landing sections */
476
+ --hero-y:5rem; /* py-20 … */
477
+ --hero-y-lg:8rem; /* … lg:py-32 */
478
+ --header-height:4rem;
479
+ }
305
480
  }
306
481
 
307
482
  /* ── tokens/grid.css ─────────────────────────────────────── */
@@ -369,14 +544,49 @@
369
544
  }
370
545
 
371
546
  /* ── tokens/elevation.css ─────────────────────────────────────── */
372
- /* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
373
- a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
374
- floating surfaces) plus the ambient radial glow used behind heroes. */
547
+ /* Elevation on a near-black ground is NOT a drop shadow. A dark blur on a dark
548
+ page is nothing you cannot cast a shadow onto black — which is why a system
549
+ that ships only `box-shadow` reads flat no matter how heavy the alphas get.
550
+
551
+ What actually lifts a surface here is LIGHT, and it arrives in three parts:
552
+
553
+ 1. a hairline EDGE (tokens/colors.css --border)
554
+ 2. an inset top HIGHLIGHT (--edge-highlight, below)
555
+ 3. a wide, very dark DROP (--shadow-*)
556
+
557
+ (2) is the one that was missing and the one that does most of the work: a 1px
558
+ inset white line along the top edge reads as the surface catching the light
559
+ from above, and it is the whole difference between a panel that sits ON the
560
+ page and a rectangle that is merely a different colour than the page. The
561
+ reference spends it on its login card (`inset 0 1px 0 rgb(255 255 255 / .05)`)
562
+ and it is the single cheapest premium signal available on dark UI.
563
+
564
+ They are separate tokens rather than one baked shadow because a component
565
+ composes the ones it needs — `box-shadow: var(--edge-highlight), var(--shadow-lg)`
566
+ — and a card that wants the highlight without the drop should not have to
567
+ accept both. */
375
568
  :root{
376
569
  --shadow-none:none;
377
- --shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
570
+ /* The floating surface: dropdowns, dialogs, sheets, the composer. Wide, soft,
571
+ and pushed well down. It was Tailwind's `shadow-2xl` at .25 alpha, which is
572
+ invisible on #0a0a0a — so the surfaces that float highest had the weakest
573
+ drop in the system while the t-shirt ramp below was correctly tuned. */
574
+ --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .75);
378
575
  --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
379
- --ring-focus:0 0 0 2px var(--ring);
576
+
577
+ /* ——— the light ——— */
578
+ /* The top edge, catching light. Add it to any raised surface. */
579
+ --edge-highlight:inset 0 1px 0 0 rgb(255 255 255 / .10);
580
+ /* The bloom around a LIT action on hover — a filled primary button, the send
581
+ key. Monochrome brand: this is where "alive" comes from instead of a hue,
582
+ and it is the whole hover signal on a fill that is already at the top of
583
+ the ink ramp. It was .10, which is invisible: a white glow has to compete
584
+ with the white object casting it, so the alpha that reads around a WHITE
585
+ button is far higher than the one that reads around a dark one. */
586
+ --bloom:0 0 24px -2px rgb(255 255 255 / .28);
587
+ /* The focus halo: soft, outside the ring, carrying no ratio of its own. This
588
+ was `0 0 0 2px var(--ring)` — a second hard ring around the hard ring. */
589
+ --ring-focus:0 0 0 3px var(--ring-halo);
380
590
 
381
591
  /* The t-shirt ramp. Named by size rather than by role, because that is how
382
592
  every component library already asks for a shadow (and how @hanzo/brand
@@ -394,6 +604,11 @@
394
604
  --glow-hero-blur:120px;
395
605
  /* Card top-corner sheen used on the story cards. */
396
606
  --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
607
+ /* The panel's top hairline, brightest at the centre and dissolving before it
608
+ reaches either corner — so an edge never terminates in a hard stop. One of
609
+ the two details that make the reference's panels look machined rather than
610
+ drawn (sidebar.css .panel::before). Paint it on a 1px-tall ::before. */
611
+ --sheen-edge:linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent); /* @kind color */
397
612
  /* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
398
613
  --gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
399
614
  --gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
@@ -401,6 +616,37 @@
401
616
  --gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
402
617
  }
403
618
 
619
+ /* Light theme — the same three parts, redistributed.
620
+ On white the order of work inverts: the DROP does almost all of it and the
621
+ inset highlight does none, because a white line on a white card is nothing.
622
+ Every token below is one whose dark value is white — i.e. one that silently
623
+ stops existing in `.light` unless it is restated here. check-tokens.mjs
624
+ scans this file for exactly that omission.
625
+
626
+ --edge-highlight is `inset 0 0 0 0 transparent` rather than `none` on
627
+ purpose: components compose these — `box-shadow: var(--edge-highlight),
628
+ var(--shadow-lg)` — and `none` is not a legal layer in a comma list, so the
629
+ obvious spelling would invalidate the whole declaration and take the drop
630
+ shadow down with it. A zero-size transparent shadow composes to nothing and
631
+ costs nothing. */
632
+ .light{
633
+ --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .18);
634
+ --shadow-inset-hairline:inset 0 0 0 1px rgb(0 0 0 / .10);
635
+ --edge-highlight:inset 0 0 0 0 transparent;
636
+ --bloom:0 6px 20px -4px rgb(0 0 0 / .18);
637
+ --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .06);
638
+ --shadow:0 1px 3px 0 rgb(0 0 0 / .08), 0 1px 2px -1px rgb(0 0 0 / .06);
639
+ --shadow-md:0 4px 6px -1px rgb(0 0 0 / .08), 0 2px 4px -2px rgb(0 0 0 / .06);
640
+ --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .09), 0 4px 6px -4px rgb(0 0 0 / .07);
641
+ --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .10), 0 8px 10px -6px rgb(0 0 0 / .08);
642
+ --glow-hero:radial-gradient(circle,rgb(0 0 0 / .06) 0%,transparent 68%);
643
+ --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(0 0 0 / .04) 0%,transparent 55%);
644
+ --sheen-edge:linear-gradient(90deg,transparent,rgb(0 0 0 / .08) 50%,transparent);
645
+ --gradient-chrome:linear-gradient(to right,#0a0a0a,rgb(10 10 10 / .8),rgb(10 10 10 / .6));
646
+ --gradient-chrome-2:linear-gradient(to right,#0a0a0a,var(--neutral-500));
647
+ --gradient-protect:linear-gradient(to bottom,rgb(0 0 0 / .10),transparent);
648
+ }
649
+
404
650
  /* ── tokens/motion.css ─────────────────────────────────────── */
405
651
  /* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
406
652
  No springs, no bounce, no parallax, no autoplay carousels. */
@@ -410,8 +656,17 @@
410
656
  --duration-slow:400ms; /* @kind other */ /* hero element entry */
411
657
  --duration-slower:500ms; /* @kind other */ /* section entry */
412
658
  --duration-glow:9s; /* @kind other */ /* ambient radial breathe */
659
+ --duration-press:90ms; /* @kind other */ /* the press-down on :active */
413
660
  --ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
414
661
  --ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
662
+ /* The entrance curve. Nearly all of the distance is covered in the first
663
+ third and it settles without overshooting, so a panel arrives DECIDED
664
+ rather than drifting in — the difference between 300ms that feels
665
+ immediate and 300ms that feels slow. Use it for anything that appears
666
+ (menus, dialogs, sheets, toasts); --ease-out stays the curve for a state
667
+ change on something already on screen. It is not a spring: this system has
668
+ no bounce anywhere. */
669
+ --ease-emphasis:cubic-bezier(0.16,1,0.3,1); /* @kind other */
415
670
  --stagger:60ms; /* @kind other */ /* per-element delay in a group */
416
671
  --entry-rise:16px; /* hero y-offset */
417
672
  --entry-rise-lg:24px; /* card y-offset */
@@ -420,6 +675,12 @@
420
675
  @keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
421
676
  @keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
422
677
  @keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
678
+ /* The entrance for anything that appears OVER the page — dialog, sheet, command
679
+ palette. It arrives from very slightly small and very slightly low, which is
680
+ what reads as "came forward" rather than "was toggled on". 3% and 8px: any
681
+ more and it is an animation you notice, which is the failure mode. Pair it
682
+ with --ease-emphasis. */
683
+ @keyframes hanzo-zoom-in{from{opacity:0;transform:scale(.97) translateY(8px)}to{opacity:1;transform:none}}
423
684
  @keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
424
685
  @keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
425
686
 
@@ -509,19 +770,36 @@
509
770
  The same trap already bit `text-decoration` here once (see the a:hover note
510
771
  below). It was fixed by deleting that one declaration, which left the identical
511
772
  defect in `color` untouched. Layering fixes the whole class: these are
512
- DEFAULTS, and a default must lose to anything an app states deliberately. */
773
+ DEFAULTS, and a default must lose to anything an app states deliberately.
774
+
775
+ The rules added for controls, media and touch go one step further and wrap
776
+ their selectors in :where(), which has ZERO specificity. @layer decides who
777
+ wins BETWEEN layers; :where() decides who wins INSIDE this one. An app that
778
+ ships its own reset — `input { … }`, `img { … }` — very often lands in the
779
+ same base layer, and these defaults have to lose that collision too. */
513
780
  @layer base {
514
781
  *{box-sizing:border-box;border-color:var(--border)}
515
- html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
782
+ html{
783
+ -webkit-font-smoothing:antialiased;
784
+ text-rendering:optimizeLegibility;
785
+ scroll-behavior:smooth;
786
+ /* iOS inflates text when a phone is rotated to landscape unless this is
787
+ pinned. It is the single most common reason a mobile layout that was
788
+ verified in portrait comes apart on its side. */
789
+ -webkit-text-size-adjust:100%;
790
+ }
516
791
  /* Each font-family carries the stack as a literal fallback. --font-sans lives in
517
792
  tokens/fonts.css, which a surface may legitimately import separately; without
518
793
  the fallback an unresolved var() makes font-family invalid and the UA drops to
519
794
  its SERIF default — the whole console silently rendered in Times. */
520
- 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)}
795
+ 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}
521
796
  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)}
522
797
  p{margin:0;text-wrap:pretty}
523
798
  code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
524
- a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
799
+ /* No `transition` here: the shared interactive rule below states one, and an
800
+ `a` selector (0,0,1) would outrank that :where() (0,0,0) and replace the
801
+ whole shorthand — anchors would transition colour and nothing else. */
802
+ a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px}
525
803
  /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
526
804
  bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
527
805
  blanket `a:hover` underlined every one of them on every surface that imports
@@ -530,8 +808,132 @@
530
808
  value `a` already has — a no-op that only served to outrank a component's own
531
809
  hover colour. */
532
810
  :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
811
+
812
+ /* ——— the smooth ———
813
+ Every interactive element in the reference transitions its colour, its fill
814
+ and its EDGE. That is most of what separates "classy and smooth" from a
815
+ correct set of colours: a hairline that steps from .10 to .16 instantly
816
+ reads as a flicker, and over 150ms reads as a response. Granted once, here,
817
+ rather than restated on every component. tokens/motion.css already zeroes
818
+ all of it under prefers-reduced-motion. */
819
+ :where(a,button,[role=button],input,select,textarea,summary,[tabindex]){
820
+ transition:color var(--duration-fast) var(--ease-out),
821
+ background-color var(--duration-fast) var(--ease-out),
822
+ border-color var(--duration-fast) var(--ease-out),
823
+ box-shadow var(--duration-fast) var(--ease-out),
824
+ opacity var(--duration-fast) var(--ease-out),
825
+ transform var(--duration-press) var(--ease-out);
826
+ }
827
+
828
+ /* ——— the press ———
829
+ A button that moves under the finger is the cheapest "this is a real
830
+ control" signal there is, and its absence is most of why a correct-looking
831
+ button still feels like a picture of a button. 2% over 90ms — small enough
832
+ that it reads as pressure rather than as animation, and it is the
833
+ reference's own value (.auth-signin-btn:active). Inputs are excluded: a
834
+ text field does not depress, and scaling one mid-caret is a glitch. */
835
+ :where(button,[role=button],a[role=button],summary):active:not(:disabled){
836
+ transform:scale(.98);
837
+ }
838
+
839
+ /* ——— controls ———
840
+ A bare <input> renders in the UA's face (Arial) at the UA's size with the
841
+ UA's border, which puts two typefaces and a foreign blue focus ring inside
842
+ Hanzo cards on every surface that has not styled its own fields yet. The
843
+ rules below are what a control looks like here. --border-control, NOT
844
+ --border: the boundary IS the affordance, so it is the rung that owes 3:1. */
845
+ :where(input,select,textarea,button){font:inherit;color:inherit}
846
+ :where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
847
+ background:var(--surface-2);
848
+ border:1px solid var(--border-control);
849
+ /* --radius-md (8px), not --radius-sm (6px). On a 36px control the two are
850
+ one step apart and it is the step between "boxy" and "considered";
851
+ --radius-sm keeps its job on genuinely small parts — badges, chips, menu
852
+ rows — where 8px would look bubbly. The reference draws its composer and
853
+ its buttons at 8-10px and nothing at 6px except 22px chips. */
854
+ border-radius:var(--radius-md);
855
+ padding:0 var(--space-3);
856
+ }
857
+ :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
858
+ /* ——— focus, on a control ———
859
+ Not the generic ring. A field already HAS an edge, so focus brightens that
860
+ edge (.15 -> .22) and adds a soft halo just outside it — which is what the
861
+ reference does (.composer-box:focus-within) and what separates a focused
862
+ field from a browser default. The generic `outline` is suppressed here
863
+ precisely because it would draw a second, harder box around this one. */
864
+ :where(input,select,textarea):focus-visible{
865
+ outline:none;
866
+ border-color:var(--border-focus);
867
+ box-shadow:var(--ring-focus);
868
+ }
869
+ /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
870
+ is a state nobody can see. */
871
+ :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
872
+ :where(input,textarea)::placeholder{color:var(--text-disabled)}
873
+ :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
874
+ :where(button,[role=button],summary,label,select){cursor:pointer}
875
+
876
+ /* ——— media ———
877
+ An image, a video or an embed at its intrinsic width is the usual cause of a
878
+ 390px page that scrolls sideways. */
879
+ :where(img,svg,video,canvas,iframe,picture,object){max-width:100%}
880
+ :where(img,video){height:auto}
881
+ /* A code block scrolls ITSELF rather than widening the document. */
882
+ :where(pre){overflow-x:auto}
883
+
884
+ /* ——— touch ———
885
+ Everything a finger aims at clears 44px, and every field renders at 16px so
886
+ iOS does not zoom the viewport on focus. Both are keyed on pointer:coarse —
887
+ the actual signal — so a desktop mouse still gets compact controls. This is
888
+ the whole of "mobile-first without per-app work": an app that never thought
889
+ about phones still has hittable buttons and a viewport that stays put. */
890
+ @media (pointer:coarse){
891
+ :where(button,[role=button],a[role=button],summary,select,textarea,
892
+ input:not([type=checkbox]):not([type=radio]):not([type=hidden])){
893
+ min-height:var(--tap-target);
894
+ }
895
+ :where(input,select,textarea){font-size:var(--text-control)}
896
+ /* A checkbox, radio or switch must NOT grow to 44px — the box is 16px
897
+ because that is what a checkbox looks like. What has to reach 44px is the
898
+ area a finger may land in, so the target is expanded with a centred
899
+ pseudo-element that changes nothing about layout or paint. WCAG 2.5.8 puts
900
+ the floor at 24px; a 16px box misses it, and every one of these ships at
901
+ 16px. */
902
+ :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio]){position:relative}
903
+ :where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio])::after{
904
+ content:'';position:absolute;top:50%;left:50%;
905
+ width:var(--tap-target);height:var(--tap-target);
906
+ transform:translate(-50%,-50%);
907
+ }
908
+ }
909
+
533
910
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
534
- ::selection{background:var(--white-20);color:#fff}
911
+ /* --white-20 is white-on-white in the light theme, so selection reads through
912
+ --selection, which BOTH themes define. */
913
+ ::selection{background:var(--selection);color:var(--text-primary)}
914
+
915
+ /* ——— the scrollbar ———
916
+ The single largest piece of UNSTYLED chrome left on a dark surface: a
917
+ default 15px light-grey scrollbar down the side of a #0a0a0a panel is the
918
+ one element on the page that did not get the memo, and it is visible on
919
+ every scrolling surface in the product. 6px, no track, thumb on the same
920
+ hairline ladder as everything else. Both syntaxes ship because they are
921
+ disjoint — `scrollbar-*` is Firefox and the `::-webkit-*` pseudos are
922
+ Chromium/Safari; neither engine reads the other. */
923
+ The thumb is a --white-* rung, so it does not invert; the `.light`
924
+ restatements sit here rather than in colors.css because a scrollbar is an
925
+ element default and this file is where element defaults live. Everything
926
+ stays INSIDE the layer — an unlayered rule here would outrank an app's own
927
+ utilities, which is the defect check 1c exists to catch. */
928
+ :where(html){scrollbar-color:var(--white-15) transparent;scrollbar-width:thin}
929
+ .light{scrollbar-color:rgb(0 0 0 / .18) transparent}
930
+ :where(*)::-webkit-scrollbar{width:6px;height:6px}
931
+ :where(*)::-webkit-scrollbar-track{background:transparent}
932
+ :where(*)::-webkit-scrollbar-thumb{background:var(--white-15);border-radius:var(--radius-full)}
933
+ :where(*)::-webkit-scrollbar-thumb:hover{background:var(--white-30)}
934
+ :where(*)::-webkit-scrollbar-corner{background:transparent}
935
+ .light :where(*)::-webkit-scrollbar-thumb{background:rgb(0 0 0 / .18)}
936
+ .light :where(*)::-webkit-scrollbar-thumb:hover{background:rgb(0 0 0 / .32)}
535
937
  }
536
938
 
537
939