actual-css 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +7 -8
  3. package/components.json +8 -2
  4. package/dist/actual.full.css +505 -233
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/pages/components/avatar.md +17 -11
  10. package/docs/pages/components/breadcrumb.md +16 -2
  11. package/docs/pages/components/list.md +9 -13
  12. package/docs/pages/components/navbar.md +42 -22
  13. package/docs/pages/components/pagination.md +19 -0
  14. package/docs/pages/components/steps.md +24 -20
  15. package/docs/pages/enhancements/scrollspy.md +6 -6
  16. package/docs/pages/enhancements/tabs.md +74 -3
  17. package/docs/pages/forms/floating-field.md +21 -7
  18. package/docs/pages/forms/form-actions.md +3 -6
  19. package/docs/pages/forms/states-and-sizes.md +15 -15
  20. package/docs/pages/foundations/philosophy.md +97 -1
  21. package/docs/pages/foundations/tokens.md +40 -4
  22. package/docs/pages/guides/design-tools.md +1 -1
  23. package/docs/pages/guides/integrating-actual-css.md +74 -8
  24. package/docs/pages/guides/introduction.md +3 -2
  25. package/docs/pages/guides/modular-import.md +1 -1
  26. package/docs/pages/guides/progressive-enhancement.md +25 -7
  27. package/docs/pages/guides/upgrading.md +86 -9
  28. package/docs/pages/layout/center.md +2 -1
  29. package/docs/pages/layout/cluster.md +11 -4
  30. package/docs/pages/layout/grid.md +2 -0
  31. package/docs/pages/layout/sidebar-layout.md +3 -0
  32. package/docs/pages/layout/switcher.md +2 -0
  33. package/docs/pages/layout/topbar.md +6 -4
  34. package/docs/pages/patterns/icon-slot.md +17 -1
  35. package/docs/pages/utilities/flex.md +1 -1
  36. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  37. package/package.json +5 -5
  38. package/src/cli/actual-css.js +1 -1
  39. package/src/css/components/alert.css +34 -34
  40. package/src/css/components/avatar.css +2 -18
  41. package/src/css/components/badge.css +0 -1
  42. package/src/css/components/breadcrumb.css +74 -8
  43. package/src/css/components/drawer.css +23 -42
  44. package/src/css/components/flyout.css +0 -1
  45. package/src/css/components/join.css +2 -2
  46. package/src/css/components/list.css +10 -10
  47. package/src/css/components/modal.css +17 -10
  48. package/src/css/components/navbar.css +10 -8
  49. package/src/css/components/pagination.css +5 -0
  50. package/src/css/components/scrollspy.css +1 -1
  51. package/src/css/components/steps.css +66 -12
  52. package/src/css/components/tab.css +134 -4
  53. package/src/css/core/focus.css +8 -6
  54. package/src/css/core/theme.css +6 -1
  55. package/src/css/core/tokens.css +4 -2
  56. package/src/css/forms/choice-card.css +1 -1
  57. package/src/css/forms/choice.css +1 -1
  58. package/src/css/forms/control.css +1 -1
  59. package/src/css/forms/floating-field.css +20 -18
  60. package/src/css/forms/form-actions.css +5 -8
  61. package/src/css/forms/native.css +2 -2
  62. package/src/css/forms/otp.css +4 -5
  63. package/src/css/forms/switch.css +1 -1
  64. package/src/css/forms/validation.css +23 -30
  65. package/src/css/layout/center.css +2 -0
  66. package/src/css/layout/cluster.css +14 -0
  67. package/src/css/layout/grid.css +7 -0
  68. package/src/css/layout/scroller.css +19 -3
  69. package/src/css/layout/sidebar-layout.css +3 -0
  70. package/src/css/layout/switcher.css +3 -0
  71. package/src/css/utilities/extra.css +3 -3
  72. package/src/js/full.js +4 -0
  73. package/src/js/inline-visible.js +54 -0
  74. package/src/js/parsed.js +21 -0
  75. package/src/js/tab.js +33 -7
  76. package/src/tooling/design-export.js +1 -1
  77. package/src/tooling/design-tokens.js +44 -33
  78. package/src/tooling/theme-contrast.js +32 -24
