actual-css 0.13.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 (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +4 -4
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +648 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/navigation.json +2 -0
  8. package/docs/pages/components/alert.md +12 -2
  9. package/docs/pages/components/button.md +3 -2
  10. package/docs/pages/components/list.md +6 -3
  11. package/docs/pages/components/segmented.md +83 -0
  12. package/docs/pages/components/steps.md +4 -2
  13. package/docs/pages/enhancements/flyout.md +5 -0
  14. package/docs/pages/enhancements/tabs.md +7 -2
  15. package/docs/pages/examples/overview.md +3 -1
  16. package/docs/pages/forms/color-swatches.md +131 -0
  17. package/docs/pages/forms/combobox.md +2 -2
  18. package/docs/pages/forms/controls.md +6 -3
  19. package/docs/pages/forms/joined-controls.md +6 -2
  20. package/docs/pages/forms/overview.md +1 -0
  21. package/docs/pages/forms/switch.md +4 -2
  22. package/docs/pages/foundations/theming.md +1 -1
  23. package/docs/pages/foundations/tokens.md +54 -23
  24. package/docs/pages/guides/design-tools.md +13 -7
  25. package/docs/pages/guides/introduction.md +1 -1
  26. package/docs/pages/layout/scroll-snap.md +4 -2
  27. package/package.json +2 -2
  28. package/reserved-classes.json +3 -0
  29. package/src/cli/actual-css.js +3 -3
  30. package/src/css/components/alert.css +65 -14
  31. package/src/css/components/app-nav.css +17 -9
  32. package/src/css/components/badge.css +23 -9
  33. package/src/css/components/button.css +17 -2
  34. package/src/css/components/close.css +6 -3
  35. package/src/css/components/index.css +1 -0
  36. package/src/css/components/list.css +18 -3
  37. package/src/css/components/navbar.css +5 -2
  38. package/src/css/components/segmented.css +186 -0
  39. package/src/css/components/steps.css +6 -3
  40. package/src/css/components/tab.css +1 -1
  41. package/src/css/core/focus.css +3 -0
  42. package/src/css/core/tokens.css +20 -4
  43. package/src/css/core/variants.css +16 -5
  44. package/src/css/forms/color-swatch.css +159 -0
  45. package/src/css/forms/form-actions.css +9 -0
  46. package/src/css/forms/index.css +1 -0
  47. package/src/css/forms/native.css +50 -20
  48. package/src/css/forms/switch.css +5 -2
  49. package/src/css/layout/scroll-snap.css +30 -0
  50. package/src/css/layout/topbar.css +10 -0
  51. package/src/js/focus.js +16 -1
  52. package/src/js/surface.js +10 -0
  53. package/src/tooling/color.js +14 -1
  54. package/src/tooling/css-bundle.js +0 -5
  55. package/src/tooling/design-components.js +9 -5
  56. package/src/tooling/design-export.js +8 -0
  57. package/src/tooling/design-tokens.js +163 -43
  58. package/src/tooling/theme-contrast.js +151 -22
@@ -40,8 +40,10 @@ Items snap to the inline start edge by default, with `proximity` snapping so a s
40
40
 
41
41
  - `--scroll-snap-item-size` — item `flex` basis; set it for card rails.
42
42
  - `--scroll-snap-gap` — space between items.
43
- - `--scroll-snap-padding` — inline scroll padding, so a snapped item does not sit
44
- flush against the container edge. Not declared; falls back to `--scroll-snap-gap`.
43
+ - `--scroll-snap-padding` — preferred inline scroll inset; defaults to
44
+ `--scroll-snap-gap`. Use a length (`0px` for zero); conforming values stay at
45
+ least large enough for the focus gutter, and the rail's edge items keep that
46
+ gutter even at rest.
45
47
  - `--scroll-snap-align` — item snap alignment. Prefer `data-snap-align="center"`
46
48
  over setting this directly.
47
49
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "actual-css",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Plain CSS component framework with semantic classes, universal variants, small tokens, theme hooks, and progressive enhancements.",
5
5
  "type": "module",
6
- "packageManager": "bun@1.4.2",
6
+ "packageManager": "bun@1.4.3",
7
7
  "license": "MIT",
8
8
  "author": "Thomas <thomas@lekoala.be>",
9
9
  "repository": {
@@ -48,6 +48,7 @@
48
48
  "close",
49
49
  "cluster",
50
50
  "color",
51
+ "color-swatch",
51
52
  "column-gap-context",
52
53
  "column-layout",
53
54
  "compact",
@@ -198,6 +199,8 @@
198
199
  "scroller",
199
200
  "scrollspy",
200
201
  "secondary",
202
+ "segment",
203
+ "segmented",
201
204
  "select",
202
205
  "separated",
203
206
  "shadow-context",
@@ -22,9 +22,9 @@ function usage() {
22
22
  "stands for. Remote imports are kept and hoisted to the top of the bundle.",
23
23
  "Leaves modern CSS syntax untouched; this is a bundler, not a transpiler.",
24
24
  "",
25
- "design: export a theme's tokens for Figma and Penpot, plus Button, Input, Badge,",
26
- "Alert and Card measured in headless Chrome and a Penpot plugin that builds them.",
27
- "Needs Bun (bunx --bun actual-css design ...).",
25
+ "design: export a theme's tokens for Figma, Penpot and a portable DTCG profile,",
26
+ "plus Button, Input, Badge, Alert and Card measured in headless Chrome and a",
27
+ "Penpot plugin that builds them. Needs Bun (bunx --bun actual-css design ...).",
28
28
  "",
29
29
  "contrast: measure the pairs a theme tunes by hand in headless Chrome, in every",
30
30
  "scheme it declares: soft ink on its resting and hovered fill, the focus line on",
@@ -61,6 +61,16 @@
61
61
  font-size: var(--alert-font-size);
62
62
  }
63
63
 
64
+ /* Density steps the block padding only: the inline padding is the text
65
+ column's edge and stays stable. Local to the alert, like .card.compact. */
66
+ .alert.compact {
67
+ --alert-pad-block: var(--space-20);
68
+ }
69
+
70
+ .alert.spacious {
71
+ --alert-pad-block: var(--space-40);
72
+ }
73
+
64
74
  .alert > * {
65
75
  min-inline-size: 0;
66
76
  }
@@ -85,28 +95,40 @@
85
95
  }
86
96
 
87
97
  /* Alerts are soft by default. The recipe writes local defaults rather than
88
- --ui-* so an explicit shared treatment always wins without exclusions. */
98
+ --ui-* so an explicit shared treatment always wins without exclusions. The
99
+ fill is the translucent soft tint (see badge.css), with no rim unless a
100
+ theme sets --soft-border-mix. */
89
101
  @supports (color: color-mix(in oklch, red, white)) {
90
102
  .alert {
91
- /* @sync soft-recipe */
103
+ /* @sync soft-tint */
92
104
  --alert-default-bg: color-mix(
93
105
  in oklab,
94
- var(--surface) var(--soft-bg-mix),
95
- var(--intent, var(--neutral))
106
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
107
+ transparent
96
108
  );
97
109
  --alert-default-border: color-mix(
98
110
  in oklab,
99
- var(--surface) var(--soft-border-mix),
100
- var(--intent, var(--neutral))
111
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
112
+ transparent
101
113
  );
114
+ /* @sync soft-tint */
115
+ /* @sync soft-ink */
102
116
  --alert-default-fg: var(
103
117
  --intent-soft-fg,
104
118
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
105
119
  );
106
- /* @sync soft-recipe */
120
+ /* @sync soft-ink */
107
121
  }
108
122
  }
109
123
 
124
+ /* Soft is the alert's own default: an explicit .soft must not swap the tint
125
+ for the shared opaque .soft recipe, so it defers to the default. */
126
+ .alert.soft {
127
+ --ui-bg: initial;
128
+ --ui-fg: initial;
129
+ --ui-border: initial;
130
+ }
131
+
110
132
  /* Decorative leading icon — use .alert-icon on the icon element. display and
111
133
  vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
112
134
  before the text); the grid anatomy below replaces them. */
@@ -205,7 +227,10 @@
205
227
 
206
228
  /* Admonition — structured static box with a tinted title bar and a body
207
229
  that rests on the page surface. Not collapsible — use .accordion inside
208
- the body if the content needs to be expandable. */
230
+ the body if the content needs to be expandable. The title bar is what a
231
+ shared treatment paints (.solid fills it, .surface/.outline clear it): the
232
+ root's fill never shows behind the opaque title and body, and the title ink
233
+ already follows --ui-fg, so a bar left on the soft tint is unreadable. */
209
234
  .alert.admonition {
210
235
  padding: 0;
211
236
  display: block;
@@ -219,6 +244,7 @@
219
244
  gap: var(--space-20);
220
245
  padding-block: var(--alert-pad-block);
221
246
  padding-inline: var(--alert-pad-inline);
247
+ font-weight: var(--font-weight-semibold);
222
248
  border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
223
249
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
224
250
  }
@@ -230,18 +256,17 @@
230
256
 
231
257
  @supports (color: color-mix(in oklch, red, white)) {
232
258
  .alert.admonition > .alert-title {
233
- background: color-mix(
234
- in oklab,
235
- var(--surface) var(--soft-bg-mix),
236
- var(--intent, var(--neutral))
259
+ background: var(
260
+ --ui-bg,
261
+ color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)))
237
262
  );
238
263
  }
