@hanzo/design 0.4.1 → 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/styles.css CHANGED
@@ -79,59 +79,82 @@
79
79
  --popover:#0f0f0f;
80
80
  --popover-foreground:#fafafa;
81
81
  --primary:#fafafa;
82
- --primary-hover:#d4d4d4;
82
+ /* A 5% step, not the 16% this used to be. A filled button's fill is already
83
+ the top of the ink ramp, so the only directions available on hover are
84
+ "dimmer" and "brighter-around-the-edges" — and a 16% drop reads as the
85
+ button going DISABLED under the cursor, which is the one thing a hover must
86
+ never look like. The step is now small enough to register as pressure while
87
+ --bloom does the actual work of lighting it up. */
88
+ --primary-hover:#e5e5e5;
83
89
  --primary-foreground:#0a0a0a;
84
90
  --secondary:#262626;
91
+ /* One rung up, so a secondary button HAS a hover. It previously resolved to
92
+ --surface-3, which is --secondary — the same value it starts at. */
93
+ --secondary-hover:#333333;
85
94
  --secondary-foreground:#fafafa;
86
95
  --muted:#171717;
87
96
  --muted-foreground:#a3a3a3;
88
97
  --accent:#262626;
89
98
  --accent-foreground:#fafafa;
90
- --destructive:#666666;
99
+ /* Destructive is the ONE place the monochrome rule already yields, and it was
100
+ yielding in only half the system: a field that fails validation draws
101
+ --state-error (#ef4444, which DESIGN.md §2.4 lists as permitted precisely
102
+ for "destructive / blocking error"), while the button that performs the
103
+ destruction drew #666666 — a mid-grey that is indistinguishable from a
104
+ disabled control. "Delete everything" and "you cannot click this" must not
105
+ be the same colour. So the two converge on the one token that already
106
+ names this meaning, and the ramp stays monochrome everywhere else. */
107
+ --destructive:var(--state-error);
108
+ --destructive-hover:#dc2626; /* red-600 — deepens under the cursor */
91
109
  --destructive-foreground:#fafafa;
92
110
 
93
111
  /* ——— boundaries ———
94
- THREE tiers, and the split is the whole point. The first two are DECORATIVE:
95
- they separate content, WCAG imposes no ratio on them, and they must stay
96
- quiet. The third is PERCEIVABLE: it is the edge that IS the affordance.
97
-
98
- --border the hairline. Every card, panel, divider, list row.
99
- --border-strong the SAME hairline, one step up. Hover, active, emphasis.
100
- --border-control a control's edge input, select, textarea, checkbox,
101
- switch. Must clear 3:1 on every surface in both themes
102
- (WCAG 1.4.11); check-tokens.mjs pins it.
103
-
104
- Before this split there were only two rungs and they were an octave apart:
105
- --border at #1f1f1f (1.27:1, invisible on anything lifted) and
106
- --border-strong at --neutral-500 (4.43:1). Nothing sat between, so every
107
- surface that wanted a border it could actually SEE reached for the control
108
- rung and a 1px mid-grey box around a card on near-black is a wireframe.
109
- That is the "garish outlines" complaint, and it is fixed by having a middle.
110
-
111
- A boundary that is WCAG-perceivable on a near-black ground IS a mid-grey;
112
- that cannot be tuned away. So it is not tuned away it is NAMED, so it can
113
- only be spent where it is owed. "strong" is a degree word and will always be
114
- read as one; "control" is a duty.
115
-
116
- Which ladder a boundary is cut from follows from that, and is the whole
117
- rule: DECORATIVE edges come off the ALPHA ladder, because they owe quiet
118
- and they must composite over whatever surface they land on. CONTROL edges
119
- come off the NEUTRAL ladder, because they owe a contrast ratio, and a ratio
120
- you can only meet at a fixed value is not something to express as alpha.
121
- --border-focus is therefore one NEUTRAL rung above --border-control, not an
122
- alpha step: a focused field has to read brighter than a resting one, and
123
- the resting one already sits at the WCAG floor. */
112
+ ONE ladder, cut from alpha, graded by duty. Every rung is a rung of the
113
+ opacity ladder above, so every boundary composites correctly on the page,
114
+ on a card, and inside a popover and every rung is the reference's own
115
+ measured value, not a rounded ramp:
116
+
117
+ --border .10 the hairline. Cards, panels, dividers, list rows.
118
+ --border-strong .16 the same hairline, one step up. Hover, emphasis.
119
+ --border-control .15 a control's resting edge input, select, textarea.
120
+ --border-focus .22 that control, focused.
121
+ --border-selected .30 the thing that is currently CHOSEN — the active
122
+ tab, the current tile, the featured plan.
123
+
124
+ The previous release cut the last three from the NEUTRAL ladder instead,
125
+ because a boundary that clears WCAG 1.4.11's 3:1 on a near-black ground IS
126
+ a mid-grey and no amount of tuning changes that. It is true, and it is also
127
+ what a wireframe looks like: a 1px #737373 box around every field, four
128
+ times heavier than the hairline beside it, so a form read as a debug
129
+ overlay rather than a surface. Aesthetics decided it the reference draws
130
+ an input at rgb(255 255 255 / .15) and a focused one at .22 (sidebar.css
131
+ --input-border / .composer-box:focus-within), and that is what ships here.
132
+
133
+ What replaces the ratio is the FOCUS INDICATOR, which is the thing a
134
+ keyboard user actually navigates by and the one boundary still pinned at
135
+ 3:1 (--ring, below). A resting edge is an affordance; a focus ring is a
136
+ position. Only the second one is load-bearing when you cannot see well, and
137
+ spending the contrast budget there rather than on all four edges of every
138
+ idle field is what lets the system be quiet AND findable at once. */
124
139
  --border:var(--white-10);
