@codenhub/styles 0.1.1 → 0.3.0

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.
Files changed (42) hide show
  1. package/README.md +2 -1
  2. package/dist/aesthetics/chunky-tile.css +1 -1
  3. package/dist/aesthetics/index.css +1 -1
  4. package/dist/components.css +1 -1
  5. package/dist/index.css +1 -1
  6. package/dist/native.css +1 -1
  7. package/dist/palette.css +1 -0
  8. package/dist/theme.css +1 -1
  9. package/dist/tw/aesthetics/chunky-tile.css +16 -0
  10. package/dist/tw/box.css +24 -2
  11. package/dist/tw/components/button.css +22 -33
  12. package/dist/tw/components/feedback.css +87 -48
  13. package/dist/tw/components/form.css +139 -45
  14. package/dist/tw/components/index.css +1 -1
  15. package/dist/tw/components/loader.css +2 -16
  16. package/dist/tw/components/surface.css +93 -0
  17. package/dist/tw/components/tooltip.css +102 -91
  18. package/dist/tw/elevation.css +43 -7
  19. package/dist/tw/index.css +7 -4
  20. package/dist/tw/intent.css +16 -12
  21. package/dist/tw/palette.css +451 -0
  22. package/dist/tw/reset.css +21 -0
  23. package/dist/tw/theme.css +54 -8
  24. package/dist/tw/utilities/content.css +90 -16
  25. package/dist/tw/utilities/index.css +1 -1
  26. package/dist/tw/utilities/layout.css +33 -0
  27. package/docs/accessibility.md +34 -23
  28. package/docs/changelog/0.2.0.md +41 -0
  29. package/docs/changelog/0.3.0.md +15 -0
  30. package/docs/changelog/index.md +2 -0
  31. package/docs/concepts.md +1 -0
  32. package/docs/setup.md +42 -22
  33. package/docs/usage/aesthetics.md +4 -3
  34. package/docs/usage/buttons.md +17 -13
  35. package/docs/usage/composing.md +28 -21
  36. package/docs/usage/content-and-layout.md +10 -3
  37. package/docs/usage/customizing.md +27 -0
  38. package/docs/usage/feedback.md +18 -10
  39. package/docs/usage/forms.md +36 -26
  40. package/docs/usage/tooltips.md +48 -22
  41. package/llms-full.txt +318 -143
  42. package/package.json +9 -2
@@ -58,8 +58,21 @@
58
58
  tick on light grey.
59
59
 
60
60
  Ramping against `--_fill` keeps the two in step at every bound, including
