@codenhub/styles 0.0.4 → 0.1.1

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 (74) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +27 -0
  3. package/README.md +22 -116
  4. package/dist/aesthetics/chunky-tile.css +1 -0
  5. package/dist/aesthetics/glass.css +1 -0
  6. package/dist/aesthetics/index.css +2 -0
  7. package/dist/aesthetics/neobrutalism.css +1 -0
  8. package/dist/aesthetics/pixel.css +1 -0
  9. package/dist/components.css +2 -2
  10. package/dist/index.css +2 -2
  11. package/dist/native.css +2 -0
  12. package/dist/theme.css +2 -2
  13. package/dist/tw/aesthetics/chunky-tile.css +109 -0
  14. package/dist/tw/aesthetics/glass.css +66 -0
  15. package/dist/tw/aesthetics/index.css +9 -0
  16. package/dist/tw/aesthetics/neobrutalism.css +63 -0
  17. package/dist/tw/aesthetics/pixel.css +88 -0
  18. package/dist/tw/box.css +206 -0
  19. package/dist/tw/components/button.css +108 -0
  20. package/dist/tw/components/feedback.css +163 -0
  21. package/dist/tw/components/form.css +599 -0
  22. package/dist/tw/components/index.css +28 -0
  23. package/dist/tw/components/loader.css +122 -0
  24. package/dist/tw/components/surface.css +122 -0
  25. package/dist/tw/components/tooltip.css +132 -0
  26. package/dist/tw/elevation.css +46 -0
  27. package/dist/tw/index.css +27 -0
  28. package/dist/tw/intent.css +209 -0
  29. package/dist/tw/native.css +187 -0
  30. package/dist/tw/presentation.css +77 -0
  31. package/dist/tw/reset.css +129 -0
  32. package/dist/tw/theme.css +223 -0
  33. package/dist/tw/typography.css +34 -0
  34. package/dist/tw/utilities/content.css +361 -0
  35. package/dist/tw/utilities/index.css +6 -0
  36. package/dist/tw/utilities/layout.css +107 -0
  37. package/docs/accessibility.md +92 -0
  38. package/docs/changelog/0.1.0.md +37 -0
  39. package/docs/changelog/0.1.1.md +10 -0
  40. package/docs/changelog/index.md +10 -0
  41. package/docs/concepts.md +68 -0
  42. package/docs/index.md +35 -0
  43. package/docs/integrating/astro.md +35 -0
  44. package/docs/integrating/index.md +41 -0
  45. package/docs/integrating/nextjs.md +67 -0
  46. package/docs/integrating/svelte.md +43 -0
  47. package/docs/integrating/tailwind.md +56 -0
  48. package/docs/integrating/vue.md +31 -0
  49. package/docs/setup.md +96 -0
  50. package/docs/usage/aesthetics.md +120 -0
  51. package/docs/usage/buttons.md +58 -0
  52. package/docs/usage/composing.md +143 -0
  53. package/docs/usage/content-and-layout.md +175 -0
  54. package/docs/usage/customizing.md +148 -0
  55. package/docs/usage/feedback.md +79 -0
  56. package/docs/usage/forms.md +135 -0
  57. package/docs/usage/index.md +24 -0
  58. package/docs/usage/theming.md +154 -0
  59. package/docs/usage/tooltips.md +39 -0
  60. package/llms-full.txt +1676 -0
  61. package/llms.txt +26 -0
  62. package/package.json +176 -33
  63. package/src/components/button.css +0 -155
  64. package/src/components/feedback.css +0 -214
  65. package/src/components/form.css +0 -303
  66. package/src/components/index.css +0 -9
  67. package/src/components/loader.css +0 -56
  68. package/src/components/surface.css +0 -6
  69. package/src/components/tooltip.css +0 -60
  70. package/src/index.css +0 -7
  71. package/src/overrides.css +0 -76
  72. package/src/theme.css +0 -234
  73. package/src/typography.css +0 -30
  74. package/src/utilities.css +0 -40