239
264
  }
240
265
 
241
266
  @supports not (color: color-mix(in oklch, red, white)) {
242
267
  .alert.admonition > .alert-title {
243
- background: var(--intent, var(--neutral));
244
- color: var(--intent-fg, var(--neutral-fg));
268
+ background: var(--ui-bg, var(--intent, var(--neutral)));
269
+ color: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
245
270
  }
246
271
  }
247
272
 
@@ -252,6 +277,32 @@
252
277
  color: var(--text);
253
278
  }
254
279
 
280
+ /* Solid: the filled title bar already carries the intent, so the frame moves
281
+ to the body and stays neutral. The root border goes because a transparent
282
+ one would show the root's intent fill around the body. */
283
+ .alert.admonition.solid {
284
+ border: 0;
285
+ }
286
+
287
+ .alert.admonition.solid > .alert-body {
288
+ border: var(--border-width) solid var(--border);
289
+ border-block-start: 0;
290
+ border-end-start-radius: var(--alert-radius, var(--radius-lg));
291
+ border-end-end-radius: var(--alert-radius, var(--radius-lg));
292
+ }
293
+
294
+ /* Forced colors drop the title fill, so the bar would lose its edge: the
295
+ frame returns to the root as one uniform system-colored border. */
296
+ @media (forced-colors: active) {
297
+ .alert.admonition.solid {
298
+ border: var(--border-width) solid CanvasText;
299
+ }
300
+
301
+ .alert.admonition.solid > .alert-body {
302
+ border: 0;
303
+ }
304
+ }
305
+
255
306
  .alert.admonition > .alert-body > :first-child {
256
307
  margin-block-start: 0;
257
308
  }
