actual-css 0.12.0 → 0.13.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 (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +7 -8
  3. package/components.json +8 -2
  4. package/dist/actual.full.css +505 -233
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/pages/components/avatar.md +17 -11
  10. package/docs/pages/components/breadcrumb.md +16 -2
  11. package/docs/pages/components/list.md +9 -13
  12. package/docs/pages/components/navbar.md +42 -22
  13. package/docs/pages/components/pagination.md +19 -0
  14. package/docs/pages/components/steps.md +24 -20
  15. package/docs/pages/enhancements/scrollspy.md +6 -6
  16. package/docs/pages/enhancements/tabs.md +74 -3
  17. package/docs/pages/forms/floating-field.md +21 -7
  18. package/docs/pages/forms/form-actions.md +3 -6
  19. package/docs/pages/forms/states-and-sizes.md +15 -15
  20. package/docs/pages/foundations/philosophy.md +97 -1
  21. package/docs/pages/foundations/tokens.md +40 -4
  22. package/docs/pages/guides/design-tools.md +1 -1
  23. package/docs/pages/guides/integrating-actual-css.md +74 -8
  24. package/docs/pages/guides/introduction.md +3 -2
  25. package/docs/pages/guides/modular-import.md +1 -1
  26. package/docs/pages/guides/progressive-enhancement.md +25 -7
  27. package/docs/pages/guides/upgrading.md +86 -9
  28. package/docs/pages/layout/center.md +2 -1
  29. package/docs/pages/layout/cluster.md +11 -4
  30. package/docs/pages/layout/grid.md +2 -0
  31. package/docs/pages/layout/sidebar-layout.md +3 -0
  32. package/docs/pages/layout/switcher.md +2 -0
  33. package/docs/pages/layout/topbar.md +6 -4
  34. package/docs/pages/patterns/icon-slot.md +17 -1
  35. package/docs/pages/utilities/flex.md +1 -1
  36. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  37. package/package.json +5 -5
  38. package/src/cli/actual-css.js +1 -1
  39. package/src/css/components/alert.css +34 -34
  40. package/src/css/components/avatar.css +2 -18
  41. package/src/css/components/badge.css +0 -1
  42. package/src/css/components/breadcrumb.css +74 -8
  43. package/src/css/components/drawer.css +23 -42
  44. package/src/css/components/flyout.css +0 -1
  45. package/src/css/components/join.css +2 -2
  46. package/src/css/components/list.css +10 -10
  47. package/src/css/components/modal.css +17 -10
  48. package/src/css/components/navbar.css +10 -8
  49. package/src/css/components/pagination.css +5 -0
  50. package/src/css/components/scrollspy.css +1 -1
  51. package/src/css/components/steps.css +66 -12
  52. package/src/css/components/tab.css +134 -4
  53. package/src/css/core/focus.css +8 -6
  54. package/src/css/core/theme.css +6 -1
  55. package/src/css/core/tokens.css +4 -2
  56. package/src/css/forms/choice-card.css +1 -1
  57. package/src/css/forms/choice.css +1 -1
  58. package/src/css/forms/control.css +1 -1
  59. package/src/css/forms/floating-field.css +20 -18
  60. package/src/css/forms/form-actions.css +5 -8
  61. package/src/css/forms/native.css +2 -2
  62. package/src/css/forms/otp.css +4 -5
  63. package/src/css/forms/switch.css +1 -1
  64. package/src/css/forms/validation.css +23 -30
  65. package/src/css/layout/center.css +2 -0
  66. package/src/css/layout/cluster.css +14 -0
  67. package/src/css/layout/grid.css +7 -0
  68. package/src/css/layout/scroller.css +19 -3
  69. package/src/css/layout/sidebar-layout.css +3 -0
  70. package/src/css/layout/switcher.css +3 -0
  71. package/src/css/utilities/extra.css +3 -3
  72. package/src/js/full.js +4 -0
  73. package/src/js/inline-visible.js +54 -0
  74. package/src/js/parsed.js +21 -0
  75. package/src/js/tab.js +33 -7
  76. package/src/tooling/design-export.js +1 -1
  77. package/src/tooling/design-tokens.js +44 -33
  78. package/src/tooling/theme-contrast.js +32 -24
@@ -12,8 +12,18 @@
12
12
  * author class can then adjust a vertical strip without restating the
13
13
  * attribute to reach its specificity.
14
14
  *
15
+ * A horizontal strip is one axis of navigation, so it never wraps: a second
16
+ * line no longer matches the Left/Right model and reads as a nested level.
17
+ * When the tabs do not fit, the strip scrolls without a scrollbar; focus
18
+ * scrolls the active tab into view.
19
+ *
15
20
  * Public hooks:
21
+ * --tab-flex
16
22
  * --tab-gap
23
+ *
24
+ * Internal:
25
+ * --tab-edge
26
+ * --tab-fade-dir (read inside @keyframes, so it resolves per element)
17
27
  */
18
28
  /* Intent boundary — inherited intents do not tint this component.
19
29
  Local intent classes still win because :where() has zero specificity. */
@@ -24,27 +34,129 @@
24
34
  .tabs {
25
35
  display: flex;
26
36
  flex-direction: row;
27
- flex-wrap: wrap;
37
+ flex-wrap: nowrap;
28
38
  gap: var(--space-10);
29
39
  margin: 0;
30
40
  padding: 0;
41
+ /* Block overflow is hidden, not auto: overflow-x: auto alone computes the
42
+ block axis to auto, so a sub-pixel line box would add a vertical
43
+ scrollbar inside the strip. Nothing paints outside it: the focus outline
44
+ is inset and the indicator sits inside the tab. */
45
+ overflow: auto hidden;
46
+ overscroll-behavior-inline: contain;
47
+ /* why: a visible bar sits on the indicator and the hairline and reads as a
48
+ second underline. The strip has its own scroll controls: selecting the
49
+ clipped tab, or focusing it, brings it into view with the next one
50
+ hinted by scroll-padding; arrow keys and swipes do the same. */
51
+ scrollbar-width: none;
52
+ /* Focus scrolls a tab in with room to spare, hinting at the next one; the
53
+ edge fade below spans the same inset, so a revealed tab stays clear of it. */
54
+ --tab-edge: var(--space-60);
55
+
56
+ scroll-padding-inline: var(--tab-edge);
31
57
  border-block-end: var(--border-width) solid var(--border);
32
58
  list-style: none;
33
59
  }
34
60
 
61
+ /* The landmark around link tabs is not a scroll container, so as a flex or
62
+ grid item it would keep the strip's full width and push the page into
63
+ scrolling. Letting it shrink hands the overflow back to the strip.
64
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
65
+ wrapper is the fallback. */
66
+ :where(nav:has(> .tabs)) {
67
+ min-inline-size: 0;
68
+ }
69
+
70
+ /* Items keep their size and the strip scrolls instead. Covers both
71
+ anatomies: .tab directly in a tablist, or <li> in a navigation list.
72
+ --tab-flex: 1 1 0 shares the width equally; an item still never shrinks
73
+ below its label (flex items keep min-content), so a strip that cannot fit
74
+ scrolls as before. */
75
+ .tabs > * {
76
+ flex: var(--tab-flex, none);
77
+ }
78
+
35
79
  .tabs:where([aria-orientation="vertical"]) {
36
80
  display: grid;
81
+ overflow: visible;
37
82
  align-content: start;
38
83
  justify-items: stretch;
39
84
  border-block-end: 0;
40
85
  border-inline-end: var(--border-width) solid var(--border);
41
86
  }
42
87
 
88
+ /* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
89
+ toward. It runs on the strip's own inline timeline, which is inactive on a
90
+ strip that fits or a vertical rail, so those stay unmasked. Keep the
91
+ @supports: without scroll timelines the animation would end at once and pin
92
+ its last mask. It follows scroll, not time, so reduced motion keeps it. */
93
+ @supports (animation-timeline: scroll()) {
94
+ .tabs {
95
+ --tab-fade-dir: to right;
96
+
97
+ animation: actual-tabs-fade linear both;
98
+ animation-timeline: scroll(self inline);
99
+ }
100
+
101
+ /* Gradient keywords are physical; :dir() follows the strip's own
102
+ direction, so an LTR island in an RTL page keeps its fade on the right. */
103
+ .tabs:dir(rtl) {
104
+ --tab-fade-dir: to left;
105
+ }
106
+ }
107
+
108
+ /* A strip that loads on a current link tab starts scrolled to it. Horizontal
109
+ only: the strip is then its nearest scroll container, while a vertical
110
+ rail would hand the target to the page. Engines without the property start
111
+ at the first tab, and the fade says there is more. */
112
+ :where(.tabs:not([aria-orientation="vertical"]))
113
+ .tab:is([aria-current="page"], [aria-current="true"]) {
114
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
115
+ scroll-initial-target: nearest;
116
+ }
117
+
118
+ /* Forced colors keep every label at full contrast. */
119
+ @media (forced-colors: active) {
120
+ .tabs {
121
+ animation: none;
122
+ }
123
+ }
124
+
125
+ /* mask-image does not interpolate, so each mask holds until halfway to the
126
+ next keyframe: the start fade appears within the first pixels of scroll and
127
+ the end fade drops at the very end. */
128
+ @keyframes actual-tabs-fade {
129
+ 0% {
130
+ mask-image: linear-gradient(
131
+ var(--tab-fade-dir),
132
+ #000 calc(100% - var(--tab-edge)),
133
+ transparent
134
+ );
135
+ }
136
+
137
+ 1%,
138
+ 99% {
139
+ mask-image: linear-gradient(
140
+ var(--tab-fade-dir),
141
+ transparent,
142
+ #000 var(--tab-edge) calc(100% - var(--tab-edge)),
143
+ transparent
144
+ );
145
+ }
146
+
147
+ 100% {
148
+ mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
149
+ }
150
+ }
151
+
43
152
  .tab {
44
153
  /* Positioning context for the selected indicator pseudo. */
45
154
  position: relative;
46
155
  display: inline-flex;
47
156
  align-items: center;
157
+ /* A tab wider than its label (--tab-flex) centers it; at natural width this
158
+ changes nothing, and a vertical rail resets it to the start below. */
159
+ justify-content: center;
48
160
  gap: var(--tab-gap, 0.375em);
49
161
  min-block-size: var(--control-size);
50
162
  padding-inline: var(--space-30);
@@ -58,12 +170,18 @@
58
170
  the strip. The active state is carried by color and the indicator line. */
59
171
  font-weight: var(--font-weight-semibold);
60
172
  text-decoration: none;
173
+ /* A label breaking inside a one-line strip is the same defect as the strip
174
+ wrapping. */
175
+ white-space: nowrap;
61
176
  cursor: pointer;
62
177
  }
63
178
 
179
+ /* A vertical strip has no inline axis to protect: long labels wrap within the
180
+ rail instead of overflowing it. */
64
181
  .tabs:where([aria-orientation="vertical"]) .tab {
65
182
  justify-content: flex-start;
66
183
  padding-inline-end: var(--space-40);
184
+ white-space: normal;
67
185
  }
68
186
 
69
187
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -94,8 +212,13 @@
94
212
  border-end-end-radius: 0;
95
213
  }
