@codenhub/styles 0.4.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 (61) 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 -0
  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 +144 -128
  15. package/dist/tw/aesthetics/cyber.css +138 -96
  16. package/dist/tw/aesthetics/glass.css +332 -66
  17. package/dist/tw/aesthetics/index.css +3 -1
  18. package/dist/tw/aesthetics/neobrutalism.css +113 -54
  19. package/dist/tw/aesthetics/pixel.css +137 -87
  20. package/dist/tw/aesthetics/sketch.css +333 -0
  21. package/dist/tw/box.css +139 -52
  22. package/dist/tw/components/button.css +67 -28
  23. package/dist/tw/components/feedback.css +85 -67
  24. package/dist/tw/components/form.css +300 -230
  25. package/dist/tw/components/index.css +17 -9
  26. package/dist/tw/components/loader.css +38 -30
  27. package/dist/tw/components/surface.css +89 -129
  28. package/dist/tw/components/tooltip.css +91 -47
  29. package/dist/tw/corner.css +26 -0
  30. package/dist/tw/elevation.css +19 -28
  31. package/dist/tw/index.css +15 -19
  32. package/dist/tw/intent.css +47 -32
  33. package/dist/tw/native.css +58 -42
  34. package/dist/tw/palette.css +455 -446
  35. package/dist/tw/presentation.css +20 -29
  36. package/dist/tw/reset.css +21 -28
  37. package/dist/tw/surface.css +61 -0
  38. package/dist/tw/theme.css +156 -153
  39. package/dist/tw/utilities/content.css +283 -205
  40. package/dist/tw/utilities/layout.css +17 -27
  41. package/docs/accessibility.md +63 -46
  42. package/docs/changelog/0.5.0.md +38 -0
  43. package/docs/changelog/0.6.0.md +52 -0
  44. package/docs/changelog/index.md +2 -0
  45. package/docs/concepts.md +6 -6
  46. package/docs/index.md +12 -3
  47. package/docs/integrating/nextjs.md +1 -1
  48. package/docs/integrating/tailwind.md +14 -6
  49. package/docs/setup.md +28 -8
  50. package/docs/usage/aesthetics.md +88 -18
  51. package/docs/usage/buttons.md +14 -14
  52. package/docs/usage/composing.md +12 -12
  53. package/docs/usage/content-and-layout.md +25 -24
  54. package/docs/usage/customizing.md +92 -55
  55. package/docs/usage/feedback.md +5 -3
  56. package/docs/usage/forms.md +61 -45
  57. package/docs/usage/theming.md +41 -42
  58. package/docs/usage/tooltips.md +37 -17
  59. package/llms-full.txt +590 -304
  60. package/llms.txt +6 -0
  61. package/package.json +15 -5
@@ -24,55 +24,21 @@
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.
37
+
38
+ The fill and the ink are `box`'s own, unrestated: its `--_capped` reads
39
+ this cap, so the plate and the ink ramp stay in step at every bound,
40
+ including the checked state below that lifts it. */
39
41
  --_fill-cap: 6%;
40
- /* The floor is zero everywhere except a checked toggle, which needs a ground
41
- under its mark. A container's `.ghost` would otherwise leave a checked
42
- checkbox as a tick floating on the page -- our own cascade producing the
43
- broken result, which is the test a bound has to pass. */
44
- --_fill-floor: 0%;
45
- --_fill: calc(
46
- clamp(
47
- var(--_fill-floor),
48
- min(var(--ui-fill, var(--_d-fill)), var(--_fill-cap), var(--intent-fill-max, 100%)),
49
- 100%
50
- ) *
51
- var(--ui-bg-alpha, 1)
52
- );
53
- /* The ink follows *this* control's fill, not `box`'s. `box` ramps against
54
- `--_capped`, which knows `--intent-fill-max` but not `--_fill-cap`, so on a
55
- control it would read a fill far higher than the one actually painted: a
56
- `.primary.solid` checkbox has `--ui-fill: 100%` and an unbounded intent, so
57
- `box` would call for the contrast tone while the plate sits at 20%. White
58
- tick on light grey.
59
-
60
- Ramping against `--_fill` keeps the two in step at every bound, including
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
- );
76
42
  /* The resting line is a fraction of the intent rather than all of it, and the
77
43
  fraction is what makes the pointer visible. Drawn at full strength a control
78
44
  already sits at its intent's darkest, and hover moves it to a neighbouring
@@ -86,15 +52,16 @@
86
52
  back to 100% -- its line is a silhouette rather than a field's edge, and
87
53
  there is nothing else marking an unchecked box. */
