actual-css 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -8
  3. package/components.json +124 -12
  4. package/dist/actual.full.css +1152 -309
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/architecture.md +67 -0
  8. package/docs/design-notes/steps.md +45 -28
  9. package/docs/navigation.json +2 -0
  10. package/docs/pages/components/alert.md +12 -2
  11. package/docs/pages/components/avatar.md +17 -11
  12. package/docs/pages/components/breadcrumb.md +16 -2
  13. package/docs/pages/components/button.md +3 -2
  14. package/docs/pages/components/list.md +15 -16
  15. package/docs/pages/components/navbar.md +42 -22
  16. package/docs/pages/components/pagination.md +19 -0
  17. package/docs/pages/components/segmented.md +83 -0
  18. package/docs/pages/components/steps.md +28 -22
  19. package/docs/pages/enhancements/flyout.md +5 -0
  20. package/docs/pages/enhancements/scrollspy.md +6 -6
  21. package/docs/pages/enhancements/tabs.md +79 -3
  22. package/docs/pages/examples/overview.md +3 -1
  23. package/docs/pages/forms/color-swatches.md +131 -0
  24. package/docs/pages/forms/combobox.md +2 -2
  25. package/docs/pages/forms/controls.md +6 -3
  26. package/docs/pages/forms/floating-field.md +21 -7
  27. package/docs/pages/forms/form-actions.md +3 -6
  28. package/docs/pages/forms/joined-controls.md +6 -2
  29. package/docs/pages/forms/overview.md +1 -0
  30. package/docs/pages/forms/states-and-sizes.md +15 -15
  31. package/docs/pages/forms/switch.md +4 -2
  32. package/docs/pages/foundations/philosophy.md +97 -1
  33. package/docs/pages/foundations/theming.md +1 -1
  34. package/docs/pages/foundations/tokens.md +94 -27
  35. package/docs/pages/guides/design-tools.md +14 -8
  36. package/docs/pages/guides/integrating-actual-css.md +74 -8
  37. package/docs/pages/guides/introduction.md +3 -2
  38. package/docs/pages/guides/modular-import.md +1 -1
  39. package/docs/pages/guides/progressive-enhancement.md +25 -7
  40. package/docs/pages/guides/upgrading.md +86 -9
  41. package/docs/pages/layout/center.md +2 -1
  42. package/docs/pages/layout/cluster.md +11 -4
  43. package/docs/pages/layout/grid.md +2 -0
  44. package/docs/pages/layout/scroll-snap.md +4 -2
  45. package/docs/pages/layout/sidebar-layout.md +3 -0
  46. package/docs/pages/layout/switcher.md +2 -0
  47. package/docs/pages/layout/topbar.md +6 -4
  48. package/docs/pages/patterns/icon-slot.md +17 -1
  49. package/docs/pages/utilities/flex.md +1 -1
  50. package/docs/pages/utilities/sizing-wrapping.md +51 -6
  51. package/package.json +6 -6
  52. package/reserved-classes.json +3 -0
  53. package/src/cli/actual-css.js +4 -4
  54. package/src/css/components/alert.css +99 -48
  55. package/src/css/components/app-nav.css +17 -9
  56. package/src/css/components/avatar.css +2 -18
  57. package/src/css/components/badge.css +23 -10
  58. package/src/css/components/breadcrumb.css +74 -8
  59. package/src/css/components/button.css +17 -2
  60. package/src/css/components/close.css +6 -3
  61. package/src/css/components/drawer.css +23 -42
  62. package/src/css/components/flyout.css +0 -1
  63. package/src/css/components/index.css +1 -0
  64. package/src/css/components/join.css +2 -2
  65. package/src/css/components/list.css +28 -13
  66. package/src/css/components/modal.css +17 -10
  67. package/src/css/components/navbar.css +15 -10
  68. package/src/css/components/pagination.css +5 -0
  69. package/src/css/components/scrollspy.css +1 -1
  70. package/src/css/components/segmented.css +186 -0
  71. package/src/css/components/steps.css +72 -15
  72. package/src/css/components/tab.css +135 -5
  73. package/src/css/core/focus.css +11 -6
  74. package/src/css/core/theme.css +6 -1
  75. package/src/css/core/tokens.css +24 -6
  76. package/src/css/core/variants.css +16 -5
  77. package/src/css/forms/choice-card.css +1 -1
  78. package/src/css/forms/choice.css +1 -1
  79. package/src/css/forms/color-swatch.css +159 -0
  80. package/src/css/forms/control.css +1 -1
  81. package/src/css/forms/floating-field.css +20 -18
  82. package/src/css/forms/form-actions.css +14 -8
  83. package/src/css/forms/index.css +1 -0
  84. package/src/css/forms/native.css +51 -21
  85. package/src/css/forms/otp.css +4 -5
  86. package/src/css/forms/switch.css +6 -3
  87. package/src/css/forms/validation.css +23 -30
  88. package/src/css/layout/center.css +2 -0
  89. package/src/css/layout/cluster.css +14 -0
  90. package/src/css/layout/grid.css +7 -0
  91. package/src/css/layout/scroll-snap.css +30 -0
  92. package/src/css/layout/scroller.css +19 -3
  93. package/src/css/layout/sidebar-layout.css +3 -0
  94. package/src/css/layout/switcher.css +3 -0
  95. package/src/css/layout/topbar.css +10 -0
  96. package/src/css/utilities/extra.css +3 -3
  97. package/src/js/focus.js +16 -1
  98. package/src/js/full.js +4 -0
  99. package/src/js/inline-visible.js +54 -0
  100. package/src/js/parsed.js +21 -0
  101. package/src/js/surface.js +10 -0
  102. package/src/js/tab.js +33 -7
  103. package/src/tooling/color.js +14 -1
  104. package/src/tooling/css-bundle.js +0 -5
  105. package/src/tooling/design-components.js +9 -5
  106. package/src/tooling/design-export.js +9 -1
  107. package/src/tooling/design-tokens.js +207 -76
  108. package/src/tooling/theme-contrast.js +182 -45
@@ -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
 