@@ -40,6 +40,7 @@
40
40
  }
41
41
 
42
42
  .app-nav > a {
43
+ position: relative;
43
44
  display: grid;
44
45
  grid-template-rows: auto auto;
45
46
  place-content: center;
@@ -80,18 +81,25 @@
80
81
  }
81
82
 
82
83
  /* Current tile is already a full selected surface. Selection never changes
83
- text metrics: the shared medium weight is kept, no permanent indicator is
84
- added here. */
84
+ text metrics: the shared medium weight is kept. The forced-colors cue is a
85
+ transparent border on a ::before overlay, not on the tile: an element border
86
+ shrinks the content box, and the side layout starts its content at the inline
87
+ edge, so the icon and label shifted by --border-width when a tile became
88
+ current. The overlay costs no layout, yet forced colors still turn its
89
+ transparent border into a system-color frame once --state-selected flattens
90
+ to the bar's own Canvas. No forced-colors repaint: a Highlight fill put
91
+ HighlightText on Chromium's Canvas backplate, which hid the label. The
92
+ positioning context is on every link so it does not move when one becomes
93
+ current or an application places its own decoration. */
85
94
  .app-nav > a[aria-current="page"] {
86
95
  background: var(--state-selected);
87
96
  color: var(--state-selected-fg);
88
97
  }