88
54
  --_line-rest: 60%;
89
- /* Two seams rather than one composed result. `--_line-tone` is what colour the
90
- line is, and the states below move it; `--_line` folds in P3's blend toward
91
- the fill, exactly as `box` does it, so the two agree and a state never has to
92
- restate the blend to keep it.
93
-
94
- Written as a single `--_line` that states overwrote, a hovered or focused
95
- control lost the blend along with the tone, because replacing a composed
96
- result drops every term it was composed from. Same failure as the edge
97
- 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. */
98
65
  --_line-tone: color-mix(in oklab, var(--intent-border) var(--_line-rest), transparent);
99
66
  --_line: color-mix(in oklab, var(--intent-color) var(--_fill), var(--_line-tone));
100
67
  /* The edge floor, as an input to `box` rather than a rewrite of its result.
@@ -106,17 +73,18 @@
106
73
  An element's own `.edgeless` lowers the floor -- see the rule below the
107
74
  utility -- which is a consumer naming what they want rather than a cascade
108
75
  reaching something nobody classed. `.checkbox` and `.radio` are left off
109
- that rule and keep the floor absolute: they have nothing outside their line,
110
- so removing it removes the control.
111
-
112
- This used to be `--_edge: color-mix(...)` written after `@apply box`, which
113
- dropped `--ui-border` out of the composition entirely. `.edged` and
114
- `.edgeless` were both inert on all six text controls, and `--_d-border` was
115
- 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. */
116
78
  --_edge-floor: 100%;
117
79
 
118
80
  @apply w-full appearance-none;
119
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
+
120
88
  &::placeholder {
121
89
  color: var(--color-text-secondary);
122
90
  }
@@ -133,148 +101,169 @@
133
101
  --_line-tone: var(--intent-color);
134
102
  }
135
103
 
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. */
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. */
167
126
  &:checked {
127
+ --ui-fill: 100%;
128
+ --ui-fg-on-fill: 100%;
168
129
  --_fill-cap: 100%;
169
- --_fill-floor: 100%;
170
- --_fg-on-fill-floor: 100%;
171
130
  }
131
+ }
172
132
 
