@codenhub/styles 0.5.0 → 0.6.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 (59) hide show
  1. package/README.md +7 -7
  2. package/dist/aesthetics/chunky-tile.css +1 -1
  3. package/dist/aesthetics/cyber.css +1 -1
  4. package/dist/aesthetics/glass.css +1 -1
  5. package/dist/aesthetics/index.css +1 -1
  6. package/dist/aesthetics/neobrutalism.css +1 -1
  7. package/dist/aesthetics/pixel.css +1 -1
  8. package/dist/aesthetics/sketch.css +1 -1
  9. package/dist/components.css +1 -1
  10. package/dist/index.css +1 -1
  11. package/dist/native.css +1 -1
  12. package/dist/palette.css +1 -1
  13. package/dist/theme.css +1 -1
  14. package/dist/tw/aesthetics/chunky-tile.css +39 -39
  15. package/dist/tw/aesthetics/cyber.css +16 -11
  16. package/dist/tw/aesthetics/glass.css +235 -10
  17. package/dist/tw/aesthetics/index.css +2 -1
  18. package/dist/tw/aesthetics/neobrutalism.css +23 -11
  19. package/dist/tw/aesthetics/pixel.css +18 -8
  20. package/dist/tw/aesthetics/sketch.css +14 -7
  21. package/dist/tw/box.css +49 -27
  22. package/dist/tw/components/button.css +35 -14
  23. package/dist/tw/components/feedback.css +59 -56
  24. package/dist/tw/components/form.css +200 -126
  25. package/dist/tw/components/index.css +3 -7
  26. package/dist/tw/components/loader.css +4 -5
  27. package/dist/tw/components/surface.css +71 -68
  28. package/dist/tw/components/tooltip.css +75 -41
  29. package/dist/tw/elevation.css +14 -22
  30. package/dist/tw/index.css +15 -19
  31. package/dist/tw/intent.css +31 -24
  32. package/dist/tw/native.css +15 -5
  33. package/dist/tw/palette.css +8 -5
  34. package/dist/tw/presentation.css +16 -30
  35. package/dist/tw/reset.css +21 -28
  36. package/dist/tw/surface.css +11 -12
  37. package/dist/tw/theme.css +58 -86
  38. package/dist/tw/utilities/content.css +268 -199
  39. package/dist/tw/utilities/layout.css +15 -25
  40. package/docs/accessibility.md +50 -33
  41. package/docs/changelog/0.6.0.md +52 -0
  42. package/docs/changelog/index.md +1 -0
  43. package/docs/concepts.md +6 -6
  44. package/docs/index.md +9 -0
  45. package/docs/integrating/nextjs.md +1 -1
  46. package/docs/integrating/tailwind.md +14 -6
  47. package/docs/setup.md +6 -6
  48. package/docs/usage/aesthetics.md +35 -8
  49. package/docs/usage/buttons.md +1 -1
  50. package/docs/usage/composing.md +1 -1
  51. package/docs/usage/content-and-layout.md +12 -11
  52. package/docs/usage/customizing.md +8 -9
  53. package/docs/usage/feedback.md +5 -3
  54. package/docs/usage/forms.md +51 -35
  55. package/docs/usage/theming.md +39 -40
  56. package/docs/usage/tooltips.md +37 -17
  57. package/llms-full.txt +331 -183
  58. package/llms.txt +6 -0
  59. package/package.json +5 -5
@@ -24,18 +24,16 @@
24
24
  --_d-fg-on-fill: 0%;
25
25
  --_d-border: 100%;
26
26
 