@@ -213,9 +215,20 @@ select {
213
215
  [data-theme] {
214
216
  /* State tokens — semantic colors for selected/pressed/current and disabled.
215
217
  Components consume these instead of hard-wiring --primary/--text-subtle
216
- so forced-colors can override them in one place. */
218
+ so forced-colors can override them in one place. --state-selected is a
219
+ selected fill or accent, --state-selected-fg the ink on that fill.
220
+ --state-selected-text is selected ink on a regular surface (current nav
221
+ link, selected tab, current step number). It is reset to the
222
+ guaranteed-invalid value on every theme boundary, so a consumer reads its
223
+ local --state-selected through the fallback var(--state-selected-text,
224
+ var(--state-selected)): a theme that resolves it to var(--link) bakes that
225
+ reference where it is declared, so a nested island that does not set it
226
+ inherits an already-resolved value the local fallback can never reach. A
227
+ theme sets it when one color cannot be both a fill under its -fg and text
228
+ on the page, typically in a dark scheme. */
217
229
  --state-selected: var(--primary);
218
230
  --state-selected-fg: var(--primary-fg);
231
+ --state-selected-text: initial;
219
232
  --state-disabled: var(--text-subtle);
220
233
 
221
234
  /* Status dot ring — cuts the dot out of what it overlaps. */
@@ -224,10 +237,15 @@ select {
224
237
 
225
238
  :root {
226
239
  /* Variant tuning — themes may adjust generated soft surfaces without
227
- replacing the component rules. */
240
+ replacing the component rules. --soft-bg-mix is the share of surface in a
241
+ soft fill: an opaque mix on the shared .soft, an opacity (100% minus it)
242
+ on the translucent badge, alert and soft-button tint. --soft-hover-alpha
243
+ is the extra intent share on hover. --soft-border-mix has no default: it
244
+ is a fallback-only hook whose absence lets each recipe keep its own rim
245
+ (a tinted rim on .soft, none on the tint), and a theme that sets it gives
246
+ both a rim of that weight. */
228
247
  --soft-bg-mix: 88%;
229
- --soft-border-mix: 65%;
230
- --soft-hover-alpha: 12%;
248
+ --soft-hover-alpha: 6%;
231
249
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
232
250
  pure intent color; lower values rebate it toward --text, which vivid or
233
251
  light intent palettes need to stay legible on their own soft surface.
@@ -315,7 +333,9 @@ select {
315
333
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
316
334
  * other tokens; hard-coded overrides on a custom theme will break the intent.
317
335
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
318
- * translucent surfaces overrides them with opaque values.
336
+ * translucent surfaces overrides them with opaque values. --border-control is
337
+ * public too: the resting edge of a neutral control, apart from structural
338
+ * separators.
319
339
  * Internal: --hgroup-gap.
320
340
  */
321
341
  :root {
@@ -417,6 +437,9 @@ select {
417
437
  never a fill. --surface-solid-fg is the ink on --surface-solid. */
418
438
  --surface-opaque: var(--surface);
419
439
  --surface-solid-fg: var(--surface);
440
+ /* The edge that tells a control apart from the surface, where --border only
441
+ separates surfaces: fields, choices, switch, OTP cells, choice cards. */
442
+ --border-control: var(--border);
420
443
  }
421
444
 
422
445
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -711,6 +734,9 @@ small {
711
734
  *
712
735
  * Intent list source of truth: intents.css. Components consume --intent* tokens
713
736
  * instead of re-listing intent classes.
737
+ *
738
+ * Public hooks:
739
+ * --soft-border-mix (fallback-only: a soft rim of that weight on every recipe)
714
740
  */
715
741
  .solid {
716
742
  --ui-bg: var(--intent, var(--neutral));
@@ -774,7 +800,7 @@ small {
774
800
 
775
801
  /* Shared control scale. Wrappers are listed when their descendants form one
776
802
  component (field, choice-card, floating-field, input-icon, join, pagination,
777
- tabs, flyout). A local size declaration beats inherited density because it
803
+ tabs, segmented, flyout). A local size declaration beats inherited density because it
778
804
  is declared on the sized component itself. Guarded by check:sync
779
805
  (size-scale-participants): .sm and .lg must list the same components. */
780
806
  :where(
@@ -787,6 +813,7 @@ small {
787
813
  .field,
788
814
  .choice,
789
815
  .choice-card,
816
+ .color-swatch,
790
817
  .input-icon,
791
818
  .floating-field,
792
819
  .otp,
@@ -794,6 +821,7 @@ small {
794
821
  .pagination,
795
822
  .tabs,
796
823
  .tab,
824
+ .segmented,
797
825
  .flyout
798
826
  ).sm {
799
827
  --control-size: var(--control-size-sm);
@@ -814,6 +842,7 @@ small {
814
842
  .field,
815
843
  .choice,
816
844
  .choice-card,
845
+ .color-swatch,
817
846
  .input-icon,
818
847
  .floating-field,
819
848
  .otp,
@@ -821,6 +850,7 @@ small {
821
850
  .pagination,
822
851
  .tabs,
823
852
  .tab,
853
+ .segmented,
824
854
  .flyout
825
855
  ).lg {
826
856
  --control-size: var(--control-size-lg);
@@ -832,21 +862,25 @@ small {
832
862
  }
833
863
 
834
864
  /* Modern color enhancement. Baseline variant values above remain usable
835
- everywhere. Interactive states live in the components that consume them. */
865
+ everywhere. Interactive states live in the components that consume them.
866
+ The shared .soft is opaque: it paints zones (.card, .navbar, .app-nav,
867
+ .chat-bubble) an author may lay over an image, where a translucent fill
868
+ would hand the text's contrast to whatever is behind it. The badge, alert
869
+ and soft button own a translucent tint instead (soft-tint). */
836
870
  @supports (color: color-mix(in oklch, red, white)) {
837
871
  .soft {
838
- /* @sync soft-recipe */
839
872
  --ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
840
873
  --ui-border: color-mix(
841
874
  in oklab,
842
- var(--surface) var(--soft-border-mix),
875
+ var(--surface) var(--soft-border-mix, 65%),
843
876
  var(--intent, var(--neutral))
844
877
  );
878
+ /* @sync soft-ink */
845
879
  --ui-fg: var(
846
880
  --intent-soft-fg,
847
881
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
848
882
  );
849
- /* @sync soft-recipe */
883
+ /* @sync soft-ink */
850
884
  }
851
885
  }
852
886
 
@@ -873,19 +907,24 @@ small {
873
907
  --surface-solid, so one token reads on the page, on a solid band and on
874
908
  any nested surface.
875
909
 
910
+ A .segment is an action but takes the inset ring: its track's padding is
911
+ narrower than an offset ring, which would cover the neighbouring segment.
912
+
876
913
  Documented exception: an invalid field focuses in --form-invalid-border
877
914
  (danger), so the error stays visible at the point of correction. The border
878
915
  carries the same hook; report:theme-contrast measures that line at 3:1
879
- against the field's background alongside --focus. */
916
+ against the field's background alongside --focus.
917
+
918
+ Public hooks:
919
+ --focus-outline (fallback-only: replaces the whole generic line)
920
+ */
880
921
  :focus {
881
- outline: var(--focus-outline);
922
+ outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
882
923
  outline-offset: var(--focus-outline-offset);
883
924
  }
884
925
 
885
- @supports selector(:focus-visible) {
886
- :focus:not(:focus-visible) {
887
- outline: none;
888
- }
926
+ :focus:not(:focus-visible) {
927
+ outline: none;
889
928
  }
890
929
 
891
930
  @media print {
@@ -1349,7 +1388,21 @@ small {
1349
1388
  *
1350
1389
  * Child contract: direct-child margins are reset, spacing comes from --gap.
1351
1390
  * A ul/ol/menu keeps its semantics but loses list chrome (see below).
1391
+ *
1392
+ * The hooks describe this row's own geometry, so they stop at each cluster:
1393
+ * set them on the cluster itself, or target nested rows with a selector. The
1394
+ * gap token is context rhythm and keeps inheriting.
1352
1395
  */
1396
+
1397
+ /* Instance boundary: an ancestor's value never reaches a nested cluster.
1398
+ Zero specificity, so any class or inline style on the cluster wins without
1399
+ depending on import order; `initial` makes the var() fallbacks apply. */
1400
+ :where(.cluster) {
1401
+ --cluster-align: initial;
1402
+ --cluster-justify: initial;
1403
+ --cluster-wrap: initial;
1404
+ }
1405
+
1353
1406
  .cluster {
1354
1407
  display: flex;
1355
1408
  flex-direction: row;
@@ -1382,6 +1435,8 @@ small {
1382
1435
  * --center-size
1383
1436
  * --center-pad
1384
1437
  */
1438
+ /* The hooks inherit on purpose: a site's content width is page policy, so a
1439
+ theme sets them once on :root for every .center. */
1385
1440
  .center {
1386
1441
  box-sizing: content-box;
1387
1442
  inline-size: calc(100% - var(--center-pad, 1rem) * 2);
@@ -1554,6 +1609,9 @@ small {
1554
1609
  *
1555
1610
  * Child contract: every direct child is a peer; the flex-basis applies to all.
1556
1611
  */
1612
+ /* No instance reset, unlike .cluster and .grid: the children read the hook,
1613
+ and a switcher nested as a direct child takes its own flex-basis from its
1614
+ parent's value, so the hook must reach it. */
1557
1615
  .switcher {
1558
1616
  display: flex;
1559
1617
  flex-direction: row;
@@ -1586,6 +1644,9 @@ small {
1586
1644
  * visual, reading and keyboard order all agree — the role rules swap so the
1587
1645
  * aside still takes its preferred width and the main still reclaims the row.
1588
1646
  */
1647
+ /* No instance reset, unlike .cluster and .grid: the regions read the hooks,
1648
+ and a layout nested as a region takes its own size from its parent's
1649
+ values, so the hooks must reach it. */
1589
1650
  .sidebar-layout {
1590
1651
  display: flex;
1591
1652
  flex-direction: row;
@@ -1647,6 +1708,13 @@ small {
1647
1708
  * the thresholds were arrived at: docs/design-notes/grid.md.
1648
1709
  */
1649
1710
 
1711
+ /* Instance boundary: a nested grid never takes its parent's track template.
1712
+ Zero specificity, so a class or inline style on the grid itself wins. */
1713
+ :where(.grid) {
1714
+ --grid-columns: initial;
1715
+ --grid-min: initial;
1716
+ }
1717
+
1650
1718
  /* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
1651
1719
  no longer collapses on its own and the author owns narrow-container
1652
1720
  collapse. */
@@ -1832,7 +1900,16 @@ small {
1832
1900
  * draw their native scrollbar — the intended fallback, not a broken state.
1833
1901
  *
1834
1902
  * Styles the scrollbar only; it does not create the overflow. Pair it with
1835
- * .overflow-auto or a component that already scrolls.
1903
+ * .overflow-auto or a component that already scrolls (horizontal steps, a
1904
+ * table wrap; tabs hide their bar and fade their own edges). No edge fade
1905
+ * here: a mask would dictate the strip's scroll-padding and focus geometry for
1906
+ * a decorative cue, and an item clipped at the edge already says the strip
1907
+ * continues.
1908
+ *
1909
+ * Not on touch: Chromium trades the overlay scrollbar of a touch screen,
1910
+ * hidden at rest, for a classic always-visible one as soon as scrollbar-color
1911
+ * or scrollbar-width is set. On a phone that painted a permanent bar under
1912
+ * every strip; the native overlay is the better cue there.
1836
1913
  *
1837
1914
  * Public hooks:
1838
1915
  * --scroller-track
@@ -1841,9 +1918,16 @@ small {
1841
1918
  .scroller {
1842
1919
  --scroller-track: transparent;
1843
1920
  --scroller-thumb: var(--border);
1921
+ }
1844
1922
 
1845
- scrollbar-color: var(--scroller-thumb) var(--scroller-track);
1846
- scrollbar-width: thin;
1923
+ /* Any primary pointer but touch: a keyboard-only or pointerless device
1924
+ (pointer: none) draws classic scrollbars too. `all and` keeps the negation
1925
+ valid in Media Queries 3 parsers. */
1926
+ @media not all and (pointer: coarse) {
1927
+ .scroller {
1928
+ scrollbar-color: var(--scroller-thumb) var(--scroller-track);
1929
+ scrollbar-width: thin;
1930
+ }
1847
1931
  }
1848
1932
 
1849
1933
  /* Opt-in gutter: reserves space so the layout does not shift when
@@ -1873,16 +1957,36 @@ small {
1873
1957
  * --scroll-snap-align
1874
1958
  * --scroll-snap-item-size
1875
1959
  * --scroll-snap-padding
1960
+ *
1961
+ * Internal:
1962
+ * --scroll-snap-focus-bleed
1876
1963
  */
1877
1964
  .scroll-snap {
1878
1965
  --scroll-snap-gap: var(--gap);
1879
1966
  --scroll-snap-align: start;
1880
1967
  --scroll-snap-item-size: auto;
1968
+ /* Declared here, not as a :root alias: an inherited alias arrives already
1969
+ computed, so a --focus-ring-width override on the rail would not reach it. */
1970
+ --scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
1881
1971
 
1882
1972
  display: flex;
1883
1973
  flex-direction: row;
1884
1974
  gap: var(--scroll-snap-gap);
1975
+ /* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
1976
+ the top or bottom item would be lost too. Every side reserves the ring's
1977
+ own width; the block reserve returns to the flow through a negative
1978
+ margin, while the inline reserve stays as the rail's focus gutter.
1979
+ Bleeding the inline reserve outward instead would make the border box
1980
+ `containing block + 2 * reserve` wide, which at the viewport edge grows
1981
+ the page: a rail cannot be flush with arbitrary surrounding content and
1982
+ free of page overflow at once. */
1983
+ margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
1984
+ padding-block: var(--scroll-snap-focus-bleed);
1985
+ padding-inline: var(--scroll-snap-focus-bleed);
1885
1986
  overflow-x: auto;
1987
+ /* One scroll axis, stated: a fractional content height could otherwise raise
1988
+ a phantom vertical scrollbar under one font or zoom step. */
1989
+ overflow-y: hidden;
1886
1990
  }
1887
1991
 
1888
1992
  .scroll-snap > * {
@@ -1892,7 +1996,17 @@ small {
1892
1996
  @supports (scroll-snap-type: x mandatory) {
1893
1997
  .scroll-snap {
1894
1998
  overscroll-behavior-inline: contain;
1999
+ /* Fallback only, never the ring's protector: padding-inline above already
2000
+ guarantees paint. A unitless `0` drops the clamp below but keeps this
2001
+ declaration, so the worst case is a snapped item flush with the gutter,
2002
+ not a clipped ring. */
1895
2003
  scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
2004
+ /* A conforming <length> stays at least the focus reserve, so scroll and
2005
+ snap stop with the gutter's air. */
2006
+ scroll-padding-inline: max(
2007
+ var(--scroll-snap-padding, var(--scroll-snap-gap)),
2008
+ var(--scroll-snap-focus-bleed)
2009
+ );
1896
2010
  /* x then inline: an engine that lacks the logical keyword keeps x.
1897
2011
  proximity is the default, so a short rail cannot trap the pointer. */
1898
2012
  scroll-snap-type: x proximity;
@@ -1989,6 +2103,16 @@ small {
1989
2103
  }
1990
2104
  }
1991
2105
 
2106
+ /* A reduce override, not a no-preference gate: a browser without the feature
2107
+ evaluates it false and keeps the frosted bar instead of losing it. */
2108
+ @media (prefers-reduced-transparency: reduce) {
2109
+ .topbar {
2110
+ background: var(--surface);
2111
+ backdrop-filter: none;
2112
+ -webkit-backdrop-filter: none;
2113
+ }
2114
+ }
2115
+
1992
2116
  /* Readable content measure. Composes with .center for centering or with
1993
2117
  margin-inline: auto for a one-off centered block.
1994
2118
  *
@@ -2107,7 +2231,7 @@ small {
2107
2231
  .input,
2108
2232
  .textarea,
2109
2233
  .select {
2110
- --control-border: var(--border);
2234
+ --control-border: var(--border-control);
2111
2235
  --control-bg: var(--surface);
2112
2236
  --control-fg: var(--text);
2113
2237
  --control-cursor: auto;
@@ -2333,7 +2457,7 @@ small {
2333
2457
  /* Control geometry. --choice-* are the per-state tokens. */
2334
2458
  .check,
2335
2459
  .radio {
2336
- --choice-border: var(--border);
2460
+ --choice-border: var(--border-control);
2337
2461
  --choice-bg: var(--surface);
2338
2462
  --choice-mark: var(--primary-fg);
2339
2463
 
@@ -2503,6 +2627,9 @@ small {
2503
2627
  *
2504
2628
  * Range has its own custom-styled file (range.css) so the focus ring can
2505
2629
  * target the thumb pseudo-element rather than the rectangular CSS box.
2630
+ *
2631
+ * Public hooks:
2632
+ * --color-size
2506
2633
  */
2507
2634
  .file {
2508
2635
  color: var(--text-muted);
@@ -2521,7 +2648,7 @@ small {
2521
2648
  calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
2522
2649
  );
2523
2650
  padding-inline: var(--space-20);
2524
- border: var(--border-width) solid var(--border);
2651
+ border: var(--border-width) solid var(--border-control);
2525
2652
  border-radius: var(--radius-sm);
2526
2653
  background: var(--surface-subtle);
2527
2654
  color: var(--text);
@@ -2548,16 +2675,24 @@ small {
2548
2675
  outline-offset: var(--focus-outline-offset);
2549
2676
  }
2550
2677
 
2551
- /* Native color picker — keep the browser's dialog and selected-color UI. The
2552
- swatch fills almost the whole control so it reads as a color, not as a tiny
2553
- square inside a button; the 1px padding keeps a hair of --surface around it. */
2678
+ /* Native color picker — keep the browser's dialog and selected-color UI. Same
2679
+ geometry as .color-swatch: a --control-size hit area around a round swatch
2680
+ the height of a .switch track, so a picker, a palette and a switch compose.
2681
+ The input is the hit area; its padding leaves the swatch, which carries the
2682
+ edge and the focus ring. Vendor pseudos cannot share a rule. */
2554
2683
  .color {
2555
- inline-size: var(--control-size);
2556
- block-size: var(--control-size);
2557
- padding: var(--border-width);
2558
- border: var(--border-width) solid var(--border);
2559
- border-radius: var(--radius);
2560
- background: var(--surface);
2684
+ --color-size: calc(var(--choice-control-size) + 0.125em);
2685
+
2686
+ inline-size: max(var(--control-size), var(--color-size));
2687
+ block-size: max(var(--control-size), var(--color-size));
2688
+ padding: calc((max(var(--control-size), var(--color-size)) - var(--color-size)) / 2);
2689
+ /* Lay out at the visible width, as .color-swatch does: the hit area
2690
+ overflows on both sides so a label gap is measured from the swatch. */
2691
+ margin: 0 calc((var(--color-size) - max(var(--control-size), var(--color-size))) / 2);
2692
+ border: 0;
2693
+ background: transparent;
2694
+ /* Consumed so .sm/.lg reach the em-sized swatch. */
2695
+ font-size: var(--control-font-size);
2561
2696
  cursor: pointer;
2562
2697
  }
2563
2698
 
@@ -2566,13 +2701,21 @@ small {
2566
2701
  }
2567
2702
 
2568
2703
  .color::-webkit-color-swatch {
2569
- border: 0;
2570
- border-radius: calc(var(--radius) - var(--border-width));
2704
+ border: var(--border-width) solid var(--border-control);
2705
+ border-radius: var(--radius-full);
2571
2706
  }
2572
2707
 
2573
2708
  .color::-moz-color-swatch {
2574
- border: 0;
2575
- border-radius: calc(var(--radius) - var(--border-width));
2709
+ border: var(--border-width) solid var(--border-control);
2710
+ border-radius: var(--radius-full);
2711
+ }
2712
+
2713
+ .color:hover::-webkit-color-swatch {
2714
+ border-color: var(--text-subtle);
2715
+ }
2716
+
2717
+ .color:hover::-moz-color-swatch {
2718
+ border-color: var(--text-subtle);
2576
2719
  }
2577
2720
 
2578
2721
  .color:disabled {
@@ -2580,25 +2723,36 @@ small {
2580
2723
  opacity: var(--disabled-opacity);
2581
2724
  }
2582
2725
 
2726
+ /* The ring goes around the visible swatch, not the larger hit area. */
2583
2727
  .color:focus-visible {
2728
+ outline: none;
2729
+ }
2730
+
2731
+ .color:focus-visible::-webkit-color-swatch {
2584
2732
  outline: var(--focus-ring-width) solid var(--focus);
2585
2733
  outline-offset: var(--focus-outline-offset);
2586
2734
  }
2587
2735
 
2588
- @media (forced-colors: active) {
2589
- .color {
2590
- border-color: ButtonText;
2591
- background: ButtonFace;
2592
- }
2736
+ .color:focus-visible::-moz-color-swatch {
2737
+ outline: var(--focus-ring-width) solid var(--focus);
2738
+ outline-offset: var(--focus-outline-offset);
2739
+ }
2593
2740
 
2594
- /* The swatch color is content, so preserve it without disabling the host's
2595
- native forced-color focus treatment. Vendor pseudos cannot share a rule. */
2741
+ @media (forced-colors: active) {
2742
+ /* The swatch color is content, so the swatch opts out of forcing and takes
2743
+ system colors for its edge and ring itself. */
2596
2744
  .color::-webkit-color-swatch {
2745
+ --focus: CanvasText;
2746
+
2597
2747
  forced-color-adjust: none;
2748
+ border-color: CanvasText;
2598
2749
  }
2599
2750
 
2600
2751
  .color::-moz-color-swatch {
2752
+ --focus: CanvasText;
2753
+
2601
2754
  forced-color-adjust: none;
2755
+ border-color: CanvasText;
2602
2756
  }
2603
2757
  }
2604
2758
 
@@ -2609,6 +2763,8 @@ small {
2609
2763
  *
2610
2764
  * Imported by default through forms/index.css. Custom builds may omit this
2611
2765
  * file when an app wants to own validation styling completely.
2766
+ * Without :has(), automatic error display and choice-card borders drop;
2767
+ * native control states and the explicit .field.danger route stay available.
2612
2768
  *
2613
2769
  * State coverage (which states each component ships) is audited in
2614
2770
  * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
@@ -2630,11 +2786,14 @@ small {
2630
2786
  display: block;
2631
2787
  }
2632
2788
 
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
- }
2789
+ .field:has([aria-invalid="true"]) > .field-error,
2790
+ .needs-validation.was-validated .field:has(:invalid) > .field-error {
2791
+ display: block;
2792
+ }
2793
+
2794
+ /* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
2795
+ .needs-validation .field:has(:user-invalid) > .field-error {
2796
+ display: block;
2638
2797
  }
2639
2798
 
2640
2799
  :where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
@@ -2658,41 +2817,29 @@ small {
2658
2817
  --switch-bg: var(--danger);
2659
2818
  }
2660
2819
 
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
- }
2820
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
2821
+ .needs-validation .radio:checked:user-invalid {
2822
+ --choice-bg: var(--danger);
2823
+ }
2666
2824
 
2667
- .needs-validation .switch:checked:user-invalid {
2668
- --switch-bg: var(--danger);
2669
- }
2825
+ .needs-validation .switch:checked:user-invalid {
2826
+ --switch-bg: var(--danger);
2670
2827
  }
2671
2828
 
2672
2829
  /* Choice cards hide their native input, so invalid state must surface on the
2673
2830
  card itself. Sets the same --form-invalid-border hook the card's border
2674
2831
  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
- }
2832
+ .choice-card:has([aria-invalid="true"]),
2833
+ .needs-validation.was-validated .choice-card:has(:invalid) {
2834
+ --form-invalid-border: var(--danger);
2680
2835
  }
2681
2836
 
2682
- @supports selector(:user-invalid) {
2683
- .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
2684
- --form-invalid-border: var(--danger);
2685
- }
2837
+ .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
2838
+ --form-invalid-border: var(--danger);
2686
2839
  }
2687
2840
 
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
- }
2841
+ .needs-validation .choice-card:has(:user-invalid) {
2842
+ --form-invalid-border: var(--danger);
2696
2843
  }
2697
2844
 
2698
2845
  /*
@@ -2718,15 +2865,12 @@ small {
2718
2865
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
2719
2866
  */
2720
2867
 
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. */
2868
+ /* The role relays its policy into the row's cluster hooks. Cluster hooks stop
2869
+ at every nested .cluster, so the relay cannot leak, and this class beats the
2870
+ cluster's zero-specificity reset in any import order. */
2727
2871
  .form-actions {
2728
- align-items: var(--form-actions-align, center);
2729
- justify-content: var(--form-actions-justify, flex-start);
2872
+ --cluster-align: var(--form-actions-align, center);
2873
+ --cluster-justify: var(--form-actions-justify, flex-start);
2730
2874
  }
2731
2875
 
2732
2876
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -2758,6 +2902,15 @@ small {
2758
2902
  }
2759
2903
  }
2760
2904
 
2905
+ /* Same reduce override as .topbar (src/css/layout/topbar.css). */
2906
+ @media (prefers-reduced-transparency: reduce) {
2907
+ .form-actions.sticky {
2908
+ background: var(--surface);
2909
+ -webkit-backdrop-filter: none;
2910
+ backdrop-filter: none;
2911
+ }
2912
+ }
2913
+
2761
2914
  /*
2762
2915
  * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
2763
2916
  *
@@ -2837,6 +2990,7 @@ small {
2837
2990
  * --switch-block-size
2838
2991
  * --switch-knob-margin
2839
2992
  * --switch-knob-size
2993
+ * --switch-knob-inline-size
2840
2994
  * --switch-travel
2841
2995
  *
2842
2996
  * The track is sized from --choice-control-size (tokens.css), not
@@ -2852,7 +3006,7 @@ small {
2852
3006
 
2853
3007
  .switch {
2854
3008
  --switch-bg: var(--border);
2855
- --switch-border: var(--border);
3009
+ --switch-border: var(--border-control);
2856
3010
  --switch-knob: var(--surface-raised);
2857
3011
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
2858
3012
  --switch-width: calc(var(--switch-block-size) * 1.8);
@@ -2870,9 +3024,11 @@ small {
2870
3024
  2 *
2871
3025
  var(--switch-knob-margin)
2872
3026
  );
3027
+ /* A wider knob is a capsule; the travel follows its width. */
3028
+ --switch-knob-inline-size: var(--switch-knob-size);
2873
3029
  --switch-travel: calc(
2874
3030
  var(--switch-width) -
2875
- var(--switch-knob-size) -
3031
+ var(--switch-knob-inline-size) -
2876
3032
  2 *
2877
3033
  var(--border-width) -
2878
3034
  2 *
@@ -2908,7 +3064,7 @@ small {
2908
3064
  position: absolute;
2909
3065
  inset-block-start: var(--switch-knob-margin);
2910
3066
  inset-inline-start: var(--switch-knob-margin);
2911
- inline-size: var(--switch-knob-size);
3067
+ inline-size: var(--switch-knob-inline-size);
2912
3068
  block-size: var(--switch-knob-size);
2913
3069
  border-radius: var(--radius-full);
2914
3070
  background: var(--switch-knob);
@@ -3189,7 +3345,7 @@ small {
3189
3345
  --choice-card-pad: var(--density-space, var(--space-40));
3190
3346
  --choice-card-gap: var(--space-30);
3191
3347
  --choice-card-radius: var(--radius);
3192
- --choice-card-border: var(--border);
3348
+ --choice-card-border: var(--border-control);
3193
3349
  --choice-card-bg: var(--surface);
3194
3350
  --choice-card-check-size: 1.25em;
3195
3351
 
@@ -3420,6 +3576,166 @@ small {
3420
3576
  }
3421
3577
  }
3422
3578
 
3579
+ /*
3580
+ * Color swatch — a color chosen from a palette the application defines.
3581
+ *
3582
+ * Docs: docs/pages/forms/color-swatches.md
3583
+ *
3584
+ * A <label class="color-swatch"> wraps a native radio (one color) or checkbox
3585
+ * (several) and paints --color-swatch-value. The same class on a <button> is
3586
+ * the trigger of a palette flyout. The application owns the colors; Actual
3587
+ * owns the shape, the selection mark and the focus ring.
3588
+ *
3589
+ * Two sizes: the visible swatch (::before) is the height of a .switch track, so
3590
+ * a swatch and a switch in neighbouring rows match; the element is the hit
3591
+ * area around it. The hit area reaches half the inherited --gap past the
3592
+ * swatch, up to --control-size, and overflows its layout box: neighbours in a
3593
+ * .cluster or .grid meet in the middle of the gap instead of overlapping, and
3594
+ * rows sit one gap apart like columns. Edge, selection ring and focus ring all
3595
+ * belong to the visible swatch.
3596
+ *
3597
+ * The color is arbitrary, so nothing drawn on it may assume a contrast: the
3598
+ * selected mark is a double ring (--text outside, --surface-opaque inside) that
3599
+ * reads on any fill, and a hairline edge keeps a near-white color apart from
3600
+ * the page. ::before opts out of forced colors to keep the color, so it takes
3601
+ * system colors itself there.
3602
+ *
3603
+ * Without :has() the native radio stays visible in the swatch and carries the
3604
+ * state; the ring needs :has() to mirror it.
3605
+ *
3606
+ * Public hooks:
3607
+ * --color-swatch-value (fallback-only: the color, set per swatch)
3608
+ * --color-swatch-size
3609
+ * --color-swatch-radius
3610
+ */
3611
+ .color-swatch {
3612
+ /* The .switch track height (switch.css); a browser test holds them equal. */
3613
+ --color-swatch-size: calc(var(--choice-control-size) + 0.125em);
3614
+ --color-swatch-radius: var(--radius-full);
3615
+ /* Internal: what the hit area adds to the swatch, half on each side. One gap
3616
+ at most, so two neighbours never claim the same point — a wider reach let
3617
+ the next swatch take a click on this one's center. */
3618
+ --color-swatch-reach: clamp(0px, var(--control-size) - var(--color-swatch-size), var(--gap));
3619
+
3620
+ position: relative;
3621
+ display: inline-grid;
3622
+ place-items: center;
3623
+ flex: none;
3624
+ inline-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
3625
+ block-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
3626
+ /* Lay out at the visible size: gaps are measured from the swatch, not from
3627
+ its invisible reach. */
3628
+ margin: calc(var(--color-swatch-reach) / -2);
3629
+ padding: 0;
3630
+ border: 0;
3631
+ background: transparent;
3632
+ color: inherit;
3633
+ /* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
3634
+ font: inherit;
3635
+ font-size: var(--control-font-size);
3636
+ cursor: pointer;
3637
+ }
3638
+
3639
+ .color-swatch::before {
3640
+ content: "";
3641
+ grid-area: 1 / 1;
3642
+ inline-size: var(--color-swatch-size);
3643
+ block-size: var(--color-swatch-size);
3644
+ box-sizing: border-box;
3645
+ border: var(--border-width) solid var(--border-control);
3646
+ border-radius: var(--color-swatch-radius);
3647
+ background: var(--color-swatch-value, transparent) padding-box;
3648
+ /* The color is the content, as on .color (native.css). */
3649
+ forced-color-adjust: none;
3650
+ transition:
3651
+ border-color var(--duration),
3652
+ box-shadow var(--duration);
3653
+ }
3654
+
3655
+ /* Shares the swatch's cell and paints after ::before, so a native radio left
3656
+ visible sits on the color. */
3657
+ .color-swatch > input {
3658
+ grid-area: 1 / 1;
3659
+ position: relative;
3660
+ margin: 0;
3661
+ }
3662
+
3663
+ /* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
3664
+ pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
3665
+ visible native radio is the fallback. */
3666
+ @supports selector(:has(*)) {
3667
+ .color-swatch > input {
3668
+ position: absolute;
3669
+ inline-size: 1px;
3670
+ block-size: 1px;
3671
+ padding: 0;
3672
+ margin: -1px;
3673
+ overflow: hidden;
3674
+ clip: rect(0, 0, 0, 0);
3675
+ white-space: nowrap;
3676
+ border: 0;
3677
+ }
3678
+ }
3679
+
3680
+ .color-swatch:hover::before {
3681
+ border-color: var(--text-subtle);
3682
+ }
3683
+
3684
+ .color-swatch:has(> input:checked)::before {
3685
+ box-shadow:
3686
+ inset 0 0 0 2px var(--text),
3687
+ inset 0 0 0 4px var(--surface-opaque);
3688
+ }
3689
+
3690
+ /* The ring goes around the visible swatch, not the larger hit area. */
3691
+ .color-swatch:focus-visible {
3692
+ outline: none;
3693
+ }
3694
+
3695
+ .color-swatch:focus-visible::before {
3696
+ outline: var(--focus-ring-width) solid var(--focus);
3697
+ outline-offset: var(--focus-outline-offset);
3698
+ }
3699
+
3700
+ .color-swatch:has(> input:focus-visible)::before {
3701
+ outline: var(--focus-ring-width) solid var(--focus);
3702
+ outline-offset: var(--focus-outline-offset);
3703
+ }
3704
+
3705
+ .color-swatch:disabled {
3706
+ opacity: var(--disabled-opacity);
3707
+ cursor: not-allowed;
3708
+ }
3709
+
3710
+ .color-swatch:has(> input:disabled) {
3711
+ opacity: var(--disabled-opacity);
3712
+ cursor: not-allowed;
3713
+ }
3714
+
3715
+ /* A disabled swatch keeps its color: --disabled-opacity is 1 here
3716
+ (tokens.css), and the edge carries the state. */
3717
+ @media (forced-colors: active) {
3718
+ .color-swatch::before {
3719
+ --focus: CanvasText;
3720
+
3721
+ border-color: CanvasText;
3722
+ }
3723
+
3724
+ .color-swatch:has(> input:checked)::before {
3725
+ box-shadow:
3726
+ inset 0 0 0 2px CanvasText,
3727
+ inset 0 0 0 4px Canvas;
3728
+ }
3729
+
3730
+ .color-swatch:disabled::before {
3731
+ border-color: GrayText;
3732
+ }
3733
+
3734
+ .color-swatch:has(> input:disabled)::before {
3735
+ border-color: GrayText;
3736
+ }
3737
+ }
3738
+
3423
3739
  /*
3424
3740
  * Customizable select — appearance: base-select picker enhancement.
3425
3741
  *
@@ -3525,10 +3841,13 @@ small {
3525
3841
  * messages stay in the surrounding .field, not inside the cell, so the
3526
3842
  * resting label can center on the control alone.
3527
3843
  *
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
3844
+ * The cell reads its control's state through :has(), so the label may come
3845
+ * before or after the control (a form theme rendering label then widget needs
3846
+ * no reordering). Injected sibling nodes are tolerated (password managers,
3847
+ * extension wrappers) as long as the control stays a direct child; the cell
3848
+ * contract stays a single control plus one label, since a second control
3849
+ * would float the label on either one's state.
3850
+ * Every .input/.textarea needs
3532
3851
  * placeholder=" " to expose a reliable empty state — without it the label
3533
3852
  * only reacts to focus.
3534
3853
  *
@@ -3578,23 +3897,22 @@ small {
3578
3897
  }
3579
3898
 
3580
3899
  /* Single-line controls rest in the middle until they receive a value. */
3581
- .floating-field > :is(.input, .select) ~ .field-label {
3900
+ .floating-field:has(> :is(.input, .select)) > .field-label {
3582
3901
  inset-block-start: 50%;
3583
3902
  transform: translateY(-50%);
3584
3903
  }
3585
3904
 
3586
3905
  /* An empty textarea label follows the control's normal first-line position;
3587
3906
  the control itself keeps the extra headroom reserved for its floated state. */
3588
- .floating-field > .textarea ~ .field-label {
3907
+ .floating-field:has(> .textarea) > .field-label {
3589
3908
  inset-block-start: calc(1em + var(--border-width));
3590
3909
  transform: none;
3591
3910
  }
3592
3911
 
3593
3912
  /* :autofill covers prefilled values before interaction, when the browser may
3594
3913
  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 {
3914
+ .floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
3915
+ > .field-label {
3598
3916
  inset-block-start: var(--floating-label-top);
3599
3917
  transform: none;
3600
3918
  font-size: var(--font-size-xs);
@@ -3604,16 +3922,16 @@ small {
3604
3922
  /* Controls without a dependable :placeholder-shown empty state stay floated:
3605
3923
  select always, and date/time inputs whose placeholder rendering is
3606
3924
  inconsistent across engines (Firefox shows none). */
3607
- .floating-field
3925
+ .floating-field:has(
3608
3926
  > :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 {
3927
+ .select,
3928
+ .input[type="date"],
3929
+ .input[type="time"],
3930
+ .input[type="datetime-local"],
3931
+ .input[type="month"],
3932
+ .input[type="week"]
3933
+ )
3934
+ ) > .field-label {
3617
3935
  inset-block-start: var(--floating-label-top);
3618
3936
  transform: none;
3619
3937
  font-size: var(--font-size-xs);
@@ -3649,7 +3967,7 @@ small {
3649
3967
  --otp-cell-size: var(--control-size);
3650
3968
  --otp-gap: var(--space-10);
3651
3969
  --otp-font-size: 1.5em;
3652
- --otp-cell-border: var(--border);
3970
+ --otp-cell-border: var(--border-control);
3653
3971
  --otp-cell-bg: var(--surface);
3654
3972
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
3655
3973
 
@@ -3735,10 +4053,9 @@ small {
3735
4053
  --otp-cell-border: var(--danger);
3736
4054
  }
3737
4055
 
3738
- @supports selector(:user-invalid) {
3739
- .needs-validation .otp > input:user-invalid ~ span {
3740
- --otp-cell-border: var(--danger);
3741
- }
4056
+ /* 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. */
4057
+ .needs-validation .otp > input:user-invalid ~ span {
4058
+ --otp-cell-border: var(--danger);
3742
4059
  }
3743
4060
 
3744
4061
  .otp > input:disabled {
@@ -3913,12 +4230,27 @@ small {
3913
4230
  --ui-hover-bg: transparent;
3914
4231
  }
3915
4232
 
4233
+ /* A soft button takes the badge and alert tint, not the shared opaque .soft:
4234
+ it carries a short label like them, and the translucent fill keeps
4235
+ separating on a subtle or raised surface. Ink stays the shared .soft one. */
3916
4236
  @supports (color: color-mix(in oklch, red, white)) {
3917
4237
  .btn.soft {
4238
+ /* @sync soft-tint */
4239
+ --ui-bg: color-mix(
4240
+ in oklab,
4241
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4242
+ transparent
4243
+ );
4244
+ --ui-border: color-mix(
4245
+ in oklab,
4246
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4247
+ transparent
4248
+ );
4249
+ /* @sync soft-tint */
3918
4250
  --ui-hover-bg: color-mix(
3919
4251
  in oklab,
3920
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
3921
- var(--intent, var(--neutral))
4252
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
4253
+ transparent
3922
4254
  );
3923
4255
  }
3924
4256
 
@@ -4117,11 +4449,14 @@ a.btn[aria-disabled="true"] {
4117
4449
  }
4118
4450
 
4119
4451
  @media (forced-colors: active) {
4120
- /* The X is a currentColor mask fill: without this the system remaps the
4121
- background to Canvas and the glyph disappears. Kept on the pseudo so the
4122
- host keeps its native color and focus adaptation. */
4452
+ /* The X is a mask fill: without this the system remaps the background to
4453
+ Canvas and the glyph disappears. Kept on the pseudo so the host keeps its
4454
+ native color and focus adaptation. The fill is the system color, not
4455
+ currentColor: an unadjusted pseudo inherits the authored ink, so a white
4456
+ .solid ink painted a white X on Canvas. */
4123
4457
  .close::before {
4124
4458
  forced-color-adjust: none;
4459
+ background: ButtonText;
4125
4460
  }
4126
4461
  }
4127
4462
 
@@ -4238,33 +4573,46 @@ a.btn[aria-disabled="true"] {
4238
4573
  }
4239
4574
 
4240
4575
  /* Soft by default. Local defaults remain behind --ui-* in the resolution
4241
- chain, so explicit shared treatments compose without selector exclusions. */
4576
+ chain, so explicit shared treatments compose without selector exclusions.
4577
+ The fill is a translucent intent tint, so it shows the surface it sits on
4578
+ instead of a fixed mix with --surface: a mix stops separating on a subtle
4579
+ or raised surface of its own lightness. No rim unless a theme sets
4580
+ --soft-border-mix (tokens.css). */
4242
4581
  @supports (color: color-mix(in oklch, red, white)) {
4243
4582
  .badge {
4244
- /* @sync soft-recipe */
4583
+ /* @sync soft-tint */
4245
4584
  --badge-default-bg: color-mix(
4246
4585
  in oklab,
4247
- var(--surface) var(--soft-bg-mix),
4248
- var(--intent, var(--neutral))
4586
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4587
+ transparent
4249
4588
  );
4250
4589
  --badge-default-border: color-mix(
4251
4590
  in oklab,
4252
- var(--surface) var(--soft-border-mix),
4253
- var(--intent, var(--neutral))
4591
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4592
+ transparent
4254
4593
  );
4594
+ /* @sync soft-tint */
4595
+ /* @sync soft-ink */
4255
4596
  --badge-default-fg: var(
4256
4597
  --intent-soft-fg,
4257
4598
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
4258
4599
  );
4259
- /* @sync soft-recipe */
4600
+ /* @sync soft-ink */
4260
4601
  }
4261
4602
  }
4262
4603
 
4604
+ /* Soft is the badge's own default: an explicit .soft must not swap the tint
4605
+ for the shared opaque .soft recipe, so it defers to the default. */
4606
+ .badge.soft {
4607
+ --ui-bg: initial;
4608
+ --ui-fg: initial;
4609
+ --ui-border: initial;
4610
+ }
4611
+
4263
4612
  .badge:empty {
4264
4613
  --badge-size: var(--badge-dot-size);
4265
4614
  --badge-bg: var(--intent, var(--neutral));
4266
4615
  --badge-fg: var(--intent-fg, var(--neutral-fg));
4267
- --badge-border: transparent;
4268
4616
  padding: 0;
4269
4617
  border: 0;
4270
4618
  inline-size: var(--badge-size);
@@ -4299,8 +4647,8 @@ a.btn[aria-disabled="true"] {
4299
4647
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
4300
4648
  --badge-default-hover-bg: color-mix(
4301
4649
  in oklab,
4302
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
4303
- var(--intent, var(--neutral))
4650
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
4651
+ transparent
4304
4652
  );
4305
4653
  }
4306
4654
 
@@ -4418,6 +4766,16 @@ a.badge[aria-disabled="true"] {
4418
4766
  font-size: var(--alert-font-size);
4419
4767
  }
4420
4768
 
4769
+ /* Density steps the block padding only: the inline padding is the text
4770
+ column's edge and stays stable. Local to the alert, like .card.compact. */
4771
+ .alert.compact {
4772
+ --alert-pad-block: var(--space-20);
4773
+ }
4774
+
4775
+ .alert.spacious {
4776
+ --alert-pad-block: var(--space-40);
4777
+ }
4778
+
4421
4779
  .alert > * {
4422
4780
  min-inline-size: 0;
4423
4781
  }
@@ -4442,32 +4800,45 @@ a.badge[aria-disabled="true"] {
4442
4800
  }
4443
4801
 
4444
4802
  /* Alerts are soft by default. The recipe writes local defaults rather than
4445
- --ui-* so an explicit shared treatment always wins without exclusions. */
4803
+ --ui-* so an explicit shared treatment always wins without exclusions. The
4804
+ fill is the translucent soft tint (see badge.css), with no rim unless a
4805
+ theme sets --soft-border-mix. */
4446
4806
  @supports (color: color-mix(in oklch, red, white)) {
4447
4807
  .alert {
4448
- /* @sync soft-recipe */
4808
+ /* @sync soft-tint */
4449
4809
  --alert-default-bg: color-mix(
4450
4810
  in oklab,
4451
- var(--surface) var(--soft-bg-mix),
4452
- var(--intent, var(--neutral))
4811
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4812
+ transparent
4453
4813
  );
4454
4814
  --alert-default-border: color-mix(
4455
4815
  in oklab,
4456
- var(--surface) var(--soft-border-mix),
4457
- var(--intent, var(--neutral))
4816
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4817
+ transparent
4458
4818
  );
4819
+ /* @sync soft-tint */
4820
+ /* @sync soft-ink */
4459
4821
  --alert-default-fg: var(
4460
4822
  --intent-soft-fg,
4461
4823
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
4462
4824
  );
4463
- /* @sync soft-recipe */
4825
+ /* @sync soft-ink */
4464
4826
  }
4465
4827
  }
4466
4828
 
4467
- /* Decorative leading icon — use .alert-icon on the icon element. */
4829
+ /* Soft is the alert's own default: an explicit .soft must not swap the tint
4830
+ for the shared opaque .soft recipe, so it defers to the default. */
4831
+ .alert.soft {
4832
+ --ui-bg: initial;
4833
+ --ui-fg: initial;
4834
+ --ui-border: initial;
4835
+ }
4836
+
4837
+ /* Decorative leading icon — use .alert-icon on the icon element. display and
4838
+ vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
4839
+ before the text); the grid anatomy below replaces them. */
4468
4840
  .alert > .alert-icon {
4469
4841
  display: inline-block;
4470
- flex: none;
4471
4842
  inline-size: var(--alert-icon-size);
4472
4843
  block-size: var(--alert-icon-size);
4473
4844
  font-size: var(--alert-icon-size);
@@ -4499,45 +4870,44 @@ a.badge[aria-disabled="true"] {
4499
4870
 
4500
4871
  /* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
4501
4872
  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
- }
4873
+ message content) flows into the remaining text column. Without :has(), the
4874
+ grid rules drop together and the alert keeps ordinary block flow. */
4875
+ .alert:has(> .alert-icon, > .close) {
4876
+ display: grid;
4877
+ /* No grid-template-columns: the icon and close branches below always set
4878
+ it, since one of them is what matched. */
4879
+ align-items: var(--items-align, start);
4880
+ column-gap: var(--space-30);
4881
+ }
4514
4882
 
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
- }
4883
+ .alert:has(> .alert-icon) {
4884
+ grid-template-columns: auto minmax(0, 1fr);
4885
+ }
4522
4886
 
4523
- .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4524
- grid-column: 2;
4525
- }
4887
+ .alert:has(> .alert-icon) > .alert-icon {
4888
+ grid-column: 1;
4889
+ grid-row: 1 / -1;
4890
+ display: inline-grid;
4891
+ place-items: center;
4892
+ margin-block-start: 0.125em;
4893
+ }
4526
4894
 
4527
- .alert:has(> .close) {
4528
- grid-template-columns: minmax(0, 1fr) auto;
4529
- }
4895
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4896
+ grid-column: 2;
4897
+ }
4530
4898
 
4531
- .alert:has(> .alert-icon):has(> .close) {
4532
- grid-template-columns: auto minmax(0, 1fr) auto;
4533
- }
4899
+ .alert:has(> .close) {
4900
+ grid-template-columns: minmax(0, 1fr) auto;
4901
+ }
4534
4902
 
4535
- .alert:has(> .close) > .close {
4536
- grid-column: -2 / -1;
4537
- grid-row: 1;
4538
- }
4903
+ .alert:has(> .alert-icon):has(> .close) {
4904
+ grid-template-columns: auto minmax(0, 1fr) auto;
4539
4905
  }
4540
4906
 
4907
+ .alert:has(> .close) > .close {
4908
+ grid-column: -2 / -1;
4909
+ grid-row: 1;
4910
+ }
4541
4911
  /* Links */
4542
4912
  .alert :where(a:not(.btn)) {
4543
4913
  color: inherit;
@@ -4562,7 +4932,10 @@ a.badge[aria-disabled="true"] {
4562
4932
 
4563
4933
  /* Admonition — structured static box with a tinted title bar and a body
4564
4934
  that rests on the page surface. Not collapsible — use .accordion inside
4565
- the body if the content needs to be expandable. */
4935
+ the body if the content needs to be expandable. The title bar is what a
4936
+ shared treatment paints (.solid fills it, .surface/.outline clear it): the
4937
+ root's fill never shows behind the opaque title and body, and the title ink
4938
+ already follows --ui-fg, so a bar left on the soft tint is unreadable. */
4566
4939
  .alert.admonition {
4567
4940
  padding: 0;
4568
4941
  display: block;
@@ -4576,6 +4949,7 @@ a.badge[aria-disabled="true"] {
4576
4949
  gap: var(--space-20);
4577
4950
  padding-block: var(--alert-pad-block);
4578
4951
  padding-inline: var(--alert-pad-inline);
4952
+ font-weight: var(--font-weight-semibold);
4579
4953
  border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4580
4954
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4581
4955
  }
@@ -4587,18 +4961,17 @@ a.badge[aria-disabled="true"] {
4587
4961
 
4588
4962
  @supports (color: color-mix(in oklch, red, white)) {
4589
4963
  .alert.admonition > .alert-title {
4590
- background: color-mix(
4591
- in oklab,
4592
- var(--surface) var(--soft-bg-mix),
4593
- var(--intent, var(--neutral))
4964
+ background: var(
4965
+ --ui-bg,
4966
+ color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)))
4594
4967
  );
4595
4968
  }
4596
4969
  }
4597
4970
 
4598
4971
  @supports not (color: color-mix(in oklch, red, white)) {
4599
4972
  .alert.admonition > .alert-title {
4600
- background: var(--intent, var(--neutral));
4601
- color: var(--intent-fg, var(--neutral-fg));
4973
+ background: var(--ui-bg, var(--intent, var(--neutral)));
4974
+ color: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
4602
4975
  }
4603
4976
  }
4604
4977
 
@@ -4609,6 +4982,32 @@ a.badge[aria-disabled="true"] {
4609
4982
  color: var(--text);
4610
4983
  }
4611
4984
 
4985
+ /* Solid: the filled title bar already carries the intent, so the frame moves
4986
+ to the body and stays neutral. The root border goes because a transparent
4987
+ one would show the root's intent fill around the body. */
4988
+ .alert.admonition.solid {
4989
+ border: 0;
4990
+ }
4991
+
4992
+ .alert.admonition.solid > .alert-body {
4993
+ border: var(--border-width) solid var(--border);
4994
+ border-block-start: 0;
4995
+ border-end-start-radius: var(--alert-radius, var(--radius-lg));
4996
+ border-end-end-radius: var(--alert-radius, var(--radius-lg));
4997
+ }
4998
+
4999
+ /* Forced colors drop the title fill, so the bar would lose its edge: the
5000
+ frame returns to the root as one uniform system-colored border. */
5001
+ @media (forced-colors: active) {
5002
+ .alert.admonition.solid {
5003
+ border: var(--border-width) solid CanvasText;
5004
+ }
5005
+
5006
+ .alert.admonition.solid > .alert-body {
5007
+ border: 0;
5008
+ }
5009
+ }
5010
+
4612
5011
  .alert.admonition > .alert-body > :first-child {
4613
5012
  margin-block-start: 0;
4614
5013
  }
@@ -4896,6 +5295,12 @@ a.badge[aria-disabled="true"] {
4896
5295
  background: var(--surface-subtle);
4897
5296
  }
4898
5297
 
5298
+ /* The chrome header is a centered row, so prose margins on a bare title only
5299
+ push the band taller and pull the title off the close axis. */
5300
+ :where(dialog).modal > header > :is(h1, h2, h3, h4, h5, h6) {
5301
+ margin-block: 0;
5302
+ }
5303
+
4899
5304
  :where(dialog).modal > header > .media {
4900
5305
  flex: 1 1 auto;
4901
5306
  min-inline-size: 0;
@@ -4959,19 +5364,20 @@ a.badge[aria-disabled="true"] {
4959
5364
 
4960
5365
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4961
5366
  :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
- );
5367
+ /* Declared here, not as a :root alias: an inherited alias arrives already
5368
+ computed, so a --focus-ring-width override on the dialog would not reach
5369
+ it. */
5370
+ --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
4966
5371
 
4967
5372
  flex: 1 1 auto;
4968
5373
  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);
5374
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
5375
+ both axes), without moving the content. Every outside indicator is
5376
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
5377
+ reaches into the slot gap, which is wider, so the scrollport never slides
5378
+ under the header or footer. */
5379
+ margin: calc(var(--dialog-focus-bleed) * -1);
5380
+ padding: var(--dialog-focus-bleed);
4975
5381
  overflow: auto;
4976
5382
  overscroll-behavior: contain;
4977
5383
  }
@@ -5176,6 +5582,7 @@ html.has-modal-open.had-scrollbar {
5176
5582
  * --drawer-close-reserve (header room for a corner close; zero without a close)
5177
5583
  * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
5178
5584
  * changes in one place; entry state redefines it longer)
5585
+ * --drawer-motion-ease (panel easing owned by the open/closed state)
5179
5586
  */
5180
5587
  :where(dialog).drawer {
5181
5588
  --drawer-size: 20rem;
@@ -5190,6 +5597,7 @@ html.has-modal-open.had-scrollbar {
5190
5597
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
5191
5598
  the same variable through inheritance and never lags the panel. */
5192
5599
  --drawer-motion-duration: var(--duration);
5600
+ --drawer-motion-ease: var(--ease-exit);
5193
5601
 
5194
5602
  /* Keep the surface anchored to the viewport in the top layer. This also
5195
5603
  provides the containing block for the absolutely positioned close button.
@@ -5221,21 +5629,21 @@ html.has-modal-open.had-scrollbar {
5221
5629
  box-shadow: var(--shadow);
5222
5630
  opacity: 0;
5223
5631
  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);
5632
+ /* Unsupported discrete behavior drops alone, preserving opacity/transform. */
5633
+ transition-property: opacity, transform, overlay, display;
5634
+ transition-duration: var(--drawer-motion-duration);
5635
+ transition-timing-function: var(--drawer-motion-ease);
5636
+ transition-behavior: allow-discrete;
5227
5637
  }
5228
5638
 
5229
5639
  :where(dialog).drawer[open] {
5230
5640
  --drawer-motion-duration: var(--duration-slow);
5641
+ --drawer-motion-ease: var(--ease-enter);
5231
5642
  display: grid;
5232
5643
  grid-template-rows: auto minmax(0, 1fr) auto;
5233
5644
  gap: var(--space-40);
5234
5645
  opacity: 1;
5235
5646
  transform: translateX(0);
5236
- transition:
5237
- opacity var(--drawer-motion-duration) var(--ease-enter),
5238
- transform var(--drawer-motion-duration) var(--ease-enter);
5239
5647
  }
5240
5648
 
5241
5649
  /* A form is the drawer's content wrapper when actions need method="dialog".
@@ -5255,7 +5663,10 @@ html.has-modal-open.had-scrollbar {
5255
5663
  opacity: 0;
5256
5664
  /* Scrim curve stays neutral `ease`; only the panel's character curves
5257
5665
  apply. Duration follows --drawer-motion-duration through inheritance. */
5258
- transition: opacity var(--drawer-motion-duration) ease;
5666
+ transition-property: opacity, overlay, display;
5667
+ transition-duration: var(--drawer-motion-duration);
5668
+ transition-timing-function: ease;
5669
+ transition-behavior: allow-discrete;
5259
5670
  }
5260
5671
 
5261
5672
  :where(dialog).drawer[data-side="end"] {
@@ -5289,47 +5700,23 @@ html.has-modal-open.had-scrollbar {
5289
5700
  exclusion list is easier to trust than its `position: absolute`. */
5290
5701
  :where(dialog).drawer > :not(:is(header, footer, form, .close)),
5291
5702
  :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
- );
5703
+ /* Declared here, not as a :root alias: an inherited alias arrives already
5704
+ computed, so a --focus-ring-width override on the drawer would not reach
5705
+ it. */
5706
+ --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
5296
5707
 
5297
5708
  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);
5709
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
5710
+ both axes), without moving the content. Every outside indicator is
5711
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
5712
+ reaches into the slot gap, which is wider, so the scrollport never slides
5713
+ under the header or footer. */
5714
+ margin: calc(var(--drawer-focus-bleed) * -1);
5715
+ padding: var(--drawer-focus-bleed);
5304
5716
  overflow: auto;
5305
5717
  overscroll-behavior: contain;
5306
5718
  }
5307
5719
 
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
5720
  /* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
5334
5721
  path as the Degraded-tier fallback. The :dir() rules are dropped on their
5335
5722
  own there, so they need no wrapper. :dir() follows inherited HTML
@@ -5561,7 +5948,6 @@ picture.bleed > img {
5561
5948
  .flyout[popover] {
5562
5949
  position: fixed;
5563
5950
  inset: auto;
5564
- margin: 0;
5565
5951
  }
5566
5952
 
5567
5953
  /* A presentation class may own its *visible* display type, but must not
@@ -6530,13 +6916,13 @@ picture.bleed > img {
6530
6916
  * --avatar-size
6531
6917
  * --avatar-radius
6532
6918
  * --avatar-font-size
6919
+ * --avatar-bg
6533
6920
  * --avatar-stack-size
6534
6921
  * --avatar-stack-font-size
6535
6922
  * --avatar-stack-overlap
6536
6923
  * --avatar-stack-ring
6537
6924
  *
6538
6925
  * Internal:
6539
- * --avatar-bg
6540
6926
  * --avatar-fg
6541
6927
  * --avatar-status-translate-x
6542
6928
  */
@@ -6614,16 +7000,13 @@ picture.bleed > img {
6614
7000
 
6615
7001
  .avatar > img,
6616
7002
  .avatar > picture > img {
7003
+ display: block;
6617
7004
  inline-size: 100%;
6618
7005
  block-size: 100%;
6619
7006
  object-fit: cover;
6620
7007
  border-radius: inherit;
6621
7008
  }
6622
7009
 
6623
- .avatar > picture > img {
6624
- display: block;
6625
- }
6626
-
6627
7010
  button.avatar {
6628
7011
  appearance: none;
6629
7012
  cursor: pointer;
@@ -6717,19 +7100,6 @@ a.avatar[aria-disabled="true"] {
6717
7100
  --avatar-stack-font-size: var(--font-size-lg);
6718
7101
  }
6719
7102
 
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
7103
  /* Overlapping avatars hide their neighbour: a translucent fill (--surface-subtle
6734
7104
  on a glass palette) would show the next avatar's edge through it, most of all
6735
7105
  when hover raises one above the other. The fill is layered over the opaque
@@ -7235,7 +7605,8 @@ a.avatar[aria-disabled="true"] {
7235
7605
  * destinations. Anything else focusable in a step is out of contract.
7236
7606
  *
7237
7607
  * 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
7608
+ * labelled row wraps its labels, then scrolls if its markers or authored item
7609
+ * minimum outgrow the available space. An `actual-container` size
7239
7610
  * context buys it one thing: the wide representation, marker beside label.
7240
7611
  *
7241
7612
  * See docs/design-notes/steps.md for the threshold, the selector shapes, the
@@ -7346,11 +7717,14 @@ a.avatar[aria-disabled="true"] {
7346
7717
 
7347
7718
  /* Complete owns the accented connector that reaches the current step, so the
7348
7719
  connector leaving current goes back to neutral. Declared after
7349
- .step-complete at equal specificity: current wins on every channel. */
7720
+ .step-complete at equal specificity: current wins on every channel. The
7721
+ current marker is a regular surface, so its number and ring are selected
7722
+ ink on a surface, not a selected fill; they are held to different ratios
7723
+ (4.5:1 for the number, 3:1 for the ring) by report:theme-contrast. */
7350
7724
  .steps > [aria-current="step"] {
7351
7725
  --step-marker-bg: var(--surface-opaque);
7352
- --step-marker-border: var(--state-selected);
7353
- --step-marker-fg: var(--state-selected);
7726
+ --step-marker-border: var(--state-selected-text, var(--state-selected));
7727
+ --step-marker-fg: var(--state-selected-text, var(--state-selected));
7354
7728
  --step-line: var(--border);
7355
7729
 
7356
7730
  color: var(--text);
@@ -7371,10 +7745,11 @@ a.avatar[aria-disabled="true"] {
7371
7745
  /* Horizontal ------------------------------------------------------------- */
7372
7746
 
7373
7747
  .steps-horizontal {
7374
- --step-min: 7rem;
7748
+ --step-min: var(--step-size);
7375
7749
  --step-gap: var(--space-20);
7376
7750
 
7377
7751
  inline-size: 100%;
7752
+ column-gap: var(--step-gap);
7378
7753
  overflow-x: auto;
7379
7754
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
7380
7755
  other to `auto`, and a row hugs fractional content closely enough that a
@@ -7383,11 +7758,22 @@ a.avatar[aria-disabled="true"] {
7383
7758
  overscroll-behavior-inline: contain;
7384
7759
  }
7385
7760
 
7761
+ /* A row that loads mid-flow starts scrolled to its current step; engines
7762
+ without the property start at the first. */
7763
+ .steps-horizontal > [aria-current="step"] {
7764
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
7765
+ scroll-initial-target: nearest;
7766
+ }
7767
+
7386
7768
  /* 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. */
7769
+ focus ring's own width and offset, and focus scrolls a link in that far from
7770
+ the inline clip. Only this composition scrolls, and only a link has a ring to
7771
+ protect. */
7389
7772
  .steps-horizontal:has(a[href]) {
7773
+ /* Wrapped names can fill their cell right up to either inline clip edge. */
7774
+ padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7390
7775
  padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7776
+ scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7391
7777
  }
7392
7778
 
7393
7779
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -7399,6 +7785,8 @@ a.avatar[aria-disabled="true"] {
7399
7785
  .steps-horizontal > li {
7400
7786
  display: grid;
7401
7787
  flex: 1 0 var(--step-min);
7788
+ /* Bound each label to its share so it cannot change the marker pitch. */
7789
+ grid-template-columns: minmax(0, 1fr);
7402
7790
  grid-template-rows: var(--step-size);
7403
7791
  gap: var(--step-gap);
7404
7792
  justify-items: center;
@@ -7406,9 +7794,42 @@ a.avatar[aria-disabled="true"] {
7406
7794
  text-align: center;
7407
7795
  }
7408
7796
 
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.
7797
+ .steps-horizontal .step-label {
7798
+ /* Even an unbroken name must fit its share instead of overlapping a peer.
7799
+ Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
7800
+ overflow-wrap: anywhere;
7801
+ hyphens: auto;
7802
+ }
7803
+
7804
+ /* Half end cells put the outer markers on the row's edges without changing
7805
+ their pitch, so the track lines up with the text around it, as the compact
7806
+ and inline forms do. The marker's half-width keeps the first connector equal
7807
+ to its peers.
7808
+
7809
+ Source order is load-bearing: these tie the `:empty` rules below and the
7810
+ inline query on specificity, and both must win over them. */
7811
+ .steps-horizontal > li:is(:first-child, :last-child) {
7812
+ flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
7813
+ min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
7814
+ }
7815
+
7816
+ .steps-horizontal > li:first-child {
7817
+ justify-items: start;
7818
+ text-align: start;
7819
+ }
7820
+
7821
+ .steps-horizontal > li:first-child::after {
7822
+ inset-inline-start: var(--step-size);
7823
+ inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
7824
+ }
7825
+
7826
+ .steps-horizontal > li:last-child {
7827
+ justify-items: end;
7828
+ text-align: end;
7829
+ }
7830
+
7831
+ /* An empty step ignores any authored reading-width floor and needs only its
7832
+ marker. That lets five of them fit a phone without a size context.
7412
7833
 
7413
7834
  `justify-items` is not an override of the row's centring: the marker is
7414
7835
  centred because a label sits under it and the two share a centre, and with no
@@ -7418,9 +7839,8 @@ a.avatar[aria-disabled="true"] {
7418
7839
  the text around it, the way the inline representation does.
7419
7840
 
7420
7841
  `: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
7842
+ it reads the item's own content: bare text keeps the same authored floor as
7843
+ its wrapped peers. `:empty` also keeps the compact form reachable on the
7424
7844
  Degraded tier, where only the inline bonus needs `:has()`. */
7425
7845
  .steps-horizontal > li:empty {
7426
7846
  flex: 1 1 0;
@@ -7430,6 +7850,8 @@ a.avatar[aria-disabled="true"] {
7430
7850
 
7431
7851
  .steps-horizontal > li:empty::after {
7432
7852
  inset-inline-start: var(--step-size);
7853
+ /* Marker-only end cells keep full shares, unlike labelled end cells. */
7854
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
7433
7855
  }
7434
7856
 
7435
7857
  .steps-horizontal > li:empty:last-child {
@@ -7437,12 +7859,12 @@ a.avatar[aria-disabled="true"] {
7437
7859
  }
7438
7860
 
7439
7861
  /* The pseudo-element is marker-height so --step-connector may be any complete
7440
- background value. */
7862
+ background value; its length also crosses the gap to the next equal track. */
7441
7863
  .steps-horizontal > li::after {
7442
7864
  position: absolute;
7443
7865
  inset-block-start: 0;
7444
7866
  inset-inline-start: calc(50% + var(--step-size) / 2);
7445
- inline-size: calc(100% - var(--step-size));
7867
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
7446
7868
  block-size: var(--step-size);
7447
7869
  background: var(
7448
7870
  --step-connector,
@@ -7461,6 +7883,11 @@ a.avatar[aria-disabled="true"] {
7461
7883
  * written — a row out of contract is not owed a curated fallback.
7462
7884
  */
7463
7885
  @container actual-container (inline-size >= 60rem) {
7886
+ .steps-horizontal:has(.step-label) {
7887
+ /* Inline connectors already own the spacing between marker-label groups. */
7888
+ column-gap: 0;
7889
+ }
7890
+
7464
7891
  .steps-horizontal:has(.step-label) > li {
7465
7892
  display: flex;
7466
7893
  flex: 1 1 auto;
@@ -7598,6 +8025,7 @@ a.avatar[aria-disabled="true"] {
7598
8025
  }
7599
8026
 
7600
8027
  .app-nav > a {
8028
+ position: relative;
7601
8029
  display: grid;
7602
8030
  grid-template-rows: auto auto;
7603
8031
  place-content: center;
@@ -7638,20 +8066,27 @@ a.avatar[aria-disabled="true"] {
7638
8066
  }
7639
8067
 
7640
8068
  /* Current tile is already a full selected surface. Selection never changes
7641
- text metrics: the shared medium weight is kept, no permanent indicator is
7642
- added here. */
8069
+ text metrics: the shared medium weight is kept. The forced-colors cue is a
8070
+ transparent border on a ::before overlay, not on the tile: an element border
8071
+ shrinks the content box, and the side layout starts its content at the inline
8072
+ edge, so the icon and label shifted by --border-width when a tile became
8073
+ current. The overlay costs no layout, yet forced colors still turn its
8074
+ transparent border into a system-color frame once --state-selected flattens
8075
+ to the bar's own Canvas. No forced-colors repaint: a Highlight fill put
8076
+ HighlightText on Chromium's Canvas backplate, which hid the label. The
8077
+ positioning context is on every link so it does not move when one becomes
8078
+ current or an application places its own decoration. */
7643
8079
  .app-nav > a[aria-current="page"] {
7644
8080
  background: var(--state-selected);
7645
8081
  color: var(--state-selected-fg);
7646
8082
  }
7647
8083
 
7648
- @media (forced-colors: active) {
7649
- /* --state-selected flattens to a UA color that no longer distinguishes the
7650
- current tile from the rest of the bar, so paint the system Highlight pair. */
7651
- .app-nav > a[aria-current="page"] {
7652
- background: Highlight;
7653
- color: HighlightText;
7654
- }
8084
+ .app-nav > a[aria-current="page"]::before {
8085
+ content: "";
8086
+ position: absolute;
8087
+ inset: 0;
8088
+ border: var(--border-width) solid transparent;
8089
+ border-radius: inherit;
7655
8090
  }
7656
8091
 
7657
8092
  /*
@@ -7663,6 +8098,9 @@ a.avatar[aria-disabled="true"] {
7663
8098
  * links are a .cluster using .nav-link items. .nav-list is the vertical nav
7664
8099
  * list shared with .drawer, fed by the shared --gap token.
7665
8100
  *
8101
+ * Public hooks:
8102
+ * --state-selected-text (fallback-only: current-link ink, see tokens.css)
8103
+ *
7666
8104
  * Internal:
7667
8105
  * --gap (local value for the shared gap token)
7668
8106
  */
@@ -7727,8 +8165,7 @@ a.avatar[aria-disabled="true"] {
7727
8165
 
7728
8166
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
7729
8167
  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".
8168
+ Those two values are the contract; any other, "false" included, is inert.
7732
8169
  Selection never changes text metrics: the shared medium weight is kept and
7733
8170
  the current link is carried by the selected accent. */
7734
8171
  .nav-link:hover {
@@ -7739,9 +8176,9 @@ a.avatar[aria-disabled="true"] {
7739
8176
  /* Current last, equal specificity to :hover (0,2,0): a hovered current link
7740
8177
  keeps the selected accent instead of dropping to --text. The surface fill is
7741
8178
  shared with the hover rule above. */
7742
- .nav-link[aria-current] {
8179
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
7743
8180
  background: var(--surface-subtle);
7744
- color: var(--state-selected);
8181
+ color: var(--state-selected-text, var(--state-selected));
7745
8182
  }
7746
8183
 
7747
8184
  /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
@@ -7752,7 +8189,7 @@ a.avatar[aria-disabled="true"] {
7752
8189
  above keeps the light chip where color-mix is missing, with ink that is
7753
8190
  legible on it. */
7754
8191
  @supports (color: color-mix(in oklch, red, white)) {
7755
- .nav-link:is(:hover, [aria-current]) {
8192
+ .nav-link:is(:hover, [aria-current="page"], [aria-current="location"]) {
7756
8193
  background: color-mix(in oklch, currentColor 10%, transparent);
7757
8194
  }
7758
8195
 
@@ -7761,8 +8198,8 @@ a.avatar[aria-disabled="true"] {
7761
8198
  }
7762
8199
 
7763
8200
  /* After :hover so a hovered current link keeps the selected accent. */
7764
- .nav-link[aria-current] {
7765
- color: var(--ui-fg, var(--state-selected));
8201
+ .nav-link:is([aria-current="page"], [aria-current="location"]) {
8202
+ color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
7766
8203
  }
7767
8204
  }
7768
8205
 
@@ -7795,13 +8232,16 @@ a.avatar[aria-disabled="true"] {
7795
8232
  follows the forced palette without a forced-colors block and never touches
7796
8233
  the row separators. Navbar horizontal links keep color + surface only. The
7797
8234
  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 {
8235
+ an app-positioned decoration to a new containing block.
8236
+ The trait sits centered in the link's inline padding with rounded ends: flush
8237
+ on the edge, a square line read as pasted onto the rounded fill. */
8238
+ .nav-list .nav-link:is([aria-current="page"], [aria-current="location"])::before {
7800
8239
  content: "";
7801
8240
  position: absolute;
7802
8241
  inset-block: var(--space-20);
7803
- inset-inline-start: 0;
8242
+ inset-inline-start: calc((var(--space-20) - 2px) / 2);
7804
8243
  border-inline-start: 2px solid currentColor;
8244
+ border-radius: var(--radius-full);
7805
8245
  }
7806
8246
 
7807
8247
  /*
@@ -7810,14 +8250,21 @@ a.avatar[aria-disabled="true"] {
7810
8250
  * Docs: docs/pages/components/breadcrumb.md
7811
8251
  *
7812
8252
  * Inline list separated by "/". Mark the current page with `aria-current="page"`
7813
- * either on the item or on the current link.
8253
+ * either on the item or on the current link. Ancestor labels sit in an <a> or a
8254
+ * <span>, the box that truncates.
8255
+ *
8256
+ * Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
8257
+ * like separate rows and causes unstable header height, and scrolling hides the
8258
+ * root behind a scrollbar. A trail that does not fit truncates instead:
8259
+ * ancestors first, the current page last, each label behind an ellipsis.
7814
8260
  */
8261
+
7815
8262
  .breadcrumb {
7816
8263
  display: flex;
7817
- flex-direction: row;
7818
- flex-wrap: wrap;
8264
+ flex-wrap: nowrap;
7819
8265
  align-items: center;
7820
8266
  gap: var(--space-10);
8267
+ min-inline-size: 0;
7821
8268
  margin: 0;
7822
8269
  padding: 0;
7823
8270
  color: var(--text-subtle);
@@ -7825,15 +8272,74 @@ a.avatar[aria-disabled="true"] {
7825
8272
  list-style: none;
7826
8273
  }
7827
8274
 
7828
- .breadcrumb li {
7829
- display: inline-flex;
8275
+ /* The landmark around the trail must shrink with it in a flex or grid row, or
8276
+ it keeps the trail's full width and the labels never get to truncate.
8277
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
8278
+ wrapper is the fallback. */
8279
+ :where(nav:has(> .breadcrumb)) {
8280
+ min-inline-size: 0;
8281
+ }
8282
+
8283
+ /* Grid, not flex: under a min-content constraint a grid track keeps its base
8284
+ size, which is the label's minimum below, so the item's own minimum is
8285
+ separator + label floor without anyone measuring a "/". A flex item would
8286
+ report the full label as its minimum and never shrink.
8287
+
8288
+ Ancestors take the shrink before the current page, so the location is the
8289
+ last thing to lose characters. The ratio is deliberately huge: at 1000 the
8290
+ current page still took a fraction of a pixel, enough to trigger its
8291
+ ellipsis while ancestors had room. Its own factor stays 1, because flex
8292
+ distributes only a share of the deficit when the unfrozen factors sum below
8293
+ one. Keyed on the last item, the current page by convention, so the order
8294
+ holds without :has(). */
8295
+ .breadcrumb > li {
8296
+ display: inline-grid;
8297
+ flex: 0 1000000 auto;
8298
+ grid-auto-flow: column;
7830
8299
  align-items: center;
7831
- gap: var(--space-10);
8300
+ white-space: nowrap;
7832
8301
  }
7833
8302
 
7834
- .breadcrumb li + li::before {
7835
- content: "/";
8303
+ .breadcrumb > li:last-child {
8304
+ flex-shrink: 1;
8305
+ }
8306
+
8307
+ /* A margin rather than a grid gap, so the separator keeps its space when the
8308
+ current page below lays it out inline. The weight stays normal there, where
8309
+ it would inherit the current page's. */
8310
+ .breadcrumb > li + li::before {
8311
+ margin-inline-end: var(--space-10);
7836
8312
  color: var(--text-subtle);
8313
+ font-weight: var(--font-weight-normal);
8314
+ content: "/";
8315
+ }
8316
+
8317
+ /* The ellipsis lives on the label, not on the item: an element never clips
8318
+ its own outline, but an overflow-hidden item would clip its link's focus
8319
+ line, which sits outside the link box.
8320
+
8321
+ The floor keeps a truncated label readable as a step ("Po…") instead of
8322
+ collapsing to its separator. calc-size() lets a shorter label keep its own
8323
+ width; without it, the plain 2em floor leaves a label under 2em a little
8324
+ trailing room. */
8325
+ .breadcrumb > li > :is(a, span) {
8326
+ min-inline-size: 2em;
8327
+ min-inline-size: calc-size(max-content, min(size, 2em));
8328
+ overflow: hidden;
8329
+ text-overflow: ellipsis;
8330
+ }
8331
+
8332
+ /* The current page written as bare text has no label box, so its item
8333
+ truncates itself. Only with no element inside: an aria-current item that
8334
+ holds a link would clip that link's focus line, and keeps the label rule
8335
+ above instead. Its own rule, never merged with the one above: a browser
8336
+ without :has() drops it alone, and bare text then keeps its full width. */
8337
+ .breadcrumb > li[aria-current="page"]:not(:has(> *)) {
8338
+ display: block;
8339
+ min-inline-size: 2em;
8340
+ min-inline-size: calc-size(max-content, min(size, 2em));
8341
+ overflow: hidden;
8342
+ text-overflow: ellipsis;
7837
8343
  }
7838
8344
 
7839
8345
  .breadcrumb a {
@@ -7867,6 +8373,11 @@ a.avatar[aria-disabled="true"] {
7867
8373
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
7868
8374
  * — they are decorative, not actionable. They still get the same box sizing
7869
8375
  * as .btn below so the row stays visually aligned.
8376
+ *
8377
+ * The row wraps when it does not fit: every page stays visible and
8378
+ * clickable. A strip that scrolled hid pages behind a scrollbar that clicking
8379
+ * cannot move, since a click changes the page. Keeping the rendered range
8380
+ * compact is the application's job.
7870
8381
  */
7871
8382
  .pagination {
7872
8383
  display: flex;
@@ -7916,8 +8427,18 @@ a.avatar[aria-disabled="true"] {
7916
8427
  * author class can then adjust a vertical strip without restating the
7917
8428
  * attribute to reach its specificity.
7918
8429
  *
8430
+ * A horizontal strip is one axis of navigation, so it never wraps: a second
8431
+ * line no longer matches the Left/Right model and reads as a nested level.
8432
+ * When the tabs do not fit, the strip scrolls without a scrollbar; focus
8433
+ * scrolls the active tab into view.
8434
+ *
7919
8435
  * Public hooks:
8436
+ * --tab-flex
7920
8437
  * --tab-gap
8438
+ *
8439
+ * Internal:
8440
+ * --tab-edge
8441
+ * --tab-fade-dir (read inside @keyframes, so it resolves per element)
7921
8442
  */
7922
8443
  /* Intent boundary — inherited intents do not tint this component.
7923
8444
  Local intent classes still win because :where() has zero specificity. */
@@ -7928,27 +8449,129 @@ a.avatar[aria-disabled="true"] {
7928
8449
  .tabs {
7929
8450
  display: flex;
7930
8451
  flex-direction: row;
7931
- flex-wrap: wrap;
8452
+ flex-wrap: nowrap;
7932
8453
  gap: var(--space-10);
7933
8454
  margin: 0;
7934
8455
  padding: 0;
8456
+ /* Block overflow is hidden, not auto: overflow-x: auto alone computes the
8457
+ block axis to auto, so a sub-pixel line box would add a vertical
8458
+ scrollbar inside the strip. Nothing paints outside it: the focus outline
8459
+ is inset and the indicator sits inside the tab. */
8460
+ overflow: auto hidden;
8461
+ overscroll-behavior-inline: contain;
8462
+ /* why: a visible bar sits on the indicator and the hairline and reads as a
8463
+ second underline. The strip has its own scroll controls: selecting the
8464
+ clipped tab, or focusing it, brings it into view with the next one
8465
+ hinted by scroll-padding; arrow keys and swipes do the same. */
8466
+ scrollbar-width: none;
8467
+ /* Focus scrolls a tab in with room to spare, hinting at the next one; the
8468
+ edge fade below spans the same inset, so a revealed tab stays clear of it. */
8469
+ --tab-edge: var(--space-60);
8470
+
8471
+ scroll-padding-inline: var(--tab-edge);
7935
8472
  border-block-end: var(--border-width) solid var(--border);
7936
8473
  list-style: none;
7937
8474
  }
7938
8475
 
8476
+ /* The landmark around link tabs is not a scroll container, so as a flex or
8477
+ grid item it would keep the strip's full width and push the page into
8478
+ scrolling. Letting it shrink hands the overflow back to the strip.
8479
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
8480
+ wrapper is the fallback. */
8481
+ :where(nav:has(> .tabs)) {
8482
+ min-inline-size: 0;
8483
+ }
8484
+
8485
+ /* Items keep their size and the strip scrolls instead. Covers both
8486
+ anatomies: .tab directly in a tablist, or <li> in a navigation list.
8487
+ --tab-flex: 1 1 0 shares the width equally; an item still never shrinks
8488
+ below its label (flex items keep min-content), so a strip that cannot fit
8489
+ scrolls as before. */
8490
+ .tabs > * {
8491
+ flex: var(--tab-flex, none);
8492
+ }
8493
+
7939
8494
  .tabs:where([aria-orientation="vertical"]) {
7940
8495
  display: grid;
8496
+ overflow: visible;
7941
8497
  align-content: start;
7942
8498
  justify-items: stretch;
7943
8499
  border-block-end: 0;
7944
8500
  border-inline-end: var(--border-width) solid var(--border);
7945
8501
  }
7946
8502
 
8503
+ /* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
8504
+ toward. It runs on the strip's own inline timeline, which is inactive on a
8505
+ strip that fits or a vertical rail, so those stay unmasked. Keep the
8506
+ @supports: without scroll timelines the animation would end at once and pin
8507
+ its last mask. It follows scroll, not time, so reduced motion keeps it. */
8508
+ @supports (animation-timeline: scroll()) {
8509
+ .tabs {
8510
+ --tab-fade-dir: to right;
8511
+
8512
+ animation: actual-tabs-fade linear both;
8513
+ animation-timeline: scroll(self inline);
8514
+ }
8515
+
8516
+ /* Gradient keywords are physical; :dir() follows the strip's own
8517
+ direction, so an LTR island in an RTL page keeps its fade on the right. */
8518
+ .tabs:dir(rtl) {
8519
+ --tab-fade-dir: to left;
8520
+ }
8521
+ }
8522
+
8523
+ /* A strip that loads on a current link tab starts scrolled to it. Horizontal
8524
+ only: the strip is then its nearest scroll container, while a vertical
8525
+ rail would hand the target to the page. Engines without the property start
8526
+ at the first tab, and the fade says there is more. */
8527
+ :where(.tabs:not([aria-orientation="vertical"]))
8528
+ .tab:is([aria-current="page"], [aria-current="true"]) {
8529
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
8530
+ scroll-initial-target: nearest;
8531
+ }
8532
+
8533
+ /* Forced colors keep every label at full contrast. */
8534
+ @media (forced-colors: active) {
8535
+ .tabs {
8536
+ animation: none;
8537
+ }
8538
+ }
8539
+
8540
+ /* mask-image does not interpolate, so each mask holds until halfway to the
8541
+ next keyframe: the start fade appears within the first pixels of scroll and
8542
+ the end fade drops at the very end. */
8543
+ @keyframes actual-tabs-fade {
8544
+ 0% {
8545
+ mask-image: linear-gradient(
8546
+ var(--tab-fade-dir),
8547
+ #000 calc(100% - var(--tab-edge)),
8548
+ transparent
8549
+ );
8550
+ }
8551
+
8552
+ 1%,
8553
+ 99% {
8554
+ mask-image: linear-gradient(
8555
+ var(--tab-fade-dir),
8556
+ transparent,
8557
+ #000 var(--tab-edge) calc(100% - var(--tab-edge)),
8558
+ transparent
8559
+ );
8560
+ }
8561
+
8562
+ 100% {
8563
+ mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
8564
+ }
8565
+ }
8566
+
7947
8567
  .tab {
7948
8568
  /* Positioning context for the selected indicator pseudo. */
7949
8569
  position: relative;
7950
8570
  display: inline-flex;
7951
8571
  align-items: center;
8572
+ /* A tab wider than its label (--tab-flex) centers it; at natural width this
8573
+ changes nothing, and a vertical rail resets it to the start below. */
8574
+ justify-content: center;
7952
8575
  gap: var(--tab-gap, 0.375em);
7953
8576
  min-block-size: var(--control-size);
7954
8577
  padding-inline: var(--space-30);
@@ -7962,12 +8585,18 @@ a.avatar[aria-disabled="true"] {
7962
8585
  the strip. The active state is carried by color and the indicator line. */
7963
8586
  font-weight: var(--font-weight-semibold);
7964
8587
  text-decoration: none;
8588
+ /* A label breaking inside a one-line strip is the same defect as the strip
8589
+ wrapping. */
8590
+ white-space: nowrap;
7965
8591
  cursor: pointer;
7966
8592
  }
7967
8593
 
8594
+ /* A vertical strip has no inline axis to protect: long labels wrap within the
8595
+ rail instead of overflowing it. */
7968
8596
  .tabs:where([aria-orientation="vertical"]) .tab {
7969
8597
  justify-content: flex-start;
7970
8598
  padding-inline-end: var(--space-40);
8599
+ white-space: normal;
7971
8600
  }
7972
8601
 
7973
8602
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -7998,9 +8627,14 @@ a.avatar[aria-disabled="true"] {
7998
8627
  border-end-end-radius: 0;
7999
8628
  }
8000
8629
 
8630
+ /* A link tab is current on aria-current="page". "true" is for the one place
8631
+ where no page token fits: a navigation flyout trigger whose panel holds the
8632
+ current page. State selectors name their values, never the attribute's
8633
+ presence, so "false" (what React and Vue serialize for a false binding)
8634
+ stays inert. */
8001
8635
  .tab[aria-selected="true"],
8002
- .tab[aria-current="page"] {
8003
- color: var(--intent, var(--state-selected));
8636
+ .tab:is([aria-current="page"], [aria-current="true"]) {
8637
+ color: var(--intent, var(--state-selected-text, var(--state-selected)));
8004
8638
  }
8005
8639
 
8006
8640
  /* The selected indicator lives on a positioned pseudo so that only the active
@@ -8008,7 +8642,7 @@ a.avatar[aria-disabled="true"] {
8008
8642
  forced colors and erase the distinction. currentColor follows the palette the
8009
8643
  UA forces, so no forced-colors block is needed. */
8010
8644
  .tab[aria-selected="true"]::after,
8011
- .tab[aria-current="page"]::after {
8645
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
8012
8646
  content: "";
8013
8647
  position: absolute;
8014
8648
  inset-inline: 0;
@@ -8017,13 +8651,207 @@ a.avatar[aria-disabled="true"] {
8017
8651
  }
8018
8652
 
8019
8653
  .tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
8020
- .tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
8654
+ .tabs:where([aria-orientation="vertical"])
8655
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
8021
8656
  inset-block: 0;
8022
8657
  inset-inline: auto 0;
8023
8658
  border-block-end: 0;
8024
8659
  border-inline-end: 2px solid currentColor;
8025
8660
  }
8026
8661
 
8662
+ /* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
8663
+ it, so the indicator and the hit area span the whole share. */
8664
+ .tabs > li > .tab {
8665
+ inline-size: 100%;
8666
+ }
8667
+
8668
+ /*
8669
+ * Segmented control — one compact choice among a few, on a shared track.
8670
+ *
8671
+ * Docs: docs/pages/components/segmented.md
8672
+ *
8673
+ * .segmented is the track, each .segment a direct child. The look is shared;
8674
+ * the semantics stay with the markup, and the selected state is read from it:
8675
+ * radios <label class="segment"><input type="radio"> :checked
8676
+ * tabs <button class="segment" role="tab"> aria-selected="true"
8677
+ * links <a class="segment"> aria-current="page"
8678
+ * No script of its own: radios bring their arrow keys, a tablist takes
8679
+ * data-enhance="tabs". Without :has() the radio anatomy loses its selected
8680
+ * fill and focus ring, and the native radio stays visible to carry both.
8681
+ *
8682
+ * One row of equal shares (grid-auto-columns: 1fr), at least --control-size
8683
+ * tall so it lines up with .btn and .input. Labels wrap instead of truncating;
8684
+ * a word longer than its share breaks, so the control never overflows its
8685
+ * container. A strip too long for one row is .tabs, which scrolls.
8686
+ *
8687
+ * Focus is drawn inside the segment, unlike other discrete actions
8688
+ * (focus.css): an offset ring would cross the track's padding and cover the
8689
+ * neighbouring segment.
8690
+ *
8691
+ * Public hooks:
8692
+ * --segmented-bg
8693
+ * --segmented-pad
8694
+ * --segmented-radius
8695
+ * --segmented-fg
8696
+ * --segmented-selected-bg
8697
+ * --segmented-selected-border
8698
+ */
8699
+ .segmented {
8700
+ --segmented-bg: var(--surface-subtle);
8701
+ --segmented-pad: var(--space-10);
8702
+ --segmented-radius: var(--radius);
8703
+ --segmented-fg: var(--text-muted);
8704
+ --segmented-selected-bg: var(--surface-raised);
8705
+ /* The edge is what separates the selected segment where --surface-raised
8706
+ is darker than --surface-subtle (the default dark palette). */
8707
+ --segmented-selected-border: var(--border);
8708
+
8709
+ display: inline-grid;
8710
+ grid-auto-flow: column;
8711
+ grid-auto-columns: 1fr;
8712
+ gap: var(--segmented-pad);
8713
+ /* why: margin and border also reset <fieldset>, the radio anatomy's
8714
+ element. The border is transparent, not 0: forced colors paint it, which
8715
+ outlines the track once its fill is gone. */
8716
+ margin: 0;
8717
+ padding: var(--segmented-pad);
8718
+ border: var(--border-width) solid transparent;
8719
+ border-radius: var(--segmented-radius);
8720
+ background: var(--segmented-bg);
8721
+ font-size: var(--control-font-size);
8722
+ vertical-align: middle;
8723
+ }
8724
+
8725
+ .segment {
8726
+ position: relative;
8727
+ display: flex;
8728
+ align-items: center;
8729
+ justify-content: center;
8730
+ gap: 0.375em;
8731
+ /* The track is --control-size tall through its segments, not through its
8732
+ own min-block-size: a <fieldset> lays its content in an anonymous box
8733
+ sized to the content, so a minimum on the track left the segments short.
8734
+ A minimum, not a height: a wrapped label grows the whole row. */
8735
+ min-block-size: calc(var(--control-size) - 2 * (var(--segmented-pad) + var(--border-width)));
8736
+ padding: calc(var(--space-10) / 2) var(--space-30);
8737
+ border: 0;
8738
+ border-radius: calc(var(--segmented-radius) - var(--segmented-pad));
8739
+ background: transparent;
8740
+ color: var(--segmented-fg);
8741
+ font: inherit;
8742
+ /* Constant across states: a heavier selected label would resize its share. */
8743
+ font-weight: var(--font-weight-medium);
8744
+ line-height: 1.2;
8745
+ text-align: center;
8746
+ text-decoration: none;
8747
+ /* why: also shrinks the min-content contribution, so 1fr keeps equal
8748
+ shares under constraint instead of widening one around a long word. */
8749
+ overflow-wrap: anywhere;
8750
+ cursor: pointer;
8751
+ transition:
8752
+ background-color var(--duration),
8753
+ color var(--duration),
8754
+ box-shadow var(--duration);
8755
+ }
8756
+
8757
+ .segment > input {
8758
+ margin: 0;
8759
+ }
8760
+
8761
+ /* The radio is visually hidden but stays focusable, submitted and arrow-key
8762
+ navigable (sr-only pattern). Guarded: without :has() the segment cannot
8763
+ mirror :checked, so the visible native radio is the fallback. */
8764
+ @supports selector(:has(*)) {
8765
+ .segment > input {
8766
+ position: absolute;
8767
+ inline-size: 1px;
8768
+ block-size: 1px;
8769
+ padding: 0;
8770
+ margin: -1px;
8771
+ overflow: hidden;
8772
+ clip: rect(0, 0, 0, 0);
8773
+ white-space: nowrap;
8774
+ border: 0;
8775
+ }
8776
+ }
8777
+
8778
+ .segment:hover {
8779
+ color: var(--text);
8780
+ }
8781
+
8782
+ /* Separate rules from the :has() forms below: an engine without :has() would
8783
+ discard a shared selector list, ARIA states included. */
8784
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
8785
+ background: var(--segmented-selected-bg);
8786
+ color: var(--text);
8787
+ box-shadow:
8788
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
8789
+ var(--shadow);
8790
+ }
8791
+
8792
+ .segment:has(> input:checked) {
8793
+ background: var(--segmented-selected-bg);
8794
+ color: var(--text);
8795
+ box-shadow:
8796
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
8797
+ var(--shadow);
8798
+ }
8799
+
8800
+ .segment:focus-visible {
8801
+ outline: var(--focus-ring-width) solid var(--focus);
8802
+ outline-offset: calc(var(--focus-ring-width) * -1);
8803
+ }
8804
+
8805
+ .segment:has(> input:focus-visible) {
8806
+ outline: var(--focus-ring-width) solid var(--focus);
8807
+ outline-offset: calc(var(--focus-ring-width) * -1);
8808
+ }
8809
+
8810
+ .segment:is(:disabled, [aria-disabled="true"]) {
8811
+ opacity: var(--disabled-opacity);
8812
+ cursor: not-allowed;
8813
+ }
8814
+
8815
+ .segment:has(> input:disabled) {
8816
+ opacity: var(--disabled-opacity);
8817
+ cursor: not-allowed;
8818
+ }
8819
+
8820
+ /* Forced colors drop the raised fill and its edge. The selected segment
8821
+ takes the system highlight — a background, so the outline stays the focus
8822
+ channel alone. forced-color-adjust: none is required, not cosmetic: the UA
8823
+ otherwise paints a Canvas backplate behind the label, which hid the text on
8824
+ the highlight. Opting out also stops the forcing of the rest, so the shadow
8825
+ is dropped here and the focus line takes the highlight's text color. */
8826
+ @media (forced-colors: active) {
8827
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
8828
+ --focus: HighlightText;
8829
+
8830
+ forced-color-adjust: none;
8831
+ background: Highlight;
8832
+ color: HighlightText;
8833
+ box-shadow: none;
8834
+ }
8835
+
8836
+ .segment:has(> input:checked) {
8837
+ --focus: HighlightText;
8838
+
8839
+ forced-color-adjust: none;
8840
+ background: Highlight;
8841
+ color: HighlightText;
8842
+ box-shadow: none;
8843
+ }
8844
+
8845
+ /* --disabled-opacity is 1 here (tokens.css): the dimming needs a color. */
8846
+ .segment:is(:disabled, [aria-disabled="true"]) {
8847
+ color: GrayText;
8848
+ }
8849
+
8850
+ .segment:has(> input:disabled) {
8851
+ color: GrayText;
8852
+ }
8853
+ }
8854
+
8027
8855
  /*
8028
8856
  * Scrollspy — behavior hook for navigation that marks the active section
8029
8857
  * while the page scrolls.
@@ -8034,7 +8862,7 @@ a.avatar[aria-disabled="true"] {
8034
8862
  * <nav class="scrollspy">
8035
8863
  * containing <a href="#section"> links and toggles aria-current="location" on
8036
8864
  * the link whose target is in view. Pair with .nav-list and .nav-link, or style
8037
- * [aria-current] yourself.
8865
+ * [aria-current="location"] yourself.
8038
8866
  *
8039
8867
  * Progressive enhancement: on browsers that support :target-current,
8040
8868
  * this file enables the native target group and marks the active link.
@@ -8149,9 +8977,13 @@ a.avatar[aria-disabled="true"] {
8149
8977
  .list > :where(.list-item),
8150
8978
  .list > li > .list-item {
8151
8979
  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;
8980
+ /* A trailing control that no longer fits beside the content's longest word
8981
+ moves to its own line instead of breaking that word. */
8982
+ flex-wrap: wrap;
8983
+ /* Centered fits the short rows lists mostly hold. Read through
8984
+ --items-align so .items-start on the row or the list wins without
8985
+ outranking this selector — the opt-out for long supporting text. */
8986
+ align-items: var(--items-align, center);
8155
8987
  gap: var(--list-item-gap);
8156
8988
  min-block-size: var(--list-item-min-size);
8157
8989
  padding-block: var(--list-item-pad-block);
@@ -8173,21 +9005,20 @@ a.list-item:hover {
8173
9005
  background: var(--surface-subtle);
8174
9006
  }
8175
9007
 
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.
9008
+ /* Current row in a navigational list: aria-current="page" when the row opens
9009
+ a page, "true" when it is the current item of a master/detail view (a
9010
+ conversation, a thread, a record) that is no page of its own. Any other
9011
+ value, "false" included, is inert.
8181
9012
  Calm by design: the row keeps its normal text metrics and color on a subtle
8182
9013
  surface, and the selected accent lives on an inset inline-start trait only.
8183
9014
  The trait is a real border, so it survives forced colors without a repaint
8184
9015
  block. The positioning context lives on every row link, so becoming current
8185
9016
  never moves an app-positioned decoration to a new containing block. */
8186
- a.list-item[aria-current] {
9017
+ a.list-item:is([aria-current="page"], [aria-current="true"]) {
8187
9018
  background: var(--surface-subtle);
8188
9019
  }
8189
9020
 
8190
- a.list-item[aria-current]::before {
9021
+ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
8191
9022
  content: "";
8192
9023
  position: absolute;
8193
9024
  inset-block: var(--list-item-pad-block);
@@ -8195,17 +9026,29 @@ a.list-item[aria-current]::before {
8195
9026
  border-inline-start: 2px solid var(--state-selected);
8196
9027
  }
8197
9028
 
8198
- /* The content region is the one that flexes, whichever position it holds. */
9029
+ /* The content region is the one that flexes, whichever position it holds.
9030
+ Its automatic minimum is its longest word, which is what wraps the row; the
9031
+ max size caps that minimum at the row, so a long token breaks rather than
9032
+ widening the page. A zero minimum shrank it until words broke mid-way. */
8199
9033
  .list-item-content {
8200
9034
  flex: 1 1 0;
8201
- min-inline-size: 0;
9035
+ max-inline-size: 100%;
8202
9036
  }
8203
9037
 
9038
+ /* break-word, not anywhere: anywhere also lowers the min-content to a single
9039
+ character, and the row would never wrap. */
8204
9040
  .list-item-title,
8205
9041
  .list-item-text {
8206
9042
  display: block;
8207
9043
  margin: 0;
8208
- overflow-wrap: anywhere;
9044
+ overflow-wrap: break-word;
9045
+ }
9046
+
9047
+ /* Truncated text gives up its width by design: without containment its
9048
+ unbreakable line would become the content's minimum and push the trailing
9049
+ region onto a line of its own. */
9050
+ .list-item-content .truncate {
9051
+ contain: inline-size;
8209
9052
  }
8210
9053
 
8211
9054
  .list-item-text {
@@ -8456,7 +9299,7 @@ a.list-item[aria-current]::before {
8456
9299
  }
8457
9300
 
8458
9301
  .join-addon {
8459
- --control-border: var(--border);
9302
+ --control-border: var(--border-control);
8460
9303
  --control-bg: var(--surface-subtle);
8461
9304
  --control-fg: var(--text-muted);
8462
9305
 
@@ -8561,7 +9404,7 @@ a.list-item[aria-current]::before {
8561
9404
  The second :has() covers a control nested one wrapper level deep. */
8562
9405
  .join:has(> :is(.input, .textarea, .select)) > .btn.outline,
8563
9406
  .join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
8564
- --btn-border-color: var(--intent, var(--border));
9407
+ --btn-border-color: var(--intent, var(--border-control));
8565
9408
  }
8566
9409
 
8567
9410
  /* Effects — purely visual enrichments. */
@@ -9095,9 +9938,9 @@ a.list-item[aria-current]::before {
9095
9938
  flex-wrap: nowrap;
9096
9939
  }
9097
9940
 
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. */
9941
+ /* The property is written directly, so the utility works on any flex row, not
9942
+ only a .cluster. Utilities load last, so the direct declaration beats
9943
+ .cluster at equal specificity. */
9101
9944
  .justify-content-start {
9102
9945
  justify-content: flex-start;
9103
9946
  }