61
- the checked state that lifts them. */
62
- --_on-fill: min(var(--ui-fg-on-fill, var(--_d-fg-on-fill)), max(0%, calc(var(--_fill) * 2 - 100%)));
61
+ the checked state that lifts them.
62
+
63
+ `--_fg-on-fill-floor` is the seam `:checked` pins instead of touching
64
+ `--ui-fg-on-fill` directly. `.ghost`/`.soft` are unlayered CSS (P3, and see
65
+ model.md's `--_fill-cap` history) and `text-control` compiles into
66
+ Tailwind's `utilities` layer, so a layered `:checked` writing
67
+ `--ui-fg-on-fill` the way it writes `--_fill-cap`/`--_fill-floor` would
68
+ lose to `.ghost`'s unlayered `--ui-fg-on-fill: 0%` regardless of
69
+ specificity -- the same trap `--_fill-cap` exists to avoid on the fill
70
+ itself. A private floor `.ghost`/`.soft` never touch has nothing
71
+ unlayered contesting it. */
72
+ --_on-fill: max(
73
+ var(--_fg-on-fill-floor, 0%),
74
+ min(var(--ui-fg-on-fill, var(--_d-fg-on-fill)), max(0%, calc(var(--_fill) * 2 - 100%)))
75
+ );
63
76
  /* The resting line is a fraction of the intent rather than all of it, and the
64
77
  fraction is what makes the pointer visible. Drawn at full strength a control
65
78
  already sits at its intent's darkest, and hover moves it to a neighbouring
@@ -120,14 +133,41 @@
120
133
  --_line-tone: var(--intent-color);
121
134
  }
122
135
 
123
- /* R8, P6: `:checked` lifts the bounds and lets the fill class compose, rather
124
- than pinning `--_fill`/`--_fg` (which made every checked toggle render
125
- identically). The floor keeps a mark on a ground when a container's `.ghost`
126
- would take it away. Lives here, not on the toggles, because a text input is
127
- never `:checked`. */
136
+ /* R8, P6: `:checked` pins the fill by making the floor and the cap the same
137
+ value, so `clamp()` returns exactly that value whatever `--ui-fill` asks --
138
+ still a bound on the input, still lifted rather than a rewrite of the
139
+ result (the failure mode P6 exists for: an early version pinned `--_fill`
140
+ and `--_fg` directly, which made a checked toggle ignore its own aesthetic
141
+ and elevation along with presentation).
142
+
143
+ The floor used to sit at `12%` rather than `100%`, which kept presentation
144
+ live on the checked mark too -- a checked `.soft` toggle rendered a pale
145
+ plate with just the mark at full strength, the classic ring-and-dot radio.
146
+ The stress-test pass found that cost more than it bought on a real screen:
147
+ three presentations gave a checkbox, a radio, and a switch three different
148
+ "on" identities to learn, while every *unchecked* toggle read the same
149
+ washed-out plate regardless of which class asked for it -- close enough to
150
+ the disabled look that a form full of ordinary, unchecked, un-intended
151
+ controls read as half-disabled. Pinning checked to one plate and freeing
152
+ presentation to separate the *unchecked* one instead (see
153
+ `checkbox`/`radio`/`switch` below) moves the axis to where it was doing
154
+ something. Lives here, not on the toggles, because a text input is never
155
+ `:checked`.
156
+
157
+ The ink is pinned alongside the fill, through `--_fg-on-fill-floor` rather
158
+ than `--ui-fg-on-fill` directly (see that seam, above): the other half of
159
+ what a fill class asks for is how much contrast ink it wants
160
+ (`--ui-fg-on-fill: 0%` is `.ghost`/`.soft` declaring "none, my plate stays
161
+ quiet"), and pinning the fill without pinning this left a checked
162
+ `.ghost`/`.soft` toggle with an opaque plate and a mark that still read as
163
+ though the plate were quiet -- both derived from the same intent tone,
164
+ close enough to disappear into each other. `--_on-fill` ramps against the
165
+ *composed* `--_fill`, so once that is pinned to 100% the ink has to reach
166
+ full contrast too, or the ramp's own ceiling has nothing to reach. */
128
167
  &:checked {
129
168
  --_fill-cap: 100%;
130
- --_fill-floor: 12%;
169
+ --_fill-floor: 100%;
170
+ --_fg-on-fill-floor: 100%;
131
171
  }
132
172
 
133
173
  /* Native decorations a field replaces with its own artwork. */
@@ -164,11 +204,25 @@
164
204
  --_fill-cap: 20%;
165
205
  }
166
206
 