125
140
  --border-strong:var(--white-16);
126
- --border-control:var(--neutral-500); /* 4.43:1 worst case — see --ring */
127
- --border-focus:var(--neutral-400); /* one rung up: the field is focused */
128
- --input:var(--border-control); /* shadcn `border-input` a control */
141
+ --border-control:var(--white-15); /* sidebar.css --input-border */
142
+ --border-focus:var(--white-22); /* .composer-box:focus-within */
143
+ --border-selected:var(--white-30); /* the current tab / tile / plan */
144
+ --input:var(--border-control); /* shadcn `border-input` — a control */
129
145
  /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
130
- 3:1 against every surface it can land on. --neutral-500 is the only rung on
131
- this ladder that clears 3:1 on all of them #0a0a0a, #0f0f0f, #171717,
132
- #262626, AND the light theme's #ffffff/#fafafa so one value serves both
133
- themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
134
- --ring:var(--neutral-500);
146
+ 3:1 against every surface it can land on, and unlike a resting edge it is
147
+ worth the loudness it is transient, it only appears on keyboard focus,
148
+ and it is the whole of how a keyboard user knows where they are.
149
+ --white-40 clears it on every canvas in both themes precisely BECAUSE it is
150
+ alpha: on a lifted surface the ring lifts with it (3.77:1 on #0a0a0a,
151
+ 3.66:1 on #262626), which a fixed grey cannot do. */
152
+ --ring:var(--white-40);
153
+ /* The soft halo that sits outside the ring — the reference's
154
+ `box-shadow: 0 0 0 2px rgb(255 255 255 / .06)` on a focused composer. It
155
+ carries no ratio; it is what makes focus read as designed rather than as a
156
+ browser default. Spent through --ring-focus (tokens/elevation.css). */
157
+ --ring-halo:var(--white-10);
135
158
  --brand:#e4e4e7;
136
159
  --brand-foreground:#09090b;
137
160
  --brand-muted:#a3a3a3;
@@ -146,12 +169,27 @@
146
169
  --glass:var(--white-05);
147
170
  --glass-strong:var(--white-08);
148
171
 
149
- /* ——— surface recipes (card fills used across hanzo.ai) ——— */
172
+ /* ——— surface recipes ———
173
+ A card has to be a SURFACE, which means you have to be able to see that it
174
+ is not the page. These were cut from neutral-900 (#171717) at .4/.5/.8, and
175
+ at .5 that composites to #101010 on a #0a0a0a ground — six levels above the
176
+ page, which is not a surface, it is a very slightly different black. Three
177
+ cards side by side then read as one flat field with hairlines ruled across
178
+ it, and no amount of shadow fixes it, because you cannot cast a shadow onto
179
+ black.
180
+
181
+ Cut from neutral-800 (#262626) instead, they land on 14 / 18 / 1f — the
182
+ reference's own panel (#171717 on a #0f0f0f container) with room either
183
+ side of it. They stay ALPHA so a card nested in a popover still lifts off
184
+ its parent rather than punching a hole in it; that is the whole reason
185
+ these are recipes and not just --surface-* rungs. */
150
186
  --surface-page:var(--background);
151
- --surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 grid tiles */
152
- --surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
153
- --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
154
- --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
187
+ --surface-card:rgb(38 38 38 / .5); /* -> #181818 on the page */
188
+ --surface-card-emphasis:rgb(38 38 38 / .75);/* -> #1f1f1f — featured */
189
+ --surface-card-quiet:rgb(38 38 38 / .35); /* -> #141414 — story cards */
190
+ /* A floating panel that is the SAME colour as the page is not floating. This
191
+ was rgb(10 10 10 / .95) — the page, at 95% of the page. */
192
+ --surface-overlay:rgb(23 23 23 / .92); /* dropdown / popover panels */
155
193
  --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
156
194
  --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
157
195
 
@@ -203,22 +241,30 @@
203
241
  --primary-hover:#262626;
204
242
  --primary-foreground:#fafafa;
205
243
  --secondary:#ededed;
244
+ --secondary-hover:#e0e0e0;
206
245
  --secondary-foreground:#0a0a0a;
207
246
  --muted:#f5f5f5;
208
247
  --muted-foreground:#525252;
209
248
  --accent:#ededed;
210
249
  --accent-foreground:#0a0a0a;
211
- --destructive:#999999;
250
+ --destructive:var(--state-error);
251
+ --destructive-hover:#dc2626;
212
252
  --destructive-foreground:#ffffff;
213
253
  --border:rgb(0 0 0 / .10);
214
254
  --border-strong:rgb(0 0 0 / .16);
215
- --border-control:var(--neutral-500); /* 4.74:1 on #ffffff — conformant here too */
216
- --border-focus:var(--neutral-600); /* one rung DOWN darker reads as brighter here */
255
+ --border-control:rgb(0 0 0 / .15);
256
+ /* Focus and selection are pushed further here than the .22/.30 they sit at in
257
+ dark. Black-on-white at a given alpha reads FAINTER than white-on-black at
258
+ the same alpha — .22 black on #ffffff measures 1.69:1 where .22 white on
259
+ #0a0a0a measures 1.97:1 — so mirroring the number would quietly make the
260
+ light theme's focus state the weakest state in the system. */
261
+ --border-focus:rgb(0 0 0 / .32);
262
+ --border-selected:rgb(0 0 0 / .42);
217
263
  --input:var(--border-control);
218
- /* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
219
- focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
220
- #f5f5f5, so ONE value is conformant in both themes. */
221
- --ring:var(--neutral-500);
264
+ /* Same duty, same 3:1 floor, opposite direction: .50 black is 3.98:1 on
265
+ #ffffff and 3.67:1 on #f5f5f5. */
266
+ --ring:rgb(0 0 0 / .5);
267
+ --ring-halo:rgb(0 0 0 / .07);
222
268
  --black:#0a0a0a;
223
269
  --white:#ffffff;
224
270
  --selection:rgb(0 0 0 / .16);
@@ -475,14 +521,49 @@
475
521
  }