89
98
 
90
- @media (forced-colors: active) {
91
- /* --state-selected flattens to a UA color that no longer distinguishes the
92
- current tile from the rest of the bar, so paint the system Highlight pair. */
93
- .app-nav > a[aria-current="page"] {
94
- background: Highlight;
95
- color: HighlightText;
96
- }
99
+ .app-nav > a[aria-current="page"]::before {
100
+ content: "";
101
+ position: absolute;
102
+ inset: 0;
103
+ border: var(--border-width) solid transparent;
104
+ border-radius: inherit;
97
105
  }
@@ -111,28 +111,42 @@
111
111
  }
112
112
 
113
113
  /* Soft by default. Local defaults remain behind --ui-* in the resolution
114
- chain, so explicit shared treatments compose without selector exclusions. */
114
+ chain, so explicit shared treatments compose without selector exclusions.
115
+ The fill is a translucent intent tint, so it shows the surface it sits on
116
+ instead of a fixed mix with --surface: a mix stops separating on a subtle
117
+ or raised surface of its own lightness. No rim unless a theme sets
118
+ --soft-border-mix (tokens.css). */
115
119
  @supports (color: color-mix(in oklch, red, white)) {
116
120
  .badge {
117
- /* @sync soft-recipe */
121
+ /* @sync soft-tint */
118
122
  --badge-default-bg: color-mix(
119
123
  in oklab,
120
- var(--surface) var(--soft-bg-mix),
121
- var(--intent, var(--neutral))
124
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
125
+ transparent
122
126
  );
123
127
  --badge-default-border: color-mix(
124
128
  in oklab,
125
- var(--surface) var(--soft-border-mix),
126
- var(--intent, var(--neutral))
129
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
130
+ transparent
127
131
  );
132
+ /* @sync soft-tint */
133
+ /* @sync soft-ink */
128
134
  --badge-default-fg: var(
129
135
  --intent-soft-fg,
130
136
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
131
137
  );
132
- /* @sync soft-recipe */
138
+ /* @sync soft-ink */
133
139
  }
134
140
  }
135
141
 
142
+ /* Soft is the badge's own default: an explicit .soft must not swap the tint
143
+ for the shared opaque .soft recipe, so it defers to the default. */
144
+ .badge.soft {
145
+ --ui-bg: initial;
146
+ --ui-fg: initial;
147
+ --ui-border: initial;
148
+ }
149
+
136
150
  .badge:empty {
137
151
  --badge-size: var(--badge-dot-size);
138
152
  --badge-bg: var(--intent, var(--neutral));
@@ -171,8 +185,8 @@
171
185
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
172
186
  --badge-default-hover-bg: color-mix(
173
187
  in oklab,
174
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
175
- var(--intent, var(--neutral))
188
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
189
+ transparent
176
190
  );
177
191
  }
178
192
 
@@ -133,12 +133,27 @@
133
133
  --ui-hover-bg: transparent;
134
134
  }
135
135
 
136
+ /* A soft button takes the badge and alert tint, not the shared opaque .soft:
137
+ it carries a short label like them, and the translucent fill keeps
138
+ separating on a subtle or raised surface. Ink stays the shared .soft one. */
136
139
  @supports (color: color-mix(in oklch, red, white)) {
137
140
  .btn.soft {
141
+ /* @sync soft-tint */
142
+ --ui-bg: color-mix(
143
+ in oklab,
144
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
145
+ transparent
146
+ );
147
+ --ui-border: color-mix(
148
+ in oklab,
149
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
150
+ transparent
151
+ );
152
+ /* @sync soft-tint */
138
153
  --ui-hover-bg: color-mix(
139
154
  in oklab,
140
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
141
- var(--intent, var(--neutral))
155
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
156
+ transparent
142
157
  );
143
158
  }
144
159
 
@@ -93,10 +93,13 @@
93
93
  }
94
94
 