173
- /* 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 {
174
144
  &::-webkit-search-decoration,
175
145
  &::-webkit-search-cancel-button,
176
146
  &::-webkit-search-results-button,
177
147
  &::-webkit-search-results-decoration,
178
148
  &::-webkit-calendar-picker-indicator,
179
149
  &::-webkit-inner-spin-button,
180
- &::-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 {
181
160
  -webkit-appearance: none;
182
- display: none !important;
183
- opacity: 0;
184
- pointer-events: none;
185
- width: 0;
186
- height: 0;
161
+ display: none;
187
162
  }
188
- }
189
163
 
190
- /* A fill class on the element names its own cap; the 6% cascade cap in the
191
- utility is for a container reaching a field nobody classed. Both raise
192
- `--_fill-cap` rather than writing `--_fill` (P6), which is also what keeps
193
- `.soft` < `.solid` -- written as a rewrite the louder name rendered the
194
- quieter box, measured and inverted on `.ipt`, `.checkbox` and `.radio`.
195
- 20% clears `.soft`'s 12% and matches the plate a `.solid` badge draws.
196
-
197
- `.switch` is deliberately absent: its track caps at 40% in its own utility,
198
- and an unlayered rule here would overrule that. See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
199
- :is(.text-control, .ipt, .textarea, .select, .input-group).soft {
200
- --_fill-cap: 12%;
164
+ &[type="number"],
165
+ & > input[type="number"] {
166
+ appearance: textfield;
167
+ }
201
168
  }
202
169
 
203
- :is(.text-control, .ipt, .textarea, .select, .input-group).solid {
204
- --_fill-cap: 20%;
205
- }
170
+ /* The component seams: rules that exist to beat their own component's
171
+ `@utility`, placed in `utilities` and winning there on specificity, each at
172
+ least 0-2-0 against the component's 0-1-0. They write only package privates
173
+ (`--_fill-cap`, `--_edge-floor`) or a state's intent slots, so nothing a
174
+ consumer writes competes with them. See
175
+ docs/internal/model.md#cascade-layers. */
176
+ @layer utilities {
177
+ /* A fill class on the element names its own cap; the 6% cascade cap in the
178
+ utility is for a container reaching a field nobody classed. Both raise
179
+ `--_fill-cap` rather than writing `--_fill` (P6), which is also what keeps
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.
183
+
184
+ `.switch` is deliberately absent: its track caps at 40% in its own utility,
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. */
187
+ :is(.text-control, .ipt, .textarea, .select, .input-group).soft {
188
+ --_fill-cap: 12%;
189
+ }
206
190
 
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
- }
191
+ :is(.text-control, .ipt, .textarea, .select, .input-group).solid {
192
+ --_fill-cap: 20%;
193
+ }
215
194
 
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) {
226
- --intent-fill-max: 100%;
227
- }
195
+ /* A checked text-control fills with its own intent, so neutral has to drop its
196
+ 20% cap here. No toggle needs this arm any more (see below), but a text
197
+ input is never `:checked` in practice either -- kept for whatever custom
198
+ `.text-control` composition does check itself. A plain rule at 0-2-0 in
199
+ `utilities`, above every intent class in `components`, so `.neutral` on the
200
+ element cannot put the cap back. */
201
+ :is(.text-control, input):checked {
202
+ --intent-fill-max: 100%;
203
+ }
228
204
 
229
- /* The element's own `.edgeless` lowers the floor `text-control` raises -- a
230
- class on the element is a consumer's request, a class on a container is a
231
- cascade. `.checkbox` and `.radio` are absent: an unchecked one is a
232
- transparent square/circle with nothing but its line, so the floor is absolute
233
- and `.edgeless` on them is published unsupported. `.input-group` is on it
234
- because the group is the field boundary now. */
235
- :is(.text-control, .ipt, .textarea, .select, .switch, .input-group).edgeless {
236
- --_edge-floor: 0%;
237
- }
205
+ /* The three toggles read their own component cap (`--_fill-cap`, below) as the
206
+ ceiling regardless of intent, checked or not -- not just at `:checked`, the
207
+ way the arm above still works for a hypothetical checked text-control. A
208
+ neutral fill stops at 20% everywhere else because it is the page's own ink,
209
+ and a full one reads as a slab on a badge or a card. That reasoning does not
210
+ hold for a toggle at rest: a neutral checkbox is the common case, most
211
+ controls carry no intent class at all, and it has to look like a present,
212
+ interactive box rather than a washed-out one just because nothing colored
213
+ it. Declared where the intents are, for the reason given above. */
214
+ :is(.checkbox, .radio, .switch) {
215
+ --intent-fill-max: 100%;
216
+ }
238
217
 
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
- }
218
+ /* The element's own `.edgeless` lowers the floor `text-control` raises -- a
219
+ class on the element is a consumer's request, a class on a container is a
220
+ cascade. `.checkbox` and `.radio` are absent: an unchecked one is a
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. */
224
+ :is(.text-control, .ipt, .textarea, .select, .switch, .input-group).edgeless {
225
+ --_edge-floor: 0%;
226
+ }
253
227
 