476
522
 
477
523
  /* ── tokens/elevation.css ─────────────────────────────────────── */
478
- /* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
479
- a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
480
- floating surfaces) plus the ambient radial glow used behind heroes. */
524
+ /* Elevation on a near-black ground is NOT a drop shadow. A dark blur on a dark
525
+ page is nothing you cannot cast a shadow onto black — which is why a system
526
+ that ships only `box-shadow` reads flat no matter how heavy the alphas get.
527
+
528
+ What actually lifts a surface here is LIGHT, and it arrives in three parts:
529
+
530
+ 1. a hairline EDGE (tokens/colors.css --border)
531
+ 2. an inset top HIGHLIGHT (--edge-highlight, below)
532
+ 3. a wide, very dark DROP (--shadow-*)
533
+
534
+ (2) is the one that was missing and the one that does most of the work: a 1px
535
+ inset white line along the top edge reads as the surface catching the light
536
+ from above, and it is the whole difference between a panel that sits ON the
537
+ page and a rectangle that is merely a different colour than the page. The
538
+ reference spends it on its login card (`inset 0 1px 0 rgb(255 255 255 / .05)`)
539
+ and it is the single cheapest premium signal available on dark UI.
540
+
541
+ They are separate tokens rather than one baked shadow because a component
542
+ composes the ones it needs — `box-shadow: var(--edge-highlight), var(--shadow-lg)`
543
+ — and a card that wants the highlight without the drop should not have to
544
+ accept both. */
481
545
  :root{
482
546
  --shadow-none:none;
483
- --shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
547
+ /* The floating surface: dropdowns, dialogs, sheets, the composer. Wide, soft,
548
+ and pushed well down. It was Tailwind's `shadow-2xl` at .25 alpha, which is
549
+ invisible on #0a0a0a — so the surfaces that float highest had the weakest
550
+ drop in the system while the t-shirt ramp below was correctly tuned. */
551
+ --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .75);
484
552
  --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