167
- /* A checked toggle fills with its own intent, so neutral has to drop its 20%
168
- cap here. Declared where the intents are (0-2-0, unlayered): inside
169
- `text-control` `.neutral` would overrule it and a checked toggle would fill to
170
- a fifth of its colour. */
171
- :is(.text-control, .checkbox, .radio, .switch, input):checked {
207
+ /* A checked text-control fills with its own intent, so neutral has to drop its
208
+ 20% cap here. No toggle needs this arm any more (see below), but a text
209
+ input is never `:checked` in practice either -- kept for whatever custom
210
+ `.text-control` composition does check itself. Declared where the intents
211
+ are (0-2-0, unlayered): inside `text-control` `.neutral` would overrule it. */
212
+ :is(.text-control, input):checked {
213
+ --intent-fill-max: 100%;
214
+ }
215
+
216
+ /* The three toggles read their own component cap (`--_fill-cap`, below) as the
217
+ ceiling regardless of intent, checked or not -- not just at `:checked`, the
218
+ way the arm above still works for a hypothetical checked text-control. A
219
+ neutral fill stops at 20% everywhere else because it is the page's own ink,
220
+ and a full one reads as a slab on a badge or a card. That reasoning does not
221
+ hold for a toggle at rest: a neutral checkbox is the common case, most
222
+ controls carry no intent class at all, and it has to look like a present,
223
+ interactive box rather than a washed-out one just because nothing colored
224
+ it. Declared where the intents are, for the reason given above. */
225
+ :is(.checkbox, .radio, .switch) {
172
226
  --intent-fill-max: 100%;
173
227
  }
174
228
 
@@ -182,6 +236,21 @@
182
236
  --_edge-floor: 0%;
183
237
  }
184
238
 
239
+ /* `.switch`'s one exception to the opt-out above: `.ghost` already asks for
240
+ zero fill, so a `.switch.ghost.edgeless` track has neither a line nor a
241
+ plate and disappears entirely -- nothing marks its bounds but the floating
242
+ knob. Every other fill on this component still has something to paint
243
+ (`.soft`/`.solid`/default all rest above 0%), so only this one pairing
244
+ needs the floor held up. `.switch.ghost.edgeless` renders identically to
245
+ `.switch.ghost.edged` now, published unsupported as a distinct look --
246
+ the same "produces a row indistinguishable from its neighbour" reason
247
+ `.solid.edgeless`/`.solid.edged` already collapse to one row for. Higher
248
+ specificity than the rule above (three classes, not an `:is()` of one)
249
+ wins regardless of source order. */
250
+ .switch.ghost.edgeless {
251
+ --_edge-floor: 100%;
252
+ }
253
+
185
254
  /* An invalid control is destructive whatever else it was asked to be. A plain
186
255
  unlayered rule, not `&[aria-invalid]` inside `@utility`: the intent reset is
187
256
  unlayered too and would overrule a layered version (leaving a grey line and
@@ -307,9 +376,17 @@
307
376
  No icons ship. Any element works in a slot -- an inline `<svg>`, an `<img>`,
308
377
  or a class from an icon set such as `@codenhub/icons` -- placed before the
309
378
  control for a leading adornment and after it for a trailing one. See
310
- docs/usage/forms.md#icons. */
379
+ docs/usage/forms.md#icons.
380
+
381
+ The gap between the control and its adornment is a token, `--input-group-gap`,
382
+ rather than a bare Tailwind `gap-2`: an icon reads that 0.5rem as the normal
383
+ breathing room a distinct glyph needs, but a text affix meant to glue onto the
384
+ value -- a URL host, a currency symbol, a unit -- reads the same gap as a
385
+ stray space breaking the two apart. A consumer sets `--input-group-gap: 0` on
386
+ their own `.input-group` for a flush affix; the default stays the icon-tuned
387
+ 0.5rem so every existing icon composition is unchanged. */
311
388
  @utility input-group {
312
- @apply text-control flex w-full items-center gap-2;
389
+ @apply text-control flex w-full items-center;
313
390
 
314
391
  /* After the `@apply`, for the reason `ipt` states it: `text-control` inlines
315
392
  its own resting geometry there, so a default written above it loses to the
@@ -320,9 +397,11 @@
320
397
  --_d-border: 100%;
321
398
  --_d-elevation: 0;
322
399
 
400
+ --input-group-gap: 0.5rem;
323
401
  --input-group-pad: 0.75rem;
324
402
 
325
403
  min-height: var(--control-height);
404
+ gap: var(--input-group-gap);
326
405
  padding-inline: var(--input-group-pad);
327
406
 
328
407
  /* The focus ring the wrapper cannot get from `:focus-visible` -- a `<div>` is
@@ -382,18 +461,26 @@
382
461
 
383
462
  /* The three toggles compose `text-control` for its bounds, not its chrome: the
384
463
  edge floor keeps a container's `.edgeless` from erasing the closed silhouette
385
- (WCAG 1.4.11), and the checked state that lifts the fill bounds lives there.
386
- They raise the fill cap to 20% -- the 6% one keeps typed text legible and a
387
- toggle has none; 20% is a `.solid` badge's plate and separates the three
388
- fills. `.ghost` is unsupported (an unchecked ghost toggle is the silhouette
389
- it already has, a checked one a mark on nothing); the registry records it and
390
- the checked fill floor clamps a cascaded one. See model.md#unsupported-values. */
464
+ (WCAG 1.4.11), and the checked state that pins the fill (see `text-control`'s
465
+ `:checked` above) lives there. They raise the fill cap to 40% -- the 6% one
466
+ keeps typed text legible and a toggle has none, and 40% is where a resting
467
+ `.ghost` (0%), `.soft`/default (12%), and `.solid` (the cap itself) read as
468
+ three distinct plates rather than three shades of the same wash, the same
469
+ separation `.switch` was already measured against. Unclassed rests at
470
+ `.soft` rather than at the cap: `--_d-fill`/`--_d-fg-on-fill` below name the
471
+ same pair `.soft` does, so a bare `<input class="checkbox">` reads as the
472
+ quiet tint the stress-test pass preferred over the louder plate `.solid`
473
+ asks for -- a consumer still reaches that plate by writing `.solid` itself.
474
+ `.ghost` used to be unsupported here -- a checked one used to be a mark on
475
+ nothing -- and is not any more, now that checked reads the same plate
476
+ whatever the presentation.
477
+ See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
391
478
  @utility checkbox {
392
479
  @apply text-control inline-grid size-4 cursor-pointer place-content-center;
393
480
 
394
- --_d-fill: 100%;
395
- --_d-fg-on-fill: 100%;
396
- --_fill-cap: 20%;
481
+ --_d-fill: 12%;
482
+ --_d-fg-on-fill: 0%;
483
+ --_fill-cap: 40%;
397
484
  --_line-rest: 100%;
398
485
  --_d-border: 100%;
399
486
  --_d-elevation: 0;
@@ -439,13 +526,14 @@
439
526
  }
440
527
  }