95
95
  @media (forced-colors: active) {
96
- /* The X is a currentColor mask fill: without this the system remaps the
97
- background to Canvas and the glyph disappears. Kept on the pseudo so the
98
- host keeps its native color and focus adaptation. */
96
+ /* The X is a mask fill: without this the system remaps the background to
97
+ Canvas and the glyph disappears. Kept on the pseudo so the host keeps its
98
+ native color and focus adaptation. The fill is the system color, not
99
+ currentColor: an unadjusted pseudo inherits the authored ink, so a white
100
+ .solid ink painted a white X on Canvas. */
99
101
  .close::before {
100
102
  forced-color-adjust: none;
103
+ background: ButtonText;
101
104
  }
102
105
  }
@@ -40,6 +40,7 @@
40
40
  @import "./breadcrumb.css";
41
41
  @import "./pagination.css";
42
42
  @import "./tab.css";
43
+ @import "./segmented.css";
43
44
  @import "./scrollspy.css";
44
45
 
45
46
  /* Data */
@@ -43,6 +43,9 @@
43
43
  .list > :where(.list-item),
44
44
  .list > li > .list-item {
45
45
  display: flex;
46
+ /* A trailing control that no longer fits beside the content's longest word
47
+ moves to its own line instead of breaking that word. */
48
+ flex-wrap: wrap;
46
49
  /* Centered fits the short rows lists mostly hold. Read through
47
50
  --items-align so .items-start on the row or the list wins without
48
51
  outranking this selector — the opt-out for long supporting text. */
@@ -89,17 +92,29 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
89
92
  border-inline-start: 2px solid var(--state-selected);
90
93
  }
91
94
 
92
- /* The content region is the one that flexes, whichever position it holds. */
95
+ /* The content region is the one that flexes, whichever position it holds.
96
+ Its automatic minimum is its longest word, which is what wraps the row; the
97
+ max size caps that minimum at the row, so a long token breaks rather than
98
+ widening the page. A zero minimum shrank it until words broke mid-way. */
93
99
  .list-item-content {
94
100
  flex: 1 1 0;
95
- min-inline-size: 0;
101
+ max-inline-size: 100%;
96
102
  }
97
103
 
104
+ /* break-word, not anywhere: anywhere also lowers the min-content to a single
105
+ character, and the row would never wrap. */
98
106
  .list-item-title,
99
107
  .list-item-text {
100
108
  display: block;
101
109
  margin: 0;
102
- overflow-wrap: anywhere;
110
+ overflow-wrap: break-word;
111
+ }
112
+
113
+ /* Truncated text gives up its width by design: without containment its
114
+ unbreakable line would become the content's minimum and push the trailing
115
+ region onto a line of its own. */
116
+ .list-item-content .truncate {
117
+ contain: inline-size;
103
118
  }
104
119
 
105
120
  .list-item-text {
@@ -7,6 +7,9 @@
7
7
  * links are a .cluster using .nav-link items. .nav-list is the vertical nav
8
8
  * list shared with .drawer, fed by the shared --gap token.
9
9
  *
10
+ * Public hooks:
11
+ * --state-selected-text (fallback-only: current-link ink, see tokens.css)
12
+ *
10
13
  * Internal:
11
14
  * --gap (local value for the shared gap token)
12
15
  */
@@ -84,7 +87,7 @@
84
87
  shared with the hover rule above. */
85
88
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
86
89
  background: var(--surface-subtle);
87
- color: var(--state-selected);
90
+ color: var(--state-selected-text, var(--state-selected));
88
91
  }
89
92
 
90
93
  /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
@@ -105,7 +108,7 @@
105
108
 
106
109
  /* After :hover so a hovered current link keeps the selected accent. */
107
110
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
108
- color: var(--ui-fg, var(--state-selected));
111
+ color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
109
112
  }
110
113
  }
111
114
 