485
- --ring-focus:0 0 0 2px var(--ring);
553
+
554
+ /* ——— the light ——— */
555
+ /* The top edge, catching light. Add it to any raised surface. */
556
+ --edge-highlight:inset 0 1px 0 0 rgb(255 255 255 / .10);
557
+ /* The bloom around a LIT action on hover — a filled primary button, the send
558
+ key. Monochrome brand: this is where "alive" comes from instead of a hue,
559
+ and it is the whole hover signal on a fill that is already at the top of
560
+ the ink ramp. It was .10, which is invisible: a white glow has to compete
561
+ with the white object casting it, so the alpha that reads around a WHITE
562
+ button is far higher than the one that reads around a dark one. */
563
+ --bloom:0 0 24px -2px rgb(255 255 255 / .28);
564
+ /* The focus halo: soft, outside the ring, carrying no ratio of its own. This
565
+ was `0 0 0 2px var(--ring)` — a second hard ring around the hard ring. */
566
+ --ring-focus:0 0 0 3px var(--ring-halo);
486
567
 
487
568
  /* The t-shirt ramp. Named by size rather than by role, because that is how
488
569
  every component library already asks for a shadow (and how @hanzo/brand
@@ -500,6 +581,11 @@
500
581
  --glow-hero-blur:120px;
501
582
  /* Card top-corner sheen used on the story cards. */
502
583
  --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
