actual-css 0.12.0 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  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/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. package/src/tooling/theme-contrast.js +182 -45
@@ -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,9 +212,14 @@
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"] {
99
- color: var(--intent, var(--state-selected));
221
+ .tab:is([aria-current="page"], [aria-current="true"]) {
222
+ color: var(--intent, var(--state-selected-text, var(--state-selected)));
100
223
  }
101
224
 
102
225
  /* The selected indicator lives on a positioned pseudo so that only the active
@@ -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
+ }
@@ -18,17 +18,22 @@
18
18
  --surface-solid, so one token reads on the page, on a solid band and on
19
19
  any nested surface.
20
20
 
21
+ A .segment is an action but takes the inset ring: its track's padding is
22
+ narrower than an offset ring, which would cover the neighbouring segment.
23
+
21
24
  Documented exception: an invalid field focuses in --form-invalid-border
22
25
  (danger), so the error stays visible at the point of correction. The border
23
26
  carries the same hook; report:theme-contrast measures that line at 3:1
24
- against the field's background alongside --focus. */
27
+ against the field's background alongside --focus.
28
+
29
+ Public hooks:
30
+ --focus-outline (fallback-only: replaces the whole generic line)
31
+ */
25
32
  :focus {
26
- outline: var(--focus-outline);
33
+ outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
27
34
  outline-offset: var(--focus-outline-offset);
28
35
  }
29
36
 