27
- /* The cap used to be 12%, which is exactly what `.soft` asks for, so `min()`
28
- handed `.solid` and `.soft` the same tint and three fill names rendered two
29
- boxes. 6% is a quiet wash, and it is the cap rather than a presentation:
30
- `.soft` names the tint it wants and takes it whole below, so what this
31
- governs is `.solid` and the container that cascades one onto a field nobody
32
- classed.
27
+ /* 6% is a quiet wash, and it is the cap rather than a presentation: `.soft`
28
+ names the tint it wants and takes it whole below, so what this governs is
29
+ `.solid` and the container that cascades one onto a field nobody classed. A
30
+ cap equal to `.soft`'s 12% would hand `.solid` and `.soft` the same tint
31
+ through `min()`.
33
32
 
34
33
  Named rather than written into the `min()`, because the soft hover below is
35
34
  derived from it and the two must not drift apart. Typed text is
36
35
  `--intent-color` over a wash of its own tone, past 10:1 in both themes at
37
- any value this cap is likely to take, so the bound still does what it was
38
- written for.
36
+ any value this cap is likely to take.
39
37
 
40
38
  The fill and the ink are `box`'s own, unrestated: its `--_capped` reads
41
39
  this cap, so the plate and the ink ramp stay in step at every bound,
@@ -54,17 +52,16 @@
54
52
  back to 100% -- its line is a silhouette rather than a field's edge, and
55
53
  there is nothing else marking an unchecked box. */
56
54
  --_line-rest: 60%;
57
- /* Two seams rather than one composed result. `--_line-tone` is what colour the
58
- line is, and the states below move it; `--_line` folds in the fill, walking
59
- the line toward the intent colour by the fill amount, so a state never has
60
- to restate that blend to keep it. It is a field's own form of P3 rather than
61
- `box`'s: a field keeps its line whatever it is filled with, so the line walks
62
- toward the fill's colour instead of fading out into it.
63
-
64
- Written as a single `--_line` that states overwrote, a hovered or focused
65
- control lost the blend along with the tone, because replacing a composed
66
- result drops every term it was composed from. Same failure as the edge
67
- below, one property over. */
55
+ /* Two seams rather than one composed result. `--_line-tone` is what colour
56
+ the line is, and the states below move it; `--_line` folds in the fill,
57
+ walking the line toward the intent colour by the fill amount, so a state
58
+ never has to restate that blend to keep it. It is a field's own form of P3
59
+ rather than `box`'s: a field keeps its line whatever it is filled with, so
60
+ the line walks toward the fill's colour instead of fading out into it.
61
+
62
+ A single `--_line` that states overwrote would lose the blend along with
63
+ the tone, because replacing a composed result drops every term it was
64
+ composed from -- the same failure as the edge below. */
68
65
  --_line-tone: color-mix(in oklab, var(--intent-border) var(--_line-rest), transparent);
69
66
  --_line: color-mix(in oklab, var(--intent-color) var(--_fill), var(--_line-tone));
70
67
  /* The edge floor, as an input to `box` rather than a rewrite of its result.
@@ -76,17 +73,18 @@
76
73
  An element's own `.edgeless` lowers the floor -- see the rule below the
77
74
  utility -- which is a consumer naming what they want rather than a cascade
78
75
  reaching something nobody classed. `.checkbox` and `.radio` are left off
79
- that rule and keep the floor absolute: they have nothing outside their line,
80
- so removing it removes the control.
81
-
82
- This used to be `--_edge: color-mix(...)` written after `@apply box`, which
83
- dropped `--ui-border` out of the composition entirely. `.edged` and
84
- `.edgeless` were both inert on all six text controls, and `--_d-border` was
85
- declared and never read on each of them. */
76
+ that rule and keep the floor absolute: they have nothing outside their
77
+ line, so removing it removes the control. */
86
78
  --_edge-floor: 100%;
87
79
 
88
80
  @apply w-full appearance-none;
89
81
 
82
+ /* The surrounding font rather than the user agent's smaller field one, on
83
+ every entry: `/components` carries no reset to say so. */
84
+ font-family: inherit;
85
+ font-size: inherit;
86
+ line-height: inherit;
87
+
90
88
  &::placeholder {
91
89
  color: var(--color-text-secondary);
92
90
  }
@@ -103,60 +101,69 @@
103
101
  --_line-tone: var(--intent-color);
104
102
  }
105
103
 