584
+ /* The panel's top hairline, brightest at the centre and dissolving before it
585
+ reaches either corner — so an edge never terminates in a hard stop. One of
586
+ the two details that make the reference's panels look machined rather than
587
+ drawn (sidebar.css .panel::before). Paint it on a 1px-tall ::before. */
588
+ --sheen-edge:linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent); /* @kind color */
503
589
  /* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
504
590
  --gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
505
591
  --gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
@@ -507,6 +593,37 @@
507
593
  --gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
508
594
  }
509
595
 
596
+ /* Light theme — the same three parts, redistributed.
597
+ On white the order of work inverts: the DROP does almost all of it and the
598
+ inset highlight does none, because a white line on a white card is nothing.
599
+ Every token below is one whose dark value is white — i.e. one that silently
600
+ stops existing in `.light` unless it is restated here. check-tokens.mjs
601
+ scans this file for exactly that omission.
602
+
603
+ --edge-highlight is `inset 0 0 0 0 transparent` rather than `none` on
604
+ purpose: components compose these — `box-shadow: var(--edge-highlight),
605
+ var(--shadow-lg)` — and `none` is not a legal layer in a comma list, so the
606
+ obvious spelling would invalidate the whole declaration and take the drop
607
+ shadow down with it. A zero-size transparent shadow composes to nothing and
608
+ costs nothing. */
609
+ .light{
610
+ --shadow-floating:0 24px 60px -16px rgb(0 0 0 / .18);
611
+ --shadow-inset-hairline:inset 0 0 0 1px rgb(0 0 0 / .10);
612
+ --edge-highlight:inset 0 0 0 0 transparent;
613
+ --bloom:0 6px 20px -4px rgb(0 0 0 / .18);
614
+ --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .06);
615
+ --shadow:0 1px 3px 0 rgb(0 0 0 / .08), 0 1px 2px -1px rgb(0 0 0 / .06);
616
+ --shadow-md:0 4px 6px -1px rgb(0 0 0 / .08), 0 2px 4px -2px rgb(0 0 0 / .06);
617
+ --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .09), 0 4px 6px -4px rgb(0 0 0 / .07);
618
+ --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .10), 0 8px 10px -6px rgb(0 0 0 / .08);
619
+ --glow-hero:radial-gradient(circle,rgb(0 0 0 / .06) 0%,transparent 68%);
620
+ --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(0 0 0 / .04) 0%,transparent 55%);
621
+ --sheen-edge:linear-gradient(90deg,transparent,rgb(0 0 0 / .08) 50%,transparent);
622
+ --gradient-chrome:linear-gradient(to right,#0a0a0a,rgb(10 10 10 / .8),rgb(10 10 10 / .6));
623
+ --gradient-chrome-2:linear-gradient(to right,#0a0a0a,var(--neutral-500));
624
+ --gradient-protect:linear-gradient(to bottom,rgb(0 0 0 / .10),transparent);
625
+ }
626
+
510
627
  /* ── tokens/motion.css ─────────────────────────────────────── */
511
628
  /* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
512
629
  No springs, no bounce, no parallax, no autoplay carousels. */
@@ -516,8 +633,17 @@
516
633
  --duration-slow:400ms; /* @kind other */ /* hero element entry */
517
634
  --duration-slower:500ms; /* @kind other */ /* section entry */
518
635
  --duration-glow:9s; /* @kind other */ /* ambient radial breathe */
636
+ --duration-press:90ms; /* @kind other */ /* the press-down on :active */
519
637
  --ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
520
638
  --ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
639
+ /* The entrance curve. Nearly all of the distance is covered in the first
640
+ third and it settles without overshooting, so a panel arrives DECIDED
641
+ rather than drifting in — the difference between 300ms that feels
642
+ immediate and 300ms that feels slow. Use it for anything that appears
643
+ (menus, dialogs, sheets, toasts); --ease-out stays the curve for a state
644
+ change on something already on screen. It is not a spring: this system has
645
+ no bounce anywhere. */
646
+ --ease-emphasis:cubic-bezier(0.16,1,0.3,1); /* @kind other */
521
647
  --stagger:60ms; /* @kind other */ /* per-element delay in a group */
522
648
  --entry-rise:16px; /* hero y-offset */
523
649
  --entry-rise-lg:24px; /* card y-offset */
@@ -526,6 +652,12 @@
526
652
  @keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
527
653
  @keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
528
654
  @keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
655
+ /* The entrance for anything that appears OVER the page — dialog, sheet, command
656
+ palette. It arrives from very slightly small and very slightly low, which is
657
+ what reads as "came forward" rather than "was toggled on". 3% and 8px: any
658
+ more and it is an animation you notice, which is the failure mode. Pair it
659
+ with --ease-emphasis. */
660
+ @keyframes hanzo-zoom-in{from{opacity:0;transform:scale(.97) translateY(8px)}to{opacity:1;transform:none}}
529
661
  @keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
530
662
  @keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
531
663
 
@@ -666,7 +798,19 @@
666
798
  background-color var(--duration-fast) var(--ease-out),
667
799
  border-color var(--duration-fast) var(--ease-out),
668
800
  box-shadow var(--duration-fast) var(--ease-out),
669
- opacity var(--duration-fast) var(--ease-out);
801
+ opacity var(--duration-fast) var(--ease-out),
802
+ transform var(--duration-press) var(--ease-out);
803
+ }
804
+
805
+ /* ——— the press ———
806
+ A button that moves under the finger is the cheapest "this is a real
807
+ control" signal there is, and its absence is most of why a correct-looking
808
+ button still feels like a picture of a button. 2% over 90ms — small enough
809
+ that it reads as pressure rather than as animation, and it is the
810
+ reference's own value (.auth-signin-btn:active). Inputs are excluded: a
811
+ text field does not depress, and scaling one mid-caret is a glitch. */
812
+ :where(button,[role=button],a[role=button],summary):active:not(:disabled){
813
+ transform:scale(.98);
670
814
  }