30
- @supports selector(:focus-visible) {
31
- :focus:not(:focus-visible) {
32
- outline: none;
33
- }
37
+ :focus:not(:focus-visible) {
38
+ outline: none;
34
39
  }
@@ -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
 
@@ -160,9 +162,20 @@
160
162
  [data-theme] {
161
163
  /* State tokens — semantic colors for selected/pressed/current and disabled.
162
164
  Components consume these instead of hard-wiring --primary/--text-subtle
163
- so forced-colors can override them in one place. */
165
+ so forced-colors can override them in one place. --state-selected is a
166
+ selected fill or accent, --state-selected-fg the ink on that fill.
167
+ --state-selected-text is selected ink on a regular surface (current nav
168
+ link, selected tab, current step number). It is reset to the
169
+ guaranteed-invalid value on every theme boundary, so a consumer reads its
170
+ local --state-selected through the fallback var(--state-selected-text,
171
+ var(--state-selected)): a theme that resolves it to var(--link) bakes that
172
+ reference where it is declared, so a nested island that does not set it
173
+ inherits an already-resolved value the local fallback can never reach. A
174
+ theme sets it when one color cannot be both a fill under its -fg and text
175
+ on the page, typically in a dark scheme. */
164
176
  --state-selected: var(--primary);
165
177
  --state-selected-fg: var(--primary-fg);
178
+ --state-selected-text: initial;
166
179
  --state-disabled: var(--text-subtle);
167
180
 
168
181
  /* Status dot ring — cuts the dot out of what it overlaps. */
@@ -171,10 +184,15 @@
171
184
 
172
185
  :root {
173
186
  /* Variant tuning — themes may adjust generated soft surfaces without
174
- replacing the component rules. */
187
+ replacing the component rules. --soft-bg-mix is the share of surface in a
188
+ soft fill: an opaque mix on the shared .soft, an opacity (100% minus it)
189
+ on the translucent badge, alert and soft-button tint. --soft-hover-alpha
190
+ is the extra intent share on hover. --soft-border-mix has no default: it
191
+ is a fallback-only hook whose absence lets each recipe keep its own rim
192
+ (a tinted rim on .soft, none on the tint), and a theme that sets it gives
193
+ both a rim of that weight. */
175
194
  --soft-bg-mix: 88%;
176
- --soft-border-mix: 65%;
177
- --soft-hover-alpha: 12%;
195
+ --soft-hover-alpha: 6%;
178
196
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
179
197
  pure intent color; lower values rebate it toward --text, which vivid or
180
198
  light intent palettes need to stay legible on their own soft surface.
@@ -34,6 +34,9 @@
34
34
  *
35
35
  * Intent list source of truth: intents.css. Components consume --intent* tokens
36
36
  * instead of re-listing intent classes.
37
+ *
38
+ * Public hooks:
39
+ * --soft-border-mix (fallback-only: a soft rim of that weight on every recipe)
37
40
  */
38
41
  .solid {
39
42
  --ui-bg: var(--intent, var(--neutral));
@@ -97,7 +100,7 @@
97
100
 
98
101
  /* Shared control scale. Wrappers are listed when their descendants form one
99
102
  component (field, choice-card, floating-field, input-icon, join, pagination,
100
- tabs, flyout). A local size declaration beats inherited density because it
103
+ tabs, segmented, flyout). A local size declaration beats inherited density because it
101
104
  is declared on the sized component itself. Guarded by check:sync
102
105
  (size-scale-participants): .sm and .lg must list the same components. */
103
106
  :where(
@@ -110,6 +113,7 @@
110
113
  .field,
111
114
  .choice,
112
115
  .choice-card,
116
+ .color-swatch,
113
117
  .input-icon,
114
118
  .floating-field,
115
119
  .otp,
@@ -117,6 +121,7 @@
117
121
  .pagination,
118
122
  .tabs,
119
123
  .tab,
124
+ .segmented,
120
125
  .flyout
121
126
  ).sm {
122
127
  --control-size: var(--control-size-sm);
@@ -137,6 +142,7 @@
137
142
  .field,
138
143
  .choice,
139
144
  .choice-card,
145
+ .color-swatch,
140
146
  .input-icon,
141
147
  .floating-field,
142
148
  .otp,
@@ -144,6 +150,7 @@
144
150
  .pagination,
145
151
  .tabs,
146
152
  .tab,
153
+ .segmented,
147
154
  .flyout
148
155
  ).lg {
149
156
  --control-size: var(--control-size-lg);
@@ -155,21 +162,25 @@
155
162
  }
156
163
 
157
164
  /* Modern color enhancement. Baseline variant values above remain usable
158
- everywhere. Interactive states live in the components that consume them. */
165
+ everywhere. Interactive states live in the components that consume them.
166
+ The shared .soft is opaque: it paints zones (.card, .navbar, .app-nav,
167
+ .chat-bubble) an author may lay over an image, where a translucent fill
168
+ would hand the text's contrast to whatever is behind it. The badge, alert
169
+ and soft button own a translucent tint instead (soft-tint). */
159
170
  @supports (color: color-mix(in oklch, red, white)) {
160
171
  .soft {
161
- /* @sync soft-recipe */
162
172
  --ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
163
173
  --ui-border: color-mix(
164
174
  in oklab,
165
- var(--surface) var(--soft-border-mix),
175
+ var(--surface) var(--soft-border-mix, 65%),
166
176
  var(--intent, var(--neutral))
167
177
  );
178
+ /* @sync soft-ink */
168
179
  --ui-fg: var(
169
180
  --intent-soft-fg,
170
181
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
171
182
  );
172
- /* @sync soft-recipe */
183
+ /* @sync soft-ink */
173
184
  }
174
185
  }
175
186
 
@@ -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
 
@@ -0,0 +1,159 @@
1
+ /*
2
+ * Color swatch — a color chosen from a palette the application defines.
3
+ *
4
+ * Docs: docs/pages/forms/color-swatches.md
5
+ *
6
+ * A <label class="color-swatch"> wraps a native radio (one color) or checkbox
7
+ * (several) and paints --color-swatch-value. The same class on a <button> is
8
+ * the trigger of a palette flyout. The application owns the colors; Actual
9
+ * owns the shape, the selection mark and the focus ring.
10
+ *
11
+ * Two sizes: the visible swatch (::before) is the height of a .switch track, so
12
+ * a swatch and a switch in neighbouring rows match; the element is the hit
13
+ * area around it. The hit area reaches half the inherited --gap past the
14
+ * swatch, up to --control-size, and overflows its layout box: neighbours in a
15
+ * .cluster or .grid meet in the middle of the gap instead of overlapping, and
16
+ * rows sit one gap apart like columns. Edge, selection ring and focus ring all
17
+ * belong to the visible swatch.
18
+ *
19
+ * The color is arbitrary, so nothing drawn on it may assume a contrast: the
20
+ * selected mark is a double ring (--text outside, --surface-opaque inside) that
21
+ * reads on any fill, and a hairline edge keeps a near-white color apart from
22
+ * the page. ::before opts out of forced colors to keep the color, so it takes
23
+ * system colors itself there.
24
+ *
25
+ * Without :has() the native radio stays visible in the swatch and carries the
26
+ * state; the ring needs :has() to mirror it.
27
+ *
28
+ * Public hooks:
29
+ * --color-swatch-value (fallback-only: the color, set per swatch)
30
+ * --color-swatch-size
31
+ * --color-swatch-radius
32
+ */
33
+ .color-swatch {
34
+ /* The .switch track height (switch.css); a browser test holds them equal. */
35
+ --color-swatch-size: calc(var(--choice-control-size) + 0.125em);
36
+ --color-swatch-radius: var(--radius-full);
37
+ /* Internal: what the hit area adds to the swatch, half on each side. One gap
38
+ at most, so two neighbours never claim the same point — a wider reach let
39
+ the next swatch take a click on this one's center. */
40
+ --color-swatch-reach: clamp(0px, var(--control-size) - var(--color-swatch-size), var(--gap));
41
+
42
+ position: relative;
43
+ display: inline-grid;
44
+ place-items: center;
45
+ flex: none;
46
+ inline-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
47
+ block-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
48
+ /* Lay out at the visible size: gaps are measured from the swatch, not from
49
+ its invisible reach. */
50
+ margin: calc(var(--color-swatch-reach) / -2);
51
+ padding: 0;
52
+ border: 0;
53
+ background: transparent;
54
+ color: inherit;
55
+ /* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
56
+ font: inherit;
57
+ font-size: var(--control-font-size);
58
+ cursor: pointer;
59
+ }
60
+
61
+ .color-swatch::before {
62
+ content: "";
63
+ grid-area: 1 / 1;
64
+ inline-size: var(--color-swatch-size);
65
+ block-size: var(--color-swatch-size);
66
+ box-sizing: border-box;
67
+ border: var(--border-width) solid var(--border-control);
68
+ border-radius: var(--color-swatch-radius);
69
+ background: var(--color-swatch-value, transparent) padding-box;
70
+ /* The color is the content, as on .color (native.css). */
71
+ forced-color-adjust: none;
72
+ transition:
73
+ border-color var(--duration),
74
+ box-shadow var(--duration);
75
+ }
76
+
77
+ /* Shares the swatch's cell and paints after ::before, so a native radio left
78
+ visible sits on the color. */
79
+ .color-swatch > input {
80
+ grid-area: 1 / 1;
81
+ position: relative;
82
+ margin: 0;
83
+ }
84
+
85
+ /* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
86
+ pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
87
+ visible native radio is the fallback. */
88
+ @supports selector(:has(*)) {
89
+ .color-swatch > input {
90
+ position: absolute;
91
+ inline-size: 1px;
92
+ block-size: 1px;
93
+ padding: 0;
94
+ margin: -1px;
95
+ overflow: hidden;
96
+ clip: rect(0, 0, 0, 0);
97
+ white-space: nowrap;
98
+ border: 0;
99
+ }
100
+ }
101
+
102
+ .color-swatch:hover::before {
103
+ border-color: var(--text-subtle);
104
+ }
105
+
106
+ .color-swatch:has(> input:checked)::before {
107
+ box-shadow:
108
+ inset 0 0 0 2px var(--text),
109
+ inset 0 0 0 4px var(--surface-opaque);
110
+ }
111
+
112
+ /* The ring goes around the visible swatch, not the larger hit area. */
113
+ .color-swatch:focus-visible {
114
+ outline: none;
115
+ }
116
+
117
+ .color-swatch:focus-visible::before {
118
+ outline: var(--focus-ring-width) solid var(--focus);
119
+ outline-offset: var(--focus-outline-offset);
120
+ }
121
+
122
+ .color-swatch:has(> input:focus-visible)::before {
123
+ outline: var(--focus-ring-width) solid var(--focus);
124
+ outline-offset: var(--focus-outline-offset);
125
+ }
126
+
127
+ .color-swatch:disabled {
128
+ opacity: var(--disabled-opacity);
129
+ cursor: not-allowed;
130
+ }
131
+
132
+ .color-swatch:has(> input:disabled) {
133
+ opacity: var(--disabled-opacity);
134
+ cursor: not-allowed;
135
+ }
136
+
137
+ /* A disabled swatch keeps its color: --disabled-opacity is 1 here
138
+ (tokens.css), and the edge carries the state. */
139
+ @media (forced-colors: active) {
140
+ .color-swatch::before {
141
+ --focus: CanvasText;
142
+
143
+ border-color: CanvasText;
144
+ }
145
+
146
+ .color-swatch:has(> input:checked)::before {
147
+ box-shadow:
148
+ inset 0 0 0 2px CanvasText,
149
+ inset 0 0 0 4px Canvas;
150
+ }
151
+
152
+ .color-swatch:disabled::before {
153
+ border-color: GrayText;
154
+ }
155
+
156
+ .color-swatch:has(> input:disabled)::before {
157
+ border-color: GrayText;
158
+ }
159
+ }
@@ -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);