@@ -0,0 +1,599 @@
1
+ @reference "tailwindcss";
2
+ @import "../theme.css";
3
+ @reference "../box.css";
4
+
5
+ /* What all six text controls share: the box, the chrome, and two bounds that
6
+ exist because presentation cascades -- a cascaded-fill cap so a container's
7
+ `.solid` cannot fill a field with its own text colour, and an edge floor so a
8
+ container's `.edgeless` cannot leave a field or an unchecked toggle with no
9
+ boundary (WCAG 1.4.11). `switch` opts out of the floor on two of its own
10
+ fills, the only component that can, because its knob still carries the state;
11
+ `.checkbox` and `.radio` have nothing outside their line and cannot.
12
+
13
+ No derived hover: on something you type into, only the line responds to a
14
+ pointer. See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
15
+ @utility text-control {
16
+ @apply box;
17
+
18
+ /* The resting geometry, stated whole for the same reason every component
19
+ states it: `box` reads these three with no fallback, so a slot left out is
20
+ an undefined `var()` inside a `color-mix()` and the declaration reading it
21
+ collapses to its initial value rather than reporting anything. `text-control`
22
+ is typeable on its own, so it owes them exactly as a component does. */
23
+ --_d-fill: 0%;
24
+ --_d-fg-on-fill: 0%;
25
+ --_d-border: 100%;
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.
33
+
34
+ Named rather than written into the `min()`, because the soft hover below is
35
+ derived from it and the two must not drift apart. Typed text is
36
+ `--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. */
39
+ --_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
+ --_on-fill: min(var(--ui-fg-on-fill, var(--_d-fg-on-fill)), max(0%, calc(var(--_fill) * 2 - 100%)));
63
+ /* The resting line is a fraction of the intent rather than all of it, and the
64
+ fraction is what makes the pointer visible. Drawn at full strength a control
65
+ already sits at its intent's darkest, and hover moves it to a neighbouring
66
+ tone -- `--color-primary` to `--color-primary-hover` is one step in the
67
+ palette, which reads as nothing. Only the neutral family looked right,
68
+ because its resting tone is a border token and its hover is the text one,
69
+ two places apart.
70
+
71
+ Resting at 60% gives every intent that distance: hover and focus both take
72
+ the tone whole, so the line arrives rather than shifts. A toggle sets this
73
+ back to 100% -- its line is a silhouette rather than a field's edge, and
74
+ there is nothing else marking an unchecked box. */
75
+ --_line-rest: 60%;
76
+ /* Two seams rather than one composed result. `--_line-tone` is what colour the
77
+ line is, and the states below move it; `--_line` folds in P3's blend toward
78
+ the fill, exactly as `box` does it, so the two agree and a state never has to
79
+ restate the blend to keep it.
80
+
81
+ Written as a single `--_line` that states overwrote, a hovered or focused
82
+ control lost the blend along with the tone, because replacing a composed
83
+ result drops every term it was composed from. Same failure as the edge
84
+ below, one property over. */
85
+ --_line-tone: color-mix(in oklab, var(--intent-border) var(--_line-rest), transparent);
86
+ --_line: color-mix(in oklab, var(--intent-color) var(--_fill), var(--_line-tone));
87
+ /* The edge floor, as an input to `box` rather than a rewrite of its result.
88
+ The line is drawn whatever a *container's* edge class says, because a
89
+ `.edgeless` toolbar would otherwise leave a field with no mark of where
90
+ typing goes and a toggle with no silhouette at all (WCAG 1.4.11). That is
91
+ our own cascade producing the broken result, so our own floor answers it.
92
+
93
+ An element's own `.edgeless` lowers the floor -- see the rule below the
94
+ utility -- which is a consumer naming what they want rather than a cascade
95
+ reaching something nobody classed. `.checkbox` and `.radio` are left off
96
+ that rule and keep the floor absolute: they have nothing outside their line,
97
+ so removing it removes the control.
98
+
99
+ This used to be `--_edge: color-mix(...)` written after `@apply box`, which
100
+ dropped `--ui-border` out of the composition entirely. `.edged` and
101
+ `.edgeless` were both inert on all six text controls, and `--_d-border` was
102
+ declared and never read on each of them. */
103
+ --_edge-floor: 100%;
104
+
105
+ @apply w-full appearance-none;
106
+
107
+ &::placeholder {
108
+ color: var(--color-text-secondary);
109
+ }
110
+
111
+ &:hover:not(:disabled, [aria-disabled="true"], [data-disabled], :focus-visible) {
112
+ --_line-tone: var(--intent-hover);
113
+ }
114
+
115
+ /* The line moves to the intent on top of the `reset.css` `:focus-visible`
116
+ outline -- a second signal, not a replacement. This must NOT suppress the
117
+ outline: `&:focus { outline-none }` nested here lands in the utilities layer
118
+ and beats `reset.css`'s `@layer base` ring, killing the keyboard ring too. */
119
+ &:focus-visible {
120
+ --_line-tone: var(--intent-color);
121
+ }
122
+
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`. */
128
+ &:checked {
129
+ --_fill-cap: 100%;
130
+ --_fill-floor: 12%;
131
+ }
132
+
133
+ /* Native decorations a field replaces with its own artwork. */
134
+ &::-webkit-search-decoration,
135
+ &::-webkit-search-cancel-button,
136
+ &::-webkit-search-results-button,
137
+ &::-webkit-search-results-decoration,
138
+ &::-webkit-calendar-picker-indicator,
139
+ &::-webkit-inner-spin-button,
140
+ &::-webkit-clear-button {
141
+ -webkit-appearance: none;
142
+ display: none !important;
143
+ opacity: 0;
144
+ pointer-events: none;
145
+ width: 0;
146
+ height: 0;
147
+ }
148
+ }
149
+
150
+ /* A fill class on the element names its own cap; the 6% cascade cap in the
151
+ utility is for a container reaching a field nobody classed. Both raise
152
+ `--_fill-cap` rather than writing `--_fill` (P6), which is also what keeps
153
+ `.soft` < `.solid` -- written as a rewrite the louder name rendered the
154
+ quieter box, measured and inverted on `.ipt`, `.checkbox` and `.radio`.
155
+ 20% clears `.soft`'s 12% and matches the plate a `.solid` badge draws.
156
+
157
+ `.switch` is deliberately absent: its track caps at 40% in its own utility,
158
+ and an unlayered rule here would overrule that. See model.md#the-bounds-that-survive-and-the-test-for-keeping-one. */
159
+ :is(.text-control, .ipt, .textarea, .select, .input-group).soft {
160
+ --_fill-cap: 12%;
161
+ }
162
+
163
+ :is(.text-control, .ipt, .textarea, .select, .input-group).solid {
164
+ --_fill-cap: 20%;
165
+ }
166
+
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 {
172
+ --intent-fill-max: 100%;
173
+ }
174
+
175
+ /* The element's own `.edgeless` lowers the floor `text-control` raises -- a
176
+ class on the element is a consumer's request, a class on a container is a
177
+ cascade. `.checkbox` and `.radio` are absent: an unchecked one is a
178
+ transparent square/circle with nothing but its line, so the floor is absolute
179
+ and `.edgeless` on them is published unsupported. `.input-group` is on it
180
+ because the group is the field boundary now. */
181
+ :is(.text-control, .ipt, .textarea, .select, .switch, .input-group).edgeless {
182
+ --_edge-floor: 0%;
183
+ }
184
+
185
+ /* An invalid control is destructive whatever else it was asked to be. A plain
186
+ unlayered rule, not `&[aria-invalid]` inside `@utility`: the intent reset is
187
+ unlayered too and would overrule a layered version (leaving a grey line and
188
+ only the hint marking the error). `:is()` gives it 0-2-0 to outrank an intent
189
+ class. `--intent-hover` is redirected with the others so the line does not go
190
+ grey under the pointer. The `:has()` arm bridges an inner control's state to
191
+ an `.input-group`. */
192
+ :is(
193
+ .text-control,
194
+ .ipt,
195
+ .textarea,
196
+ .select,
197
+ .checkbox,
198
+ .radio,
199
+ .switch,
200
+ .input-group,
201
+ input,
202
+ textarea,
203
+ select
204
+ )[aria-invalid="true"],
205
+ .input-group:has(> :is(.ipt, .textarea, .select, .text-control, input, textarea, select)[aria-invalid="true"]) {
206
+ --intent-color: var(--color-destructive);
207
+ --intent-hover: var(--color-destructive-hover);
208
+ --intent-border: var(--color-destructive);
209
+ }
210
+
211
+ /* A ghost, edged, unelevated text control. */
212
+ @utility ipt {
213
+ @apply text-control h-(--control-height,2.5rem) px-3 py-2;
214
+
215
+ /* After the `@apply`, not before: it inlines `text-control`'s own resting
216
+ geometry at that point, so a component's defaults written above it lose to
217
+ the utility they are meant to specialise. */
218
+ --_d-fill: 0%;
219
+ --_d-fg-on-fill: 0%;
220
+ --_d-border: 100%;
221
+ --_d-elevation: 0;
222
+
223
+ /* Typed text is the intent colour at full strength -- on the three text
224
+ inputs, not `text-control`, because a toggle's mark takes `box`'s composed
225
+ `--_fg` instead. */
226
+ --_fg: var(--intent-color);
227
+
228
+ min-height: var(--control-height);
229
+ }
230
+
231
+ @utility field {
232
+ --field-gap: 0.35rem;
233
+
234
+ @apply flex w-full flex-col gap-(--field-gap);
235
+ }
236
+
237
+ @utility label {
238
+ font-size: 1rem;
239
+ font-weight: 500;
240
+ letter-spacing: 0.025em;
241
+ }
242
+
243
+ @utility hint {
244
+ color: var(--color-text-secondary);
245
+ font-size: 0.875rem;
246
+ font-weight: 400;
247
+ letter-spacing: 0.05em;
248
+ line-height: 100%;
249
+
250
+ &.error {
251
+ color: var(--intent-color);
252
+ }
253
+ }
254
+
255
+ /* A ghost, edged, unelevated multi-line text control. */
256
+ @utility textarea {
257
+ @apply text-control px-3 py-2;
258
+
259
+ --_d-fill: 0%;
260
+ --_d-fg-on-fill: 0%;
261
+ --_d-border: 100%;
262
+ --_d-elevation: 0;
263
+
264
+ /* Typed text is the intent colour at full strength -- on the three text
265
+ inputs, not `text-control`, because a toggle's mark takes `box`'s composed
266
+ `--_fg` instead. */
267
+ --_fg: var(--intent-color);
268
+
269
+ @apply min-h-28 resize-y;
270
+ }
271
+
272
+ /* The same control with a native list behind it. The chevron is drawn from two
273
+ gradients rather than masked, because `appearance: none` removes the platform
274
+ arrow and a background image is the only thing a replaced element can paint. */
275
+ @utility select {
276
+ @apply text-control h-(--control-height,2.5rem) py-2 pr-10 pl-3;
277
+
278
+ --_d-fill: 0%;
279
+ --_d-fg-on-fill: 0%;
280
+ --_d-border: 100%;
281
+ --_d-elevation: 0;
282
+
283
+ /* Typed text is the intent colour at full strength -- on the three text
284
+ inputs, not `text-control`, because a toggle's mark takes `box`'s composed
285
+ `--_fg` instead. */
286
+ --_fg: var(--intent-color);
287
+
288
+ min-height: var(--control-height);
289
+ background-image:
290
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
291
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
292
+ background-position:
293
+ calc(100% - 1rem) center,
294
+ calc(100% - 0.6875rem) center;
295
+ background-repeat: no-repeat;
296
+ background-size: 0.3125rem 0.3125rem;
297
+ }
298
+
299
+ /* A wrapper that owns the field box so a control can carry adornments beside it
300
+ -- a leading or trailing icon today, an affix or an in-field button later. It
301
+ composes `text-control` for the border, radius, fill cap, aesthetic shape,
302
+ and the invalid and disabled handling a lone field carries; `:focus-within`
303
+ stands in for the `:focus-visible` a wrapper cannot have. The control inside
304
+ goes flush: its own box paint is switched off below, so the boundary is drawn
305
+ once, by the group.
306
+
307
+ No icons ship. Any element works in a slot -- an inline `<svg>`, an `<img>`,
308
+ or a class from an icon set such as `@codenhub/icons` -- placed before the
309
+ control for a leading adornment and after it for a trailing one. See
310
+ docs/usage/forms.md#icons. */
311
+ @utility input-group {
312
+ @apply text-control flex w-full items-center gap-2;
313
+
314
+ /* After the `@apply`, for the reason `ipt` states it: `text-control` inlines
315
+ its own resting geometry there, so a default written above it loses to the
316
+ utility meant to specialise it. Ghost, edged, flat -- a field boundary and
317
+ nothing more. */
318
+ --_d-fill: 0%;
319
+ --_d-fg-on-fill: 0%;
320
+ --_d-border: 100%;
321
+ --_d-elevation: 0;
322
+
323
+ --input-group-pad: 0.75rem;
324
+
325
+ min-height: var(--control-height);
326
+ padding-inline: var(--input-group-pad);
327
+
328
+ /* The focus ring the wrapper cannot get from `:focus-visible` -- a `<div>` is
329
+ not focusable, and `:has(:focus-visible)` is unreliable because the inner
330
+ control only matches `:focus-visible` while the document itself has focus.
331
+ `:focus-within` is the honest stand-in: the `reset.css` outline every
332
+ control draws, plus `box`'s inset layer when a clipping aesthetic asks for
333
+ one, and the focus line tone a field states for itself. The control inside
334
+ drops its own ring below so the group shows one. The cost is that the ring
335
+ also shows on a mouse click, which a lone field's does not. */
336
+ &:focus-within {
337
+ --_line-tone: var(--intent-color);
338
+
339
+ outline: var(--focus-ring-width) solid var(--focus-ring);
340
+ outline-offset: var(--focus-ring-offset);
341
+ box-shadow: var(--_focus-stack, var(--_shadow));
342
+ }
343
+
344
+ /* A disabled control inside dims the whole group. `.input-group.disabled` and
345
+ the aria/data spellings are already handled by `box`'s own rule, carried in
346
+ by `text-control`; this is the child-driven case a wrapper cannot see
347
+ without `:has()`. */
348
+ &:has(> :is(.ipt, .textarea, .select, .text-control, input, textarea, select):disabled) {
349
+ @apply cursor-not-allowed;
350
+
351
+ opacity: 0.58;
352
+ }
353
+
354
+ /* The control goes flush: the group drew the box, so the control paints no
355
+ fill, no line and no ring, and gives its horizontal padding to the group.
356
+ `border: none` rather than a transparent 1px, so the control does not add
357
+ its own width inside the group. */
358
+ & > :is(.ipt, .textarea, .select, .text-control, input, textarea, select) {
359
+ --_fill-cap: 0%;
360
+
361
+ flex: 1 1 auto;
362
+ min-width: 0;
363
+ padding-inline: 0;
364
+ border: none;
365
+ background-color: transparent;
366
+ box-shadow: none;
367
+
368
+ &:focus-visible {
369
+ outline: none;
370
+ box-shadow: none;
371
+ }
372
+ }
373
+
374
+ /* Anything else in the group -- an icon, affix text -- does not stretch and
375
+ reads as secondary. A nested `<button>` keeps its own styling. */
376
+ & > :not(.ipt, .textarea, .select, .text-control, input, textarea, select, button) {
377
+ @apply inline-flex flex-none items-center;
378
+
379
+ color: var(--color-text-secondary);
380
+ }
381
+ }
382
+
383
+ /* The three toggles compose `text-control` for its bounds, not its chrome: the
384
+ 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. */
391
+ @utility checkbox {
392
+ @apply text-control inline-grid size-4 cursor-pointer place-content-center;
393
+
394
+ --_d-fill: 100%;
395
+ --_d-fg-on-fill: 100%;
396
+ --_fill-cap: 20%;
397
+ --_line-rest: 100%;
398
+ --_d-border: 100%;
399
+ --_d-elevation: 0;
400
+
401
+ /* Capped at its own radius: a control radius is sized for a 40px control, and
402
+ 14px of it on a 16px box renders as a circle, which is the one shape a
403
+ checkbox must not be. A squaring aesthetic still reaches it. */
404
+ border-radius: min(var(--ui-radius, var(--radius-small)), var(--radius-small));
405
+
406
+ /* Chip geometry: the tight silhouette, and an inset edge no thicker than the
407
+ line the aesthetic draws elsewhere. A 16px box cannot spend four pixels a
408
+ side on an edge -- under `.pixel` that leaves an eight-pixel hole and an
409
+ unchecked box reads as a filled one. Our own aesthetic on our own component
410
+ produces that, so our own `min()` answers it. */
411
+ --_ss: min(var(--ui-shadow-spread, 0px), var(--ui-border-width, var(--border-width)));
412
+
413
+ clip-path: var(--ui-clip-tight, var(--ui-clip, none));
414
+
415
+ /* `text-control` trades the outline for its own line colour, which reads on a
416
+ 40px field and not on a 16px box. The ring goes back as a shadow layer
417
+ rather than as an `outline`, so a clipping aesthetic clips it with
418
+ everything else and its own inset focus layer still paints over the top. */
419
+ &:focus-visible {
420
+ --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
421
+ }
422
+
423
+ &::after {
424
+ @apply block size-3 opacity-0 transition-opacity duration-(--motion-duration-normal) ease-(--motion-ease);
425
+
426
+ /* The mark takes the box's own foreground, which `box` already mixed against
427
+ this component's intent and fill, so a checked box in any intent has a
428
+ mark that reads against it. */
429
+ background-color: currentcolor;
430
+ content: "";
431
+ 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' stroke-linejoin='round'><polyline points='20 6 9 17 4 12'/></svg>");
432
+ -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' stroke-linejoin='round'><polyline points='20 6 9 17 4 12'/></svg>");
433
+ mask-size: contain;
434
+ -webkit-mask-size: contain;
435
+ }
436
+
437
+ &:checked::after {
438
+ @apply opacity-100;
439
+ }
440
+ }
441
+
442
+ /* The same control with a round silhouette and a dot for its mark. */
443
+ @utility radio {
444
+ @apply text-control inline-grid size-4 cursor-pointer place-content-center rounded-full;
445
+
446
+ --_d-fill: 100%;
447
+ --_d-fg-on-fill: 100%;
448
+ --_fill-cap: 20%;
449
+ --_line-rest: 100%;
450
+ --_d-border: 100%;
451
+ --_d-elevation: 0;
452
+
453
+ /* The inset edge is capped for the reason given on `checkbox`, but the
454
+ silhouette is not taken: the circle is the only thing telling a radio from a
455
+ checkbox at a glance, and a stepped polygon would square it. An inset shadow
456
+ follows `border-radius`, so the edge stays round without one. */
457
+ --_ss: min(var(--ui-shadow-spread, 0px), var(--ui-border-width, var(--border-width)));
458
+
459
+ clip-path: none;
460
+
461
+ /* The ring, for the reason given on `checkbox`. */
462
+ &:focus-visible {
463
+ --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
464
+ }
465
+
466
+ &::after {
467
+ @apply block size-2 scale-0 rounded-full opacity-0 transition-[transform,opacity] duration-(--motion-duration-normal) ease-(--motion-ease);
468
+
469
+ background-color: currentcolor;
470
+ content: "";
471
+ }
472
+
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`. */
479
+ &:checked {
480
+ --_line-tone: var(--intent-color);
481
+
482
+ /* Twice the line the aesthetic draws, which is the 2px the original pinned
483
+ at the default hairline, and capped so a thick aesthetic cannot close the
484
+ circle over its own dot: 3px leaves 10px of interior for an 8px mark. */
485
+ border-width: min(calc(var(--_width) * 2), 3px);
486
+ }
487
+
488
+ &:checked::after {
489
+ @apply scale-100 opacity-100;
490
+ }
491
+ }
492
+
493
+ /* 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. */
496
+ @utility switch {
497
+ @apply text-control relative cursor-pointer;
498
+
499
+ --_d-fill: 100%;
500
+ --_d-fg-on-fill: 100%;
501
+ --_line-rest: 100%;
502
+ --_d-border: 100%;
503
+ --_d-elevation: 0;
504
+
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. */
512
+ --_fill-cap: 40%;
513
+
514
+ --switch-height: 1.25rem;
515
+ --switch-width: 2.5rem;
516
+ --switch-gap: 1px;
517
+ --switch-inset: calc(var(--_width) + var(--switch-gap));
518
+ --switch-knob: calc(var(--switch-height) - var(--switch-inset) * 2);
519
+ --switch-travel: calc(var(--switch-width) - var(--switch-inset) * 2 - var(--switch-knob));
520
+
521
+ height: var(--switch-height);
522
+ width: var(--switch-width);
523
+ /* Pill by default; a squaring aesthetic reaches the track and the knob together. */
524
+ border-radius: var(--ui-radius, calc(infinity * 1px));
525
+
526
+ /* Chip geometry, for the reason given on `checkbox`: a 20px track has no more
527
+ room for a thick inset edge than a 16px box does. */
528
+ --_ss: min(var(--ui-shadow-spread, 0px), var(--ui-border-width, var(--border-width)));
529
+
530
+ clip-path: var(--ui-clip-tight, var(--ui-clip, none));
531
+
532
+ /* The ring, for the reason given on `checkbox`. */
533
+ &:focus-visible {
534
+ --_shadow: var(--_shadow-parts), 0 0 0 var(--focus-ring-width) var(--focus-ring);
535
+ }
536
+
537
+ &::after {
538
+ @apply absolute transition-[translate,background-color] duration-(--motion-duration-normal) ease-(--motion-ease);
539
+
540
+ /* The knob is the one part that does not take the box's foreground: an
541
+ unchecked track is unfilled, so a knob in that colour would be the same
542
+ tone as the label beside it. Checked, it takes the mark colour like the
543
+ other two toggles. */
544
+ background-color: var(--color-text-secondary);
545
+ border-radius: var(--ui-radius, calc(infinity * 1px));
546
+ content: "";
547
+ height: var(--switch-knob);
548
+ width: var(--switch-knob);
549
+ top: var(--switch-gap);
550
+ inset-inline-start: var(--switch-gap);
551
+ }
552
+
553
+ &:checked::after {
554
+ background-color: currentcolor;
555
+ /* translate has no logical form, so the checked travel is named as a
556
+ physical x offset and mirrored under :dir(rtl) to travel toward the
557
+ opposite edge, matching the inset-inline-start resting position. */
558
+ translate: var(--switch-travel);
559
+
560
+ :dir(rtl) & {
561
+ translate: calc(-1 * var(--switch-travel));
562
+ }
563
+ }
564
+ }
565
+
566
+ @media (forced-colors: active) {
567
+ .checkbox,
568
+ .radio,
569
+ .switch {
570
+ /* Keep these explicit system colors from being forced back to transparent. */
571
+ forced-color-adjust: none;
572
+ background-color: Canvas;
573
+ border-color: CanvasText;
574
+ transition: none;
575
+ }
576
+
577
+ .checkbox:checked,
578
+ .radio:checked,
579
+ .switch:checked {
580
+ background-color: Highlight;
581
+ border-color: Highlight;
582
+
583
+ &::after {
584
+ background-color: HighlightText;
585
+ }
586
+ }
587
+
588
+ .text-control:focus-visible,
589
+ .ipt:focus-visible,
590
+ .textarea:focus-visible,
591
+ .select:focus-visible,
592
+ .checkbox:focus-visible,
593
+ .radio:focus-visible,
594
+ .switch:focus-visible {
595
+ outline: 2px solid Highlight;
596
+ outline-offset: 2px;
597
+ box-shadow: none;
598
+ }
599
+ }
@@ -0,0 +1,28 @@
1
+ @reference "tailwindcss";
2
+ @import "tailwindcss/utilities.css";
3
+ /* Everything that is not `src/` -- see the note in `index.css`. Keeps a class
4
+ name in a docs code fence from shipping as a real rule in `dist/components.css`. */
5
+ @import "../theme.css";
6
+ @import "../typography.css";
7
+ @import "../box.css";
8
+ @import "./button.css";
9
+ @import "./feedback.css";
10
+ @import "./form.css";
11
+ @import "./loader.css";
12
+ @import "./surface.css";
13
+ @import "./tooltip.css";
14
+
15
+ /* The composition utilities -- `box`, `box-hover`, `box-active`, `loader-mask`,
16
+ `text-control`, `surface`, `input-group` -- are `@utility` rules whose bodies
17
+ the content scan will not emit on its own. `box` and friends appear only as
18
+ `@utility` blocks and `@apply` targets; `text-control`, `surface` and
19
+ `input-group` appear only inside `:is()`/`:where()` argument lists in
20
+ `intent.css` and `form.css`, never as a selector of their own. Their standalone
21
+ rules shipped only because docs prose happened to name them, and excluding
22
+ `docs/` from the scan removed that -- `.input-group` (and, with it, its focus
23
+ ring, its flush inner control, and `:has()`-propagated invalid state) went
24
+ silently dark, caught only by the browser suite. Safelisting all seven here
25
+ makes the emission deliberate; the composition-body check in
26
+ `exports.test.ts` fails the build if one is dropped. `text-control` and
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");