96
214
 
215
+ /* A link tab is current on aria-current="page". "true" is for the one place
216
+ where no page token fits: a navigation flyout trigger whose panel holds the
217
+ current page. State selectors name their values, never the attribute's
218
+ presence, so "false" (what React and Vue serialize for a false binding)
219
+ stays inert. */
97
220
  .tab[aria-selected="true"],
98
- .tab[aria-current="page"] {
221
+ .tab:is([aria-current="page"], [aria-current="true"]) {
99
222
  color: var(--intent, var(--state-selected));
100
223
  }
101
224
 
@@ -104,7 +227,7 @@
104
227
  forced colors and erase the distinction. currentColor follows the palette the
105
228
  UA forces, so no forced-colors block is needed. */
106
229
  .tab[aria-selected="true"]::after,
107
- .tab[aria-current="page"]::after {
230
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
108
231
  content: "";
109
232
  position: absolute;
110
233
  inset-inline: 0;
@@ -113,9 +236,16 @@
113
236
  }
114
237
 
115
238
  .tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
116
- .tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
239
+ .tabs:where([aria-orientation="vertical"])
240
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
117
241
  inset-block: 0;
118
242
  inset-inline: auto 0;
119
243
  border-block-end: 0;
120
244
  border-inline-end: 2px solid currentColor;
121
245
  }
