actual-css 0.5.0 → 0.6.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 (86) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/actual.css +1 -0
  3. package/dist/actual.full.css +480 -117
  4. package/dist/actual.full.js +1 -1
  5. package/dist/actual.full.min.css +1 -1
  6. package/dist/actual.min.css +1 -1
  7. package/docs/design-notes/browser-support.md +14 -14
  8. package/docs/design-notes/enhancement-contract.md +30 -13
  9. package/docs/design-notes/forced-colors.md +11 -7
  10. package/docs/design-notes/grid.md +4 -4
  11. package/docs/design-notes/naming.md +15 -9
  12. package/docs/design-notes/platform-alignment.md +9 -0
  13. package/docs/design-notes/references.md +4 -0
  14. package/docs/design-notes/steps.md +748 -0
  15. package/docs/design-notes/widget-primitives.md +32 -15
  16. package/docs/navigation.json +1 -0
  17. package/docs/pages/components/alert.md +13 -8
  18. package/docs/pages/components/aura.md +102 -6
  19. package/docs/pages/components/avatar.md +9 -6
  20. package/docs/pages/components/badge.md +11 -7
  21. package/docs/pages/components/button.md +17 -17
  22. package/docs/pages/components/card.md +15 -8
  23. package/docs/pages/components/chat.md +1 -1
  24. package/docs/pages/components/dialog.md +10 -7
  25. package/docs/pages/components/drawer.md +1 -1
  26. package/docs/pages/components/fab.md +12 -10
  27. package/docs/pages/components/indicator.md +6 -6
  28. package/docs/pages/components/list.md +11 -2
  29. package/docs/pages/components/navbar.md +1 -1
  30. package/docs/pages/components/pagination.md +1 -1
  31. package/docs/pages/components/rating.md +4 -4
  32. package/docs/pages/components/skeleton.md +1 -1
  33. package/docs/pages/components/spinner.md +1 -1
  34. package/docs/pages/components/steps.md +376 -52
  35. package/docs/pages/components/table.md +5 -5
  36. package/docs/pages/enhancements/flyout.md +12 -10
  37. package/docs/pages/enhancements/overview.md +2 -2
  38. package/docs/pages/enhancements/scrollspy.md +1 -1
  39. package/docs/pages/enhancements/tabs.md +1 -1
  40. package/docs/pages/examples/overview.md +2 -0
  41. package/docs/pages/forms/choice-cards.md +4 -4
  42. package/docs/pages/forms/combobox.md +87 -0
  43. package/docs/pages/forms/floating-field.md +5 -3
  44. package/docs/pages/forms/joined-controls.md +4 -4
  45. package/docs/pages/forms/labels.md +8 -5
  46. package/docs/pages/forms/overview.md +25 -18
  47. package/docs/pages/forms/password.md +3 -2
  48. package/docs/pages/forms/range.md +13 -2
  49. package/docs/pages/forms/states-and-sizes.md +6 -4
  50. package/docs/pages/forms/validation.md +13 -9
  51. package/docs/pages/foundations/tokens.md +9 -6
  52. package/docs/pages/foundations/typography.md +1 -1
  53. package/docs/pages/guides/bootstrap.md +3 -3
  54. package/docs/pages/guides/introduction.md +1 -1
  55. package/docs/pages/guides/modular-import.md +21 -10
  56. package/docs/pages/guides/progressive-enhancement.md +75 -54
  57. package/docs/pages/guides/tailwind.md +2 -2
  58. package/docs/pages/layout/app-shell.md +4 -4
  59. package/docs/pages/layout/choosing.md +10 -6
  60. package/docs/pages/layout/grid.md +11 -10
  61. package/docs/pages/layout/stack.md +10 -6
  62. package/docs/pages/patterns/structured-lists.md +7 -7
  63. package/docs/pages/utilities/accessibility.md +1 -1
  64. package/docs/pages/utilities/flex.md +20 -13
  65. package/docs/pages/utilities/links.md +1 -1
  66. package/docs/pages/utilities/list.md +1 -1
  67. package/docs/pages/utilities/overflow.md +14 -7
  68. package/docs/pages/utilities/overview.md +22 -0
  69. package/docs/pages/utilities/shape.md +1 -1
  70. package/docs/pages/utilities/sizing-wrapping.md +10 -8
  71. package/docs/pages/utilities/spacing.md +26 -19
  72. package/docs/pages/utilities/text.md +1 -1
  73. package/llms.txt +27 -4
  74. package/package.json +3 -2
  75. package/scripts/reserved-classes.json +4 -1
  76. package/src/css/components/fab.css +44 -8
  77. package/src/css/components/list.css +3 -1
  78. package/src/css/components/steps.css +277 -69
  79. package/src/css/core/tokens.css +1 -0
  80. package/src/css/effects/aura.css +101 -11
  81. package/src/css/forms/custom-select.css +1 -1
  82. package/src/css/forms/floating-field.css +8 -6
  83. package/src/css/layout/container-query.css +37 -13
  84. package/src/css/layout/grid.css +7 -7
  85. package/src/css/layout/index.css +1 -1
  86. package/src/js/password.js +1 -1