671
815
 
672
816
  /* ——— controls ———
@@ -679,11 +823,29 @@
679
823
  :where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
680
824
  background:var(--surface-2);
681
825
  border:1px solid var(--border-control);
682
- border-radius:var(--radius-sm);
826
+ /* --radius-md (8px), not --radius-sm (6px). On a 36px control the two are
827
+ one step apart and it is the step between "boxy" and "considered";
828
+ --radius-sm keeps its job on genuinely small parts — badges, chips, menu
829
+ rows — where 8px would look bubbly. The reference draws its composer and
830
+ its buttons at 8-10px and nothing at 6px except 22px chips. */
831
+ border-radius:var(--radius-md);
683
832
  padding:0 var(--space-3);
684
833
  }
685
834
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
686
- :where(input,select,textarea):focus-visible{border-color:var(--border-focus)}
835
+ /* ——— focus, on a control ———
836
+ Not the generic ring. A field already HAS an edge, so focus brightens that
837
+ edge (.15 -> .22) and adds a soft halo just outside it — which is what the
838
+ reference does (.composer-box:focus-within) and what separates a focused
839
+ field from a browser default. The generic `outline` is suppressed here
840
+ precisely because it would draw a second, harder box around this one. */
841
+ :where(input,select,textarea):focus-visible{
842
+ outline:none;
843
+ border-color:var(--border-focus);
844
+ box-shadow:var(--ring-focus);
845
+ }
846
+ /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
847
+ is a state nobody can see. */
848
+ :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
687
849
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
688
850
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
689
851
  :where(button,[role=button],summary,label,select){cursor:pointer}
@@ -726,4 +888,27 @@
726
888
  /* --white-20 is white-on-white in the light theme, so selection reads through
727
889
  --selection, which BOTH themes define. */
728
890
  ::selection{background:var(--selection);color:var(--text-primary)}
891
+
892
+ /* ——— the scrollbar ———
893
+ The single largest piece of UNSTYLED chrome left on a dark surface: a
894
+ default 15px light-grey scrollbar down the side of a #0a0a0a panel is the
895
+ one element on the page that did not get the memo, and it is visible on
896
+ every scrolling surface in the product. 6px, no track, thumb on the same
897
+ hairline ladder as everything else. Both syntaxes ship because they are
898
+ disjoint — `scrollbar-*` is Firefox and the `::-webkit-*` pseudos are
899
+ Chromium/Safari; neither engine reads the other. */
900
+ The thumb is a --white-* rung, so it does not invert; the `.light`
901
+ restatements sit here rather than in colors.css because a scrollbar is an
902
+ element default and this file is where element defaults live. Everything
903
+ stays INSIDE the layer — an unlayered rule here would outrank an app's own
904
+ utilities, which is the defect check 1c exists to catch. */
905
+ :where(html){scrollbar-color:var(--white-15) transparent;scrollbar-width:thin}
906
+ .light{scrollbar-color:rgb(0 0 0 / .18) transparent}
907
+ :where(*)::-webkit-scrollbar{width:6px;height:6px}
908
+ :where(*)::-webkit-scrollbar-track{background:transparent}
909
+ :where(*)::-webkit-scrollbar-thumb{background:var(--white-15);border-radius:var(--radius-full)}
910
+ :where(*)::-webkit-scrollbar-thumb:hover{background:var(--white-30)}
911
+ :where(*)::-webkit-scrollbar-corner{background:transparent}
912
+ .light :where(*)::-webkit-scrollbar-thumb{background:rgb(0 0 0 / .18)}
913
+ .light :where(*)::-webkit-scrollbar-thumb:hover{background:rgb(0 0 0 / .32)}
729
914
  }