106
- /* R8, P6: `:checked` is a state, so it re-declares the presentation inputs
107
- -- a full fill and full contrast ink -- and lifts the cap that would bound
108
- them, and `box` composes the rest. It does not write a result (the failure
109
- mode P6 exists for: an early version pinned `--_fill` and `--_fg` directly,
110
- which made a checked toggle ignore its own aesthetic and elevation along
111
- with presentation). It sits in `utilities` at 0-2-0, above the fill
112
- classes in `components`, so a checked toggle renders one plate whatever
113
- presentation it or a container asks for, and a consumer's utility loses to
114
- it because state wins over every choice (model.md#precedence). A toggle is
115
- a void `<input>`, so the inputs it writes reach nothing but its own mark.
116
-
117
- The fill used to be pinned at `12%` rather than `100%`, which kept presentation
118
- live on the checked mark too -- a checked `.soft` toggle rendered a pale
119
- plate with just the mark at full strength, the classic ring-and-dot radio.
120
- The stress-test pass found that cost more than it bought on a real screen:
121
- three presentations gave a checkbox, a radio, and a switch three different
122
- "on" identities to learn, while every *unchecked* toggle read the same
123
- washed-out plate regardless of which class asked for it -- close enough to
124
- the disabled look that a form full of ordinary, unchecked, un-intended
125
- controls read as half-disabled. Pinning checked to one plate and freeing
126
- presentation to separate the *unchecked* one instead (see
127
- `checkbox`/`radio`/`switch` below) moves the axis to where it was doing
128
- something. Lives here, not on the toggles, because a text input is never
129
- `:checked`.
130
-
131
- The ink is declared alongside the fill: the other half of what a fill
132
- class asks for is how much contrast ink it wants (`--ui-fg-on-fill: 0%` is
133
- `.ghost`/`.soft` declaring "none, my plate stays quiet"), and setting the
134
- fill without this left a checked `.ghost`/`.soft` toggle with an opaque
135
- plate and a mark that still read as though the plate were quiet -- both
136
- derived from the same intent tone, close enough to disappear into each
137
- other. `box` ramps the ink against the bounded fill before `--ui-bg-alpha`
138
- thins it, so a checked toggle under `.glass` keeps full contrast ink on
139
- its translucent plate. */
104
+ /* S1, P6: `:checked` is a state, so it re-declares the presentation inputs --
105
+ a full fill and full contrast ink -- and lifts the cap that would bound
106
+ them, and `box` composes the rest. It does not write a result: pinning
107
+ `--_fill` and `--_fg` directly would make a checked toggle ignore its own
108
+ aesthetic and elevation along with presentation. It sits in `utilities` at
109
+ 0-2-0, above the fill classes in `components`, so a checked toggle renders
110
+ one plate whatever presentation it or a container asks for, and a
111
+ consumer's utility loses to it because state wins over every choice
112
+ (model.md#precedence). A toggle is a void `<input>`, so the inputs it
113
+ writes reach nothing but its own mark.
114
+
115
+ The checked fill is 100% whatever the presentation, so a checkbox, a radio,
116
+ and a switch each have one "on" identity; presentation separates the
117
+ *unchecked* plate instead (see `checkbox`/`radio`/`switch` below). It lives
118
+ here, not on the toggles, because a text input is never `:checked`.
119
+
120
+ The ink is declared alongside the fill: `--ui-fg-on-fill: 0%` is
121
+ `.ghost`/`.soft` declaring "my plate stays quiet", and a checked toggle
122
+ with an opaque plate and an ink that still expects a quiet one loses its
123
+ mark into the plate. `box` ramps the ink against the bounded fill before
124
+ `--ui-bg-alpha` thins it, so a checked toggle under `.glass` keeps full
125
+ contrast ink on its translucent plate. */
140
126
  &:checked {
141
127
  --ui-fill: 100%;
142
128
  --ui-fg-on-fill: 100%;
143
129
  --_fill-cap: 100%;
144
130
  }
131
+ }
145
132
 