@@ -0,0 +1,748 @@
1
+ # Steps: representations, thresholds, and selector shapes
2
+
3
+ ## Status
4
+
5
+ This document holds the reasoning behind `steps.css`: why the component has the
6
+ representations it has, why the threshold sits where it sits, and which
7
+ plausible alternatives are wrong and by how much.
8
+
9
+ Its job is to keep the constraints legible, so a change that would reintroduce
10
+ a shape this component measures as worse can be recognised as such. Everything
11
+ here is stated as the design is, with the measurement that settles it — not as
12
+ a record of what changed when. That belongs in the changelog.
13
+
14
+ ## The markup contract
15
+
16
+ One primitive, two explicit compositions. Both classes are required:
17
+
18
+ ```html
19
+ <ol class="steps steps-horizontal">…</ol> <!-- 2 to 5 stages -->
20
+ <ol class="steps steps-vertical">…</ol> <!-- no count limit -->
21
+ ```
22
+
23
+ `.steps` is what a step *is*; the second class is what the sequence *looks
24
+ like*. A step is therefore the same thing in both.
25
+
26
+ `.step-label` is then the other decision, and it is **independent of the
27
+ first**: it says whether the sequence carries its own step names. Optional in
28
+ either orientation; when a sequence carries names, every step carries one.
29
+
30
+ ```html
31
+ <!-- the page carries the name; the sequence draws the position -->
32
+ <ol class="steps steps-horizontal" aria-hidden="true">
33
+ <li class="step-complete"></li>
34
+ <li aria-current="step"></li>
35
+ <li></li>
36
+ </ol>
37
+
38
+ <!-- the sequence carries the names -->
39
+ <ol class="steps steps-horizontal">
40
+ <li class="step-complete"><a class="step-label" href="/account">Account</a></li>
41
+ <li aria-current="step"><span class="step-label">Validation</span></li>
42
+ <li><span class="step-label">Payment</span></li>
43
+ </ol>
44
+ ```
45
+
46
+ Two dimensions, four compositions:
47
+
48
+ | | empty steps | `.step-label` on every step |
49
+ | -------------- | ------------------- | ------------------------------------- |
50
+ | **horizontal** | compact progression | named sequence, stacked or inline |
51
+ | **vertical** | compact rail | named sequence, labels beside markers |
52
+
53
+ The two forms are not two densities of the same thing. They show different
54
+ information, which is why the contract splits on content rather than on a
55
+ width. And the split is not the component's invention: a step with nothing in
56
+ it *is* just a marker. "Markers only" is not a variant — it is what a step
57
+ naturally is when it has no content.
58
+
59
+ A label may be an `a[href]` where the workflow allows navigation. That is the
60
+ whole interactive surface and it belongs to labels alone, which is the same
61
+ rule read from the other end: navigating a flow means naming its destinations —
62
+ see "Navigation is a labelled affair".
63
+
64
+ ### Why `.step-complete` is prefixed
65
+
66
+ The framework writes modifiers bare — `<button class="btn primary outline lg">`
67
+ — and a bare `.complete` would look like it belongs to that grammar. It does
68
+ not. Those modifiers qualify a component on the component's own element; this
69
+ one marks one item *inside* a component, which is the position `.step-label`
70
+ occupies. Prefixing keeps the component's public vocabulary in one namespace:
71
+ `steps`, `steps-horizontal`, `steps-vertical`, `step-label`, `step-complete`,
72
+ beside `aria-current="step"`.
73
+
74
+ It also keeps the framework from claiming `complete` — an ordinary English word
75
+ an application is likely to want for a task, a download or a form — as a
76
+ reserved global class, for one component's one state.
77
+
78
+ Complete is the only state with a class at all. Current is `aria-current="step"`
79
+ and upcoming is the absence of both, because the platform already spells those.
80
+
81
+ ### Bare text in an `<li>`
82
+
83
+ Out of contract — a step name belongs in a `.step-label` — and it degrades in
84
+ the right direction. `:empty` reads an item's own content, so `<li>Account</li>`
85
+ is not empty: it takes the reading width a wrapped label would, and only the
86
+ placing and aligning that `.step-label` rules provide are missing. It lays out
87
+ and does not fall apart, which is all an out-of-contract markup is owed.
88
+
89
+ This is the case that makes bare text expensive to support as a first-class
90
+ form, and it is worth stating precisely, because a component that sizes steps
91
+ *per label* pays for it twice.
92
+
93
+ In behaviour: a row mixing wrapped and unwrapped steps compacts only the
94
+ wrapped ones. Measured at five steps in a 559px region, the unwrapped step
95
+ holds 201px while its four peers shrink to 89px. That skew is what
96
+ `> li:has(.step-label)` produces — bare text has no label, so it takes a
97
+ marker-wide floor while its wrapped peers keep `--step-min`.
98
+
99
+ In bytes: the only valid way to express "every step is wrapped" is to enumerate
100
+ positions (see "`:has()` cannot be nested"), which grows the selector for each
101
+ supported count.
102
+
103
+ `:empty` avoids both by asking the question that actually decides the layout —
104
+ *is there anything in this step?* — and answering it the same way for a label,
105
+ a link and bare text.
106
+
107
+ The row-level alternative, `.steps-horizontal:not(:has(.step-label)) > li`,
108
+ avoids the skew differently: it makes every step reading-width, so an empty
109
+ step in a mixed row becomes a wide gap rather than a marker. It also puts the
110
+ compact form itself behind `:has()`, above the Degraded tier, for a shape that
111
+ needs no modern selector at all.
112
+
113
+ ### `:empty` means empty
114
+
115
+ No children, no text — not even whitespace. `<li></li>` is a marker;
116
+ `<li>\n</li>` is not. Comments are allowed.
117
+
118
+ That is a sharp edge, and it is the framework's existing one: `.badge:empty`
119
+ draws the same line, so it is not a new convention for an author to learn. It
120
+ is documented next to the markup that relies on it.
121
+
122
+ ## Why there is one threshold
123
+
124
+ ```text
125
+ >= 60rem marker beside label
126
+ otherwise stacked
127
+ ```
128
+
129
+ Only labelled rows see it. An unlabelled row is already the shape the query
130
+ produces, so it stays out — one representation at every width, which is what
131
+ lets it be documented as unconditional.
132
+
133
+ One threshold, and no per-count variants of it: see "Nothing counts the steps".
134
+
135
+ **60rem is where the inline connector survives on five steps.** Inline is a
136
+ bonus for generous room, not a rescue, so a late threshold costs nothing and one
137
+ threshold can serve every count. But it cannot be arbitrary: the connector
138
+ absorbing the free space between marker + label groups is the representation's
139
+ defining feature, and the region a theme replaces with a chevron.
140
+
141
+ Measured with five steps and realistic labels ("Personal details", "Delivery
142
+ address", "Shipping method", …), the connector width is:
143
+
144
+ | Container | 768px | 832px | 896px | 960px | 1024px |
145
+ | --------- | ----- | ----- | ----- | ----- | ------ |
146
+ | connector | 15px | 24px | 33px | 49px | 65px |
147
+
148
+ At 48rem (768px) the row renders inline with a 15px connector — the
149
+ representation applies, but its point has collapsed to a stub. 60rem is the
150
+ first threshold where five steps still clear the connector's own `2rem`
151
+ flex-basis. Four steps reach 58px already at 768px, and two or three steps have
152
+ room to spare, so calibrating on five costs the smaller counts only a longer
153
+ stay in the stacked layout.
154
+
155
+ **The threshold is literal rem.** `@container` cannot resolve a custom
156
+ property, so overriding `--step-min` moves the scroll budget but not this
157
+ query. Same trade as `--grid-min` in `grid.css`.
158
+
159
+ ## The compact form
160
+
161
+ The whole unlabelled contract, in both orientations:
162
+
163
+ ```css
164
+ .steps-horizontal > li:empty {
165
+ flex: 1 1 0;
166
+ justify-items: start;
167
+ min-inline-size: var(--step-size);
168
+ }
169
+
170
+ .steps-horizontal > li:empty::after {
171
+ inset-inline-start: var(--step-size);
172
+ }
173
+
174
+ .steps-horizontal > li:empty:last-child {
175
+ flex: 0 0 auto;
176
+ }
177
+
178
+ .steps-vertical > li:empty {
179
+ grid-template-columns: var(--step-size);
180
+ }
181
+ ```
182
+
183
+ The CSS then reads almost as the public contract does:
184
+
185
+ ```css
186
+ .steps-horizontal > li { /* marker over label */ }
187
+ .steps-horizontal > li:empty { /* marker only */ }
188
+ .steps-vertical > li { /* marker beside label */ }
189
+ .steps-vertical > li:empty { /* marker only */ }
190
+
191
+ @container actual-container (inline-size >= 60rem) {
192
+ .steps-horizontal:has(.step-label) > li { /* optional wide enhancement */ }
193
+ }
194
+ ```
195
+
196
+ ### Why the row's rule is two declarations and the column's is one
197
+
198
+ The row's base rule describes a step, not a stacked step:
199
+
200
+ ```css
201
+ .steps-horizontal > li {
202
+ display: grid;
203
+ grid-template-rows: var(--step-size); /* the marker's row, and only it */
204
+ gap: var(--step-gap);
205
+ …
206
+ }
207
+ ```
208
+
209
+ The template declares one track. A `.step-label` is auto-placed into an
210
+ implicit second row, and `gap` only means something once there are two tracks —
211
+ so an empty step is exactly `--step-size` tall, with no empty track and no gap
212
+ for `:empty` to take back. `grid-template-rows: var(--step-size) auto` builds
213
+ that second row whether or not a label ever arrives, and the compact rule then
214
+ has to remove the track *and* the gap. What is left to say is only the sizing:
215
+
216
+ ```text
217
+ li:empty only the marker marker floor equal share of the line
218
+ li with .step-label a second row --step-min a readable share
219
+ ```
220
+
221
+ The column cannot use that mechanism, and the reason is worth recording so it
222
+ is not "simplified" later: **`grid-auto-flow` is `row`**, so an auto-placed
223
+ label lands *under* its marker rather than beside it. Its second track is
224
+ therefore explicit, and `:empty` takes it back — one declaration, since a
225
+ single track has nothing for `gap` to sit beside.
226
+
227
+ ### Why the track runs edge to edge
228
+
229
+ `justify-items: start` on an empty step is not an override of the row's
230
+ centring, and the distinction matters because it looks like one. A marker is
231
+ centred in its share because a label sits under it and the two must share a
232
+ centre. With no label there is nothing to centre against, so the marker takes
233
+ the start of its share, its connector fills the rest, and the final step —
234
+ having no share to fill — is only as wide as its own marker.
235
+
236
+ The result is a track flush with both ends of the row, which is what lets it
237
+ line up with the heading or paragraph that names the step. Keeping the centred
238
+ geometry leaves half-cell margins at both ends: measured on five empty steps in
239
+ a 736px row, 60px of dead space on each side, with the first marker floating
240
+ inward of a left-aligned heading.
241
+
242
+ It also matches the inline representation, whose `:last-child { flex: 0 0 auto }`
243
+ is the same declaration for the same reason.
244
+
245
+ The connector follows from the alignment rather than being tuned to it. The
246
+ base geometry starts a connector at `50% + --step-size / 2` — the right edge of
247
+ a centred marker — and runs it `100% - --step-size`, to the next marker's left
248
+ edge. Move the marker to the start of the share and that origin becomes
249
+ `--step-size`; the width is unchanged, and the connector still lands exactly on
250
+ the next marker. Measured at 736px and at 200px: zero gap either side.
251
+
252
+ ### What the column's rule buys
253
+
254
+ Nothing visible in a stretched column: a flex item in a column container fills
255
+ its line whatever its tracks say, so the marker, the connector and the item's
256
+ box all measure identically either way. Only `grid-template-columns` changes.
257
+
258
+ It earns its place in a **shrink-to-fit box** — which is what a sticky progress
259
+ rail usually is. Measured at three empty steps in an `inline-size: max-content`
260
+ wrapper: **36px without the rule, 28px with it.** The 8px is `--step-inline-gap`,
261
+ the gap to a label track holding nothing. A rail eight pixels wider than the
262
+ markers it draws is not a bug anyone would file, and also not something to
263
+ leave in a component whose whole claim is that sizing follows content.
264
+
265
+ ### Why `:empty` rather than `:has()`
266
+
267
+ It says what the markup says, and it reads the item's own content, which is
268
+ what keeps bare text off a marker-wide floor (see "Bare text in an `<li>`").
269
+
270
+ It also predates `:has()` by a decade, so the compact form reaches the Degraded
271
+ tier. `:has(.step-label)` appears only in the wide container query, where
272
+ losing it costs the inline bonus and nothing structural. So the support story
273
+ has no hole in it:
274
+
275
+ ```text
276
+ no :has(), no container queries both compact forms work; labelled stays stacked
277
+ :has() + container queries labelled rows also go inline past 60rem
278
+ ```
279
+
280
+ ## Selector shapes
281
+
282
+ ### `:has()` cannot be nested
283
+
284
+ `:has()` is not valid inside `:has()`, including indirectly through `:not()` or
285
+ `:is()`. The whole selector is discarded — silently, so the rule simply never
286
+ applies. Chrome throws on `document.querySelector` for it, which is the cheapest
287
+ way to check.
288
+
289
+ This rules out the natural spelling of "every step is labelled":
290
+
291
+ ```css
292
+ /* INVALID — the rule never applies */
293
+ .steps:not(:has(> li:not(:has(> .step-label))))
294
+ ```
295
+
296
+ When a count is already known, the workaround is to enumerate positions, each in
297
+ its own flat clause, folding the last into the count check:
298
+
299
+ ```css
300
+ .steps:has(> :nth-child(1) > .step-label):has(> :nth-child(2):last-child > .step-label)
301
+ ```
302
+
303
+ That is the strict all-or-nothing label check, and it costs one clause per
304
+ position per rule.
305
+
306
+ Nothing needs it, because nothing asks that question. Sizing is decided per
307
+ item, by `:empty`, so no rule needs to know what the *sequence* contains; only
308
+ the wide enhancement asks about labels at all, and it asks the loose version:
309
+
310
+ ```css
311
+ .steps-horizontal:has(.step-label) > li /* some step is labelled */
312
+ ```
313
+
314
+ One flat clause at any count. It cannot express "every step is labelled", so
315
+ all-or-nothing is enforced by documentation rather than by the selector — and
316
+ the strict version would cost the enumeration above on every rule, to improve
317
+ markup that is already wrong. What the loose clause gives a mixed row is the
318
+ inline treatment for all of its steps, which is the harmless direction: each
319
+ item is still sized on its own content.
320
+
321
+ ### Nothing counts the steps
322
+
323
+ No selector in the component reads `:nth-child`, and none needs to. Whether a
324
+ step is compact is decided by that step's content, at every width.
325
+
326
+ A count check is the natural instinct for a stepper, so it is worth stating
327
+ what it would be for and why the component declines it. A representation that
328
+ *hides* labels below a width only helps at four or five steps — two or three
329
+ stay readable stacked far below any width worth designing for — so it needs a
330
+ lower bound:
331
+
332
+ ```css
333
+ /* four steps or more */
334
+ :has(> :nth-child(4))
335
+ ```
336
+
337
+ and, to keep a 6+ row on the base layout, one of two upper bounds as well:
338
+
339
+ ```css
340
+ /* exactly 4 or 5 */
341
+ :has(> :nth-child(4):last-child, > :nth-child(5):last-child)
342
+
343
+ /* at least 4, fewer than 6 */
344
+ :has(> :nth-child(4)):not(:has(> :nth-child(6)))
345
+ ```
346
+
347
+ Both upper bounds are defensible as "the enumeration *is* the contract" — the
348
+ selector stating the supported counts. That argument holds only if 6+
349
+ horizontal is a case worth serving carefully. It is not. It is out of contract,
350
+ and the enumeration buys it a curated fallback at the cost of four
351
+ `:nth-child` clauses on every rule of the block, on every row that *is*
352
+ supported.
353
+
354
+ So a 6+ row goes inline at wide widths, calibrated for a count it does not
355
+ have. That is what out of contract means: the rules apply as written. The docs
356
+ say 2 to 5 and point longer sequences at `.steps-vertical` rather than promising
357
+ a graceful degradation nobody asked for.
358
+
359
+ The tests hold the same line: there is no six-step fixture. Pinning what such a
360
+ row does — "no rule fires", or "it compacts" — turns an accident of the
361
+ selectors into an API nobody promised, and would have to be revisited every
362
+ time a threshold moved. What is out of contract is not tested for behaviour.
363
+
364
+ Vertical is the other thing a rule might need to exclude, and it needs no guard
365
+ either: the container query addresses `.steps-horizontal` by name, so there is
366
+ nothing to exclude. See "Two compositions, one primitive".
367
+
368
+ ### The containment grant
369
+
370
+ The inline representation reads the nearest `actual-container` size context,
371
+ which the author grants — with the `.container-query` class or by declaring the
372
+ name in their own CSS — on the row or on a region around it. It is not granted
373
+ by the component, because `container-type: inline-size` collapses `.steps` to
374
+ roughly zero in any shrink-to-fit box: a float, an inline-block, a table cell, a
375
+ grid auto track, an absolutely positioned box, a `max-content` parent. Block flow
376
+ and `.cluster` are safe.
377
+
378
+ Containment is also what makes the condition stable: content cannot perturb the
379
+ container's width, so the query cannot be flipped by its own effect.
380
+
381
+ The name is shared rather than steps-specific, so one grant serves every
382
+ size-aware component in a region.
383
+
384
+ The grant is an enhancement in both directions. An unlabelled sequence needs
385
+ none — its shape is intrinsic, in either orientation. A labelled one needs none
386
+ either: stacked is the baseline, and the grant only buys the wide composition.
387
+ **The container does not rescue the component; it improves how it composes.**
388
+
389
+ ### Navigation is a labelled affair
390
+
391
+ Only a `.step-label` can be a link, so navigation and labels are one decision.
392
+ A marker is never interactive, which rules out the doubtful case directly: five
393
+ clickable circles with no visible name.
394
+
395
+ That single rule is also what keeps a focus guard out of the layout. `.sr-only`
396
+ leaves an element in the tab order at 1×1 under `clip-path: inset(50%)`, so a
397
+ focus ring lands on nothing — and a component that hides labels to fit needs to
398
+ refuse any row whose labels are navigable:
399
+
400
+ ```css
401
+ :not(:has(a[href]))
402
+ ```
403
+
404
+ Such a guard has to read structure rather than state, because `aria-current`
405
+ moves as the workflow advances and the same link goes future → current →
406
+ complete: a state-keyed guard flickers under the user. It also has to be
407
+ deliberately loose — `:has(a[href])` reads the whole subtree — since refusing on
408
+ more than you promise is the safe direction. None of that is needed here:
409
+ nothing hides a label, so no rule has to ask whether hiding one would be safe.
410
+
411
+ The contract is one sentence. A step label may be a link, a marker never is, and
412
+ anything else focusable in a step is out of contract.
413
+
414
+ The one `:has(a[href])` in the file is unrelated: a navigable row reserves
415
+ `padding-block` for its focus ring, because the scroll container clips at the
416
+ padding edge. See "Scrolling on one axis".
417
+
418
+ A link needs no styling rule of its own to go with it. `color: inherit` sits in
419
+ the `.steps .step-label` rule: a span inherits its ink anyway, so only a link
420
+ has a `--link` colour to take back, and taking it back for every label costs
421
+ nothing and one rule less. That rule stays scoped to `.steps` — unlike
422
+ `.menu-item-text` and the framework's other sub-part classes — because a bare
423
+ `.step-label` weighs (0,1,0), exactly `.prose :where(a)`, so a stepper inside
424
+ prose would be decided by the order the bundle concatenates two files in.
425
+
426
+ ## Why nothing hides a label
427
+
428
+ **The framework does not silently remove content to solve a layout problem.**
429
+ That is the sentence the whole component turns on, and it is worth defending in
430
+ detail, because hiding labels below a width is the obvious design for a
431
+ stepper and it is a well-measured trap.
432
+
433
+ ### The shape it rules out
434
+
435
+ A container query hiding every label of a four- or five-step row below 35rem:
436
+
437
+ ```text
438
+ < 35rem + 4 steps or more markers only, unless a label is a link
439
+ ```
440
+
441
+ with `.sr-only` on `.step-label`, so the text stays in the accessibility tree
442
+ while its box disappears. Or the same rules behind a public class, so the author
443
+ picks the representation instead of a width picking it.
444
+
445
+ The threshold is not the weak part. Items hold at `flex: 1 0 var(--step-min)`,
446
+ so a row's intrinsic width is `count × --step-min` — 35rem for five steps — and
447
+ the query fires exactly where a five-step stacked row starts to scroll. Four
448
+ steps share that threshold rather than getting their own at 28rem, so between
449
+ 448px and 560px they compact although stacked would still fit; splitting the
450
+ difference at 32rem is worse, handing five-step rows a scrolling band instead
451
+ (40px of overflow at 520px, 20px at 540px).
452
+
453
+ ### Why the premise fails
454
+
455
+ It asks the author to write five step names and then takes them away. Made
456
+ explicit as a class, that is a public API whose entire meaning is *hide the
457
+ content I just made you author* — plus a `.sr-only` clone in the component,
458
+ plus a link guard so the hiding cannot clip a focus target, plus documentation
459
+ explaining why four steps behave differently from three. The hidden markup ends
460
+ up richer than the visible interface: a second, invisible version of the
461
+ component.
462
+
463
+ And the accessibility it buys is thinner than it looks. A CSS-numbered `<li>`
464
+ sequence with five hidden names is not a better announcement than the sentence
465
+ the page already contains. In practice the page says **Step 3 of 5 —
466
+ Validation**, and the sequence is a picture of it.
467
+
468
+ So the question is not "how narrow before labels must go?" but "does this
469
+ sequence carry names at all?", and that one is the author's to answer in
470
+ markup. An unlabelled sequence is compact because it has nothing to show; a
471
+ labelled one keeps everything it was given. When a labelled row does not fit,
472
+ the honest answers are scrolling, more space, or `.steps-vertical`.
473
+
474
+ ### What the rule is worth, concretely
475
+
476
+ Holding that line is what keeps all of the following out of the component:
477
+
478
+ * a second container query, and the `< 35rem` threshold in it;
479
+ * `:has(> :nth-child(4))`, and any other rule that counts steps;
480
+ * `:not(:has(a[href]))` on a layout rule — a rule asking whether hiding would
481
+ be safe;
482
+ * a `.sr-only` clone inside the component, and `clip-path` anywhere in the file;
483
+ * a marker-only variant class, and the `:not(...)` guard the wide query would
484
+ need to leave it alone;
485
+ * two of three `grid-template-rows` declarations and a `gap: 0`, since the base
486
+ template need not build a label row for a step that has no label;
487
+ * a browser test proving the focus guard reads structure rather than state, and
488
+ the four- versus five-step threshold fixtures;
489
+ * a documentation section explaining why three steps behave differently from
490
+ four.
491
+
492
+ It also generalises for free. Once "markers only" is *a step with no content*
493
+ rather than *a representation of a labelled step*, the same sentence describes
494
+ the column, and the contract is two independent dimensions rather than one
495
+ orientation with a mode:
496
+
497
+ ```text
498
+ orientation x labels = four compositions, no variant classes
499
+ ```
500
+
501
+ ### The cheap versions of hiding, for the record
502
+
503
+ Hiding a label is declarations, not a value, so it cannot ride on a custom
504
+ property the way a `--step-min` override can. The tempting shortcuts each fail
505
+ on their own terms: zero block-size and `font-size: 0` lose the accessible
506
+ name, and style queries sit above the support floor.
507
+
508
+ Relatedly, the wide block's declarations are written out rather than
509
+ abstracted. One container query with an explicit body is easier to read, and to
510
+ change, than any clever indirection.
511
+
512
+ ## States
513
+
514
+ Three states must be readable without a check glyph: future is neutral, complete
515
+ is a filled marker, current is an outlined one with a thicker ring. Filled reads
516
+ as "done", outlined as "you are here", and the distinction survives a numbered
517
+ stepper that never sets `--step-complete-mark`.
518
+
519
+ Current is declared after complete at equal specificity, so it wins on every
520
+ channel — including the connector. The accented line means "already walked", so
521
+ the segment *leaving* the current step is neutral; the segment *reaching* it is
522
+ owned by the previous step, which is complete.
523
+
524
+ The thicker ring is structural, so it survives forced colors, and it never
525
+ competes with the outline channel that belongs to focus.
526
+
527
+ ### Forced colors, and why there is no override
528
+
529
+ Forced colors drops the accent fill, so in a numbered flow a complete marker and
530
+ an upcoming one both render as a ringed disc with a number in it. Current still
531
+ reads, on its thicker ring and its bolder label; complete and upcoming do not
532
+ separate.
533
+
534
+ The obvious remedy is a `@media (forced-colors: active)` block giving complete a
535
+ system `Highlight` fill and `HighlightText` text. Captured with forced colors
536
+ emulated through the DevTools protocol, that fill renders the marker's own
537
+ content — the counter, or a `--step-complete-mark` checkmark — unreadable: a
538
+ sliver of `HighlightText` inside a solid disc. It trades an ambiguity for an
539
+ illegible glyph, in the exact mode it exists to help.
540
+
541
+ So there is no such block, and the honest answer is documented instead:
542
+ `--step-complete-mark` distinguishes the two states in forced colors as well as
543
+ anywhere else, because a different glyph survives a colour system that flattens
544
+ fills. An author whose flow needs that distinction sets it; the component does
545
+ not fake it with a fill that cannot carry its own content.
546
+
547
+ The general principle is the one this file keeps arriving at. A remedy that is
548
+ worse than the problem in its own target mode is not a remedy, and shipping it
549
+ "for accessibility" only makes the failure harder to notice.
550
+
551
+ ## Connectors
552
+
553
+ The connector pseudo-element is marker-height rather than hairline-height so
554
+ `--step-connector` can be any complete background value — a gradient, an image,
555
+ or `none`. The default paints the hairline as a gradient sized
556
+ `100% var(--step-line-size)`.
557
+
558
+ The colour rides inside that gradient as `var(--step-line)` rather than being a
559
+ `--step-connector` default on `.steps`. A custom property resolves its `var()`
560
+ before inheritance, so a parent-level default would freeze the parent's colour
561
+ and a `.step-complete` child's connector would never follow its own state.
562
+
563
+ `--step-inline-connector` exists because the inline representation gives the
564
+ connector a genuinely different job: it absorbs the free space between
565
+ marker + label groups, which is the region a theme replaces with a chevron. It
566
+ falls back to `--step-connector`, then to the default line.
567
+
568
+ There is deliberately no `--step-vertical-connector`. The vertical geometry is
569
+ different, so a horizontal value cannot transpose onto it — but no real design
570
+ has asked to repaint it, and `.steps-vertical > li::after` is a reachable
571
+ override. Unrequested API is API not worth publishing.
572
+
573
+ ## Scrolling on one axis
574
+
575
+ The row scrolls horizontally as its last resort, and never vertically. Saying
576
+ only `overflow-x: auto` does not express that: the spec computes the other axis
577
+ to `auto` too whenever one axis is a scroll container, so `overflow-y` becomes
578
+ `auto` by omission.
579
+
580
+ That would be harmless if the row had any vertical slack, and it has none. A
581
+ stacked step is a 28px marker, an 8px gap and a 17.5px label line — 53.5px, a
582
+ fractional height the box hugs exactly. Whether `scrollHeight` rounds to the
583
+ same integer as `clientHeight` then depends on the font in use, the device pixel
584
+ ratio and the zoom level, so a phantom vertical scrollbar appears on one machine
585
+ and not another — the shape of bug a fixed-width fixture cannot catch, which is
586
+ why the browser test sweeps fractional widths instead of sampling a few.
587
+
588
+ `overflow-y: hidden` settles it: there is genuinely nothing to scroll on that
589
+ axis, in any representation.
590
+
591
+ On the axis that does scroll, `overscroll-behavior-inline: contain` keeps a
592
+ swipe that reaches the end of the row from chaining to the page behind it.
593
+
594
+ The scrollbar itself is left to `.scroller`, composed on the row rather than
595
+ baked into it. A stepper is short — a stacked step is 53.5px — so an OS-default
596
+ scrollbar reads heavy under one, and the temptation is to give the component a
597
+ `scrollbar-width: thin` of its own.
598
+
599
+ The reason not to is consistency, not modularity. Every scroll container the
600
+ framework owns — `.table`, `.flyout`, a scrolling `dialog`, `.app-layout`'s
601
+ main — leaves the scrollbar to the engine. A stepper that thinned its own would
602
+ be the only one, so a page with a scrolling table above a scrolling stepper
603
+ would show two kinds of scrollbar. Scrollbar treatment is a decision an author
604
+ makes once, for everything that scrolls, which is exactly the shape `.scroller`
605
+ has: the scroller docs name this case — "pair it with `.overflow-auto` or a
606
+ component that already creates overflow" — and `.nav-list scroller` is the same
607
+ composition elsewhere.
608
+
609
+ Baking the declarations without the hooks would also strand the author: the
610
+ retuning surface is `--scroller-thumb` and `--scroller-track`, which belong to
611
+ `.scroller`. Copying those into Steps duplicates the primitive; omitting them
612
+ leaves a scrollbar no documented hook reaches.
613
+
614
+ Measured on a five-step row in a 380px region, the pairing takes the scrollbar
615
+ from 15px to 10px and the row from 69px to 64px, with the thumb on
616
+ `--text-muted` instead of the OS grey.
617
+
618
+ Its `.stable-gutter` variant, on the other hand, is a trap worth naming.
619
+ `scrollbar-gutter` reserves space on the *inline* axis, for a vertical
620
+ scrollbar — and this row sets `overflow-y: hidden`, so there is never one to
621
+ reserve for. It does not steady the row's height across the appearance of the
622
+ horizontal scrollbar, which is what an author would reach for it to do:
623
+ measured, the row is 54px when it fits and 64px when it scrolls either way,
624
+ while the reserved inline gutter takes 10px off the row's width and pushes
625
+ overflow from 36px to 46px. Strictly worse in both directions.
626
+
627
+ What makes the row scroll rather than squash is the item floor:
628
+ `min-inline-size: max(var(--step-size), var(--step-min))`. The `max()` is there
629
+ so an author who lowers `--step-min` below `--step-size` narrows the scroll
630
+ budget without collapsing items behind their own markers.
631
+
632
+ The cost of scrolling is that clipping happens at the padding edge, and a focus
633
+ ring is drawn outside its element's border box — `--focus-outline` is two
634
+ border-widths wide at `--focus-outline-offset`, so 4px past a label that sits
635
+ flush with the bottom of a stacked row. A navigable row therefore reserves
636
+ exactly that much `padding-block`, gated on `.steps-horizontal:has(a[href])`. An
637
+ informational stepper — the common case, with no ring to protect — keeps its
638
+ height, and so does a navigable column: the padding is compensation for the
639
+ row's clip, and the column is not a scroll container at all.
640
+
641
+ The two cheaper answers each cost something worse. Reserving the space
642
+ unconditionally is one rule shorter and 8px taller on every stepper in the
643
+ framework. Clipping, and letting focus scroll the ring into view, keeps the
644
+ height and moves the row under the reader instead.
645
+
646
+ ## Two compositions, one primitive
647
+
648
+ `.steps` is the component. `.steps-horizontal` and `.steps-vertical` are peers,
649
+ both required, neither the default. Vertical is an explicit orientation, never a
650
+ responsive fallback, and it has no step-count limit: its practical limit is the
651
+ surrounding layout rather than a row's width budget.
652
+
653
+ `.steps` holds what a step *is*, under its own name. `.steps-horizontal` and
654
+ `.steps-vertical` hold what is genuinely per-orientation. Nothing excludes
655
+ anything, nothing undoes anything, and the rules read as what they are:
656
+
657
+ ```text
658
+ .steps what a step is
659
+ .steps-horizontal a row, plus its container query
660
+ .steps-vertical a column
661
+ ```
662
+
663
+ There is no third class, and no third *composition* either. A compact
664
+ progression is the same `.steps-horizontal` with empty items, and a compact
665
+ rail is the same `.steps-vertical` with empty items — which is why neither
666
+ needs a name of its own. See "Why nothing hides a label".
667
+
668
+ The cost is one class on the horizontal markup, where the orientation could be
669
+ implicit. That is the right direction: if vertical is an explicit orientation
670
+ rather than a fallback, horizontal being explicit too is the same claim stated
671
+ once more.
672
+
673
+ `.steps` on its own is incomplete markup. That is a real footgun — it renders a
674
+ flex row of unpositioned markers rather than nothing — and it is the price of
675
+ having neither orientation be the default. Documented, not defended.
676
+
677
+ ### Why the primitive needs a class of its own
678
+
679
+ Both alternatives cost more, and each in a way that is easy to mistake for
680
+ economy.
681
+
682
+ **Vertical as a modifier** — `.steps` meaning both the component and the
683
+ horizontal layout — makes the column inherit a row and then undo it. Six
684
+ declarations exist for no other reason:
685
+
686
+ | Undone | Because the row sets |
687
+ | --------------------------- | ----------------------------------------------- |
688
+ | `overflow: visible` | `overflow-x: auto` |
689
+ | `overscroll-behavior: auto` | `overscroll-behavior-inline: contain` |
690
+ | `flex: none` | `flex: 1 0 var(--step-min)` |
691
+ | `grid-template-rows: auto` | `grid-template-rows: var(--step-size)` |
692
+ | `min-inline-size: 0` | `min-inline-size: max(--step-size, --step-min)` |
693
+ | `text-align: start` | `text-align: center` |
694
+
695
+ And it forces the inverse on the horizontal side: every rule that must not
696
+ reach a column carries `:not(.steps-vertical)` — the focus-ring rule plus every
697
+ rule in the container query.
698
+
699
+ **Two independent roots** — `.steps` and `.steps-vertical` sharing no class —
700
+ zeroes both of those lists. But then the shared half of the component (the
701
+ counter, the marker, `.step-label`, `.step-complete`, `aria-current="step"`) has
702
+ no name, and has to be spelled `:is(.steps, .steps-vertical)` on twelve rules.
703
+ Twelve rules needing the same two-class prefix is what a shared primitive looks
704
+ like when it has not been given a class of its own.
705
+
706
+ ### `justify-items: start` is not a reset
707
+
708
+ It looks like one, sitting in `.steps-vertical > li` next to properties the row
709
+ also sets. It is the column's own alignment, and it is load-bearing: grid items
710
+ blockify, so a label left at the default `normal` stretches across the whole
711
+ `1fr` track — measured, an `a.step-label` goes from 75px to 264px. That is a
712
+ full-width hover target and a full-width focus ring in a column, where the same
713
+ link in a row gets a box that hugs its text.
714
+
715
+ ### The connector's terminator
716
+
717
+ The connector's geometry belongs to each composition, so only the question of
718
+ *which* steps get one is shared. One rule states the condition:
719
+ `.steps > li:not(:last-child)::after`. A pseudo-element with no content
720
+ generates no box, so both compositions can address `> li::after` freely and
721
+ neither can grow a line past the final step. Nothing to order, nothing to weigh.
722
+
723
+ The alternative is two rules pulling against each other —
724
+ `> li::after { content: "" }` and `> li:last-child::after { content: none }` —
725
+ which makes "does a line trail the last step" a specificity question, and makes
726
+ a stray `content` in a geometry rule enough to break it.
727
+
728
+ Its row is aligned to `start` so a label that wraps begins level with the top of
729
+ its marker. A single line is shorter than the marker and would float above its
730
+ centre, so the label takes `align-self: center` — which only bites in that case,
731
+ since a label tall enough to fill the row has nothing left to centre in.
732
+ Measured, a single line sits 0.25px off the marker's cap-height centre with it,
733
+ and 5px above without.
734
+
735
+ ## Label typography
736
+
737
+ The marker carries the state, so the label reads a notch below it: one size
738
+ down, tight leading, neutral weight, with extra weight only on
739
+ `aria-current="step"`. A completed step gets none — its filled disc already
740
+ reads as strongly as text could.
741
+
742
+ The size is set on the component, never inside a container block. A font-size
743
+ that changed with the representation would resize the text under a reader
744
+ dragging a window, on top of the layout move the threshold already makes.
745
+
746
+ A navigable label keeps its underline, hover and focus ring but takes
747
+ `color: inherit`, because `--link` is a theme hook and a link-coloured label
748
+ would read ahead of the marker states it sits next to.