246
+
247
+ /* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
248
+ it, so the indicator and the hit area span the whole share. */
249
+ .tabs > li > .tab {
250
+ inline-size: 100%;
251
+ }
@@ -21,14 +21,16 @@
21
21
  Documented exception: an invalid field focuses in --form-invalid-border
22
22
  (danger), so the error stays visible at the point of correction. The border
23
23
  carries the same hook; report:theme-contrast measures that line at 3:1
24
- against the field's background alongside --focus. */
24
+ against the field's background alongside --focus.
25
+
26
+ Public hooks:
27
+ --focus-outline (fallback-only: replaces the whole generic line)
28
+ */
25
29
  :focus {
26
- outline: var(--focus-outline);
30
+ outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
27
31
  outline-offset: var(--focus-outline-offset);
28
32
  }
29
33
 
30
- @supports selector(:focus-visible) {
31
- :focus:not(:focus-visible) {
32
- outline: none;
33
- }
34
+ :focus:not(:focus-visible) {
35
+ outline: none;
34
36
  }
@@ -8,7 +8,9 @@
8
8
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
9
9
  * other tokens; hard-coded overrides on a custom theme will break the intent.
10
10
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
11
- * translucent surfaces overrides them with opaque values.
11
+ * translucent surfaces overrides them with opaque values. --border-control is
12
+ * public too: the resting edge of a neutral control, apart from structural
13
+ * separators.
12
14
  * Internal: --hgroup-gap.
13
15
  */
14
16
  :root {
@@ -110,6 +112,9 @@
110
112
  never a fill. --surface-solid-fg is the ink on --surface-solid. */
111
113
  --surface-opaque: var(--surface);
112
114
  --surface-solid-fg: var(--surface);
115
+ /* The edge that tells a control apart from the surface, where --border only
116
+ separates surfaces: fields, choices, switch, OTP cells, choice cards. */
117
+ --border-control: var(--border);
113
118
  }
114
119
 
115
120
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -81,9 +81,11 @@
81
81
  without a component ring (links, summary, plain items). Components draw
82
82
  the themed ring instead: --focus-ring-width solid --focus. See focus.css
83
83
  for the two contracts. Both share --focus-ring-width: they differ in
84
- color only. */
84
+ color only. No --focus-outline alias here: declared on :root it would be
85
+ inherited already computed, so a theme island or a region setting
86
+ --focus-ring-width would not reach it. focus.css composes the line on the
87
+ focused element instead. */
85
88
  --focus-outline-color: currentColor;
86
- --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
87
89
  --focus-outline-offset: 2px;
88
90
  --focus-ring-width: 2px;
89
91
 
@@ -38,7 +38,7 @@
38
38
  --choice-card-pad: var(--density-space, var(--space-40));
39
39
  --choice-card-gap: var(--space-30);
40
40
  --choice-card-radius: var(--radius);
41
- --choice-card-border: var(--border);
41
+ --choice-card-border: var(--border-control);
42
42
  --choice-card-bg: var(--surface);
43
43
  --choice-card-check-size: 1.25em;
44
44
 
@@ -144,7 +144,7 @@
144
144
  /* Control geometry. --choice-* are the per-state tokens. */
145
145
  .check,
146
146
  .radio {
147
- --choice-border: var(--border);
147
+ --choice-border: var(--border-control);
148
148
  --choice-bg: var(--surface);
149
149
  --choice-mark: var(--primary-fg);
150
150
 
@@ -30,7 +30,7 @@
30
30
  .input,
31
31
  .textarea,
32
32
  .select {
33
- --control-border: var(--border);
33
+ --control-border: var(--border-control);
34
34
  --control-bg: var(--surface);
35
35
  --control-fg: var(--text);
36
36
  --control-cursor: auto;
@@ -9,10 +9,13 @@
9
9
  * messages stay in the surrounding .field, not inside the cell, so the
10
10
  * resting label can center on the control alone.
11
11
  *
12
- * The control must precede the label: the float is driven by the general
13
- * sibling combinator and :placeholder-shown. Injected nodes may appear between
14
- * the control and the label (password managers, extension wrappers); the cell
15
- * contract stays a single control plus one label. Every .input/.textarea needs
12
+ * The cell reads its control's state through :has(), so the label may come
13
+ * before or after the control (a form theme rendering label then widget needs
14
+ * no reordering). Injected sibling nodes are tolerated (password managers,
15
+ * extension wrappers) as long as the control stays a direct child; the cell
16
+ * contract stays a single control plus one label, since a second control
17
+ * would float the label on either one's state.
18
+ * Every .input/.textarea needs
16
19
  * placeholder=" " to expose a reliable empty state — without it the label
17
20
  * only reacts to focus.
18
21
  *
@@ -62,23 +65,22 @@
62
65
  }
63
66
 
64
67
  /* Single-line controls rest in the middle until they receive a value. */
65
- .floating-field > :is(.input, .select) ~ .field-label {
68
+ .floating-field:has(> :is(.input, .select)) > .field-label {
66
69
  inset-block-start: 50%;
67
70
  transform: translateY(-50%);
68
71
  }
69
72
 
70
73
  /* An empty textarea label follows the control's normal first-line position;
71
74
  the control itself keeps the extra headroom reserved for its floated state. */
72
- .floating-field > .textarea ~ .field-label {
75
+ .floating-field:has(> .textarea) > .field-label {
73
76
  inset-block-start: calc(1em + var(--border-width));
74
77
  transform: none;
75
78
  }
76
79
 
77
80
  /* :autofill covers prefilled values before interaction, when the browser may
78
81
  not have propagated the value to the placeholder state yet. */
79
- .floating-field
80
- > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
81
- ~ .field-label {
82
+ .floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
83
+ > .field-label {
82
84
  inset-block-start: var(--floating-label-top);
83
85
  transform: none;
84
86
  font-size: var(--font-size-xs);
@@ -88,16 +90,16 @@
88
90
  /* Controls without a dependable :placeholder-shown empty state stay floated:
89
91
  select always, and date/time inputs whose placeholder rendering is
90
92
  inconsistent across engines (Firefox shows none). */
91
- .floating-field
93
+ .floating-field:has(
92
94
  > :is(
93
- .select,
94
- .input[type="date"],
95
- .input[type="time"],
96
- .input[type="datetime-local"],
97
- .input[type="month"],
98
- .input[type="week"]
99
- )
100
- ~ .field-label {
95
+ .select,
96
+ .input[type="date"],
97
+ .input[type="time"],
98
+ .input[type="datetime-local"],
99
+ .input[type="month"],
100
+ .input[type="week"]
101
+ )
102
+ ) > .field-label {
101
103
  inset-block-start: var(--floating-label-top);
102
104
  transform: none;
103
105
  font-size: var(--font-size-xs);
@@ -21,15 +21,12 @@
21
21
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
22
22
  */
23
23
 
24
- /* The policy hooks write the properties themselves rather than relaying
25
- --cluster-*: a relay would inherit into any .cluster nested in the row.
26
- These are equal-specificity overrides of .cluster, so they win only because
27
- forms load after layout in the official bundle (tests/css-audit.test.js
28
- guards the order); utilities load later and keep the author's last word. On
29
- this row --cluster-justify/--cluster-align have no effect. */
24
+ /* The role relays its policy into the row's cluster hooks. Cluster hooks stop
25
+ at every nested .cluster, so the relay cannot leak, and this class beats the
26
+ cluster's zero-specificity reset in any import order. */
30
27
  .form-actions {
31
- align-items: var(--form-actions-align, center);
32
- justify-content: var(--form-actions-justify, flex-start);
28
+ --cluster-align: var(--form-actions-align, center);
29
+ --cluster-justify: var(--form-actions-justify, flex-start);
33
30
  }
34
31
 
35
32
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -25,7 +25,7 @@
25
25
  calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
26
26
  );
27
27
  padding-inline: var(--space-20);
28
- border: var(--border-width) solid var(--border);
28
+ border: var(--border-width) solid var(--border-control);
29
29
  border-radius: var(--radius-sm);
30
30
  background: var(--surface-subtle);
31
31
  color: var(--text);
@@ -59,7 +59,7 @@
59
59
  inline-size: var(--control-size);
60
60
  block-size: var(--control-size);
61
61
  padding: var(--border-width);
62
- border: var(--border-width) solid var(--border);
62
+ border: var(--border-width) solid var(--border-control);
63
63
  border-radius: var(--radius);
64
64
  background: var(--surface);
65
65
  cursor: pointer;
@@ -21,7 +21,7 @@
21
21
  --otp-cell-size: var(--control-size);
22
22
  --otp-gap: var(--space-10);
23
23
  --otp-font-size: 1.5em;
24
- --otp-cell-border: var(--border);
24
+ --otp-cell-border: var(--border-control);
25
25
  --otp-cell-bg: var(--surface);
26
26
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
27
27
 
@@ -107,10 +107,9 @@
107
107
  --otp-cell-border: var(--danger);
108
108
  }
109
109
 
110
- @supports selector(:user-invalid) {
111
- .needs-validation .otp > input:user-invalid ~ span {
112
- --otp-cell-border: var(--danger);
113
- }
110
+ /* biome-ignore lint/style/noDescendingSpecificity: all invalid routes write the same border, so their order cannot change the state. Keep :user-invalid separate so unsupported engines retain the other routes. */
111
+ .needs-validation .otp > input:user-invalid ~ span {
112
+ --otp-cell-border: var(--danger);
114
113
  }
115
114
 
116
115
  .otp > input:disabled {
@@ -29,7 +29,7 @@
29
29
 
30
30
  .switch {
31
31
  --switch-bg: var(--border);
32
- --switch-border: var(--border);
32
+ --switch-border: var(--border-control);
33
33
  --switch-knob: var(--surface-raised);
34
34
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
35
35
  --switch-width: calc(var(--switch-block-size) * 1.8);
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * Imported by default through forms/index.css. Custom builds may omit this
7
7
  * file when an app wants to own validation styling completely.
8
+ * Without :has(), automatic error display and choice-card borders drop;
9
+ * native control states and the explicit .field.danger route stay available.
8
10
  *
9
11
  * State coverage (which states each component ships) is audited in
10
12
  * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
@@ -26,11 +28,14 @@
26
28
  display: block;
27
29
  }
28
30
 
29
- @supports selector(:has(*)) {
30
- .field:has([aria-invalid="true"]) > .field-error,
31
- .needs-validation.was-validated .field:has(:invalid) > .field-error {
32
- display: block;
33
- }
31
+ .field:has([aria-invalid="true"]) > .field-error,
32
+ .needs-validation.was-validated .field:has(:invalid) > .field-error {
33
+ display: block;
34
+ }
35
+
36
+ /* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
37
+ .needs-validation .field:has(:user-invalid) > .field-error {
38
+ display: block;
34
39
  }
35
40
 
36
41
  :where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
@@ -54,39 +59,27 @@
54
59
  --switch-bg: var(--danger);
55
60
  }
56
61
 
57
- @supports selector(:user-invalid) {
58
- .needs-validation .check:is(:checked, :indeterminate):user-invalid,
59
- .needs-validation .radio:checked:user-invalid {
60
- --choice-bg: var(--danger);
61
- }
62
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
63
+ .needs-validation .radio:checked:user-invalid {
64
+ --choice-bg: var(--danger);
65
+ }
62
66
 
63
- .needs-validation .switch:checked:user-invalid {
64
- --switch-bg: var(--danger);
65
- }
67
+ .needs-validation .switch:checked:user-invalid {
68
+ --switch-bg: var(--danger);
66
69
  }
67
70
 
68
71
  /* Choice cards hide their native input, so invalid state must surface on the
69
72
  card itself. Sets the same --form-invalid-border hook the card's border
70
73
  consumes. */
71
- @supports selector(:has(*)) {
72
- .choice-card:has([aria-invalid="true"]),
73
- .needs-validation.was-validated .choice-card:has(:invalid) {
74
- --form-invalid-border: var(--danger);
75
- }
74
+ .choice-card:has([aria-invalid="true"]),
75
+ .needs-validation.was-validated .choice-card:has(:invalid) {
76
+ --form-invalid-border: var(--danger);
76
77
  }
77
78
 
78
- @supports selector(:user-invalid) {
79
- .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
80
- --form-invalid-border: var(--danger);
81
- }
79
+ .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
80
+ --form-invalid-border: var(--danger);
82
81
  }
83
82
 
84
- @supports selector(:has(:user-invalid)) {
85
- .needs-validation .field:has(:user-invalid) > .field-error {
86
- display: block;
87
- }
88
-
89
- .needs-validation .choice-card:has(:user-invalid) {
90
- --form-invalid-border: var(--danger);
91
- }
83
+ .needs-validation .choice-card:has(:user-invalid) {
84
+ --form-invalid-border: var(--danger);
92
85
  }
@@ -7,6 +7,8 @@
7
7
  * --center-size
8
8
  * --center-pad
9
9
  */
10
+ /* The hooks inherit on purpose: a site's content width is page policy, so a
11
+ theme sets them once on :root for every .center. */
10
12
  .center {
11
13
  box-sizing: content-box;
12
14
  inline-size: calc(100% - var(--center-pad, 1rem) * 2);
@@ -10,7 +10,21 @@
10
10
  *
11
11
  * Child contract: direct-child margins are reset, spacing comes from --gap.
12
12
  * A ul/ol/menu keeps its semantics but loses list chrome (see below).
13
+ *
14
+ * The hooks describe this row's own geometry, so they stop at each cluster:
15
+ * set them on the cluster itself, or target nested rows with a selector. The
16
+ * gap token is context rhythm and keeps inheriting.
13
17
  */
18
+
19
+ /* Instance boundary: an ancestor's value never reaches a nested cluster.
20
+ Zero specificity, so any class or inline style on the cluster wins without
21
+ depending on import order; `initial` makes the var() fallbacks apply. */
22
+ :where(.cluster) {
23
+ --cluster-align: initial;
24
+ --cluster-justify: initial;
25
+ --cluster-wrap: initial;
26
+ }
27
+
14
28
  .cluster {
15
29
  display: flex;
16
30
  flex-direction: row;
@@ -22,6 +22,13 @@
22
22
  * the thresholds were arrived at: docs/design-notes/grid.md.
23
23
  */
24
24
 
25
+ /* Instance boundary: a nested grid never takes its parent's track template.
26
+ Zero specificity, so a class or inline style on the grid itself wins. */
27
+ :where(.grid) {
28
+ --grid-columns: initial;
29
+ --grid-min: initial;
30
+ }
31
+
25
32
  /* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
26
33
  no longer collapses on its own and the author owns narrow-container
27
34
  collapse. */
@@ -13,7 +13,16 @@
13
13
  * draw their native scrollbar — the intended fallback, not a broken state.
14
14
  *
15
15
  * Styles the scrollbar only; it does not create the overflow. Pair it with
16
- * .overflow-auto or a component that already scrolls.
16
+ * .overflow-auto or a component that already scrolls (horizontal steps, a
17
+ * table wrap; tabs hide their bar and fade their own edges). No edge fade
18
+ * here: a mask would dictate the strip's scroll-padding and focus geometry for
19
+ * a decorative cue, and an item clipped at the edge already says the strip
20
+ * continues.
21
+ *
22
+ * Not on touch: Chromium trades the overlay scrollbar of a touch screen,
23
+ * hidden at rest, for a classic always-visible one as soon as scrollbar-color
24
+ * or scrollbar-width is set. On a phone that painted a permanent bar under
25
+ * every strip; the native overlay is the better cue there.
17
26
  *
18
27
  * Public hooks:
19
28
  * --scroller-track
@@ -22,9 +31,16 @@
22
31
  .scroller {
23
32
  --scroller-track: transparent;
24
33
  --scroller-thumb: var(--border);
34
+ }
25
35
 
26
- scrollbar-color: var(--scroller-thumb) var(--scroller-track);
27
- scrollbar-width: thin;
36
+ /* Any primary pointer but touch: a keyboard-only or pointerless device
37
+ (pointer: none) draws classic scrollbars too. `all and` keeps the negation
38
+ valid in Media Queries 3 parsers. */
39
+ @media not all and (pointer: coarse) {
40
+ .scroller {
41
+ scrollbar-color: var(--scroller-thumb) var(--scroller-track);
42
+ scrollbar-width: thin;
43
+ }
28
44
  }
29
45
 
30
46
  /* Opt-in gutter: reserves space so the layout does not shift when
@@ -17,6 +17,9 @@
17
17
  * visual, reading and keyboard order all agree — the role rules swap so the
18
18
  * aside still takes its preferred width and the main still reclaims the row.
19
19
  */
20
+ /* No instance reset, unlike .cluster and .grid: the regions read the hooks,
21
+ and a layout nested as a region takes its own size from its parent's
22
+ values, so the hooks must reach it. */
20
23
  .sidebar-layout {
21
24
  display: flex;
22
25
  flex-direction: row;
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * Child contract: every direct child is a peer; the flex-basis applies to all.
14
14
  */
15
+ /* No instance reset, unlike .cluster and .grid: the children read the hook,
16
+ and a switcher nested as a direct child takes its own flex-basis from its
17
+ parent's value, so the hook must reach it. */
15
18
  .switcher {
16
19
  display: flex;
17
20
  flex-direction: row;
@@ -84,9 +84,9 @@
84
84
  flex-wrap: nowrap;
85
85
  }
86
86
 
87
- /* The property is written directly, never relayed through the cluster hook:
88
- a relay would inherit into every .cluster nested inside. Utilities load last,
89
- so the direct declaration already beats .cluster at equal specificity. */
87
+ /* The property is written directly, so the utility works on any flex row, not
88
+ only a .cluster. Utilities load last, so the direct declaration beats
89
+ .cluster at equal specificity. */
90
90
  .justify-content-start {
91
91
  justify-content: flex-start;
92
92
  }
package/src/js/full.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * Each module self-registers via observer: importing this bundle enables
5
5
  * common behaviors. No DOMContentLoaded ceremony, no init calls.
6
6
  * Injected content wires automatically; removal cleans up automatically.
7
+ * The entry also exposes the public enhancement API from the same module
8
+ * graph, so adopters extend the runtime they already run.
7
9
  *
8
10
  * A plain `<script src="actual.full.js"></script>` (no bundler, no import) still
9
11
  * gets the full runtime, including declarative triggers like status's
@@ -25,3 +27,5 @@ import "./range.js";
25
27
  import "./validation.js";
26
28
  import "./status.js";
27
29
  import "./index.js";
30
+
31
+ export { applyEnhancement, default as enhance, registerEnhancement } from "./enhance.js";