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
@@ -40,7 +40,7 @@
40
40
  }
41
41
 
42
42
  .join-addon {
43
- --control-border: var(--border);
43
+ --control-border: var(--border-control);
44
44
  --control-bg: var(--surface-subtle);
45
45
  --control-fg: var(--text-muted);
46
46
 
@@ -145,5 +145,5 @@
145
145
  The second :has() covers a control nested one wrapper level deep. */
146
146
  .join:has(> :is(.input, .textarea, .select)) > .btn.outline,
147
147
  .join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
148
- --btn-border-color: var(--intent, var(--border));
148
+ --btn-border-color: var(--intent, var(--border-control));
149
149
  }
@@ -43,9 +43,13 @@
43
43
  .list > :where(.list-item),
44
44
  .list > li > .list-item {
45
45
  display: flex;
46
- /* Regions align to the first line, so leading, title, and trailing controls
47
- share one horizontal line while supporting text wraps beneath. */
48
- align-items: start;
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;
49
+ /* Centered fits the short rows lists mostly hold. Read through
50
+ --items-align so .items-start on the row or the list wins without
51
+ outranking this selector — the opt-out for long supporting text. */
52
+ align-items: var(--items-align, center);
49
53
  gap: var(--list-item-gap);
50
54
  min-block-size: var(--list-item-min-size);
51
55
  padding-block: var(--list-item-pad-block);
@@ -67,21 +71,20 @@ a.list-item:hover {
67
71
  background: var(--surface-subtle);
68
72
  }
69
73
 
70
- /* Current row in a navigational list (master/detail). Generic by design: the
71
- visual treatment only cares that the row is current, so presence is the
72
- contract — the attribute is set on the current row and removed everywhere
73
- else, never serialized as aria-current="false". The value (page, location,
74
- …) stays free for assistive technology.
74
+ /* Current row in a navigational list: aria-current="page" when the row opens
75
+ a page, "true" when it is the current item of a master/detail view (a
76
+ conversation, a thread, a record) that is no page of its own. Any other
77
+ value, "false" included, is inert.
75
78
  Calm by design: the row keeps its normal text metrics and color on a subtle
76
79
  surface, and the selected accent lives on an inset inline-start trait only.
77
80
  The trait is a real border, so it survives forced colors without a repaint
78
81
  block. The positioning context lives on every row link, so becoming current
79
82
  never moves an app-positioned decoration to a new containing block. */
80
- a.list-item[aria-current] {
83
+ a.list-item:is([aria-current="page"], [aria-current="true"]) {
81
84
  background: var(--surface-subtle);
82
85
  }
83
86
 
84
- a.list-item[aria-current]::before {
87
+ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
85
88
  content: "";
86
89
  position: absolute;
87
90
  inset-block: var(--list-item-pad-block);
@@ -89,17 +92,29 @@ a.list-item[aria-current]::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 {
@@ -131,6 +131,12 @@
131
131
  background: var(--surface-subtle);
132
132
  }
133
133
 
134
+ /* The chrome header is a centered row, so prose margins on a bare title only
135
+ push the band taller and pull the title off the close axis. */
136
+ :where(dialog).modal > header > :is(h1, h2, h3, h4, h5, h6) {
137
+ margin-block: 0;
138
+ }
139
+
134
140
  :where(dialog).modal > header > .media {
135
141
  flex: 1 1 auto;
136
142
  min-inline-size: 0;
@@ -194,19 +200,20 @@
194
200
 
195
201
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
196
202
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
197
- --dialog-focus-bleed: calc(
198
- var(--focus-outline-offset) +
199
- max(var(--focus-ring-width), var(--border-width) * 2)
200
- );
203
+ /* Declared here, not as a :root alias: an inherited alias arrives already
204
+ computed, so a --focus-ring-width override on the dialog would not reach
205
+ it. */
206
+ --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
201
207
 
202
208
  flex: 1 1 auto;
203
209
  min-block-size: 0;
204
- /* Keep focus paint inside the clip without moving the body off the fixed
205
- header and footer edges. The reserve covers the thicker of the two
206
- outside indicators: the :focus baseline (2 × border) and the component
207
- line (--focus-ring-width, 3px in bootstrap-v6). */
208
- margin-inline: calc(var(--dialog-focus-bleed) * -1);
209
- padding-inline: var(--dialog-focus-bleed);
210
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
211
+ both axes), without moving the content. Every outside indicator is
212
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
213
+ reaches into the slot gap, which is wider, so the scrollport never slides
214
+ under the header or footer. */
215
+ margin: calc(var(--dialog-focus-bleed) * -1);
216
+ padding: var(--dialog-focus-bleed);
210
217
  overflow: auto;
211
218
  overscroll-behavior: contain;
212
219
  }
@@ -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
  */
@@ -71,8 +74,7 @@
71
74
 
72
75
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
73
76
  and scrollspy, which toggles aria-current="location" on the active link.
74
- Presence is the contract: the attribute is set on the current link and
75
- removed everywhere else, never serialized as aria-current="false".
77
+ Those two values are the contract; any other, "false" included, is inert.
76
78
  Selection never changes text metrics: the shared medium weight is kept and
77
79
  the current link is carried by the selected accent. */
78
80
  .nav-link:hover {
@@ -83,9 +85,9 @@
83
85
  /* Current last, equal specificity to :hover (0,2,0): a hovered current link
84
86
  keeps the selected accent instead of dropping to --text. The surface fill is
85
87
  shared with the hover rule above. */
86
- .nav-link[aria-current] {
88
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
87
89
  background: var(--surface-subtle);
88
- color: var(--state-selected);
90
+ color: var(--state-selected-text, var(--state-selected));
89
91
  }
90
92
 
91
93
  /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
@@ -96,7 +98,7 @@
96
98
  above keeps the light chip where color-mix is missing, with ink that is
97
99
  legible on it. */
98
100
  @supports (color: color-mix(in oklch, red, white)) {
99
- .nav-link:is(:hover, [aria-current]) {
101
+ .nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
100
102
  background: color-mix(in oklch, currentColor 10%, transparent);
101
103
  }
102
104
 
@@ -105,8 +107,8 @@
105
107
  }
106
108
 
107
109
  /* After :hover so a hovered current link keeps the selected accent. */
108
- .nav-link[aria-current] {
109
- color: var(--ui-fg, var(--state-selected));
110
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
111
+ color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
110
112
  }
111
113
  }
112
114
 
@@ -139,11 +141,14 @@
139
141
  follows the forced palette without a forced-colors block and never touches
140
142
  the row separators. Navbar horizontal links keep color + surface only. The
141
143
  positioning context lives on every row link, so becoming current never moves
142
- an app-positioned decoration to a new containing block. */
143
- .nav-list .nav-link[aria-current]::before {
144
+ an app-positioned decoration to a new containing block.
145
+ The trait sits centered in the link's inline padding with rounded ends: flush
146
+ on the edge, a square line read as pasted onto the rounded fill. */
147
+ .nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
144
148
  content: "";
145
149
  position: absolute;
146
150
  inset-block: var(--space-20);
147
- inset-inline-start: 0;
151
+ inset-inline-start: calc((var(--space-20) - 2px) / 2);
148
152
  border-inline-start: 2px solid currentColor;
153
+ border-radius: var(--radius-full);
149
154
  }
@@ -10,6 +10,11 @@
10
10
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
11
11
  * — they are decorative, not actionable. They still get the same box sizing
12
12
  * as .btn below so the row stays visually aligned.
13
+ *
14
+ * The row wraps when it does not fit: every page stays visible and
15
+ * clickable. A strip that scrolled hid pages behind a scrollbar that clicking
16
+ * cannot move, since a click changes the page. Keeping the rendered range
17
+ * compact is the application's job.
13
18
  */
14
19
  .pagination {
15
20
  display: flex;
@@ -8,7 +8,7 @@
8
8
  * <nav class="scrollspy">
9
9
  * containing <a href="#section"> links and toggles aria-current="location" on
10
10
  * the link whose target is in view. Pair with .nav-list and .nav-link, or style
11
- * [aria-current] yourself.
11
+ * [aria-current="location"] yourself.
12
12
  *
13
13
  * Progressive enhancement: on browsers that support :target-current,
14
14
  * this file enables the native target group and marks the active link.
@@ -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
+ }
@@ -25,7 +25,8 @@
25
25
  * destinations. Anything else focusable in a step is out of contract.
26
26
  *
27
27
  * The framework never hides an authored label to solve a layout problem. A
28
- * labelled row that outgrows its space scrolls. An `actual-container` size
28
+ * labelled row wraps its labels, then scrolls if its markers or authored item
29
+ * minimum outgrow the available space. An `actual-container` size
29
30
  * context buys it one thing: the wide representation, marker beside label.
30
31
  *
31
32
  * See docs/design-notes/steps.md for the threshold, the selector shapes, the
@@ -136,11 +137,14 @@
136
137
 
137
138
  /* Complete owns the accented connector that reaches the current step, so the
138
139
  connector leaving current goes back to neutral. Declared after
139
- .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. */
140
144
  .steps > [aria-current="step"] {
141
145
  --step-marker-bg: var(--surface-opaque);
142
- --step-marker-border: var(--state-selected);
143
- --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));
144
148
  --step-line: var(--border);
145
149
 
146
150
  color: var(--text);
@@ -161,10 +165,11 @@
161
165
  /* Horizontal ------------------------------------------------------------- */
162
166
 
163
167
  .steps-horizontal {
164
- --step-min: 7rem;
168
+ --step-min: var(--step-size);
165
169
  --step-gap: var(--space-20);
166
170
 
167
171
  inline-size: 100%;
172
+ column-gap: var(--step-gap);
168
173
  overflow-x: auto;
169
174
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
170
175
  other to `auto`, and a row hugs fractional content closely enough that a
@@ -173,11 +178,22 @@
173
178
  overscroll-behavior-inline: contain;
174
179
  }
175
180
 
181
+ /* A row that loads mid-flow starts scrolled to its current step; engines
182
+ without the property start at the first. */
183
+ .steps-horizontal > [aria-current="step"] {
184
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
185
+ scroll-initial-target: nearest;
186
+ }
187
+
176
188
  /* A scroll container clips at the padding edge, so a navigable row reserves the
177
- focus ring's own width and offset. Only this composition scrolls, and only a
178
- link has a ring to protect. */
189
+ focus ring's own width and offset, and focus scrolls a link in that far from
190
+ the inline clip. Only this composition scrolls, and only a link has a ring to
191
+ protect. */
179
192
  .steps-horizontal:has(a[href]) {
193
+ /* Wrapped names can fill their cell right up to either inline clip edge. */
194
+ padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
180
195
  padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
196
+ scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
181
197
  }
182
198
 
183
199
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -189,6 +205,8 @@
189
205
  .steps-horizontal > li {
190
206
  display: grid;
191
207
  flex: 1 0 var(--step-min);
208
+ /* Bound each label to its share so it cannot change the marker pitch. */
209
+ grid-template-columns: minmax(0, 1fr);
192
210
  grid-template-rows: var(--step-size);
193
211
  gap: var(--step-gap);
194
212
  justify-items: center;
@@ -196,9 +214,42 @@
196
214
  text-align: center;
197
215
  }
198
216
 
199
- /* An empty step is a marker and nothing else, so it needs a marker's width
200
- rather than a reading width. That is what lets five of them fit a phone with
201
- no size context and no variant class.
217
+ .steps-horizontal .step-label {
218
+ /* Even an unbroken name must fit its share instead of overlapping a peer.
219
+ Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
220
+ overflow-wrap: anywhere;
221
+ hyphens: auto;
222
+ }
223
+
224
+ /* Half end cells put the outer markers on the row's edges without changing
225
+ their pitch, so the track lines up with the text around it, as the compact
226
+ and inline forms do. The marker's half-width keeps the first connector equal
227
+ to its peers.
228
+
229
+ Source order is load-bearing: these tie the `:empty` rules below and the
230
+ inline query on specificity, and both must win over them. */
231
+ .steps-horizontal > li:is(:first-child, :last-child) {
232
+ flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
233
+ min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
234
+ }
235
+
236
+ .steps-horizontal > li:first-child {
237
+ justify-items: start;
238
+ text-align: start;
239
+ }
240
+
241
+ .steps-horizontal > li:first-child::after {
242
+ inset-inline-start: var(--step-size);
243
+ inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
244
+ }
245
+
246
+ .steps-horizontal > li:last-child {
247
+ justify-items: end;
248
+ text-align: end;
249
+ }
250
+
251
+ /* An empty step ignores any authored reading-width floor and needs only its
252
+ marker. That lets five of them fit a phone without a size context.
202
253
 
203
254
  `justify-items` is not an override of the row's centring: the marker is
204
255
  centred because a label sits under it and the two share a centre, and with no
@@ -208,9 +259,8 @@
208
259
  the text around it, the way the inline representation does.
209
260
 
210
261
  `:empty` says exactly what the markup says, the way `.badge:empty` does, and
211
- it reads the item's own content: bare text in an `<li>` keeps a reading width
212
- like its wrapped peers, where `> li:has(.step-label)` would give it a marker
213
- floor and skew the row. `:empty` also keeps the compact form reachable on the
262
+ it reads the item's own content: bare text keeps the same authored floor as
263
+ its wrapped peers. `:empty` also keeps the compact form reachable on the
214
264
  Degraded tier, where only the inline bonus needs `:has()`. */
215
265
  .steps-horizontal > li:empty {
216
266
  flex: 1 1 0;
@@ -220,6 +270,8 @@
220
270
 
221
271
  .steps-horizontal > li:empty::after {
222
272
  inset-inline-start: var(--step-size);
273
+ /* Marker-only end cells keep full shares, unlike labelled end cells. */
274
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
223
275
  }
224
276
 
225
277
  .steps-horizontal > li:empty:last-child {
@@ -227,12 +279,12 @@
227
279
  }
228
280
 
229
281
  /* The pseudo-element is marker-height so --step-connector may be any complete
230
- background value. */
282
+ background value; its length also crosses the gap to the next equal track. */
231
283
  .steps-horizontal > li::after {
232
284
  position: absolute;
233
285
  inset-block-start: 0;
234
286
  inset-inline-start: calc(50% + var(--step-size) / 2);
235
- inline-size: calc(100% - var(--step-size));
287
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
236
288
  block-size: var(--step-size);
237
289
  background: var(
238
290
  --step-connector,
@@ -251,6 +303,11 @@
251
303
  * written — a row out of contract is not owed a curated fallback.
252
304
  */
253
305
  @container actual-container (inline-size >= 60rem) {
306
+ .steps-horizontal:has(.step-label) {
307
+ /* Inline connectors already own the spacing between marker-label groups. */
308
+ column-gap: 0;
309
+ }
310
+
254
311
  .steps-horizontal:has(.step-label) > li {
255
312
  display: flex;
256
313
  flex: 1 1 auto;