@@ -0,0 +1,186 @@
1
+ /*
2
+ * Segmented control — one compact choice among a few, on a shared track.
3
+ *
4
+ * Docs: docs/pages/components/segmented.md
5
+ *
6
+ * .segmented is the track, each .segment a direct child. The look is shared;
7
+ * the semantics stay with the markup, and the selected state is read from it:
8
+ * radios <label class="segment"><input type="radio"> :checked
9
+ * tabs <button class="segment" role="tab"> aria-selected="true"
10
+ * links <a class="segment"> aria-current="page"
11
+ * No script of its own: radios bring their arrow keys, a tablist takes
12
+ * data-enhance="tabs". Without :has() the radio anatomy loses its selected
13
+ * fill and focus ring, and the native radio stays visible to carry both.
14
+ *
15
+ * One row of equal shares (grid-auto-columns: 1fr), at least --control-size
16
+ * tall so it lines up with .btn and .input. Labels wrap instead of truncating;
17
+ * a word longer than its share breaks, so the control never overflows its
18
+ * container. A strip too long for one row is .tabs, which scrolls.
19
+ *
20
+ * Focus is drawn inside the segment, unlike other discrete actions
21
+ * (focus.css): an offset ring would cross the track's padding and cover the
22
+ * neighbouring segment.
23
+ *
24
+ * Public hooks:
25
+ * --segmented-bg
26
+ * --segmented-pad
27
+ * --segmented-radius
28
+ * --segmented-fg
29
+ * --segmented-selected-bg
30
+ * --segmented-selected-border
31
+ */
32
+ .segmented {
33
+ --segmented-bg: var(--surface-subtle);
34
+ --segmented-pad: var(--space-10);
35
+ --segmented-radius: var(--radius);
36
+ --segmented-fg: var(--text-muted);
37
+ --segmented-selected-bg: var(--surface-raised);
38
+ /* The edge is what separates the selected segment where --surface-raised
39
+ is darker than --surface-subtle (the default dark palette). */
40
+ --segmented-selected-border: var(--border);
41
+
42
+ display: inline-grid;
43
+ grid-auto-flow: column;
44
+ grid-auto-columns: 1fr;
45
+ gap: var(--segmented-pad);
46
+ /* why: margin and border also reset <fieldset>, the radio anatomy's
47
+ element. The border is transparent, not 0: forced colors paint it, which
48
+ outlines the track once its fill is gone. */
49
+ margin: 0;
50
+ padding: var(--segmented-pad);
51
+ border: var(--border-width) solid transparent;
52
+ border-radius: var(--segmented-radius);
53
+ background: var(--segmented-bg);
54
+ font-size: var(--control-font-size);
55
+ vertical-align: middle;
56
+ }
57
+
58
+ .segment {
59
+ position: relative;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: 0.375em;
64
+ /* The track is --control-size tall through its segments, not through its
65
+ own min-block-size: a <fieldset> lays its content in an anonymous box
66
+ sized to the content, so a minimum on the track left the segments short.
67
+ A minimum, not a height: a wrapped label grows the whole row. */
68
+ min-block-size: calc(var(--control-size) - 2 * (var(--segmented-pad) + var(--border-width)));
69
+ padding: calc(var(--space-10) / 2) var(--space-30);
70
+ border: 0;
71
+ border-radius: calc(var(--segmented-radius) - var(--segmented-pad));
72
+ background: transparent;
73
+ color: var(--segmented-fg);
74
+ font: inherit;
75
+ /* Constant across states: a heavier selected label would resize its share. */
76
+ font-weight: var(--font-weight-medium);
77
+ line-height: 1.2;
78
+ text-align: center;
79
+ text-decoration: none;
80
+ /* why: also shrinks the min-content contribution, so 1fr keeps equal
81
+ shares under constraint instead of widening one around a long word. */
82
+ overflow-wrap: anywhere;
83
+ cursor: pointer;
84
+ transition:
85
+ background-color var(--duration),
86
+ color var(--duration),
87
+ box-shadow var(--duration);
88
+ }
89
+
90
+ .segment > input {
91
+ margin: 0;
92
+ }
93
+
94
+ /* The radio is visually hidden but stays focusable, submitted and arrow-key
95
+ navigable (sr-only pattern). Guarded: without :has() the segment cannot
96
+ mirror :checked, so the visible native radio is the fallback. */
97
+ @supports selector(:has(*)) {
98
+ .segment > input {
99
+ position: absolute;
100
+ inline-size: 1px;
101
+ block-size: 1px;
102
+ padding: 0;
103
+ margin: -1px;
104
+ overflow: hidden;
105
+ clip: rect(0, 0, 0, 0);
106
+ white-space: nowrap;
107
+ border: 0;
108
+ }
109
+ }
110
+
111
+ .segment:hover {
112
+ color: var(--text);
113
+ }
114
+
115
+ /* Separate rules from the :has() forms below: an engine without :has() would
116
+ discard a shared selector list, ARIA states included. */
117
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
118
+ background: var(--segmented-selected-bg);
119
+ color: var(--text);
120
+ box-shadow:
121
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
122
+ var(--shadow);
123
+ }
124
+
125
+ .segment:has(> input:checked) {
126
+ background: var(--segmented-selected-bg);
127
+ color: var(--text);
128
+ box-shadow:
129
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
130
+ var(--shadow);
131
+ }
132
+
133
+ .segment:focus-visible {
134
+ outline: var(--focus-ring-width) solid var(--focus);
135
+ outline-offset: calc(var(--focus-ring-width) * -1);
136
+ }
137
+
138
+ .segment:has(> input:focus-visible) {
139
+ outline: var(--focus-ring-width) solid var(--focus);
140
+ outline-offset: calc(var(--focus-ring-width) * -1);
141
+ }
142
+
143
+ .segment:is(:disabled, [aria-disabled="true"]) {
144
+ opacity: var(--disabled-opacity);
145
+ cursor: not-allowed;
146
+ }
147
+
148
+ .segment:has(> input:disabled) {
149
+ opacity: var(--disabled-opacity);
150
+ cursor: not-allowed;
151
+ }
152
+
153
+ /* Forced colors drop the raised fill and its edge. The selected segment
154
+ takes the system highlight — a background, so the outline stays the focus
155
+ channel alone. forced-color-adjust: none is required, not cosmetic: the UA
156
+ otherwise paints a Canvas backplate behind the label, which hid the text on
157
+ the highlight. Opting out also stops the forcing of the rest, so the shadow
158
+ is dropped here and the focus line takes the highlight's text color. */
159
+ @media (forced-colors: active) {
160
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
161
+ --focus: HighlightText;
162
+
163
+ forced-color-adjust: none;
164
+ background: Highlight;
165
+ color: HighlightText;
166
+ box-shadow: none;
167
+ }
168
+
169
+ .segment:has(> input:checked) {
170
+ --focus: HighlightText;
171
+
172
+ forced-color-adjust: none;
173
+ background: Highlight;
174
+ color: HighlightText;
175
+ box-shadow: none;
176
+ }
177
+
178
+ /* --disabled-opacity is 1 here (tokens.css): the dimming needs a color. */
179
+ .segment:is(:disabled, [aria-disabled="true"]) {
180
+ color: GrayText;
181
+ }
182
+
183
+ .segment:has(> input:disabled) {
184
+ color: GrayText;
185
+ }
186
+ }
@@ -137,11 +137,14 @@
137
137
 