441
528
 
442
- /* The same control with a round silhouette and a dot for its mark. */
529
+ /* The same control with a round silhouette and a dot for its mark, and the
530
+ same fill cap raise given on `checkbox`. */
443
531
  @utility radio {
444
532
  @apply text-control inline-grid size-4 cursor-pointer place-content-center rounded-full;
445
533
 
446
- --_d-fill: 100%;
447
- --_d-fg-on-fill: 100%;
448
- --_fill-cap: 20%;
534
+ --_d-fill: 12%;
535
+ --_d-fg-on-fill: 0%;
536
+ --_fill-cap: 40%;
449
537
  --_line-rest: 100%;
450
538
  --_d-border: 100%;
451
539
  --_d-elevation: 0;
@@ -470,12 +558,14 @@
470
558
  content: "";
471
559
  }
472
560
 
473
- /* A checked radio thickens its ring and takes the intent whole on the line;
474
- the fill is left to the presentation. The dot takes `box`'s composed
475
- foreground, which walks to the contrast tone as the plate fills, so a
476
- filled circle still carries a readable dot. `--_line-tone` rather than
477
- `--_edge`, so `text-control`'s edge floor still draws the ring under a
478
- container's `.edgeless`. */
561
+ /* A checked radio thickens its ring and takes the intent whole on the line.
562
+ The fill is pinned by `text-control`'s `:checked` now, not left to the
563
+ presentation -- a filled circle at every checked radio, whatever fill
564
+ class it carries, rather than the classic ring-and-dot a checked `.soft`
565
+ used to draw. The dot still takes `box`'s composed foreground, which walks
566
+ to the contrast tone as the (now always full) plate fills, so it stays
567
+ readable. `--_line-tone` rather than `--_edge`, so `text-control`'s edge
568
+ floor still draws the ring under a container's `.edgeless`. */
479
569
  &:checked {
480
570
  --_line-tone: var(--intent-color);
481
571
 
@@ -491,24 +581,28 @@
491
581
  }