@@ -4,14 +4,21 @@
4
4
  * Docs: docs/pages/components/breadcrumb.md
5
5
  *
6
6
  * Inline list separated by "/". Mark the current page with `aria-current="page"`
7
- * either on the item or on the current link.
7
+ * either on the item or on the current link. Ancestor labels sit in an <a> or a
8
+ * <span>, the box that truncates.
9
+ *
10
+ * Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
11
+ * like separate rows and causes unstable header height, and scrolling hides the
12
+ * root behind a scrollbar. A trail that does not fit truncates instead:
13
+ * ancestors first, the current page last, each label behind an ellipsis.
8
14
  */
15
+
9
16
  .breadcrumb {
10
17
  display: flex;
11
- flex-direction: row;
12
- flex-wrap: wrap;
18
+ flex-wrap: nowrap;
13
19
  align-items: center;
14
20
  gap: var(--space-10);
21
+ min-inline-size: 0;
15
22
  margin: 0;
16
23
  padding: 0;
17
24
  color: var(--text-subtle);
@@ -19,15 +26,74 @@
19
26
  list-style: none;
20
27
  }
21
28
 
22
- .breadcrumb li {
23
- display: inline-flex;
29
+ /* The landmark around the trail must shrink with it in a flex or grid row, or
30
+ it keeps the trail's full width and the labels never get to truncate.
31
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
32
+ wrapper is the fallback. */
33
+ :where(nav:has(> .breadcrumb)) {
34
+ min-inline-size: 0;
35
+ }
36
+
37
+ /* Grid, not flex: under a min-content constraint a grid track keeps its base
38
+ size, which is the label's minimum below, so the item's own minimum is
39
+ separator + label floor without anyone measuring a "/". A flex item would
40
+ report the full label as its minimum and never shrink.
41
+
42
+ Ancestors take the shrink before the current page, so the location is the
43
+ last thing to lose characters. The ratio is deliberately huge: at 1000 the
44
+ current page still took a fraction of a pixel, enough to trigger its
45
+ ellipsis while ancestors had room. Its own factor stays 1, because flex
46
+ distributes only a share of the deficit when the unfrozen factors sum below
47
+ one. Keyed on the last item, the current page by convention, so the order
48
+ holds without :has(). */
49
+ .breadcrumb > li {
50
+ display: inline-grid;
51
+ flex: 0 1000000 auto;
52
+ grid-auto-flow: column;
24
53
  align-items: center;
25
- gap: var(--space-10);
54
+ white-space: nowrap;
26
55
  }
27
56
 
28
- .breadcrumb li + li::before {
29
- content: "/";
57
+ .breadcrumb > li:last-child {
58
+ flex-shrink: 1;
59
+ }
60
+
61
+ /* A margin rather than a grid gap, so the separator keeps its space when the
62
+ current page below lays it out inline. The weight stays normal there, where
63
+ it would inherit the current page's. */
64
+ .breadcrumb > li + li::before {
65
+ margin-inline-end: var(--space-10);
30
66
  color: var(--text-subtle);
67
+ font-weight: var(--font-weight-normal);
68
+ content: "/";
69
+ }
70
+
71
+ /* The ellipsis lives on the label, not on the item: an element never clips
72
+ its own outline, but an overflow-hidden item would clip its link's focus
73
+ line, which sits outside the link box.
74
+
75
+ The floor keeps a truncated label readable as a step ("Po…") instead of
76
+ collapsing to its separator. calc-size() lets a shorter label keep its own
77
+ width; without it, the plain 2em floor leaves a label under 2em a little
78
+ trailing room. */
79
+ .breadcrumb > li > :is(a, span) {
80
+ min-inline-size: 2em;
81
+ min-inline-size: calc-size(max-content, min(size, 2em));
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ }
85
+
86
+ /* The current page written as bare text has no label box, so its item
87
+ truncates itself. Only with no element inside: an aria-current item that
88
+ holds a link would clip that link's focus line, and keeps the label rule
89
+ above instead. Its own rule, never merged with the one above: a browser
90
+ without :has() drops it alone, and bare text then keeps its full width. */
91
+ .breadcrumb > li[aria-current="page"]:not(:has(> *)) {
92
+ display: block;
93
+ min-inline-size: 2em;
94
+ min-inline-size: calc-size(max-content, min(size, 2em));
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
31
97
  }
32
98
 
33
99
  .breadcrumb a {
@@ -25,6 +25,7 @@
25
25
  * --drawer-close-reserve (header room for a corner close; zero without a close)
26
26
  * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
27
27
  * changes in one place; entry state redefines it longer)
28
+ * --drawer-motion-ease (panel easing owned by the open/closed state)
28
29
  */
29
30
  :where(dialog).drawer {
30
31
  --drawer-size: 20rem;
@@ -39,6 +40,7 @@
39
40
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
40
41
  the same variable through inheritance and never lags the panel. */
41
42
  --drawer-motion-duration: var(--duration);
43
+ --drawer-motion-ease: var(--ease-exit);
42
44
 
43
45
  /* Keep the surface anchored to the viewport in the top layer. This also
44
46
  provides the containing block for the absolutely positioned close button.
@@ -70,21 +72,21 @@
70
72
  box-shadow: var(--shadow);
71
73
  opacity: 0;
72
74
  transform: translateX(var(--drawer-closed-translate));
73
- transition:
74
- opacity var(--drawer-motion-duration) var(--ease-exit),
75
- transform var(--drawer-motion-duration) var(--ease-exit);
75
+ /* Unsupported discrete behavior drops alone, preserving opacity/transform. */
76
+ transition-property: opacity, transform, overlay, display;
77
+ transition-duration: var(--drawer-motion-duration);
78
+ transition-timing-function: var(--drawer-motion-ease);
79
+ transition-behavior: allow-discrete;
76
80
  }
77
81
 
78
82
  :where(dialog).drawer[open] {
79
83
  --drawer-motion-duration: var(--duration-slow);
84
+ --drawer-motion-ease: var(--ease-enter);
80
85
  display: grid;
81
86
  grid-template-rows: auto minmax(0, 1fr) auto;
82
87
  gap: var(--space-40);
83
88
  opacity: 1;
84
89
  transform: translateX(0);
85
- transition:
86
- opacity var(--drawer-motion-duration) var(--ease-enter),
87
- transform var(--drawer-motion-duration) var(--ease-enter);
88
90
  }
89
91
 
90
92
  /* A form is the drawer's content wrapper when actions need method="dialog".
@@ -104,7 +106,10 @@
104
106
  opacity: 0;
105
107
  /* Scrim curve stays neutral `ease`; only the panel's character curves
106
108
  apply. Duration follows --drawer-motion-duration through inheritance. */
107
- transition: opacity var(--drawer-motion-duration) ease;
109
+ transition-property: opacity, overlay, display;
110
+ transition-duration: var(--drawer-motion-duration);
111
+ transition-timing-function: ease;
112
+ transition-behavior: allow-discrete;
108
113
  }
109
114
 
110
115
  :where(dialog).drawer[data-side="end"] {
@@ -138,47 +143,23 @@
138
143
  exclusion list is easier to trust than its `position: absolute`. */
139
144
  :where(dialog).drawer > :not(:is(header, footer, form, .close)),
140
145
  :where(dialog).drawer > form > :not(:is(header, footer, .close)) {
141
- --drawer-focus-bleed: calc(
142
- var(--focus-outline-offset) +
143
- max(var(--focus-ring-width), var(--border-width) * 2)
144
- );
146
+ /* Declared here, not as a :root alias: an inherited alias arrives already
147
+ computed, so a --focus-ring-width override on the drawer would not reach
148
+ it. */
149
+ --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
145
150
 
146
151
  min-block-size: 0;
147
- /* Keep focus paint inside the clip without moving the body off the fixed
148
- header and footer edges. The reserve covers the thicker of the two
149
- outside indicators: the :focus baseline (2 × border) and the component
150
- line (--focus-ring-width, 3px in bootstrap-v6). */
151
- margin-inline: calc(var(--drawer-focus-bleed) * -1);
152
- padding-inline: var(--drawer-focus-bleed);
152
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
153
+ both axes), without moving the content. Every outside indicator is
154
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
155
+ reaches into the slot gap, which is wider, so the scrollport never slides
156
+ under the header or footer. */
157
+ margin: calc(var(--drawer-focus-bleed) * -1);
158
+ padding: var(--drawer-focus-bleed);
153
159
  overflow: auto;
154
160
  overscroll-behavior: contain;
155
161
  }
156
162
 
157
- @supports (transition-behavior: allow-discrete) {
158
- :where(dialog).drawer {
159
- transition:
160
- opacity var(--drawer-motion-duration) var(--ease-exit),
161
- transform var(--drawer-motion-duration) var(--ease-exit),
162
- overlay var(--drawer-motion-duration) allow-discrete,
163
- display var(--drawer-motion-duration) allow-discrete;
164
- }
165
-
166
- :where(dialog).drawer[open] {
167
- transition:
168
- opacity var(--drawer-motion-duration) var(--ease-enter),
169
- transform var(--drawer-motion-duration) var(--ease-enter),
170
- overlay var(--drawer-motion-duration) allow-discrete,
171
- display var(--drawer-motion-duration) allow-discrete;
172
- }
173
-
174
- :where(dialog).drawer::backdrop {
175
- transition:
176
- opacity var(--drawer-motion-duration) ease,
177
- overlay var(--drawer-motion-duration) allow-discrete,
178
- display var(--drawer-motion-duration) allow-discrete;
179
- }
180
- }
181
-
182
163
  /* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
183
164
  path as the Degraded-tier fallback. The :dir() rules are dropped on their
184
165
  own there, so they need no wrapper. :dir() follows inherited HTML
@@ -93,7 +93,6 @@
93
93
  .flyout[popover] {
94
94
  position: fixed;
95
95
  inset: auto;
96
- margin: 0;
97
96
  }
98
97
 
99
98
  /* A presentation class may own its *visible* display type, but must not
@@ -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,10 @@
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
+ /* Centered fits the short rows lists mostly hold. Read through
47
+ --items-align so .items-start on the row or the list wins without
48
+ outranking this selector — the opt-out for long supporting text. */
49
+ align-items: var(--items-align, center);
49
50
  gap: var(--list-item-gap);
50
51
  min-block-size: var(--list-item-min-size);
51
52
  padding-block: var(--list-item-pad-block);
@@ -67,21 +68,20 @@ a.list-item:hover {
67
68
  background: var(--surface-subtle);
68
69
  }
69
70
 
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.
71
+ /* Current row in a navigational list: aria-current="page" when the row opens
72
+ a page, "true" when it is the current item of a master/detail view (a
73
+ conversation, a thread, a record) that is no page of its own. Any other
74
+ value, "false" included, is inert.
75
75
  Calm by design: the row keeps its normal text metrics and color on a subtle
76
76
  surface, and the selected accent lives on an inset inline-start trait only.
77
77
  The trait is a real border, so it survives forced colors without a repaint
78
78
  block. The positioning context lives on every row link, so becoming current
79
79
  never moves an app-positioned decoration to a new containing block. */
80
- a.list-item[aria-current] {
80
+ a.list-item:is([aria-current="page"], [aria-current="true"]) {
81
81
  background: var(--surface-subtle);
82
82
  }
83
83
 
84
- a.list-item[aria-current]::before {
84
+ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
85
85
  content: "";
86
86
  position: absolute;
87
87
  inset-block: var(--list-item-pad-block);
@@ -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
  }
@@ -71,8 +71,7 @@
71
71
 
72
72
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
73
73
  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".
74
+ Those two values are the contract; any other, "false" included, is inert.
76
75
  Selection never changes text metrics: the shared medium weight is kept and
77
76
  the current link is carried by the selected accent. */
78
77
  .nav-link:hover {
@@ -83,7 +82,7 @@
83
82
  /* Current last, equal specificity to :hover (0,2,0): a hovered current link
84
83
  keeps the selected accent instead of dropping to --text. The surface fill is
85
84
  shared with the hover rule above. */
86
- .nav-link[aria-current] {
85
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
87
86
  background: var(--surface-subtle);
88
87
  color: var(--state-selected);
89
88
  }
@@ -96,7 +95,7 @@
96
95
  above keeps the light chip where color-mix is missing, with ink that is
97
96
  legible on it. */
98
97
  @supports (color: color-mix(in oklch, red, white)) {
99
- .nav-link:is(:hover, [aria-current]) {
98
+ .nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
100
99
  background: color-mix(in oklch, currentColor 10%, transparent);
101
100
  }
102
101
 
@@ -105,7 +104,7 @@
105
104
  }
106
105
 
107
106
  /* After :hover so a hovered current link keeps the selected accent. */
108
- .nav-link[aria-current] {
107
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
109
108
  color: var(--ui-fg, var(--state-selected));
110
109
  }
111
110
  }
@@ -139,11 +138,14 @@
139
138
  follows the forced palette without a forced-colors block and never touches
140
139
  the row separators. Navbar horizontal links keep color + surface only. The
141
140
  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 {
141
+ an app-positioned decoration to a new containing block.
142
+ The trait sits centered in the link's inline padding with rounded ends: flush
143
+ on the edge, a square line read as pasted onto the rounded fill. */
144
+ .nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
144
145
  content: "";
145
146
  position: absolute;
146
147
  inset-block: var(--space-20);
147
- inset-inline-start: 0;
148
+ inset-inline-start: calc((var(--space-20) - 2px) / 2);
148
149
  border-inline-start: 2px solid currentColor;
150
+ border-radius: var(--radius-full);
149
151
  }
@@ -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.
@@ -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
@@ -161,10 +162,11 @@
161
162
  /* Horizontal ------------------------------------------------------------- */
162
163
 
163
164
  .steps-horizontal {
164
- --step-min: 7rem;
165
+ --step-min: var(--step-size);
165
166
  --step-gap: var(--space-20);
166
167
 
167
168
  inline-size: 100%;
169
+ column-gap: var(--step-gap);
168
170
  overflow-x: auto;
169
171
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
170
172
  other to `auto`, and a row hugs fractional content closely enough that a
@@ -173,11 +175,22 @@
173
175
  overscroll-behavior-inline: contain;
174
176
  }
175
177
 
178
+ /* A row that loads mid-flow starts scrolled to its current step; engines
179
+ without the property start at the first. */
180
+ .steps-horizontal > [aria-current="step"] {
181
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
182
+ scroll-initial-target: nearest;
183
+ }
184
+
176
185
  /* 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. */
186
+ focus ring's own width and offset, and focus scrolls a link in that far from
187
+ the inline clip. Only this composition scrolls, and only a link has a ring to
188
+ protect. */
179
189
  .steps-horizontal:has(a[href]) {
190
+ /* Wrapped names can fill their cell right up to either inline clip edge. */
191
+ padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
180
192
  padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
193
+ scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
181
194
  }
182
195
 
183
196
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -189,6 +202,8 @@
189
202
  .steps-horizontal > li {
190
203
  display: grid;
191
204
  flex: 1 0 var(--step-min);
205
+ /* Bound each label to its share so it cannot change the marker pitch. */
206
+ grid-template-columns: minmax(0, 1fr);
192
207
  grid-template-rows: var(--step-size);
193
208
  gap: var(--step-gap);
194
209
  justify-items: center;
@@ -196,9 +211,42 @@
196
211
  text-align: center;
197
212
  }
198
213
 
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.
214
+ .steps-horizontal .step-label {
215
+ /* Even an unbroken name must fit its share instead of overlapping a peer.
216
+ Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
217
+ overflow-wrap: anywhere;
218
+ hyphens: auto;
219
+ }
220
+
221
+ /* Half end cells put the outer markers on the row's edges without changing
222
+ their pitch, so the track lines up with the text around it, as the compact
223
+ and inline forms do. The marker's half-width keeps the first connector equal
224
+ to its peers.
225
+
226
+ Source order is load-bearing: these tie the `:empty` rules below and the
227
+ inline query on specificity, and both must win over them. */
228
+ .steps-horizontal > li:is(:first-child, :last-child) {
229
+ flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
230
+ min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
231
+ }
232
+
233
+ .steps-horizontal > li:first-child {
234
+ justify-items: start;
235
+ text-align: start;
236
+ }
237
+
238
+ .steps-horizontal > li:first-child::after {
239
+ inset-inline-start: var(--step-size);
240
+ inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
241
+ }
242
+
243
+ .steps-horizontal > li:last-child {
244
+ justify-items: end;
245
+ text-align: end;
246
+ }
247
+
248
+ /* An empty step ignores any authored reading-width floor and needs only its
249
+ marker. That lets five of them fit a phone without a size context.
202
250
 
203
251
  `justify-items` is not an override of the row's centring: the marker is
204
252
  centred because a label sits under it and the two share a centre, and with no
@@ -208,9 +256,8 @@
208
256
  the text around it, the way the inline representation does.
209
257
 
210
258
  `: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
259
+ it reads the item's own content: bare text keeps the same authored floor as
260
+ its wrapped peers. `:empty` also keeps the compact form reachable on the
214
261
  Degraded tier, where only the inline bonus needs `:has()`. */
215
262
  .steps-horizontal > li:empty {
216
263
  flex: 1 1 0;
@@ -220,6 +267,8 @@
220
267
 
221
268
  .steps-horizontal > li:empty::after {
222
269
  inset-inline-start: var(--step-size);
270
+ /* Marker-only end cells keep full shares, unlike labelled end cells. */
271
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
223
272
  }
224
273
 
225
274
  .steps-horizontal > li:empty:last-child {
@@ -227,12 +276,12 @@
227
276
  }
228
277
 
229
278
  /* The pseudo-element is marker-height so --step-connector may be any complete
230
- background value. */
279
+ background value; its length also crosses the gap to the next equal track. */
231
280
  .steps-horizontal > li::after {
232
281
  position: absolute;
233
282
  inset-block-start: 0;
234
283
  inset-inline-start: calc(50% + var(--step-size) / 2);
235
- inline-size: calc(100% - var(--step-size));
284
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
236
285
  block-size: var(--step-size);
237
286
  background: var(
238
287
  --step-connector,
@@ -251,6 +300,11 @@
251
300
  * written — a row out of contract is not owed a curated fallback.
252
301
  */
253
302
  @container actual-container (inline-size >= 60rem) {
303
+ .steps-horizontal:has(.step-label) {
304
+ /* Inline connectors already own the spacing between marker-label groups. */
305
+ column-gap: 0;
306
+ }
307
+
254
308
  .steps-horizontal:has(.step-label) > li {
255
309
  display: flex;
256
310
  flex: 1 1 auto;