138
138
  /* Complete owns the accented connector that reaches the current step, so the
139
139
  connector leaving current goes back to neutral. Declared after
140
- .step-complete at equal specificity: current wins on every channel. */
140
+ .step-complete at equal specificity: current wins on every channel. The
141
+ current marker is a regular surface, so its number and ring are selected
142
+ ink on a surface, not a selected fill; they are held to different ratios
143
+ (4.5:1 for the number, 3:1 for the ring) by report:theme-contrast. */
141
144
  .steps > [aria-current="step"] {
142
145
  --step-marker-bg: var(--surface-opaque);
143
- --step-marker-border: var(--state-selected);
144
- --step-marker-fg: var(--state-selected);
146
+ --step-marker-border: var(--state-selected-text, var(--state-selected));
147
+ --step-marker-fg: var(--state-selected-text, var(--state-selected));
145
148
  --step-line: var(--border);
146
149
 
147
150
  color: var(--text);
@@ -219,7 +219,7 @@
219
219
  stays inert. */
220
220
  .tab[aria-selected="true"],
221
221
  .tab:is([aria-current="page"], [aria-current="true"]) {
222
- color: var(--intent, var(--state-selected));
222
+ color: var(--intent, var(--state-selected-text, var(--state-selected)));
223
223
  }
224
224
 
225
225
  /* The selected indicator lives on a positioned pseudo so that only the active
@@ -18,6 +18,9 @@
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