492
582
 
493
583
  /* A2: every dimension derives from the border width `box` resolved, so the knob
494
- cannot drift out of a track a thick aesthetic widened. A switch caps its fill
495
- higher than the other toggles because its track is the whole component. */
584
+ cannot drift out of a track a thick aesthetic widened. */
496
585
  @utility switch {
497
586
  @apply text-control relative cursor-pointer;
498
587
 
499
- --_d-fill: 100%;
500
- --_d-fg-on-fill: 100%;
588
+ --_d-fill: 12%;
589
+ --_d-fg-on-fill: 0%;
501
590
  --_line-rest: 100%;
502
591
  --_d-border: 100%;
503
592
  --_d-elevation: 0;
504
593
 
505
- /* 40%, measured: at `text-control`'s 6% the three fills collapsed to two looks
506
- and the switch had to vary its *line* per fill class -- the one place a fill
507
- class decided an edge. 40% separates all three (29/67/144 sRGB steps on the
508
- light page, against ~20 for a visible one) without reaching a checked
509
- track's 100%, so the knob's position still says which. `axes.spec.ts`
510
- asserts all three. `--intent-fill-max` still caps neutral at 20%. A cap
511
- rather than a resting value, because it answers the cascade case. */
594
+ /* 40%, measured: at `text-control`'s 6% the three resting fills collapsed to
595
+ two looks and the switch had to vary its *line* per fill class instead --
596
+ the one place a fill class decided an edge. 40% separates all three
597
+ (29/67/144 sRGB steps on the light page, against ~20 for a visible one)
598
+ without reaching a checked track's pinned 100%, so the knob's position
599
+ still says which. `axes.spec.ts` asserts all three. `checkbox`/`radio`
600
+ raise their own cap to the same value for the same reason -- a switch does
601
+ not need a higher one, a track is just the component every toggle's box
602
+ already was. A cap rather than a resting value, because it answers the
603
+ cascade case; `--intent-fill-max` (declared where the intents are, above)
604
+ lifts to 100% for all three toggles so a neutral one reads this cap too,
605
+ not the 20% ceiling a badge or a card stays under. */
512
606
  --_fill-cap: 40%;
513
607
 
514
608
  --switch-height: 1.25rem;
@@ -25,4 +25,4 @@
25
25
  makes the emission deliberate; the composition-body check in
26
26
  `exports.test.ts` fails the build if one is dropped. `text-control` and
27
27
  `surface` are also documented as consumer-usable on their own. */
28
- @source inline("box box-hover box-active loader-mask text-control surface input-group text-display text-title-lg text-title text-title-sm text-label-lg text-label text-body btn field label hint ipt email password url tel search date datetime month week time icon left right textarea select checkbox radio switch alert badge skeleton progress tooltip card panel interactive flush compact spacious neutral solid soft ghost edged edgeless flat raised floating loader");
28
+ @source inline("box box-hover box-active loader-mask text-control surface input-group text-display text-title-lg text-title text-title-sm text-label-lg text-label text-body btn field label hint ipt email password url tel search date datetime month week time icon left right textarea select checkbox radio switch alert alert-icon badge skeleton progress tooltip tooltip-bubble tooltip-icon card panel interactive flush compact spacious neutral solid soft ghost edged edgeless flat raised floating loader");
@@ -28,19 +28,6 @@
28
28
  .bars-wave,
29
29
  .pulse-ring
30
30
  )