254
- /* An invalid control is destructive whatever else it was asked to be. A plain
255
- unlayered rule, not `&[aria-invalid]` inside `@utility`: the intent reset is
256
- unlayered too and would overrule a layered version (leaving a grey line and
257
- only the hint marking the error). `:is()` gives it 0-2-0 to outrank an intent
258
- class. `--intent-hover` is redirected with the others so the line does not go
259
- grey under the pointer. The `:has()` arm bridges an inner control's state to
260
- an `.input-group`. */
261
- :is(
262
- .text-control,
263
- .ipt,
264
- .textarea,
265
- .select,
266
- .checkbox,
267
- .radio,
268
- .switch,
269
- .input-group,
270
- input,
271
- textarea,
272
- select
273
- )[aria-invalid="true"],
274
- .input-group:has(> :is(.ipt, .textarea, .select, .text-control, input, textarea, select)[aria-invalid="true"]) {
275
- --intent-color: var(--color-destructive);
276
- --intent-hover: var(--color-destructive-hover);
277
- --intent-border: var(--color-destructive);
228
+ /* `.switch`'s one exception to the opt-out above: `.ghost` already asks for
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. */
237
+ .switch.ghost.edgeless {
238
+ --_edge-floor: 100%;
239
+ }
240
+
241
+ /* An invalid control is destructive whatever else it was asked to be. It sits
242
+ in `utilities`, above every intent class and both intent resets in
243
+ `components`, and `:is()` gives it 0-2-0 so it also outranks the control's
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`. */
249
+ :is(
250
+ .text-control,
251
+ .ipt,
252
+ .textarea,
253
+ .select,
254
+ .checkbox,
255
+ .radio,
256
+ .switch,
257
+ .input-group,
258
+ input,
259
+ textarea,
260
+ select
261
+ )[aria-invalid="true"],
262
+ .input-group:has(> :is(.ipt, .textarea, .select, .text-control, input, textarea, select)[aria-invalid="true"]) {
263
+ --intent-color: var(--color-destructive);
264
+ --intent-hover: var(--color-destructive-hover);
265
+ --intent-border: var(--color-destructive);
266
+ }
278
267
  }
279
268
 
280
269
  /* A ghost, edged, unelevated text control. */
@@ -309,14 +298,16 @@
309
298
  letter-spacing: 0.025em;
310
299
  }
