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
@@ -134,9 +134,11 @@ select {
134
134
  without a component ring (links, summary, plain items). Components draw
135
135
  the themed ring instead: --focus-ring-width solid --focus. See focus.css
136
136
  for the two contracts. Both share --focus-ring-width: they differ in
137
- color only. */
137
+ color only. No --focus-outline alias here: declared on :root it would be
138
+ inherited already computed, so a theme island or a region setting
139
+ --focus-ring-width would not reach it. focus.css composes the line on the
140
+ focused element instead. */
138
141
  --focus-outline-color: currentColor;
139
- --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
140
142
  --focus-outline-offset: 2px;
141
143
  --focus-ring-width: 2px;
142
144
 
@@ -315,7 +317,9 @@ select {
315
317
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
316
318
  * other tokens; hard-coded overrides on a custom theme will break the intent.
317
319
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
318
- * translucent surfaces overrides them with opaque values.
320
+ * translucent surfaces overrides them with opaque values. --border-control is
321
+ * public too: the resting edge of a neutral control, apart from structural
322
+ * separators.
319
323
  * Internal: --hgroup-gap.
320
324
  */
321
325
  :root {
@@ -417,6 +421,9 @@ select {
417
421
  never a fill. --surface-solid-fg is the ink on --surface-solid. */
418
422
  --surface-opaque: var(--surface);
419
423
  --surface-solid-fg: var(--surface);
424
+ /* The edge that tells a control apart from the surface, where --border only
425
+ separates surfaces: fields, choices, switch, OTP cells, choice cards. */
426
+ --border-control: var(--border);
420
427
  }
421
428
 
422
429
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -876,16 +883,18 @@ small {
876
883
  Documented exception: an invalid field focuses in --form-invalid-border
877
884
  (danger), so the error stays visible at the point of correction. The border
878
885
  carries the same hook; report:theme-contrast measures that line at 3:1
879
- against the field's background alongside --focus. */
886
+ against the field's background alongside --focus.
887
+
888
+ Public hooks:
889
+ --focus-outline (fallback-only: replaces the whole generic line)
890
+ */
880
891
  :focus {
881
- outline: var(--focus-outline);
892
+ outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
882
893
  outline-offset: var(--focus-outline-offset);
883
894
  }
884
895
 
885
- @supports selector(:focus-visible) {
886
- :focus:not(:focus-visible) {
887
- outline: none;
888
- }
896
+ :focus:not(:focus-visible) {
897
+ outline: none;
889
898
  }
890
899
 
891
900
  @media print {
@@ -1349,7 +1358,21 @@ small {
1349
1358
  *
1350
1359
  * Child contract: direct-child margins are reset, spacing comes from --gap.
1351
1360
  * A ul/ol/menu keeps its semantics but loses list chrome (see below).
1361
+ *
1362
+ * The hooks describe this row's own geometry, so they stop at each cluster:
1363
+ * set them on the cluster itself, or target nested rows with a selector. The
1364
+ * gap token is context rhythm and keeps inheriting.
1352
1365
  */
1366
+
1367
+ /* Instance boundary: an ancestor's value never reaches a nested cluster.
1368
+ Zero specificity, so any class or inline style on the cluster wins without
1369
+ depending on import order; `initial` makes the var() fallbacks apply. */
1370
+ :where(.cluster) {
1371
+ --cluster-align: initial;
1372
+ --cluster-justify: initial;
1373
+ --cluster-wrap: initial;
1374
+ }
1375
+
1353
1376
  .cluster {
1354
1377
  display: flex;
1355
1378
  flex-direction: row;
@@ -1382,6 +1405,8 @@ small {
1382
1405
  * --center-size
1383
1406
  * --center-pad
1384
1407
  */
1408
+ /* The hooks inherit on purpose: a site's content width is page policy, so a
1409
+ theme sets them once on :root for every .center. */
1385
1410
  .center {
1386
1411
  box-sizing: content-box;
1387
1412
  inline-size: calc(100% - var(--center-pad, 1rem) * 2);
@@ -1554,6 +1579,9 @@ small {
1554
1579
  *
1555
1580
  * Child contract: every direct child is a peer; the flex-basis applies to all.
1556
1581
  */
1582
+ /* No instance reset, unlike .cluster and .grid: the children read the hook,
1583
+ and a switcher nested as a direct child takes its own flex-basis from its
1584
+ parent's value, so the hook must reach it. */
1557
1585
  .switcher {
1558
1586
  display: flex;
1559
1587
  flex-direction: row;
@@ -1586,6 +1614,9 @@ small {
1586
1614
  * visual, reading and keyboard order all agree — the role rules swap so the
1587
1615
  * aside still takes its preferred width and the main still reclaims the row.
1588
1616
  */
1617
+ /* No instance reset, unlike .cluster and .grid: the regions read the hooks,
1618
+ and a layout nested as a region takes its own size from its parent's
1619
+ values, so the hooks must reach it. */
1589
1620
  .sidebar-layout {
1590
1621
  display: flex;
1591
1622
  flex-direction: row;
@@ -1647,6 +1678,13 @@ small {
1647
1678
  * the thresholds were arrived at: docs/design-notes/grid.md.
1648
1679
  */
1649
1680
 
1681
+ /* Instance boundary: a nested grid never takes its parent's track template.
1682
+ Zero specificity, so a class or inline style on the grid itself wins. */
1683
+ :where(.grid) {
1684
+ --grid-columns: initial;
1685
+ --grid-min: initial;
1686
+ }
1687
+
1650
1688
  /* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
1651
1689
  no longer collapses on its own and the author owns narrow-container
1652
1690
  collapse. */
@@ -1832,7 +1870,16 @@ small {
1832
1870
  * draw their native scrollbar — the intended fallback, not a broken state.
1833
1871
  *
1834
1872
  * Styles the scrollbar only; it does not create the overflow. Pair it with
1835
- * .overflow-auto or a component that already scrolls.
1873
+ * .overflow-auto or a component that already scrolls (horizontal steps, a
1874
+ * table wrap; tabs hide their bar and fade their own edges). No edge fade
1875
+ * here: a mask would dictate the strip's scroll-padding and focus geometry for
1876
+ * a decorative cue, and an item clipped at the edge already says the strip
1877
+ * continues.
1878
+ *
1879
+ * Not on touch: Chromium trades the overlay scrollbar of a touch screen,
1880
+ * hidden at rest, for a classic always-visible one as soon as scrollbar-color
1881
+ * or scrollbar-width is set. On a phone that painted a permanent bar under
1882
+ * every strip; the native overlay is the better cue there.
1836
1883
  *
1837
1884
  * Public hooks:
1838
1885
  * --scroller-track
@@ -1841,9 +1888,16 @@ small {
1841
1888
  .scroller {
1842
1889
  --scroller-track: transparent;
1843
1890
  --scroller-thumb: var(--border);
1891
+ }
1844
1892
 
1845
- scrollbar-color: var(--scroller-thumb) var(--scroller-track);
1846
- scrollbar-width: thin;
1893
+ /* Any primary pointer but touch: a keyboard-only or pointerless device
1894
+ (pointer: none) draws classic scrollbars too. `all and` keeps the negation
1895
+ valid in Media Queries 3 parsers. */
1896
+ @media not all and (pointer: coarse) {
1897
+ .scroller {
1898
+ scrollbar-color: var(--scroller-thumb) var(--scroller-track);
1899
+ scrollbar-width: thin;
1900
+ }
1847
1901
  }
1848
1902
 
1849
1903
  /* Opt-in gutter: reserves space so the layout does not shift when
@@ -2107,7 +2161,7 @@ small {
2107
2161
  .input,
2108
2162
  .textarea,
2109
2163
  .select {
2110
- --control-border: var(--border);
2164
+ --control-border: var(--border-control);
2111
2165
  --control-bg: var(--surface);
2112
2166
  --control-fg: var(--text);
2113
2167
  --control-cursor: auto;
@@ -2333,7 +2387,7 @@ small {
2333
2387
  /* Control geometry. --choice-* are the per-state tokens. */
2334
2388
  .check,
2335
2389
  .radio {
2336
- --choice-border: var(--border);
2390
+ --choice-border: var(--border-control);
2337
2391
  --choice-bg: var(--surface);
2338
2392
  --choice-mark: var(--primary-fg);
2339
2393
 
@@ -2521,7 +2575,7 @@ small {
2521
2575
  calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
2522
2576
  );
2523
2577
  padding-inline: var(--space-20);
2524
- border: var(--border-width) solid var(--border);
2578
+ border: var(--border-width) solid var(--border-control);
2525
2579
  border-radius: var(--radius-sm);
2526
2580
  background: var(--surface-subtle);
2527
2581
  color: var(--text);
@@ -2555,7 +2609,7 @@ small {
2555
2609
  inline-size: var(--control-size);
2556
2610
  block-size: var(--control-size);
2557
2611
  padding: var(--border-width);
2558
- border: var(--border-width) solid var(--border);
2612
+ border: var(--border-width) solid var(--border-control);
2559
2613
  border-radius: var(--radius);
2560
2614
  background: var(--surface);
2561
2615
  cursor: pointer;
@@ -2609,6 +2663,8 @@ small {
2609
2663
  *
2610
2664
  * Imported by default through forms/index.css. Custom builds may omit this
2611
2665
  * file when an app wants to own validation styling completely.
2666
+ * Without :has(), automatic error display and choice-card borders drop;
2667
+ * native control states and the explicit .field.danger route stay available.
2612
2668
  *
2613
2669
  * State coverage (which states each component ships) is audited in
2614
2670
  * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
@@ -2630,11 +2686,14 @@ small {
2630
2686
  display: block;
2631
2687
  }
2632
2688
 
2633
- @supports selector(:has(*)) {
2634
- .field:has([aria-invalid="true"]) > .field-error,
2635
- .needs-validation.was-validated .field:has(:invalid) > .field-error {
2636
- display: block;
2637
- }
2689
+ .field:has([aria-invalid="true"]) > .field-error,
2690
+ .needs-validation.was-validated .field:has(:invalid) > .field-error {
2691
+ display: block;
2692
+ }
2693
+
2694
+ /* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
2695
+ .needs-validation .field:has(:user-invalid) > .field-error {
2696
+ display: block;
2638
2697
  }
2639
2698
 
2640
2699
  :where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
@@ -2658,41 +2717,29 @@ small {
2658
2717
  --switch-bg: var(--danger);
2659
2718
  }
2660
2719
 
2661
- @supports selector(:user-invalid) {
2662
- .needs-validation .check:is(:checked, :indeterminate):user-invalid,
2663
- .needs-validation .radio:checked:user-invalid {
2664
- --choice-bg: var(--danger);
2665
- }
2720
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
2721
+ .needs-validation .radio:checked:user-invalid {
2722
+ --choice-bg: var(--danger);
2723
+ }
2666
2724
 
2667
- .needs-validation .switch:checked:user-invalid {
2668
- --switch-bg: var(--danger);
2669
- }
2725
+ .needs-validation .switch:checked:user-invalid {
2726
+ --switch-bg: var(--danger);
2670
2727
  }
2671
2728
 
2672
2729
  /* Choice cards hide their native input, so invalid state must surface on the
2673
2730
  card itself. Sets the same --form-invalid-border hook the card's border
2674
2731
  consumes. */
2675
- @supports selector(:has(*)) {
2676
- .choice-card:has([aria-invalid="true"]),
2677
- .needs-validation.was-validated .choice-card:has(:invalid) {
2678
- --form-invalid-border: var(--danger);
2679
- }
2732
+ .choice-card:has([aria-invalid="true"]),
2733
+ .needs-validation.was-validated .choice-card:has(:invalid) {
2734
+ --form-invalid-border: var(--danger);
2680
2735
  }
2681
2736
 
2682
- @supports selector(:user-invalid) {
2683
- .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
2684
- --form-invalid-border: var(--danger);
2685
- }
2737
+ .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
2738
+ --form-invalid-border: var(--danger);
2686
2739
  }
2687
2740
 
2688
- @supports selector(:has(:user-invalid)) {
2689
- .needs-validation .field:has(:user-invalid) > .field-error {
2690
- display: block;
2691
- }
2692
-
2693
- .needs-validation .choice-card:has(:user-invalid) {
2694
- --form-invalid-border: var(--danger);
2695
- }
2741
+ .needs-validation .choice-card:has(:user-invalid) {
2742
+ --form-invalid-border: var(--danger);
2696
2743
  }
2697
2744
 
2698
2745
  /*
@@ -2718,15 +2765,12 @@ small {
2718
2765
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
2719
2766
  */
2720
2767
 
2721
- /* The policy hooks write the properties themselves rather than relaying
2722
- --cluster-*: a relay would inherit into any .cluster nested in the row.
2723
- These are equal-specificity overrides of .cluster, so they win only because
2724
- forms load after layout in the official bundle (tests/css-audit.test.js
2725
- guards the order); utilities load later and keep the author's last word. On
2726
- this row --cluster-justify/--cluster-align have no effect. */
2768
+ /* The role relays its policy into the row's cluster hooks. Cluster hooks stop
2769
+ at every nested .cluster, so the relay cannot leak, and this class beats the
2770
+ cluster's zero-specificity reset in any import order. */
2727
2771
  .form-actions {
2728
- align-items: var(--form-actions-align, center);
2729
- justify-content: var(--form-actions-justify, flex-start);
2772
+ --cluster-align: var(--form-actions-align, center);
2773
+ --cluster-justify: var(--form-actions-justify, flex-start);
2730
2774
  }
2731
2775
 
2732
2776
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -2852,7 +2896,7 @@ small {
2852
2896
 
2853
2897
  .switch {
2854
2898
  --switch-bg: var(--border);
2855
- --switch-border: var(--border);
2899
+ --switch-border: var(--border-control);
2856
2900
  --switch-knob: var(--surface-raised);
2857
2901
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
2858
2902
  --switch-width: calc(var(--switch-block-size) * 1.8);
@@ -3189,7 +3233,7 @@ small {
3189
3233
  --choice-card-pad: var(--density-space, var(--space-40));
3190
3234
  --choice-card-gap: var(--space-30);
3191
3235
  --choice-card-radius: var(--radius);
3192
- --choice-card-border: var(--border);
3236
+ --choice-card-border: var(--border-control);
3193
3237
  --choice-card-bg: var(--surface);
3194
3238
  --choice-card-check-size: 1.25em;
3195
3239
 
@@ -3525,10 +3569,13 @@ small {
3525
3569
  * messages stay in the surrounding .field, not inside the cell, so the
3526
3570
  * resting label can center on the control alone.
3527
3571
  *
3528
- * The control must precede the label: the float is driven by the general
3529
- * sibling combinator and :placeholder-shown. Injected nodes may appear between
3530
- * the control and the label (password managers, extension wrappers); the cell
3531
- * contract stays a single control plus one label. Every .input/.textarea needs
3572
+ * The cell reads its control's state through :has(), so the label may come
3573
+ * before or after the control (a form theme rendering label then widget needs
3574
+ * no reordering). Injected sibling nodes are tolerated (password managers,
3575
+ * extension wrappers) as long as the control stays a direct child; the cell
3576
+ * contract stays a single control plus one label, since a second control
3577
+ * would float the label on either one's state.
3578
+ * Every .input/.textarea needs
3532
3579
  * placeholder=" " to expose a reliable empty state — without it the label
3533
3580
  * only reacts to focus.
3534
3581
  *
@@ -3578,23 +3625,22 @@ small {
3578
3625
  }
3579
3626
 
3580
3627
  /* Single-line controls rest in the middle until they receive a value. */
3581
- .floating-field > :is(.input, .select) ~ .field-label {
3628
+ .floating-field:has(> :is(.input, .select)) > .field-label {
3582
3629
  inset-block-start: 50%;
3583
3630
  transform: translateY(-50%);
3584
3631
  }
3585
3632
 
3586
3633
  /* An empty textarea label follows the control's normal first-line position;
3587
3634
  the control itself keeps the extra headroom reserved for its floated state. */
3588
- .floating-field > .textarea ~ .field-label {
3635
+ .floating-field:has(> .textarea) > .field-label {
3589
3636
  inset-block-start: calc(1em + var(--border-width));
3590
3637
  transform: none;
3591
3638
  }
3592
3639
 
3593
3640
  /* :autofill covers prefilled values before interaction, when the browser may
3594
3641
  not have propagated the value to the placeholder state yet. */
3595
- .floating-field
3596
- > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
3597
- ~ .field-label {
3642
+ .floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
3643
+ > .field-label {
3598
3644
  inset-block-start: var(--floating-label-top);
3599
3645
  transform: none;
3600
3646
  font-size: var(--font-size-xs);
@@ -3604,16 +3650,16 @@ small {
3604
3650
  /* Controls without a dependable :placeholder-shown empty state stay floated:
3605
3651
  select always, and date/time inputs whose placeholder rendering is
3606
3652
  inconsistent across engines (Firefox shows none). */
3607
- .floating-field
3653
+ .floating-field:has(
3608
3654
  > :is(
3609
- .select,
3610
- .input[type="date"],
3611
- .input[type="time"],
3612
- .input[type="datetime-local"],
3613
- .input[type="month"],
3614
- .input[type="week"]
3615
- )
3616
- ~ .field-label {
3655
+ .select,
3656
+ .input[type="date"],
3657
+ .input[type="time"],
3658
+ .input[type="datetime-local"],
3659
+ .input[type="month"],
3660
+ .input[type="week"]
3661
+ )
3662
+ ) > .field-label {
3617
3663
  inset-block-start: var(--floating-label-top);
3618
3664
  transform: none;
3619
3665
  font-size: var(--font-size-xs);
@@ -3649,7 +3695,7 @@ small {
3649
3695
  --otp-cell-size: var(--control-size);
3650
3696
  --otp-gap: var(--space-10);
3651
3697
  --otp-font-size: 1.5em;
3652
- --otp-cell-border: var(--border);
3698
+ --otp-cell-border: var(--border-control);
3653
3699
  --otp-cell-bg: var(--surface);
3654
3700
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
3655
3701
 
@@ -3735,10 +3781,9 @@ small {
3735
3781
  --otp-cell-border: var(--danger);
3736
3782
  }
3737
3783
 
3738
- @supports selector(:user-invalid) {
3739
- .needs-validation .otp > input:user-invalid ~ span {
3740
- --otp-cell-border: var(--danger);
3741
- }
3784
+ /* biome-ignore lint/style/noDescendingSpecificity: all invalid routes write the same border, so their order cannot change the state. Keep :user-invalid separate so unsupported engines retain the other routes. */
3785
+ .needs-validation .otp > input:user-invalid ~ span {
3786
+ --otp-cell-border: var(--danger);
3742
3787
  }
3743
3788
 
3744
3789
  .otp > input:disabled {
@@ -4264,7 +4309,6 @@ a.btn[aria-disabled="true"] {
4264
4309
  --badge-size: var(--badge-dot-size);
4265
4310
  --badge-bg: var(--intent, var(--neutral));
4266
4311
  --badge-fg: var(--intent-fg, var(--neutral-fg));
4267
- --badge-border: transparent;
4268
4312
  padding: 0;
4269
4313
  border: 0;
4270
4314
  inline-size: var(--badge-size);
@@ -4464,10 +4508,11 @@ a.badge[aria-disabled="true"] {
4464
4508
  }
4465
4509
  }
4466
4510
 
4467
- /* Decorative leading icon — use .alert-icon on the icon element. */
4511
+ /* Decorative leading icon — use .alert-icon on the icon element. display and
4512
+ vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
4513
+ before the text); the grid anatomy below replaces them. */
4468
4514
  .alert > .alert-icon {
4469
4515
  display: inline-block;
4470
- flex: none;
4471
4516
  inline-size: var(--alert-icon-size);
4472
4517
  block-size: var(--alert-icon-size);
4473
4518
  font-size: var(--alert-icon-size);
@@ -4499,45 +4544,44 @@ a.badge[aria-disabled="true"] {
4499
4544
 
4500
4545
  /* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
4501
4546
  both. Icon and dismiss are pinned to the outer columns; anything else (the
4502
- message content) flows into the remaining text column. */
4503
- @supports selector(.alert:has(> *)) {
4504
- .alert:has(> .alert-icon, > .close) {
4505
- display: grid;
4506
- grid-template-columns: minmax(0, 1fr);
4507
- align-items: var(--items-align, start);
4508
- column-gap: var(--space-30);
4509
- }
4510
-
4511
- .alert:has(> .alert-icon) {
4512
- grid-template-columns: auto minmax(0, 1fr);
4513
- }
4547
+ message content) flows into the remaining text column. Without :has(), the
4548
+ grid rules drop together and the alert keeps ordinary block flow. */
4549
+ .alert:has(> .alert-icon, > .close) {
4550
+ display: grid;
4551
+ /* No grid-template-columns: the icon and close branches below always set
4552
+ it, since one of them is what matched. */
4553
+ align-items: var(--items-align, start);
4554
+ column-gap: var(--space-30);
4555
+ }
4514
4556
 
4515
- .alert:has(> .alert-icon) > .alert-icon {
4516
- grid-column: 1;
4517
- grid-row: 1 / -1;
4518
- display: inline-grid;
4519
- place-items: center;
4520
- margin-block-start: 0.125em;
4521
- }
4557
+ .alert:has(> .alert-icon) {
4558
+ grid-template-columns: auto minmax(0, 1fr);
4559
+ }
4522
4560
 
4523
- .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4524
- grid-column: 2;
4525
- }
4561
+ .alert:has(> .alert-icon) > .alert-icon {
4562
+ grid-column: 1;
4563
+ grid-row: 1 / -1;
4564
+ display: inline-grid;
4565
+ place-items: center;
4566
+ margin-block-start: 0.125em;
4567
+ }
4526
4568
 
4527
- .alert:has(> .close) {
4528
- grid-template-columns: minmax(0, 1fr) auto;
4529
- }
4569
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4570
+ grid-column: 2;
4571
+ }
4530
4572
 
4531
- .alert:has(> .alert-icon):has(> .close) {
4532
- grid-template-columns: auto minmax(0, 1fr) auto;
4533
- }
4573
+ .alert:has(> .close) {
4574
+ grid-template-columns: minmax(0, 1fr) auto;
4575
+ }
4534
4576
 
4535
- .alert:has(> .close) > .close {
4536
- grid-column: -2 / -1;
4537
- grid-row: 1;
4538
- }
4577
+ .alert:has(> .alert-icon):has(> .close) {
4578
+ grid-template-columns: auto minmax(0, 1fr) auto;
4539
4579
  }
4540
4580
 
4581
+ .alert:has(> .close) > .close {
4582
+ grid-column: -2 / -1;
4583
+ grid-row: 1;
4584
+ }
4541
4585
  /* Links */
4542
4586
  .alert :where(a:not(.btn)) {
4543
4587
  color: inherit;
@@ -4896,6 +4940,12 @@ a.badge[aria-disabled="true"] {
4896
4940
  background: var(--surface-subtle);
4897
4941
  }
4898
4942
 
4943
+ /* The chrome header is a centered row, so prose margins on a bare title only
4944
+ push the band taller and pull the title off the close axis. */
4945
+ :where(dialog).modal > header > :is(h1, h2, h3, h4, h5, h6) {
4946
+ margin-block: 0;
4947
+ }
4948
+
4899
4949
  :where(dialog).modal > header > .media {
4900
4950
  flex: 1 1 auto;
4901
4951
  min-inline-size: 0;
@@ -4959,19 +5009,20 @@ a.badge[aria-disabled="true"] {
4959
5009
 
4960
5010
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4961
5011
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
4962
- --dialog-focus-bleed: calc(
4963
- var(--focus-outline-offset) +
4964
- max(var(--focus-ring-width), var(--border-width) * 2)
4965
- );
5012
+ /* Declared here, not as a :root alias: an inherited alias arrives already
5013
+ computed, so a --focus-ring-width override on the dialog would not reach
5014
+ it. */
5015
+ --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
4966
5016
 
4967
5017
  flex: 1 1 auto;
4968
5018
  min-block-size: 0;
4969
- /* Keep focus paint inside the clip without moving the body off the fixed
4970
- header and footer edges. The reserve covers the thicker of the two
4971
- outside indicators: the :focus baseline (2 × border) and the component
4972
- line (--focus-ring-width, 3px in bootstrap-v6). */
4973
- margin-inline: calc(var(--dialog-focus-bleed) * -1);
4974
- padding-inline: var(--dialog-focus-bleed);
5019
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
5020
+ both axes), without moving the content. Every outside indicator is
5021
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
5022
+ reaches into the slot gap, which is wider, so the scrollport never slides
5023
+ under the header or footer. */
5024
+ margin: calc(var(--dialog-focus-bleed) * -1);
5025
+ padding: var(--dialog-focus-bleed);
4975
5026
  overflow: auto;
4976
5027
  overscroll-behavior: contain;
4977
5028
  }
@@ -5176,6 +5227,7 @@ html.has-modal-open.had-scrollbar {
5176
5227
  * --drawer-close-reserve (header room for a corner close; zero without a close)
5177
5228
  * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
5178
5229
  * changes in one place; entry state redefines it longer)
5230
+ * --drawer-motion-ease (panel easing owned by the open/closed state)
5179
5231
  */
5180
5232
  :where(dialog).drawer {
5181
5233
  --drawer-size: 20rem;
@@ -5190,6 +5242,7 @@ html.has-modal-open.had-scrollbar {
5190
5242
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
5191
5243
  the same variable through inheritance and never lags the panel. */
5192
5244
  --drawer-motion-duration: var(--duration);
5245
+ --drawer-motion-ease: var(--ease-exit);
5193
5246
 
5194
5247
  /* Keep the surface anchored to the viewport in the top layer. This also
5195
5248
  provides the containing block for the absolutely positioned close button.
@@ -5221,21 +5274,21 @@ html.has-modal-open.had-scrollbar {
5221
5274
  box-shadow: var(--shadow);
5222
5275
  opacity: 0;
5223
5276
  transform: translateX(var(--drawer-closed-translate));
5224
- transition:
5225
- opacity var(--drawer-motion-duration) var(--ease-exit),
5226
- transform var(--drawer-motion-duration) var(--ease-exit);
5277
+ /* Unsupported discrete behavior drops alone, preserving opacity/transform. */
5278
+ transition-property: opacity, transform, overlay, display;
5279
+ transition-duration: var(--drawer-motion-duration);
5280
+ transition-timing-function: var(--drawer-motion-ease);
5281
+ transition-behavior: allow-discrete;
5227
5282
  }
5228
5283
 
5229
5284
  :where(dialog).drawer[open] {
5230
5285
  --drawer-motion-duration: var(--duration-slow);
5286
+ --drawer-motion-ease: var(--ease-enter);
5231
5287
  display: grid;
5232
5288
  grid-template-rows: auto minmax(0, 1fr) auto;
5233
5289
  gap: var(--space-40);
5234
5290
  opacity: 1;
5235
5291
  transform: translateX(0);
5236
- transition:
5237
- opacity var(--drawer-motion-duration) var(--ease-enter),
5238
- transform var(--drawer-motion-duration) var(--ease-enter);
5239
5292
  }
5240
5293
 
5241
5294
  /* A form is the drawer's content wrapper when actions need method="dialog".
@@ -5255,7 +5308,10 @@ html.has-modal-open.had-scrollbar {
5255
5308
  opacity: 0;
5256
5309
  /* Scrim curve stays neutral `ease`; only the panel's character curves
5257
5310
  apply. Duration follows --drawer-motion-duration through inheritance. */
5258
- transition: opacity var(--drawer-motion-duration) ease;
5311
+ transition-property: opacity, overlay, display;
5312
+ transition-duration: var(--drawer-motion-duration);
5313
+ transition-timing-function: ease;
5314
+ transition-behavior: allow-discrete;
5259
5315
  }
5260
5316
 
5261
5317
  :where(dialog).drawer[data-side="end"] {
@@ -5289,47 +5345,23 @@ html.has-modal-open.had-scrollbar {
5289
5345
  exclusion list is easier to trust than its `position: absolute`. */
5290
5346
  :where(dialog).drawer > :not(:is(header, footer, form, .close)),
5291
5347
  :where(dialog).drawer > form > :not(:is(header, footer, .close)) {
5292
- --drawer-focus-bleed: calc(
5293
- var(--focus-outline-offset) +
5294
- max(var(--focus-ring-width), var(--border-width) * 2)
5295
- );
5348
+ /* Declared here, not as a :root alias: an inherited alias arrives already
5349
+ computed, so a --focus-ring-width override on the drawer would not reach
5350
+ it. */
5351
+ --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
5296
5352
 
5297
5353
  min-block-size: 0;
5298
- /* Keep focus paint inside the clip without moving the body off the fixed
5299
- header and footer edges. The reserve covers the thicker of the two
5300
- outside indicators: the :focus baseline (2 × border) and the component
5301
- line (--focus-ring-width, 3px in bootstrap-v6). */
5302
- margin-inline: calc(var(--drawer-focus-bleed) * -1);
5303
- padding-inline: var(--drawer-focus-bleed);
5354
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
5355
+ both axes), without moving the content. Every outside indicator is
5356
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
5357
+ reaches into the slot gap, which is wider, so the scrollport never slides
5358
+ under the header or footer. */
5359
+ margin: calc(var(--drawer-focus-bleed) * -1);
5360
+ padding: var(--drawer-focus-bleed);
5304
5361
  overflow: auto;
5305
5362
  overscroll-behavior: contain;
5306
5363
  }
5307
5364
 
5308
- @supports (transition-behavior: allow-discrete) {
5309
- :where(dialog).drawer {
5310
- transition:
5311
- opacity var(--drawer-motion-duration) var(--ease-exit),
5312
- transform var(--drawer-motion-duration) var(--ease-exit),
5313
- overlay var(--drawer-motion-duration) allow-discrete,
5314
- display var(--drawer-motion-duration) allow-discrete;
5315
- }
5316
-
5317
- :where(dialog).drawer[open] {
5318
- transition:
5319
- opacity var(--drawer-motion-duration) var(--ease-enter),
5320
- transform var(--drawer-motion-duration) var(--ease-enter),
5321
- overlay var(--drawer-motion-duration) allow-discrete,
5322
- display var(--drawer-motion-duration) allow-discrete;
5323
- }
5324
-
5325
- :where(dialog).drawer::backdrop {
5326
- transition:
5327
- opacity var(--drawer-motion-duration) ease,
5328
- overlay var(--drawer-motion-duration) allow-discrete,
5329
- display var(--drawer-motion-duration) allow-discrete;
5330
- }
5331
- }
5332
-
5333
5365
  /* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
5334
5366
  path as the Degraded-tier fallback. The :dir() rules are dropped on their
5335
5367
  own there, so they need no wrapper. :dir() follows inherited HTML
@@ -5561,7 +5593,6 @@ picture.bleed > img {
5561
5593
  .flyout[popover] {
5562
5594
  position: fixed;
5563
5595
  inset: auto;
5564
- margin: 0;
5565
5596
  }
5566
5597
 
5567
5598
  /* A presentation class may own its *visible* display type, but must not
@@ -6530,13 +6561,13 @@ picture.bleed > img {
6530
6561
  * --avatar-size
6531
6562
  * --avatar-radius
6532
6563
  * --avatar-font-size
6564
+ * --avatar-bg
6533
6565
  * --avatar-stack-size
6534
6566
  * --avatar-stack-font-size
6535
6567
  * --avatar-stack-overlap
6536
6568
  * --avatar-stack-ring
6537
6569
  *
6538
6570
  * Internal:
6539
- * --avatar-bg
6540
6571
  * --avatar-fg
6541
6572
  * --avatar-status-translate-x
6542
6573
  */
@@ -6614,16 +6645,13 @@ picture.bleed > img {
6614
6645
 
6615
6646
  .avatar > img,
6616
6647
  .avatar > picture > img {
6648
+ display: block;
6617
6649
  inline-size: 100%;
6618
6650
  block-size: 100%;
6619
6651
  object-fit: cover;
6620
6652
  border-radius: inherit;
6621
6653
  }
6622
6654
 
6623
- .avatar > picture > img {
6624
- display: block;
6625
- }
6626
-
6627
6655
  button.avatar {
6628
6656
  appearance: none;
6629
6657
  cursor: pointer;
@@ -6717,19 +6745,6 @@ a.avatar[aria-disabled="true"] {
6717
6745
  --avatar-stack-font-size: var(--font-size-lg);
6718
6746
  }
6719
6747
 
6720
- /* Infer the stack scale from its avatars. Progressive: without :has() the
6721
- explicit .avatar-stack.sm/.lg classes and the default size still size the
6722
- group, only the inference is lost. */
6723
- .avatar-stack:has(> .avatar.sm) {
6724
- --avatar-stack-size: 1.75rem;
6725
- --avatar-stack-font-size: var(--font-size-xs);
6726
- }
6727
-
6728
- .avatar-stack:has(> .avatar.lg) {
6729
- --avatar-stack-size: 3rem;
6730
- --avatar-stack-font-size: var(--font-size-lg);
6731
- }
6732
-
6733
6748
  /* Overlapping avatars hide their neighbour: a translucent fill (--surface-subtle
6734
6749
  on a glass palette) would show the next avatar's edge through it, most of all
6735
6750
  when hover raises one above the other. The fill is layered over the opaque
@@ -7235,7 +7250,8 @@ a.avatar[aria-disabled="true"] {
7235
7250
  * destinations. Anything else focusable in a step is out of contract.
7236
7251
  *
7237
7252
  * The framework never hides an authored label to solve a layout problem. A
7238
- * labelled row that outgrows its space scrolls. An `actual-container` size
7253
+ * labelled row wraps its labels, then scrolls if its markers or authored item
7254
+ * minimum outgrow the available space. An `actual-container` size
7239
7255
  * context buys it one thing: the wide representation, marker beside label.
7240
7256
  *
7241
7257
  * See docs/design-notes/steps.md for the threshold, the selector shapes, the
@@ -7371,10 +7387,11 @@ a.avatar[aria-disabled="true"] {
7371
7387
  /* Horizontal ------------------------------------------------------------- */
7372
7388
 
7373
7389
  .steps-horizontal {
7374
- --step-min: 7rem;
7390
+ --step-min: var(--step-size);
7375
7391
  --step-gap: var(--space-20);
7376
7392
 
7377
7393
  inline-size: 100%;
7394
+ column-gap: var(--step-gap);
7378
7395
  overflow-x: auto;
7379
7396
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
7380
7397
  other to `auto`, and a row hugs fractional content closely enough that a
@@ -7383,11 +7400,22 @@ a.avatar[aria-disabled="true"] {
7383
7400
  overscroll-behavior-inline: contain;
7384
7401
  }
7385
7402
 
7403
+ /* A row that loads mid-flow starts scrolled to its current step; engines
7404
+ without the property start at the first. */
7405
+ .steps-horizontal > [aria-current="step"] {
7406
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
7407
+ scroll-initial-target: nearest;
7408
+ }
7409
+
7386
7410
  /* A scroll container clips at the padding edge, so a navigable row reserves the
7387
- focus ring's own width and offset. Only this composition scrolls, and only a
7388
- link has a ring to protect. */
7411
+ focus ring's own width and offset, and focus scrolls a link in that far from
7412
+ the inline clip. Only this composition scrolls, and only a link has a ring to
7413
+ protect. */
7389
7414
  .steps-horizontal:has(a[href]) {
7415
+ /* Wrapped names can fill their cell right up to either inline clip edge. */
7416
+ padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7390
7417
  padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7418
+ scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7391
7419
  }
7392
7420
 
7393
7421
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -7399,6 +7427,8 @@ a.avatar[aria-disabled="true"] {
7399
7427
  .steps-horizontal > li {
7400
7428
  display: grid;
7401
7429
  flex: 1 0 var(--step-min);
7430
+ /* Bound each label to its share so it cannot change the marker pitch. */
7431
+ grid-template-columns: minmax(0, 1fr);
7402
7432
  grid-template-rows: var(--step-size);
7403
7433
  gap: var(--step-gap);
7404
7434
  justify-items: center;
@@ -7406,9 +7436,42 @@ a.avatar[aria-disabled="true"] {
7406
7436
  text-align: center;
7407
7437
  }
7408
7438
 
7409
- /* An empty step is a marker and nothing else, so it needs a marker's width
7410
- rather than a reading width. That is what lets five of them fit a phone with
7411
- no size context and no variant class.
7439
+ .steps-horizontal .step-label {
7440
+ /* Even an unbroken name must fit its share instead of overlapping a peer.
7441
+ Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
7442
+ overflow-wrap: anywhere;
7443
+ hyphens: auto;
7444
+ }
7445
+
7446
+ /* Half end cells put the outer markers on the row's edges without changing
7447
+ their pitch, so the track lines up with the text around it, as the compact
7448
+ and inline forms do. The marker's half-width keeps the first connector equal
7449
+ to its peers.
7450
+
7451
+ Source order is load-bearing: these tie the `:empty` rules below and the
7452
+ inline query on specificity, and both must win over them. */
7453
+ .steps-horizontal > li:is(:first-child, :last-child) {
7454
+ flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
7455
+ min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
7456
+ }
7457
+
7458
+ .steps-horizontal > li:first-child {
7459
+ justify-items: start;
7460
+ text-align: start;
7461
+ }
7462
+
7463
+ .steps-horizontal > li:first-child::after {
7464
+ inset-inline-start: var(--step-size);
7465
+ inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
7466
+ }
7467
+
7468
+ .steps-horizontal > li:last-child {
7469
+ justify-items: end;
7470
+ text-align: end;
7471
+ }
7472
+
7473
+ /* An empty step ignores any authored reading-width floor and needs only its
7474
+ marker. That lets five of them fit a phone without a size context.
7412
7475
 
7413
7476
  `justify-items` is not an override of the row's centring: the marker is
7414
7477
  centred because a label sits under it and the two share a centre, and with no
@@ -7418,9 +7481,8 @@ a.avatar[aria-disabled="true"] {
7418
7481
  the text around it, the way the inline representation does.
7419
7482
 
7420
7483
  `:empty` says exactly what the markup says, the way `.badge:empty` does, and
7421
- it reads the item's own content: bare text in an `<li>` keeps a reading width
7422
- like its wrapped peers, where `> li:has(.step-label)` would give it a marker
7423
- floor and skew the row. `:empty` also keeps the compact form reachable on the
7484
+ it reads the item's own content: bare text keeps the same authored floor as
7485
+ its wrapped peers. `:empty` also keeps the compact form reachable on the
7424
7486
  Degraded tier, where only the inline bonus needs `:has()`. */
7425
7487
  .steps-horizontal > li:empty {
7426
7488
  flex: 1 1 0;
@@ -7430,6 +7492,8 @@ a.avatar[aria-disabled="true"] {
7430
7492
 
7431
7493
  .steps-horizontal > li:empty::after {
7432
7494
  inset-inline-start: var(--step-size);
7495
+ /* Marker-only end cells keep full shares, unlike labelled end cells. */
7496
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
7433
7497
  }
7434
7498
 
7435
7499
  .steps-horizontal > li:empty:last-child {
@@ -7437,12 +7501,12 @@ a.avatar[aria-disabled="true"] {
7437
7501
  }
7438
7502
 
7439
7503
  /* The pseudo-element is marker-height so --step-connector may be any complete
7440
- background value. */
7504
+ background value; its length also crosses the gap to the next equal track. */
7441
7505
  .steps-horizontal > li::after {
7442
7506
  position: absolute;
7443
7507
  inset-block-start: 0;
7444
7508
  inset-inline-start: calc(50% + var(--step-size) / 2);
7445
- inline-size: calc(100% - var(--step-size));
7509
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
7446
7510
  block-size: var(--step-size);
7447
7511
  background: var(
7448
7512
  --step-connector,
@@ -7461,6 +7525,11 @@ a.avatar[aria-disabled="true"] {
7461
7525
  * written — a row out of contract is not owed a curated fallback.
7462
7526
  */
7463
7527
  @container actual-container (inline-size >= 60rem) {
7528
+ .steps-horizontal:has(.step-label) {
7529
+ /* Inline connectors already own the spacing between marker-label groups. */
7530
+ column-gap: 0;
7531
+ }
7532
+
7464
7533
  .steps-horizontal:has(.step-label) > li {
7465
7534
  display: flex;
7466
7535
  flex: 1 1 auto;
@@ -7727,8 +7796,7 @@ a.avatar[aria-disabled="true"] {
7727
7796
 
7728
7797
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
7729
7798
  and scrollspy, which toggles aria-current="location" on the active link.
7730
- Presence is the contract: the attribute is set on the current link and
7731
- removed everywhere else, never serialized as aria-current="false".
7799
+ Those two values are the contract; any other, "false" included, is inert.
7732
7800
  Selection never changes text metrics: the shared medium weight is kept and
7733
7801
  the current link is carried by the selected accent. */
7734
7802
  .nav-link:hover {
@@ -7739,7 +7807,7 @@ a.avatar[aria-disabled="true"] {
7739
7807
  /* Current last, equal specificity to :hover (0,2,0): a hovered current link
7740
7808
  keeps the selected accent instead of dropping to --text. The surface fill is
7741
7809
  shared with the hover rule above. */
7742
- .nav-link[aria-current] {
7810
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
7743
7811
  background: var(--surface-subtle);
7744
7812
  color: var(--state-selected);
7745
7813
  }
@@ -7752,7 +7820,7 @@ a.avatar[aria-disabled="true"] {
7752
7820
  above keeps the light chip where color-mix is missing, with ink that is
7753
7821
  legible on it. */
7754
7822
  @supports (color: color-mix(in oklch, red, white)) {
7755
- .nav-link:is(:hover, [aria-current]) {
7823
+ .nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
7756
7824
  background: color-mix(in oklch, currentColor 10%, transparent);
7757
7825
  }
7758
7826
 
@@ -7761,7 +7829,7 @@ a.avatar[aria-disabled="true"] {
7761
7829
  }
7762
7830
 
7763
7831
  /* After :hover so a hovered current link keeps the selected accent. */
7764
- .nav-link[aria-current] {
7832
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
7765
7833
  color: var(--ui-fg, var(--state-selected));
7766
7834
  }
7767
7835
  }
@@ -7795,13 +7863,16 @@ a.avatar[aria-disabled="true"] {
7795
7863
  follows the forced palette without a forced-colors block and never touches
7796
7864
  the row separators. Navbar horizontal links keep color + surface only. The
7797
7865
  positioning context lives on every row link, so becoming current never moves
7798
- an app-positioned decoration to a new containing block. */
7799
- .nav-list .nav-link[aria-current]::before {
7866
+ an app-positioned decoration to a new containing block.
7867
+ The trait sits centered in the link's inline padding with rounded ends: flush
7868
+ on the edge, a square line read as pasted onto the rounded fill. */
7869
+ .nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
7800
7870
  content: "";
7801
7871
  position: absolute;
7802
7872
  inset-block: var(--space-20);
7803
- inset-inline-start: 0;
7873
+ inset-inline-start: calc((var(--space-20) - 2px) / 2);
7804
7874
  border-inline-start: 2px solid currentColor;
7875
+ border-radius: var(--radius-full);
7805
7876
  }
7806
7877
 
7807
7878
  /*
@@ -7810,14 +7881,21 @@ a.avatar[aria-disabled="true"] {
7810
7881
  * Docs: docs/pages/components/breadcrumb.md
7811
7882
  *
7812
7883
  * Inline list separated by "/". Mark the current page with `aria-current="page"`
7813
- * either on the item or on the current link.
7884
+ * either on the item or on the current link. Ancestor labels sit in an <a> or a
7885
+ * <span>, the box that truncates.
7886
+ *
7887
+ * Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
7888
+ * like separate rows and causes unstable header height, and scrolling hides the
7889
+ * root behind a scrollbar. A trail that does not fit truncates instead:
7890
+ * ancestors first, the current page last, each label behind an ellipsis.
7814
7891
  */
7892
+
7815
7893
  .breadcrumb {
7816
7894
  display: flex;
7817
- flex-direction: row;
7818
- flex-wrap: wrap;
7895
+ flex-wrap: nowrap;
7819
7896
  align-items: center;
7820
7897
  gap: var(--space-10);
7898
+ min-inline-size: 0;
7821
7899
  margin: 0;
7822
7900
  padding: 0;
7823
7901
  color: var(--text-subtle);
@@ -7825,15 +7903,74 @@ a.avatar[aria-disabled="true"] {
7825
7903
  list-style: none;
7826
7904
  }
7827
7905
 
7828
- .breadcrumb li {
7829
- display: inline-flex;
7906
+ /* The landmark around the trail must shrink with it in a flex or grid row, or
7907
+ it keeps the trail's full width and the labels never get to truncate.
7908
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
7909
+ wrapper is the fallback. */
7910
+ :where(nav:has(> .breadcrumb)) {
7911
+ min-inline-size: 0;
7912
+ }
7913
+
7914
+ /* Grid, not flex: under a min-content constraint a grid track keeps its base
7915
+ size, which is the label's minimum below, so the item's own minimum is
7916
+ separator + label floor without anyone measuring a "/". A flex item would
7917
+ report the full label as its minimum and never shrink.
7918
+
7919
+ Ancestors take the shrink before the current page, so the location is the
7920
+ last thing to lose characters. The ratio is deliberately huge: at 1000 the
7921
+ current page still took a fraction of a pixel, enough to trigger its
7922
+ ellipsis while ancestors had room. Its own factor stays 1, because flex
7923
+ distributes only a share of the deficit when the unfrozen factors sum below
7924
+ one. Keyed on the last item, the current page by convention, so the order
7925
+ holds without :has(). */
7926
+ .breadcrumb > li {
7927
+ display: inline-grid;
7928
+ flex: 0 1000000 auto;
7929
+ grid-auto-flow: column;
7830
7930
  align-items: center;
7831
- gap: var(--space-10);
7931
+ white-space: nowrap;
7832
7932
  }
7833
7933
 
7834
- .breadcrumb li + li::before {
7835
- content: "/";
7934
+ .breadcrumb > li:last-child {
7935
+ flex-shrink: 1;
7936
+ }
7937
+
7938
+ /* A margin rather than a grid gap, so the separator keeps its space when the
7939
+ current page below lays it out inline. The weight stays normal there, where
7940
+ it would inherit the current page's. */
7941
+ .breadcrumb > li + li::before {
7942
+ margin-inline-end: var(--space-10);
7836
7943
  color: var(--text-subtle);
7944
+ font-weight: var(--font-weight-normal);
7945
+ content: "/";
7946
+ }
7947
+
7948
+ /* The ellipsis lives on the label, not on the item: an element never clips
7949
+ its own outline, but an overflow-hidden item would clip its link's focus
7950
+ line, which sits outside the link box.
7951
+
7952
+ The floor keeps a truncated label readable as a step ("Po…") instead of
7953
+ collapsing to its separator. calc-size() lets a shorter label keep its own
7954
+ width; without it, the plain 2em floor leaves a label under 2em a little
7955
+ trailing room. */
7956
+ .breadcrumb > li > :is(a, span) {
7957
+ min-inline-size: 2em;
7958
+ min-inline-size: calc-size(max-content, min(size, 2em));
7959
+ overflow: hidden;
7960
+ text-overflow: ellipsis;
7961
+ }
7962
+
7963
+ /* The current page written as bare text has no label box, so its item
7964
+ truncates itself. Only with no element inside: an aria-current item that
7965
+ holds a link would clip that link's focus line, and keeps the label rule
7966
+ above instead. Its own rule, never merged with the one above: a browser
7967
+ without :has() drops it alone, and bare text then keeps its full width. */
7968
+ .breadcrumb > li[aria-current="page"]:not(:has(> *)) {
7969
+ display: block;
7970
+ min-inline-size: 2em;
7971
+ min-inline-size: calc-size(max-content, min(size, 2em));
7972
+ overflow: hidden;
7973
+ text-overflow: ellipsis;
7837
7974
  }
7838
7975
 
7839
7976
  .breadcrumb a {
@@ -7867,6 +8004,11 @@ a.avatar[aria-disabled="true"] {
7867
8004
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
7868
8005
  * — they are decorative, not actionable. They still get the same box sizing
7869
8006
  * as .btn below so the row stays visually aligned.
8007
+ *
8008
+ * The row wraps when it does not fit: every page stays visible and
8009
+ * clickable. A strip that scrolled hid pages behind a scrollbar that clicking
8010
+ * cannot move, since a click changes the page. Keeping the rendered range
8011
+ * compact is the application's job.
7870
8012
  */
7871
8013
  .pagination {
7872
8014
  display: flex;
@@ -7916,8 +8058,18 @@ a.avatar[aria-disabled="true"] {
7916
8058
  * author class can then adjust a vertical strip without restating the
7917
8059
  * attribute to reach its specificity.
7918
8060
  *
8061
+ * A horizontal strip is one axis of navigation, so it never wraps: a second
8062
+ * line no longer matches the Left/Right model and reads as a nested level.
8063
+ * When the tabs do not fit, the strip scrolls without a scrollbar; focus
8064
+ * scrolls the active tab into view.
8065
+ *
7919
8066
  * Public hooks:
8067
+ * --tab-flex
7920
8068
  * --tab-gap
8069
+ *
8070
+ * Internal:
8071
+ * --tab-edge
8072
+ * --tab-fade-dir (read inside @keyframes, so it resolves per element)
7921
8073
  */
7922
8074
  /* Intent boundary — inherited intents do not tint this component.
7923
8075
  Local intent classes still win because :where() has zero specificity. */
@@ -7928,27 +8080,129 @@ a.avatar[aria-disabled="true"] {
7928
8080
  .tabs {
7929
8081
  display: flex;
7930
8082
  flex-direction: row;
7931
- flex-wrap: wrap;
8083
+ flex-wrap: nowrap;
7932
8084
  gap: var(--space-10);
7933
8085
  margin: 0;
7934
8086
  padding: 0;
8087
+ /* Block overflow is hidden, not auto: overflow-x: auto alone computes the
8088
+ block axis to auto, so a sub-pixel line box would add a vertical
8089
+ scrollbar inside the strip. Nothing paints outside it: the focus outline
8090
+ is inset and the indicator sits inside the tab. */
8091
+ overflow: auto hidden;
8092
+ overscroll-behavior-inline: contain;
8093
+ /* why: a visible bar sits on the indicator and the hairline and reads as a
8094
+ second underline. The strip has its own scroll controls: selecting the
8095
+ clipped tab, or focusing it, brings it into view with the next one
8096
+ hinted by scroll-padding; arrow keys and swipes do the same. */
8097
+ scrollbar-width: none;
8098
+ /* Focus scrolls a tab in with room to spare, hinting at the next one; the
8099
+ edge fade below spans the same inset, so a revealed tab stays clear of it. */
8100
+ --tab-edge: var(--space-60);
8101
+
8102
+ scroll-padding-inline: var(--tab-edge);
7935
8103
  border-block-end: var(--border-width) solid var(--border);
7936
8104
  list-style: none;
7937
8105
  }
7938
8106
 
8107
+ /* The landmark around link tabs is not a scroll container, so as a flex or
8108
+ grid item it would keep the strip's full width and push the page into
8109
+ scrolling. Letting it shrink hands the overflow back to the strip.
8110
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
8111
+ wrapper is the fallback. */
8112
+ :where(nav:has(> .tabs)) {
8113
+ min-inline-size: 0;
8114
+ }
8115
+
8116
+ /* Items keep their size and the strip scrolls instead. Covers both
8117
+ anatomies: .tab directly in a tablist, or <li> in a navigation list.
8118
+ --tab-flex: 1 1 0 shares the width equally; an item still never shrinks
8119
+ below its label (flex items keep min-content), so a strip that cannot fit
8120
+ scrolls as before. */
8121
+ .tabs > * {
8122
+ flex: var(--tab-flex, none);
8123
+ }
8124
+
7939
8125
  .tabs:where([aria-orientation="vertical"]) {
7940
8126
  display: grid;
8127
+ overflow: visible;
7941
8128
  align-content: start;
7942
8129
  justify-items: stretch;
7943
8130
  border-block-end: 0;
7944
8131
  border-inline-end: var(--border-width) solid var(--border);
7945
8132
  }
7946
8133
 
8134
+ /* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
8135
+ toward. It runs on the strip's own inline timeline, which is inactive on a
8136
+ strip that fits or a vertical rail, so those stay unmasked. Keep the
8137
+ @supports: without scroll timelines the animation would end at once and pin
8138
+ its last mask. It follows scroll, not time, so reduced motion keeps it. */
8139
+ @supports (animation-timeline: scroll()) {
8140
+ .tabs {
8141
+ --tab-fade-dir: to right;
8142
+
8143
+ animation: actual-tabs-fade linear both;
8144
+ animation-timeline: scroll(self inline);
8145
+ }
8146
+
8147
+ /* Gradient keywords are physical; :dir() follows the strip's own
8148
+ direction, so an LTR island in an RTL page keeps its fade on the right. */
8149
+ .tabs:dir(rtl) {
8150
+ --tab-fade-dir: to left;
8151
+ }
8152
+ }
8153
+
8154
+ /* A strip that loads on a current link tab starts scrolled to it. Horizontal
8155
+ only: the strip is then its nearest scroll container, while a vertical
8156
+ rail would hand the target to the page. Engines without the property start
8157
+ at the first tab, and the fade says there is more. */
8158
+ :where(.tabs:not([aria-orientation="vertical"]))
8159
+ .tab:is([aria-current="page"], [aria-current="true"]) {
8160
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
8161
+ scroll-initial-target: nearest;
8162
+ }
8163
+
8164
+ /* Forced colors keep every label at full contrast. */
8165
+ @media (forced-colors: active) {
8166
+ .tabs {
8167
+ animation: none;
8168
+ }
8169
+ }
8170
+
8171
+ /* mask-image does not interpolate, so each mask holds until halfway to the
8172
+ next keyframe: the start fade appears within the first pixels of scroll and
8173
+ the end fade drops at the very end. */
8174
+ @keyframes actual-tabs-fade {
8175
+ 0% {
8176
+ mask-image: linear-gradient(
8177
+ var(--tab-fade-dir),
8178
+ #000 calc(100% - var(--tab-edge)),
8179
+ transparent
8180
+ );
8181
+ }
8182
+
8183
+ 1%,
8184
+ 99% {
8185
+ mask-image: linear-gradient(
8186
+ var(--tab-fade-dir),
8187
+ transparent,
8188
+ #000 var(--tab-edge) calc(100% - var(--tab-edge)),
8189
+ transparent
8190
+ );
8191
+ }
8192
+
8193
+ 100% {
8194
+ mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
8195
+ }
8196
+ }
8197
+
7947
8198
  .tab {
7948
8199
  /* Positioning context for the selected indicator pseudo. */
7949
8200
  position: relative;
7950
8201
  display: inline-flex;
7951
8202
  align-items: center;
8203
+ /* A tab wider than its label (--tab-flex) centers it; at natural width this
8204
+ changes nothing, and a vertical rail resets it to the start below. */
8205
+ justify-content: center;
7952
8206
  gap: var(--tab-gap, 0.375em);
7953
8207
  min-block-size: var(--control-size);
7954
8208
  padding-inline: var(--space-30);
@@ -7962,12 +8216,18 @@ a.avatar[aria-disabled="true"] {
7962
8216
  the strip. The active state is carried by color and the indicator line. */
7963
8217
  font-weight: var(--font-weight-semibold);
7964
8218
  text-decoration: none;
8219
+ /* A label breaking inside a one-line strip is the same defect as the strip
8220
+ wrapping. */
8221
+ white-space: nowrap;
7965
8222
  cursor: pointer;
7966
8223
  }
7967
8224
 
8225
+ /* A vertical strip has no inline axis to protect: long labels wrap within the
8226
+ rail instead of overflowing it. */
7968
8227
  .tabs:where([aria-orientation="vertical"]) .tab {
7969
8228
  justify-content: flex-start;
7970
8229
  padding-inline-end: var(--space-40);
8230
+ white-space: normal;
7971
8231
  }
7972
8232
 
7973
8233
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -7998,8 +8258,13 @@ a.avatar[aria-disabled="true"] {
7998
8258
  border-end-end-radius: 0;
7999
8259
  }
8000
8260
 
8261
+ /* A link tab is current on aria-current="page". "true" is for the one place
8262
+ where no page token fits: a navigation flyout trigger whose panel holds the
8263
+ current page. State selectors name their values, never the attribute's
8264
+ presence, so "false" (what React and Vue serialize for a false binding)
8265
+ stays inert. */
8001
8266
  .tab[aria-selected="true"],
8002
- .tab[aria-current="page"] {
8267
+ .tab:is([aria-current="page"], [aria-current="true"]) {
8003
8268
  color: var(--intent, var(--state-selected));
8004
8269
  }
8005
8270
 
@@ -8008,7 +8273,7 @@ a.avatar[aria-disabled="true"] {
8008
8273
  forced colors and erase the distinction. currentColor follows the palette the
8009
8274
  UA forces, so no forced-colors block is needed. */
8010
8275
  .tab[aria-selected="true"]::after,
8011
- .tab[aria-current="page"]::after {
8276
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
8012
8277
  content: "";
8013
8278
  position: absolute;
8014
8279
  inset-inline: 0;
@@ -8017,13 +8282,20 @@ a.avatar[aria-disabled="true"] {
8017
8282
  }
8018
8283
 
8019
8284
  .tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
8020
- .tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
8285
+ .tabs:where([aria-orientation="vertical"])
8286
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
8021
8287
  inset-block: 0;
8022
8288
  inset-inline: auto 0;
8023
8289
  border-block-end: 0;
8024
8290
  border-inline-end: 2px solid currentColor;
8025
8291
  }
8026
8292
 
8293
+ /* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
8294
+ it, so the indicator and the hit area span the whole share. */
8295
+ .tabs > li > .tab {
8296
+ inline-size: 100%;
8297
+ }
8298
+
8027
8299
  /*
8028
8300
  * Scrollspy — behavior hook for navigation that marks the active section
8029
8301
  * while the page scrolls.
@@ -8034,7 +8306,7 @@ a.avatar[aria-disabled="true"] {
8034
8306
  * <nav class="scrollspy">
8035
8307
  * containing <a href="#section"> links and toggles aria-current="location" on
8036
8308
  * the link whose target is in view. Pair with .nav-list and .nav-link, or style
8037
- * [aria-current] yourself.
8309
+ * [aria-current="location"] yourself.
8038
8310
  *
8039
8311
  * Progressive enhancement: on browsers that support :target-current,
8040
8312
  * this file enables the native target group and marks the active link.
@@ -8149,9 +8421,10 @@ a.avatar[aria-disabled="true"] {
8149
8421
  .list > :where(.list-item),
8150
8422
  .list > li > .list-item {
8151
8423
  display: flex;
8152
- /* Regions align to the first line, so leading, title, and trailing controls
8153
- share one horizontal line while supporting text wraps beneath. */
8154
- align-items: start;
8424
+ /* Centered fits the short rows lists mostly hold. Read through
8425
+ --items-align so .items-start on the row or the list wins without
8426
+ outranking this selector — the opt-out for long supporting text. */
8427
+ align-items: var(--items-align, center);
8155
8428
  gap: var(--list-item-gap);
8156
8429
  min-block-size: var(--list-item-min-size);
8157
8430
  padding-block: var(--list-item-pad-block);
@@ -8173,21 +8446,20 @@ a.list-item:hover {
8173
8446
  background: var(--surface-subtle);
8174
8447
  }
8175
8448
 
8176
- /* Current row in a navigational list (master/detail). Generic by design: the
8177
- visual treatment only cares that the row is current, so presence is the
8178
- contract — the attribute is set on the current row and removed everywhere
8179
- else, never serialized as aria-current="false". The value (page, location,
8180
- …) stays free for assistive technology.
8449
+ /* Current row in a navigational list: aria-current="page" when the row opens
8450
+ a page, "true" when it is the current item of a master/detail view (a
8451
+ conversation, a thread, a record) that is no page of its own. Any other
8452
+ value, "false" included, is inert.
8181
8453
  Calm by design: the row keeps its normal text metrics and color on a subtle
8182
8454
  surface, and the selected accent lives on an inset inline-start trait only.
8183
8455
  The trait is a real border, so it survives forced colors without a repaint
8184
8456
  block. The positioning context lives on every row link, so becoming current
8185
8457
  never moves an app-positioned decoration to a new containing block. */
8186
- a.list-item[aria-current] {
8458
+ a.list-item:is([aria-current="page"], [aria-current="true"]) {
8187
8459
  background: var(--surface-subtle);
8188
8460
  }
8189
8461
 
8190
- a.list-item[aria-current]::before {
8462
+ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
8191
8463
  content: "";
8192
8464
  position: absolute;
8193
8465
  inset-block: var(--list-item-pad-block);
@@ -8456,7 +8728,7 @@ a.list-item[aria-current]::before {
8456
8728
  }
8457
8729
 
8458
8730
  .join-addon {
8459
- --control-border: var(--border);
8731
+ --control-border: var(--border-control);
8460
8732
  --control-bg: var(--surface-subtle);
8461
8733
  --control-fg: var(--text-muted);
8462
8734
 
@@ -8561,7 +8833,7 @@ a.list-item[aria-current]::before {
8561
8833
  The second :has() covers a control nested one wrapper level deep. */
8562
8834
  .join:has(> :is(.input, .textarea, .select)) > .btn.outline,
8563
8835
  .join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
8564
- --btn-border-color: var(--intent, var(--border));
8836
+ --btn-border-color: var(--intent, var(--border-control));
8565
8837
  }
8566
8838
 
8567
8839
  /* Effects — purely visual enrichments. */
@@ -9095,9 +9367,9 @@ a.list-item[aria-current]::before {
9095
9367
  flex-wrap: nowrap;
9096
9368
  }
9097
9369
 
9098
- /* The property is written directly, never relayed through the cluster hook:
9099
- a relay would inherit into every .cluster nested inside. Utilities load last,
9100
- so the direct declaration already beats .cluster at equal specificity. */
9370
+ /* The property is written directly, so the utility works on any flex row, not
9371
+ only a .cluster. Utilities load last, so the direct declaration beats
9372
+ .cluster at equal specificity. */
9101
9373
  .justify-content-start {
9102
9374
  justify-content: flex-start;
9103
9375
  }