31
- ),
32
- .loading:not(
33
- :where(
34
- .dots-wave,
35
- .dots-fade,
36
- .dots-queue,
37
- .dots-rotate,
38
- .dots-grow,
39
- .dots-grow-alternate,
40
- .dot-bounce,
41
- .bars-wave,
42
- .pulse-ring
43
- )
44
31
  ) {
45
32
  --loader-art: url("data:image/svg+xml;charset=utf-8,<svg stroke='currentColor' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><style>.spinner_V8m1{transform-origin:center;animation:spinner_zKoa 2s linear infinite}.spinner_V8m1 circle{stroke-linecap:round;animation:spinner_YpZS 1.5s ease-in-out infinite}@keyframes spinner_zKoa{100%{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%{stroke-dasharray:42 150;stroke-dashoffset:-16}95%,100%{stroke-dasharray:42 150;stroke-dashoffset:-59}}</style><g class='spinner_V8m1'><circle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'></circle></g></svg>");
46
33
  }
@@ -87,15 +74,14 @@
87
74
  a loader inside filled content matches the text around it and
88
75
  `.loader.success` is green. There is nothing else to know. */
89
76
  @utility loader {
90
- @apply loader-mask inline-block size-8 [&.lg]:size-10 [&.sm]:size-6;
77
+ @apply loader-mask inline-block size-7 [&.lg]:size-9 [&.sm]:size-5;
91
78
 
92
79
  color: var(--intent-color);
93
80
  background-color: currentColor;
94
81
  }
95
82
 