311
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. */
312
303
  @utility hint {
304
+ @apply text-sm;
305
+
313
306
  color: var(--color-text-secondary);
314
- font-size: 0.875rem;
315
307
  font-weight: 400;
316
308
  letter-spacing: 0.05em;
317
- line-height: 100%;
318
309
 
319
- &.error {
310
+ &.destructive {
320
311
  color: var(--intent-color);
321
312
  }
322
313
  }
@@ -342,7 +333,7 @@
342
333
  gradients rather than masked, because `appearance: none` removes the platform
343
334
  arrow and a background image is the only thing a replaced element can paint. */
344
335
  @utility select {
345
- @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;
346
337
 
347
338
  --_d-fill: 0%;
348
339
  --_d-fg-on-fill: 0%;
@@ -363,6 +354,35 @@
363
354
  calc(100% - 0.6875rem) center;
364
355
  background-repeat: no-repeat;
365
356
  background-size: 0.3125rem 0.3125rem;
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
+
367
+ /* The open list is painted by the browser, over the select's own background
368
+ when it has one. A ghost select has none, so Chromium falls back to its
369
+ default popup -- white on Windows -- while the options inherit the select's
370
+ text, near-white in the dark theme: white on white. The options take the
371
+ page's ground and text instead, which follow the theme. */
372
+ & option,
373
+ & optgroup {
374
+ background-color: var(--color-background);
375
+ color: var(--color-text);
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
+ }
366
386
  }
367
387
 
368
388
  /* A wrapper that owns the field box so a control can carry adornments beside it
@@ -416,7 +436,7 @@
416
436
  --_line-tone: var(--intent-color);
417
437
 
418
438
  outline: var(--focus-ring-width) solid var(--focus-ring);
419
- outline-offset: var(--focus-ring-offset);
439
+ outline-offset: var(--focus-ring-offset, calc(-1 * var(--ui-border-width, var(--border-width))));
420
440
  box-shadow: var(--_focus-stack, var(--_shadow));
421
441
  }
422
442
 
@@ -462,19 +482,14 @@
462
482
  /* The three toggles compose `text-control` for its bounds, not its chrome: the
463
483
  edge floor keeps a container's `.edgeless` from erasing the closed silhouette
464
484
  (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
485
+ `:checked` above) lives there. They raise the fill cap to 40%: the 6% cap
466
486
  keeps typed text legible and a toggle has none, and 40% is where a resting
467
487
  `.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. */
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. */
478
493
  @utility checkbox {
479
494
  @apply text-control inline-grid size-4 cursor-pointer place-content-center;
480
495
 
@@ -485,12 +500,12 @@
485
500
  --_d-border: 100%;
486
501
  --_d-elevation: 0;
487
502
 
488
- /* Capped at its own radius: a control radius is sized for a 40px control, and
489
- 14px of it on a 16px box renders as a circle, which is the one shape a
490
- checkbox must not be. A squaring aesthetic still reaches it, through the
491
- chip corner first -- `.cyber` squares chips outright, which is also what
492
- tells its checkbox from its diamond radio. */
493
- border-radius: min(var(--ui-radius-tight, var(--ui-radius, var(--radius-small))), var(--radius-small));
503
+ /* A chip keeps a small default even under a large control corner, but an
504
+ explicit tight radius is a complete shape, including elliptical corners. */
505
+ border-radius: var(
506
+ --ui-radius-tight,
507
+ min(var(--ui-radius, var(--ui-corner, var(--radius-small))), var(--radius-small))
508
+ );
494
509
 
495
510
  /* Chip geometry: the tight silhouette, and an inset edge no thicker than the
496
511
  line the aesthetic draws elsewhere. A 16px box cannot spend four pixels a
@@ -506,7 +521,7 @@
506
521
  rather than as an `outline`, so a clipping aesthetic clips it with
507
522
  everything else and its own inset focus layer still paints over the top. */
508
523
  &:focus-visible {
509
- --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
524
+ --_shadow: var(--_shadow-parts) var(--_halo-layer,), 0 0 0 var(--focus-ring-width) var(--focus-ring);
510
525
  }
511
526
 
512
527
  &::after {
@@ -526,12 +541,39 @@
526
541
  &:checked::after {
527
542
  @apply opacity-100;
528
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
+ }
529
569
  }
530
570
 
531
571
  /* The same control with a round silhouette and a dot for its mark, and the
532
572
  same fill cap raise given on `checkbox`. */
533
573
  @utility radio {
534
- @apply text-control inline-grid size-4 cursor-pointer place-content-center rounded-full;
574
+ @apply text-control inline-grid size-4 cursor-pointer place-content-center;
575
+
576
+ border-radius: var(--ui-radius-pill, calc(infinity * 1px));
535
577
 
536
578
  --_d-fill: 12%;
537
579
  --_d-fg-on-fill: 0%;
@@ -550,7 +592,7 @@
550
592
 
551
593
  /* The ring, for the reason given on `checkbox`. */
552
594
  &:focus-visible {
553
- --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
595
+ --_shadow: var(--_shadow-parts) var(--_halo-layer,), 0 0 0 var(--focus-ring-width) var(--focus-ring);
554
596
  }
555
597
 
556
598
  &::after {
@@ -565,13 +607,12 @@
565
607
  }
566
608
 
567
609
  /* A checked radio thickens its ring and takes the intent whole on the line.
568
- The fill is pinned by `text-control`'s `:checked` now, not left to the
569
- presentation -- a filled circle at every checked radio, whatever fill
570
- class it carries, rather than the classic ring-and-dot a checked `.soft`
571
- used to draw. The dot still takes `box`'s composed foreground, which walks
572
- to the contrast tone as the (now always full) plate fills, so it stays
573
- readable. `--_line-tone` rather than `--_edge`, so `text-control`'s edge
574
- 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`. */
575
616
  &:checked {
576
617
  --_line-tone: var(--intent-color);
577
618
 
@@ -584,6 +625,14 @@
584
625
  &:checked::after {
585
626
  @apply scale-100 opacity-100;
586
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
+ }
587
636
  }
588
637
 
589
638
  /* A2: every dimension derives from the border width `box` resolved, so the knob
@@ -597,24 +646,30 @@
597
646
  --_d-border: 100%;
598
647
  --_d-elevation: 0;
599
648
 
600
- /* 40%, measured: at `text-control`'s 6% the three resting fills collapsed to
601
- two looks and the switch had to vary its *line* per fill class instead --
602
- 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
603
652
  (29/67/144 sRGB steps on the light page, against ~20 for a visible one)
604
653
  without reaching a checked track's pinned 100%, so the knob's position
605
- still says which. `axes.spec.ts` asserts all three. `checkbox`/`radio`
606
- raise their own cap to the same value for the same reason -- a switch does
607
- not need a higher one, a track is just the component every toggle's box
608
- already was. A cap rather than a resting value, because it answers the
609
- cascade case; `--intent-fill-max` (declared where the intents are, above)
610
- lifts to 100% for all three toggles so a neutral one reads this cap too,
611
- 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. */
612
660
  --_fill-cap: 40%;
613
661
 
614
662
  --switch-height: 1.25rem;
615
663
  --switch-width: 2.5rem;
616
664
  --switch-gap: 1px;
617
- --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));
618
673
  --switch-knob: calc(var(--switch-height) - var(--switch-inset) * 2);
619
674
  --switch-travel: calc(var(--switch-width) - var(--switch-inset) * 2 - var(--switch-knob));
620
675
 
@@ -623,7 +678,7 @@
623
678
  /* Pill by default; a squaring aesthetic reaches the track and the knob
624
679
  together, and an aesthetic with a pill corner of its own reaches both
625
680
  through that first. */
626
- border-radius: var(--ui-radius-pill, var(--ui-radius, calc(infinity * 1px)));
681
+ border-radius: var(--ui-radius-pill, var(--ui-radius, var(--ui-corner, calc(infinity * 1px))));
627
682
 
628
683
  /* Chip geometry, for the reason given on `checkbox`: a 20px track has no more
629
684
  room for a thick inset edge than a 16px box does. */
@@ -633,7 +688,7 @@
633
688
 
634
689
  /* The ring, for the reason given on `checkbox`. */
635
690
  &:focus-visible {
636
- --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
691
+ --_shadow: var(--_shadow-parts) var(--_halo-layer,), 0 0 0 var(--focus-ring-width) var(--focus-ring);
637
692
  }
638
693
 
639
694
  &::after {
@@ -644,15 +699,15 @@
644
699
  tone as the label beside it. Checked, it takes the mark colour like the
645
700
  other two toggles. */
646
701
  background-color: var(--color-text-secondary);
647
- border-radius: var(--ui-radius-pill, var(--ui-radius, calc(infinity * 1px)));
702
+ border-radius: var(--ui-radius-pill, var(--ui-radius, var(--ui-corner, calc(infinity * 1px))));
648
703
  /* A pseudo-element is not a box: the knob takes the track's corner shape
649
704
  itself, so the two stay the same shape. */
650
705
  corner-shape: var(--ui-corner-shape, round);
651
706
  content: "";
652
707
  height: var(--switch-knob);
653
708
  width: var(--switch-knob);
654
- top: var(--switch-gap);
655
- inset-inline-start: var(--switch-gap);
709
+ top: var(--_switch-offset);
710
+ inset-inline-start: var(--_switch-offset);
656
711
  }
657
712
 
658
713
  &:checked::after {
@@ -666,6 +721,14 @@
666
721
  translate: calc(-1 * var(--switch-travel));
667
722
  }
668
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
+ }
669
732
  }
670
733
 
671
734
  @media (forced-colors: active) {
@@ -679,7 +742,14 @@
679
742
  transition: none;
680
743
  }
681
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
+
682
751
  .checkbox:checked,
752
+ .checkbox:indeterminate,
683
753
  .radio:checked,
684
754
  .switch:checked {
685
755
  background-color: Highlight;