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