96
83
  @media (prefers-reduced-motion: reduce) {
97
- .loader,
98
- .loading {
84
+ .loader {
99
85
  --loader-art: url("data:image/svg+xml;charset=utf-8,<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='9.5' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-dasharray='42 18'/></svg>");
100
86
  }
101
87
 
@@ -67,6 +67,53 @@
67
67
  --surface-padding: 0;
68
68
  }
69
69
 
70
+ /* `.soft` rests untinted for no named intent, rather than mixing its 12% --
71
+ measured on a neutral card, 12% of `--color-text` onto any of
72
+ `--color-background`, `--color-foreground`, or `--color-surface` lands
73
+ within a few units of the others (a near-black ink dominates the result
74
+ regardless of which near-white ground it is mixed onto), which is why
75
+ swapping the ground token alone does not read as quieter. A resting
76
+ no-intent `.card.soft` is instead `--color-foreground` with no ink mixed
77
+ in at all -- a plain step up from the page that marks a different
78
+ container without drawing attention to it, the way a card that is not
79
+ currently interesting should.
80
+
81
+ A named intent keeps its ordinary 12% wash rather than being untinted
82
+ unconditionally: 12% of an actual hue does not read as a slab the way
83
+ 12% of near-black ink does -- measured, `.card.soft.destructive` at 12%
84
+ is a gentle `249, 225, 225`, nothing like the `217, 217, 217` a neutral
85
+ surface mixing the same 12% of its own ink used to read as -- and
86
+ untinting it anyway would silence a `.card.soft.destructive` at rest
87
+ rather than merely quiet it, the same reason `.panel`'s own carve-out
88
+ (below) is scoped the same way. `:not(...)` names every intent class for
89
+ that reason; a version of this fix that untinted every intent shipped
90
+ first and was corrected once `.panel` needed the identical, narrower
91
+ carve-out to keep a resting `.panel.destructive` legible.
92
+
93
+ `--_fill-cap: 0%` (box.css's override slot, not `--ui-fill` directly --
94
+ see the comment there) composed with `box-hover`'s own formula
95
+ (`min(100%, resting + 14%)`) is what reveals the intent on hover without
96
+ a second fill declaration, for the no-intent case: resting at 0% means
97
+ hover lands at exactly the hover step, 14%, in `--intent-hover` as
98
+ always. The ground steps to `--color-surface` at the same moment,
99
+ through the public `--ui-surface-ground` escape hatch rather than the
100
+ private `--_d-ground`, so a consumer's own override still wins over both
101
+ states the way it always has. Scoped to `.interactive` and `.hoverable`,
102
+ the two classes that already opt a card into `box-hover` -- a plain
103
+ `.card.soft` with neither has no hover state to reveal into and stays at
104
+ the quiet, untinted rest. A named intent needs neither special case: it
105
+ already rests tinted, so the same generic `box-hover` formula deepens it
106
+ on hover (12% to 26%) with no ground shift required. */
107
+ &.soft:not(.primary, .secondary, .success, .warning, .destructive, .danger, .error, .info) {
108
+ --_fill-cap: 0%;
109
+ --ui-surface-ground: var(--color-foreground);
110
+
111
+ &.interactive:hover,
112
+ &.hoverable:hover {
113
+ --ui-surface-ground: var(--color-surface);
114
+ }
115
+ }
116
+
70
117
  /* Opt-in, because a card is only sometimes a control. Applications still owe
71
118
  it a real interactive element and an accessible name. Hover and press are
72
119
  the derived ones every other pressable box gets; there is nothing
@@ -119,4 +166,50 @@
119
166
  &.flush {
120
167
  --surface-padding: 0;
121
168
  }
169
+
170
+ /* Neutral panels are the common case -- most are plain layout wells,
171
+ sidebars, and toolbars with no named intent -- and 12% of `--color-text`
172
+ (near-black) mixed onto the page is a far more assertive move than 12%
173
+ of an actual hue: measured, `217, 217, 217` against a `250, 250, 250`
174
+ page, the same "neutral ink reads louder" effect `.card.soft`'s fix
175
+ already answered, and the same reason `--intent-fill-max` stops
176
+ neutral's fill short everywhere else in the model.
177
+
178
+ Scoped to no named intent rather than untinted unconditionally the way
179
+ `data-table` is: a panel is not interactive, so there is no hover to
180
+ reveal a colored intent back into later, and it is edgeless by default,
181
+ so the fill is the only channel a `.primary` bulk-actions bar or a
182
+ `.destructive` danger-zone panel has to say what it is. Untinting those
183
+ unconditionally would silence them, not just quiet them -- measured, a
184
+ resting `.panel.destructive` is a gentle `249, 225, 225` warm wash,
185
+ nothing like the neutral slab above. `.card.soft` carries the identical
186
+ no-named-intent scoping now, corrected to match once this carve-out
187
+ shipped -- see the comment on `.card`'s own `&.soft`, above. `:not(...)`
188
+ names every intent class for that reason, plus `.solid`/`.ghost` so an
189
+ explicit presentation on the element is never clamped to a cap it did
190
+ not ask for -- the same guard `data-table`'s own `:not(.solid, .ghost)`
191
+ uses.
192
+
193
+ Grounds on `--color-foreground` through the public `--ui-surface-ground`
194
+ escape hatch, not the private `--_d-ground`, the same token `.card.soft`
195
+ steps to -- so a consumer's own override still wins, and a bare `.panel`
196
+ rests at exactly the plate a `.card.soft` does (measured: `245, 245, 245`
197
+ light, `23, 23, 23` dark, identical both ways). Permanent rather than
198
+ hover-revealed: neither a panel nor `data-table` has an interaction
199
+ state to reveal a tint into, so both rest quiet rather than reveal.
200
+
201
+ The one cost, inherited rather than new: an own declaration always beats
202
+ an inherited one, so this also beats `.glass` setting the same token on
203
+ an ancestor for its translucent ground -- a neutral panel under `.glass`
204
+ is quiet but opaque rather than blurred. `.card.soft` has carried this
205
+ exact trade-off since it shipped; nothing exercised it there because
206
+ `tests/browser/aesthetics.spec.ts`'s glass coverage tests a default
207
+ (ghost) card, never a soft one. Accepted rather than solved differently:
208
+ there is no way to ask "did an ancestor already set this token" in CSS,
209
+ only which declaration wins, and the quiet neutral rest is the more
210
+ common case a panel needs to get right. */
211
+ &:not(.primary, .secondary, .success, .warning, .destructive, .danger, .error, .info, .solid, .ghost) {
212
+ --_fill-cap: 0%;
213
+ --ui-surface-ground: var(--color-foreground);
214
+ }
122
215
  }