146
- /* Native decorations a field replaces with its own artwork. */
133
+ /* The browser's own field decorations -- the date picker button, a number's
134
+ spin buttons, a search field's clear and magnifier -- stay by default,
135
+ because each one is also behaviour: hiding the picker button hides the only
136
+ pointer route to the picker. `.unadorned` removes them for a consumer who
137
+ draws their own adornments in an `.input-group` and wires the behaviour back
138
+ (`showPicker()`, `stepUp()`/`stepDown()`, clearing the value). On the
139
+ control, or on the group to reach the control inside.
140
+
141
+ Firefox exposes no pseudo-element for its date picker button, so that one
142
+ stays there; its number spinners go with `appearance: textfield`. */
143
+ @utility unadorned {
147
144
  &::-webkit-search-decoration,
148
145
  &::-webkit-search-cancel-button,
149
146
  &::-webkit-search-results-button,
150
147
  &::-webkit-search-results-decoration,
151
148
  &::-webkit-calendar-picker-indicator,
152
149
  &::-webkit-inner-spin-button,
153
- &::-webkit-clear-button {
150
+ &::-webkit-outer-spin-button,
151
+ &::-webkit-clear-button,
152
+ & > input::-webkit-search-decoration,
153
+ & > input::-webkit-search-cancel-button,
154
+ & > input::-webkit-search-results-button,
155
+ & > input::-webkit-search-results-decoration,
156
+ & > input::-webkit-calendar-picker-indicator,
157
+ & > input::-webkit-inner-spin-button,
158
+ & > input::-webkit-outer-spin-button,
159
+ & > input::-webkit-clear-button {
154
160
  -webkit-appearance: none;
155
- display: none !important;
156
- opacity: 0;
157
- pointer-events: none;
158
- width: 0;
159
- height: 0;
161
+ display: none;
162
+ }
163
+
164
+ &[type="number"],
165
+ & > input[type="number"] {
166
+ appearance: textfield;
160
167
  }
161
168
  }
162
169
 
@@ -164,19 +171,19 @@
164
171
  `@utility`, placed in `utilities` and winning there on specificity, each at
165
172
  least 0-2-0 against the component's 0-1-0. They write only package privates
166
173
  (`--_fill-cap`, `--_edge-floor`) or a state's intent slots, so nothing a
167
- consumer writes competes with them. They used to be unlayered, which stopped
168
- working once `/components` and `/native` put the components in `utilities`
169
- too. See docs/internal/model.md#cascade-layers. */
174
+ consumer writes competes with them. See
175
+ docs/internal/model.md#cascade-layers. */
170
176
  @layer utilities {
171
177
  /* A fill class on the element names its own cap; the 6% cascade cap in the
172
178
  utility is for a container reaching a field nobody classed. Both raise
173
179
  `--_fill-cap` rather than writing `--_fill` (P6), which is also what keeps
174
- `.soft` < `.solid` -- written as a rewrite the louder name rendered the
175
- quieter box, measured and inverted on `.ipt`, `.checkbox` and `.radio`.
176
- 20% clears `.soft`'s 12% and matches the plate a `.solid` badge draws.
180
+ `.soft` < `.solid`: written as a rewrite, the louder name renders the
181
+ quieter box. 20% clears `.soft`'s 12% and matches the plate a `.solid`
182
+ badge draws.
177
183
 
178
184
  `.switch` is deliberately absent: its track caps at 40% in its own utility,
179
- and a 0-2-0 rule here would overrule that. See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
185
+ and a 0-2-0 rule here would overrule that. See
186
+ model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
180
187
  :is(.text-control, .ipt, .textarea, .select, .input-group).soft {
181
188
  --_fill-cap: 12%;
182
189
  }
@@ -211,24 +218,22 @@
211
218
  /* The element's own `.edgeless` lowers the floor `text-control` raises -- a
212
219
  class on the element is a consumer's request, a class on a container is a
213
220
  cascade. `.checkbox` and `.radio` are absent: an unchecked one is a
214
- transparent square/circle with nothing but its line, so the floor is absolute
215
- and `.edgeless` on them is published unsupported. `.input-group` is on it
216
- because the group is the field boundary now. */
221
+ transparent square/circle with nothing but its line, so the floor is
222
+ absolute and `.edgeless` on them is published unsupported. `.input-group`
223
+ is on it because the group is the field boundary. */
217
224
  :is(.text-control, .ipt, .textarea, .select, .switch, .input-group).edgeless {
218
225
  --_edge-floor: 0%;
219
226
  }
220
227
 
221
228
  /* `.switch`'s one exception to the opt-out above: `.ghost` already asks for
222
- zero fill, so a `.switch.ghost.edgeless` track has neither a line nor a
223
- plate and disappears entirely -- nothing marks its bounds but the floating
224
- knob. Every other fill on this component still has something to paint
225
- (`.soft`/`.solid`/default all rest above 0%), so only this one pairing
226
- needs the floor held up. `.switch.ghost.edgeless` renders identically to
227
- `.switch.ghost.edged` now, published unsupported as a distinct look --
228
- the same "produces a row indistinguishable from its neighbour" reason
229
- `.solid.edgeless`/`.solid.edged` already collapse to one row for. Higher
230
- specificity than the rule above (three classes, not an `:is()` of one)
231
- wins regardless of source order. */
229
+ zero fill, so a `.switch.ghost.edgeless` track would have neither a line
230
+ nor a plate and disappear -- nothing would mark its bounds but the floating
231
+ knob. Every other fill on this component has something to paint
232
+ (`.soft`/`.solid`/default all rest above 0%), so only this pairing needs
233
+ the floor held up. `.switch.ghost.edgeless` therefore renders identically
234
+ to `.switch.ghost.edged`, and is published unsupported as a distinct look.
235
+ Higher specificity than the rule above (three classes, not an `:is()` of
236
+ one) wins regardless of source order. */
232
237
  .switch.ghost.edgeless {
233
238
  --_edge-floor: 100%;
234
239
  }
@@ -236,10 +241,11 @@
236
241
  /* An invalid control is destructive whatever else it was asked to be. It sits
237
242
  in `utilities`, above every intent class and both intent resets in
238
243
  `components`, and `:is()` gives it 0-2-0 so it also outranks the control's
239
- own utility; a consumer's utility setting an intent slot on an invalid field
240
- loses to it too, because state wins over every choice (model.md#precedence). `--intent-hover` is redirected with the others so the line does not go
241
- grey under the pointer. The `:has()` arm bridges an inner control's state to
242
- an `.input-group`. */
244
+ own utility; a consumer's utility setting an intent slot on an invalid
245
+ field loses to it too, because state wins over every choice
246
+ (model.md#precedence). `--intent-hover` is redirected with the others so
247
+ the line does not go grey under the pointer. The `:has()` arm bridges an
248
+ inner control's state to an `.input-group`. */
243
249
  :is(
244
250
  .text-control,
245
251
  .ipt,
@@ -292,12 +298,14 @@
292
298
  letter-spacing: 0.025em;
293
299
  }
294
300
 
301
+ /* A hint wraps, so it takes `text-sm`'s leading like the package's other 14px
302
+ text rather than a line height equal to its size, where wrapped lines touch. */
295
303
  @utility hint {
304
+ @apply text-sm;
305
+
296
306
  color: var(--color-text-secondary);
297
- font-size: 0.875rem;
298
307
  font-weight: 400;
299
308
  letter-spacing: 0.05em;
300
- line-height: 100%;
301
309
 
302
310
  &.destructive {
303
311
  color: var(--intent-color);
@@ -325,7 +333,7 @@
325
333
  gradients rather than masked, because `appearance: none` removes the platform
326
334
  arrow and a background image is the only thing a replaced element can paint. */
327
335
  @utility select {
328
- @apply text-control h-(--control-height,2.5rem) py-2 pr-10 pl-3;
336
+ @apply text-control h-(--control-height,2.5rem) py-2 ps-3 pe-10;
329
337
 
330
338
  --_d-fill: 0%;
331
339
  --_d-fg-on-fill: 0%;
@@ -347,6 +355,15 @@
347
355
  background-repeat: no-repeat;
348
356
  background-size: 0.3125rem 0.3125rem;
349
357
 
358
+ /* The chevron sits at the inline end, like the padding kept for it, so a
359
+ right-to-left select draws it at the left. `background-position` has no
360
+ logical form, so the mirror is named here. */
361
+ &:dir(rtl) {
362
+ background-position:
363
+ 0.6875rem center,
364
+ 1rem center;
365
+ }
366
+
350
367
  /* The open list is painted by the browser, over the select's own background
351
368
  when it has one. A ghost select has none, so Chromium falls back to its
352
369
  default popup -- white on Windows -- while the options inherit the select's
@@ -357,6 +374,15 @@
357
374
  background-color: var(--color-background);
358
375
  color: var(--color-text);
359
376
  }
377
+
378
+ /* Several rows showing is a list box, not a drop-down: no chevron, the height
379
+ of its rows, and the chevron's room given back. `--control-height` stays
380
+ the floor. */
381
+ &:is([multiple], [size]:not([size="1"])) {
382
+ height: auto;
383
+ padding-inline: 0.75rem;
384
+ background-image: none;
385
+ }
360
386
  }
361
387
 
362
388
  /* A wrapper that owns the field box so a control can carry adornments beside it
@@ -410,7 +436,7 @@
410
436
  --_line-tone: var(--intent-color);
411
437
 
412
438
  outline: var(--focus-ring-width) solid var(--focus-ring);
413
- outline-offset: var(--focus-ring-offset);
439
+ outline-offset: var(--focus-ring-offset, calc(-1 * var(--ui-border-width, var(--border-width))));
414
440
  box-shadow: var(--_focus-stack, var(--_shadow));
415
441
  }
416
442
 
@@ -456,19 +482,14 @@
456
482
  /* The three toggles compose `text-control` for its bounds, not its chrome: the
457
483
  edge floor keeps a container's `.edgeless` from erasing the closed silhouette
458
484
  (WCAG 1.4.11), and the checked state that pins the fill (see `text-control`'s
459
- `:checked` above) lives there. They raise the fill cap to 40% -- the 6% one
485
+ `:checked` above) lives there. They raise the fill cap to 40%: the 6% cap
460
486
  keeps typed text legible and a toggle has none, and 40% is where a resting
461
487
  `.ghost` (0%), `.soft`/default (12%), and `.solid` (the cap itself) read as
462
- three distinct plates rather than three shades of the same wash, the same
463
- separation `.switch` was already measured against. Unclassed rests at
464
- `.soft` rather than at the cap: `--_d-fill`/`--_d-fg-on-fill` below name the
465
- same pair `.soft` does, so a bare `<input class="checkbox">` reads as the
466
- quiet tint the stress-test pass preferred over the louder plate `.solid`
467
- asks for -- a consumer still reaches that plate by writing `.solid` itself.
468
- `.ghost` used to be unsupported here -- a checked one used to be a mark on
469
- nothing -- and is not any more, now that checked reads the same plate
470
- whatever the presentation.
471
- See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
488
+ three distinct plates rather than three shades of one wash. Unclassed rests
489
+ at `.soft` rather than at the cap: `--_d-fill`/`--_d-fg-on-fill` below name
490
+ the same pair `.soft` does, so a bare `<input class="checkbox">` reads as the
491
+ quiet tint, and a consumer reaches the louder plate by writing `.solid`. See
492
+ model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
472
493
  @utility checkbox {
473
494
  @apply text-control inline-grid size-4 cursor-pointer place-content-center;
474
495
 
@@ -520,6 +541,31 @@
520
541
  &:checked::after {
521
542
  @apply opacity-100;
522
543
  }
544
+
545
+ /* Mixed -- part of a group checked -- takes the checked plate, the same
546
+ inputs `text-control`'s `:checked` writes, and a dash for its mark. After
547
+ the checked mark, so a box that is both draws the dash, as the browser's
548
+ own checkbox does. */
549
+ &:indeterminate {
550
+ --ui-fill: 100%;
551
+ --ui-fg-on-fill: 100%;
552
+ --_fill-cap: 100%;
553
+ }
554
+
555
+ &:indeterminate::after {
556
+ @apply opacity-100;
557
+
558
+ mask-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round'><line x1='5' y1='12' x2='19' y2='12'/></svg>");
559
+ -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round'><line x1='5' y1='12' x2='19' y2='12'/></svg>");
560
+ }
561
+
562
+ /* The mark appears without moving under reduced motion, for the entries
563
+ without the reset; see `box.css`. */
564
+ @media (prefers-reduced-motion: reduce) {
565
+ &::after {
566
+ transition-duration: 0.01ms;
567
+ }
568
+ }
523
569
  }
524
570
 
525
571
  /* The same control with a round silhouette and a dot for its mark, and the
@@ -561,13 +607,12 @@
561
607
  }
562
608
 
563
609
  /* A checked radio thickens its ring and takes the intent whole on the line.
564
- The fill is pinned by `text-control`'s `:checked` now, not left to the
565
- presentation -- a filled circle at every checked radio, whatever fill
566
- class it carries, rather than the classic ring-and-dot a checked `.soft`
567
- used to draw. The dot still takes `box`'s composed foreground, which walks
568
- to the contrast tone as the (now always full) plate fills, so it stays
569
- readable. `--_line-tone` rather than `--_edge`, so `text-control`'s edge
570
- floor still draws the ring under a container's `.edgeless`. */
610
+ The fill is pinned by `text-control`'s `:checked`, so every checked radio
611
+ is a filled circle whatever fill class it carries. The dot takes `box`'s
612
+ composed foreground, which walks to the contrast tone as the (always full)
613
+ plate fills, so it stays readable. `--_line-tone` rather than `--_edge`, so
614
+ `text-control`'s edge floor still draws the ring under a container's
615
+ `.edgeless`. */
571
616
  &:checked {
572
617
  --_line-tone: var(--intent-color);
573
618
 
@@ -580,6 +625,14 @@
580
625
  &:checked::after {
581
626
  @apply scale-100 opacity-100;
582
627
  }
628
+
629
+ /* The mark appears without moving under reduced motion, for the entries
630
+ without the reset; see `box.css`. */
631
+ @media (prefers-reduced-motion: reduce) {
632
+ &::after {
633
+ transition-duration: 0.01ms;
634
+ }
635
+ }
583
636
  }
584
637
 
585
638
  /* A2: every dimension derives from the border width `box` resolved, so the knob
@@ -593,24 +646,30 @@
593
646
  --_d-border: 100%;
594
647
  --_d-elevation: 0;
595
648
 
596
- /* 40%, measured: at `text-control`'s 6% the three resting fills collapsed to
597
- two looks and the switch had to vary its *line* per fill class instead --
598
- the one place a fill class decided an edge. 40% separates all three
649
+ /* 40%, measured: at `text-control`'s 6% the three resting fills collapse to
650
+ two looks, so the switch would have to vary its *line* per fill class --
651
+ the one place a fill class would decide an edge. 40% separates all three
599
652
  (29/67/144 sRGB steps on the light page, against ~20 for a visible one)
600
653
  without reaching a checked track's pinned 100%, so the knob's position
601
- still says which. `axes.spec.ts` asserts all three. `checkbox`/`radio`
602
- raise their own cap to the same value for the same reason -- a switch does
603
- not need a higher one, a track is just the component every toggle's box
604
- already was. A cap rather than a resting value, because it answers the
605
- cascade case; `--intent-fill-max` (declared where the intents are, above)
606
- lifts to 100% for all three toggles so a neutral one reads this cap too,
607
- not the 20% ceiling a badge or a card stays under. */
654
+ still says which; `axes.spec.ts` asserts all three. `checkbox`/`radio`
655
+ raise their own cap to the same value for the same reason. A cap rather
656
+ than a resting value, because it answers the cascade case;
657
+ `--intent-fill-max` (declared where the intents are, above) lifts to 100%
658
+ for all three toggles so a neutral one reads this cap, not the 20% ceiling
659
+ a badge or a card stays under. */
608
660
  --_fill-cap: 40%;
609
661
 
610
662
  --switch-height: 1.25rem;
611
663
  --switch-width: 2.5rem;
612
664
  --switch-gap: 1px;
613
- --switch-inset: calc(var(--_width) + var(--switch-gap));
665
+ /* The edge an aesthetic draws as an inset ring rather than as a border
666
+ (`.pixel`), present only while `--ui-shadow-edge` is declared -- the idiom
667
+ `box` uses. That ring paints under the knob, so a knob sized from the
668
+ border alone covered it: under pixel the border is zero, the knob met the
669
+ edge, and an unchecked and a checked switch both read as two halves. */
670
+ --_switch-ring: var(--ui-shadow-edge) var(--_ss);
671
+ --_switch-offset: calc(var(--_switch-ring, 0px) + var(--switch-gap));
672
+ --switch-inset: calc(var(--_width) + var(--_switch-offset));
614
673
  --switch-knob: calc(var(--switch-height) - var(--switch-inset) * 2);
615
674
  --switch-travel: calc(var(--switch-width) - var(--switch-inset) * 2 - var(--switch-knob));
616
675
 
@@ -647,8 +706,8 @@
647
706
  content: "";
648
707
  height: var(--switch-knob);
649
708
  width: var(--switch-knob);
650
- top: var(--switch-gap);
651
- inset-inline-start: var(--switch-gap);
709
+ top: var(--_switch-offset);
710
+ inset-inline-start: var(--_switch-offset);
652
711
  }
653
712
 
654
713
  &:checked::after {
@@ -662,6 +721,14 @@
662
721
  translate: calc(-1 * var(--switch-travel));
663
722
  }
664
723
  }
724
+
725
+ /* The mark appears without moving under reduced motion, for the entries
726
+ without the reset; see `box.css`. */
727
+ @media (prefers-reduced-motion: reduce) {
728
+ &::after {
729
+ transition-duration: 0.01ms;
730
+ }
731
+ }
665
732
  }
666
733
 
667
734
  @media (forced-colors: active) {
@@ -675,7 +742,14 @@
675
742
  transition: none;
676
743
  }
677
744
 
745
+ /* The switch knob is the one mark visible while unchecked, and with forced
746
+ colours off it would keep the package grey on a system Canvas. */
747
+ .switch::after {
748
+ background-color: CanvasText;
749
+ }
750
+
678
751
  .checkbox:checked,
752
+ .checkbox:indeterminate,
679
753
  .radio:checked,
680
754
  .switch:checked {
681
755
  background-color: Highlight;
@@ -29,12 +29,8 @@
29
29
  the content scan will not emit on its own. `box` and friends appear only as
30
30
  `@utility` blocks and `@apply` targets; `text-control`, `surface` and
31
31
  `input-group` appear only inside `:is()`/`:where()` argument lists in
32
- `intent.css` and `form.css`, never as a selector of their own. Their standalone
33
- rules shipped only because docs prose happened to name them, and excluding
34
- `docs/` from the scan removed that -- `.input-group` (and, with it, its focus
35
- ring, its flush inner control, and `:has()`-propagated invalid state) went
36
- silently dark, caught only by the browser suite. Safelisting all seven here
37
- makes the emission deliberate; the composition-body check in
32
+ `intent.css` and `form.css`, never as a selector of their own. Safelisting
33
+ all seven makes the emission deliberate; the composition-body check in
38
34
  `exports.test.ts` fails the build if one is dropped. `text-control` and
39
35
  `surface` are also documented as consumer-usable on their own. */
40
- @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 neutral solid soft ghost edged edgeless flat raised floating loader");
36
+ @source inline("box box-hover box-active loader-mask text-control surface input-group unadorned 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 neutral solid soft ghost edged edgeless flat raised floating loader");
@@ -77,11 +77,10 @@
77
77
  --loader-art: url("data:image/svg+xml;charset=utf-8,<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><style>.pulse{animation:pulse-anim 1.2s cubic-bezier(0, 0.2, 0.8, 1) infinite;stroke:currentColor;transform-origin:center}.p2{animation-delay:-0.6s}@keyframes pulse-anim{0%{transform:scale(0);opacity:1}100%{transform:scale(1);opacity:0}}%3C/style%3E<circle class='pulse p1' cx='12' cy='12' r='10' fill='none' stroke-width='3'></circle><circle class='pulse p2' cx='12' cy='12' r='10' fill='none' stroke-width='3'></circle></svg>");
78
78
  }
79
79
 
80
- /* A loader takes a colour and its own geometry, and nothing else. No fill, no
81
- edge, no silhouette: what used to be a bordered box wrapped around a mask is
82
- now the mask. `--intent-color` defaults to `currentColor` for indicators, so
83
- a loader inside filled content matches the text around it and
84
- `.loader.success` is green. There is nothing else to know. */
80
+ /* A loader takes a colour and its own geometry, and nothing else: no fill, no
81
+ edge, no silhouette. `--intent-color` defaults to `currentColor` for
82
+ indicators, so a loader inside filled content matches the text around it and
83
+ `.loader.success` is green. */
85
84
  @utility loader {
86
85
  @apply loader-mask inline-block size-7 [&.lg]:size-9 [&.sm]:size-5;
87
86