actual-css 0.13.0 → 0.14.1

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 (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +8 -8
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +652 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/widget-primitives.md +4 -0
  8. package/docs/navigation.json +2 -0
  9. package/docs/pages/components/alert.md +12 -2
  10. package/docs/pages/components/button.md +3 -2
  11. package/docs/pages/components/list.md +6 -3
  12. package/docs/pages/components/segmented.md +83 -0
  13. package/docs/pages/components/steps.md +4 -2
  14. package/docs/pages/enhancements/flyout.md +9 -3
  15. package/docs/pages/enhancements/overview.md +2 -1
  16. package/docs/pages/enhancements/tabs.md +7 -2
  17. package/docs/pages/enhancements/tooltip.md +3 -1
  18. package/docs/pages/examples/overview.md +5 -3
  19. package/docs/pages/forms/color-swatches.md +131 -0
  20. package/docs/pages/forms/combobox.md +2 -2
  21. package/docs/pages/forms/controls.md +6 -3
  22. package/docs/pages/forms/joined-controls.md +6 -2
  23. package/docs/pages/forms/overview.md +1 -0
  24. package/docs/pages/forms/switch.md +4 -2
  25. package/docs/pages/foundations/theming.md +1 -1
  26. package/docs/pages/foundations/tokens.md +54 -23
  27. package/docs/pages/guides/design-tools.md +13 -7
  28. package/docs/pages/guides/introduction.md +1 -1
  29. package/docs/pages/layout/scroll-snap.md +4 -2
  30. package/package.json +5 -4
  31. package/reserved-classes.json +3 -0
  32. package/src/cli/actual-css.js +3 -3
  33. package/src/css/components/alert.css +65 -14
  34. package/src/css/components/app-nav.css +17 -9
  35. package/src/css/components/badge.css +23 -9
  36. package/src/css/components/button.css +17 -2
  37. package/src/css/components/close.css +6 -3
  38. package/src/css/components/index.css +1 -0
  39. package/src/css/components/list.css +18 -3
  40. package/src/css/components/navbar.css +5 -2
  41. package/src/css/components/segmented.css +186 -0
  42. package/src/css/components/steps.css +6 -3
  43. package/src/css/components/tab.css +1 -1
  44. package/src/css/core/focus.css +3 -0
  45. package/src/css/core/tokens.css +20 -4
  46. package/src/css/core/variants.css +16 -5
  47. package/src/css/forms/color-swatch.css +163 -0
  48. package/src/css/forms/form-actions.css +9 -0
  49. package/src/css/forms/index.css +1 -0
  50. package/src/css/forms/native.css +50 -20
  51. package/src/css/forms/switch.css +5 -2
  52. package/src/css/layout/scroll-snap.css +30 -0
  53. package/src/css/layout/topbar.css +10 -0
  54. package/src/js/enhance.js +14 -0
  55. package/src/js/floating-space.js +26 -0
  56. package/src/js/flyout.js +3 -1
  57. package/src/js/focus.js +16 -1
  58. package/src/js/parse-config.js +5 -2
  59. package/src/js/surface.js +10 -0
  60. package/src/js/tooltip.js +2 -2
  61. package/src/tooling/color.js +14 -1
  62. package/src/tooling/css-bundle.js +0 -5
  63. package/src/tooling/design-components.js +9 -5
  64. package/src/tooling/design-export.js +8 -0
  65. package/src/tooling/design-tokens.js +163 -43
  66. package/src/tooling/theme-contrast.js +151 -22
@@ -215,9 +215,20 @@ select {
215
215
  [data-theme] {
216
216
  /* State tokens — semantic colors for selected/pressed/current and disabled.
217
217
  Components consume these instead of hard-wiring --primary/--text-subtle
218
- 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. */
219
229
  --state-selected: var(--primary);
220
230
  --state-selected-fg: var(--primary-fg);
231
+ --state-selected-text: initial;
221
232
  --state-disabled: var(--text-subtle);
222
233
 
223
234
  /* Status dot ring — cuts the dot out of what it overlaps. */
@@ -226,10 +237,15 @@ select {
226
237
 
227
238
  :root {
228
239
  /* Variant tuning — themes may adjust generated soft surfaces without
229
- 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. */
230
247
  --soft-bg-mix: 88%;
231
- --soft-border-mix: 65%;
232
- --soft-hover-alpha: 12%;
248
+ --soft-hover-alpha: 6%;
233
249
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
234
250
  pure intent color; lower values rebate it toward --text, which vivid or
235
251
  light intent palettes need to stay legible on their own soft surface.
@@ -718,6 +734,9 @@ small {
718
734
  *
719
735
  * Intent list source of truth: intents.css. Components consume --intent* tokens
720
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)
721
740
  */
722
741
  .solid {
723
742
  --ui-bg: var(--intent, var(--neutral));
@@ -781,7 +800,7 @@ small {
781
800
 
782
801
  /* Shared control scale. Wrappers are listed when their descendants form one
783
802
  component (field, choice-card, floating-field, input-icon, join, pagination,
784
- tabs, flyout). A local size declaration beats inherited density because it
803
+ tabs, segmented, flyout). A local size declaration beats inherited density because it
785
804
  is declared on the sized component itself. Guarded by check:sync
786
805
  (size-scale-participants): .sm and .lg must list the same components. */
787
806
  :where(
@@ -794,6 +813,7 @@ small {
794
813
  .field,
795
814
  .choice,
796
815
  .choice-card,
816
+ .color-swatch,
797
817
  .input-icon,
798
818
  .floating-field,
799
819
  .otp,
@@ -801,6 +821,7 @@ small {
801
821
  .pagination,
802
822
  .tabs,
803
823
  .tab,
824
+ .segmented,
804
825
  .flyout
805
826
  ).sm {
806
827
  --control-size: var(--control-size-sm);
@@ -821,6 +842,7 @@ small {
821
842
  .field,
822
843
  .choice,
823
844
  .choice-card,
845
+ .color-swatch,
824
846
  .input-icon,
825
847
  .floating-field,
826
848
  .otp,
@@ -828,6 +850,7 @@ small {
828
850
  .pagination,
829
851
  .tabs,
830
852
  .tab,
853
+ .segmented,
831
854
  .flyout
832
855
  ).lg {
833
856
  --control-size: var(--control-size-lg);
@@ -839,21 +862,25 @@ small {
839
862
  }
840
863
 
841
864
  /* Modern color enhancement. Baseline variant values above remain usable
842
- 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). */
843
870
  @supports (color: color-mix(in oklch, red, white)) {
844
871
  .soft {
845
- /* @sync soft-recipe */
846
872
  --ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
847
873
  --ui-border: color-mix(
848
874
  in oklab,
849
- var(--surface) var(--soft-border-mix),
875
+ var(--surface) var(--soft-border-mix, 65%),
850
876
  var(--intent, var(--neutral))
851
877
  );
878
+ /* @sync soft-ink */
852
879
  --ui-fg: var(
853
880
  --intent-soft-fg,
854
881
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
855
882
  );
856
- /* @sync soft-recipe */
883
+ /* @sync soft-ink */
857
884
  }
858
885
  }
859
886
 
@@ -880,6 +907,9 @@ small {
880
907
  --surface-solid, so one token reads on the page, on a solid band and on
881
908
  any nested surface.
882
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
+
883
913
  Documented exception: an invalid field focuses in --form-invalid-border
884
914
  (danger), so the error stays visible at the point of correction. The border
885
915
  carries the same hook; report:theme-contrast measures that line at 3:1
@@ -1927,16 +1957,36 @@ small {
1927
1957
  * --scroll-snap-align
1928
1958
  * --scroll-snap-item-size
1929
1959
  * --scroll-snap-padding
1960
+ *
1961
+ * Internal:
1962
+ * --scroll-snap-focus-bleed
1930
1963
  */
1931
1964
  .scroll-snap {
1932
1965
  --scroll-snap-gap: var(--gap);
1933
1966
  --scroll-snap-align: start;
1934
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));
1935
1971
 
1936
1972
  display: flex;
1937
1973
  flex-direction: row;
1938
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);
1939
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;
1940
1990
  }
1941
1991
 
1942
1992
  .scroll-snap > * {
@@ -1946,7 +1996,17 @@ small {
1946
1996
  @supports (scroll-snap-type: x mandatory) {
1947
1997
  .scroll-snap {
1948
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. */
1949
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
+ );
1950
2010
  /* x then inline: an engine that lacks the logical keyword keeps x.
1951
2011
  proximity is the default, so a short rail cannot trap the pointer. */
1952
2012
  scroll-snap-type: x proximity;
@@ -2043,6 +2103,16 @@ small {
2043
2103
  }
2044
2104
  }
2045
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
+
2046
2116
  /* Readable content measure. Composes with .center for centering or with
2047
2117
  margin-inline: auto for a one-off centered block.
2048
2118
  *
@@ -2557,6 +2627,9 @@ small {
2557
2627
  *
2558
2628
  * Range has its own custom-styled file (range.css) so the focus ring can
2559
2629
  * target the thumb pseudo-element rather than the rectangular CSS box.
2630
+ *
2631
+ * Public hooks:
2632
+ * --color-size
2560
2633
  */
2561
2634
  .file {
2562
2635
  color: var(--text-muted);
@@ -2602,16 +2675,24 @@ small {
2602
2675
  outline-offset: var(--focus-outline-offset);
2603
2676
  }
2604
2677
 
2605
- /* Native color picker — keep the browser's dialog and selected-color UI. The
2606
- swatch fills almost the whole control so it reads as a color, not as a tiny
2607
- 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. */
2608
2683
  .color {
2609
- inline-size: var(--control-size);
2610
- block-size: var(--control-size);
2611
- padding: var(--border-width);
2612
- border: var(--border-width) solid var(--border-control);
2613
- border-radius: var(--radius);
2614
- 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);
2615
2696
  cursor: pointer;
2616
2697
  }
2617
2698
 
@@ -2620,13 +2701,21 @@ small {
2620
2701
  }
2621
2702
 
2622
2703
  .color::-webkit-color-swatch {
2623
- border: 0;
2624
- border-radius: calc(var(--radius) - var(--border-width));
2704
+ border: var(--border-width) solid var(--border-control);
2705
+ border-radius: var(--radius-full);
2625
2706
  }
2626
2707
 
2627
2708
  .color::-moz-color-swatch {
2628
- border: 0;
2629
- 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);
2630
2719
  }
2631
2720
 
2632
2721
  .color:disabled {
@@ -2634,25 +2723,36 @@ small {
2634
2723
  opacity: var(--disabled-opacity);
2635
2724
  }
2636
2725
 
2726
+ /* The ring goes around the visible swatch, not the larger hit area. */
2637
2727
  .color:focus-visible {
2728
+ outline: none;
2729
+ }
2730
+
2731
+ .color:focus-visible::-webkit-color-swatch {
2638
2732
  outline: var(--focus-ring-width) solid var(--focus);
2639
2733
  outline-offset: var(--focus-outline-offset);
2640
2734
  }
2641
2735
 
2642
- @media (forced-colors: active) {
2643
- .color {
2644
- border-color: ButtonText;
2645
- background: ButtonFace;
2646
- }
2736
+ .color:focus-visible::-moz-color-swatch {
2737
+ outline: var(--focus-ring-width) solid var(--focus);
2738
+ outline-offset: var(--focus-outline-offset);
2739
+ }
2647
2740
 
2648
- /* The swatch color is content, so preserve it without disabling the host's
2649
- 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. */
2650
2744
  .color::-webkit-color-swatch {
2745
+ --focus: CanvasText;
2746
+
2651
2747
  forced-color-adjust: none;
2748
+ border-color: CanvasText;
2652
2749
  }
2653
2750
 
2654
2751
  .color::-moz-color-swatch {
2752
+ --focus: CanvasText;
2753
+
2655
2754
  forced-color-adjust: none;
2755
+ border-color: CanvasText;
2656
2756
  }
2657
2757
  }
2658
2758
 
@@ -2802,6 +2902,15 @@ small {
2802
2902
  }
2803
2903
  }
2804
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
+
2805
2914
  /*
2806
2915
  * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
2807
2916
  *
@@ -2881,6 +2990,7 @@ small {
2881
2990
  * --switch-block-size
2882
2991
  * --switch-knob-margin
2883
2992
  * --switch-knob-size
2993
+ * --switch-knob-inline-size
2884
2994
  * --switch-travel
2885
2995
  *
2886
2996
  * The track is sized from --choice-control-size (tokens.css), not
@@ -2914,9 +3024,11 @@ small {
2914
3024
  2 *
2915
3025
  var(--switch-knob-margin)
2916
3026
  );
3027
+ /* A wider knob is a capsule; the travel follows its width. */
3028
+ --switch-knob-inline-size: var(--switch-knob-size);
2917
3029
  --switch-travel: calc(
2918
3030
  var(--switch-width) -
2919
- var(--switch-knob-size) -
3031
+ var(--switch-knob-inline-size) -
2920
3032
  2 *
2921
3033
  var(--border-width) -
2922
3034
  2 *
@@ -2952,7 +3064,7 @@ small {
2952
3064
  position: absolute;
2953
3065
  inset-block-start: var(--switch-knob-margin);
2954
3066
  inset-inline-start: var(--switch-knob-margin);
2955
- inline-size: var(--switch-knob-size);
3067
+ inline-size: var(--switch-knob-inline-size);
2956
3068
  block-size: var(--switch-knob-size);
2957
3069
  border-radius: var(--radius-full);
2958
3070
  background: var(--switch-knob);
@@ -3464,6 +3576,170 @@ small {
3464
3576
  }
3465
3577
  }
3466
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
+ /* No in-flow text, so the baseline would be the box's bottom edge and an
3631
+ inline swatch would ride above the line (a flyout trigger beside its
3632
+ label). A palette in .cluster or .grid ignores it. */
3633
+ vertical-align: middle;
3634
+ border: 0;
3635
+ background: transparent;
3636
+ color: inherit;
3637
+ /* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
3638
+ font: inherit;
3639
+ font-size: var(--control-font-size);
3640
+ cursor: pointer;
3641
+ }
3642
+
3643
+ .color-swatch::before {
3644
+ content: "";
3645
+ grid-area: 1 / 1;
3646
+ inline-size: var(--color-swatch-size);
3647
+ block-size: var(--color-swatch-size);
3648
+ box-sizing: border-box;
3649
+ border: var(--border-width) solid var(--border-control);
3650
+ border-radius: var(--color-swatch-radius);
3651
+ background: var(--color-swatch-value, transparent) padding-box;
3652
+ /* The color is the content, as on .color (native.css). */
3653
+ forced-color-adjust: none;
3654
+ transition:
3655
+ border-color var(--duration),
3656
+ box-shadow var(--duration);
3657
+ }
3658
+
3659
+ /* Shares the swatch's cell and paints after ::before, so a native radio left
3660
+ visible sits on the color. */
3661
+ .color-swatch > input {
3662
+ grid-area: 1 / 1;
3663
+ position: relative;
3664
+ margin: 0;
3665
+ }
3666
+
3667
+ /* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
3668
+ pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
3669
+ visible native radio is the fallback. */
3670
+ @supports selector(:has(*)) {
3671
+ .color-swatch > input {
3672
+ position: absolute;
3673
+ inline-size: 1px;
3674
+ block-size: 1px;
3675
+ padding: 0;
3676
+ margin: -1px;
3677
+ overflow: hidden;
3678
+ clip: rect(0, 0, 0, 0);
3679
+ white-space: nowrap;
3680
+ border: 0;
3681
+ }
3682
+ }
3683
+
3684
+ .color-swatch:hover::before {
3685
+ border-color: var(--text-subtle);
3686
+ }
3687
+
3688
+ .color-swatch:has(> input:checked)::before {
3689
+ box-shadow:
3690
+ inset 0 0 0 2px var(--text),
3691
+ inset 0 0 0 4px var(--surface-opaque);
3692
+ }
3693
+
3694
+ /* The ring goes around the visible swatch, not the larger hit area. */
3695
+ .color-swatch:focus-visible {
3696
+ outline: none;
3697
+ }
3698
+
3699
+ .color-swatch:focus-visible::before {
3700
+ outline: var(--focus-ring-width) solid var(--focus);
3701
+ outline-offset: var(--focus-outline-offset);
3702
+ }
3703
+
3704
+ .color-swatch:has(> input:focus-visible)::before {
3705
+ outline: var(--focus-ring-width) solid var(--focus);
3706
+ outline-offset: var(--focus-outline-offset);
3707
+ }
3708
+
3709
+ .color-swatch:disabled {
3710
+ opacity: var(--disabled-opacity);
3711
+ cursor: not-allowed;
3712
+ }
3713
+
3714
+ .color-swatch:has(> input:disabled) {
3715
+ opacity: var(--disabled-opacity);
3716
+ cursor: not-allowed;
3717
+ }
3718
+
3719
+ /* A disabled swatch keeps its color: --disabled-opacity is 1 here
3720
+ (tokens.css), and the edge carries the state. */
3721
+ @media (forced-colors: active) {
3722
+ .color-swatch::before {
3723
+ --focus: CanvasText;
3724
+
3725
+ border-color: CanvasText;
3726
+ }
3727
+
3728
+ .color-swatch:has(> input:checked)::before {
3729
+ box-shadow:
3730
+ inset 0 0 0 2px CanvasText,
3731
+ inset 0 0 0 4px Canvas;
3732
+ }
3733
+
3734
+ .color-swatch:disabled::before {
3735
+ border-color: GrayText;
3736
+ }
3737
+
3738
+ .color-swatch:has(> input:disabled)::before {
3739
+ border-color: GrayText;
3740
+ }
3741
+ }
3742
+
3467
3743
  /*
3468
3744
  * Customizable select — appearance: base-select picker enhancement.
3469
3745
  *
@@ -3958,12 +4234,27 @@ small {
3958
4234
  --ui-hover-bg: transparent;
3959
4235
  }
3960
4236
 
4237
+ /* A soft button takes the badge and alert tint, not the shared opaque .soft:
4238
+ it carries a short label like them, and the translucent fill keeps
4239
+ separating on a subtle or raised surface. Ink stays the shared .soft one. */
3961
4240
  @supports (color: color-mix(in oklch, red, white)) {
3962
4241
  .btn.soft {
4242
+ /* @sync soft-tint */
4243
+ --ui-bg: color-mix(
4244
+ in oklab,
4245
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4246
+ transparent
4247
+ );
4248
+ --ui-border: color-mix(
4249
+ in oklab,
4250
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4251
+ transparent
4252
+ );
4253
+ /* @sync soft-tint */
3963
4254
  --ui-hover-bg: color-mix(
3964
4255
  in oklab,
3965
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
3966
- var(--intent, var(--neutral))
4256
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
4257
+ transparent
3967
4258
  );
3968
4259
  }
3969
4260
 
@@ -4162,11 +4453,14 @@ a.btn[aria-disabled="true"] {
4162
4453
  }
4163
4454
 
4164
4455
  @media (forced-colors: active) {
4165
- /* The X is a currentColor mask fill: without this the system remaps the
4166
- background to Canvas and the glyph disappears. Kept on the pseudo so the
4167
- host keeps its native color and focus adaptation. */
4456
+ /* The X is a mask fill: without this the system remaps the background to
4457
+ Canvas and the glyph disappears. Kept on the pseudo so the host keeps its
4458
+ native color and focus adaptation. The fill is the system color, not
4459
+ currentColor: an unadjusted pseudo inherits the authored ink, so a white
4460
+ .solid ink painted a white X on Canvas. */
4168
4461
  .close::before {
4169
4462
  forced-color-adjust: none;
4463
+ background: ButtonText;
4170
4464
  }
4171
4465
  }
4172
4466
 
@@ -4283,28 +4577,42 @@ a.btn[aria-disabled="true"] {
4283
4577
  }
4284
4578
 
4285
4579
  /* Soft by default. Local defaults remain behind --ui-* in the resolution
4286
- chain, so explicit shared treatments compose without selector exclusions. */
4580
+ chain, so explicit shared treatments compose without selector exclusions.
4581
+ The fill is a translucent intent tint, so it shows the surface it sits on
4582
+ instead of a fixed mix with --surface: a mix stops separating on a subtle
4583
+ or raised surface of its own lightness. No rim unless a theme sets
4584
+ --soft-border-mix (tokens.css). */
4287
4585
  @supports (color: color-mix(in oklch, red, white)) {
4288
4586
  .badge {
4289
- /* @sync soft-recipe */
4587
+ /* @sync soft-tint */
4290
4588
  --badge-default-bg: color-mix(
4291
4589
  in oklab,
4292
- var(--surface) var(--soft-bg-mix),
4293
- var(--intent, var(--neutral))
4590
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4591
+ transparent
4294
4592
  );
4295
4593
  --badge-default-border: color-mix(
4296
4594
  in oklab,
4297
- var(--surface) var(--soft-border-mix),
4298
- var(--intent, var(--neutral))
4595
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4596
+ transparent
4299
4597
  );
4598
+ /* @sync soft-tint */
4599
+ /* @sync soft-ink */
4300
4600
  --badge-default-fg: var(
4301
4601
  --intent-soft-fg,
4302
4602
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
4303
4603
  );
4304
- /* @sync soft-recipe */
4604
+ /* @sync soft-ink */
4305
4605
  }
4306
4606
  }
4307
4607
 
4608
+ /* Soft is the badge's own default: an explicit .soft must not swap the tint
4609
+ for the shared opaque .soft recipe, so it defers to the default. */
4610
+ .badge.soft {
4611
+ --ui-bg: initial;
4612
+ --ui-fg: initial;
4613
+ --ui-border: initial;
4614
+ }
4615
+
4308
4616
  .badge:empty {
4309
4617
  --badge-size: var(--badge-dot-size);
4310
4618
  --badge-bg: var(--intent, var(--neutral));
@@ -4343,8 +4651,8 @@ a.btn[aria-disabled="true"] {
4343
4651
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
4344
4652
  --badge-default-hover-bg: color-mix(
4345
4653
  in oklab,
4346
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
4347
- var(--intent, var(--neutral))
4654
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix) + var(--soft-hover-alpha)),
4655
+ transparent
4348
4656
  );
4349
4657
  }
4350
4658
 
@@ -4462,6 +4770,16 @@ a.badge[aria-disabled="true"] {
4462
4770
  font-size: var(--alert-font-size);
4463
4771
  }
4464
4772
 
4773
+ /* Density steps the block padding only: the inline padding is the text
4774
+ column's edge and stays stable. Local to the alert, like .card.compact. */
4775
+ .alert.compact {
4776
+ --alert-pad-block: var(--space-20);
4777
+ }
4778
+
4779
+ .alert.spacious {
4780
+ --alert-pad-block: var(--space-40);
4781
+ }
4782
+
4465
4783
  .alert > * {
4466
4784
  min-inline-size: 0;
4467
4785
  }
@@ -4486,28 +4804,40 @@ a.badge[aria-disabled="true"] {
4486
4804
  }
4487
4805
 
4488
4806
  /* Alerts are soft by default. The recipe writes local defaults rather than
4489
- --ui-* so an explicit shared treatment always wins without exclusions. */
4807
+ --ui-* so an explicit shared treatment always wins without exclusions. The
4808
+ fill is the translucent soft tint (see badge.css), with no rim unless a
4809
+ theme sets --soft-border-mix. */
4490
4810
  @supports (color: color-mix(in oklch, red, white)) {
4491
4811
  .alert {
4492
- /* @sync soft-recipe */
4812
+ /* @sync soft-tint */
4493
4813
  --alert-default-bg: color-mix(
4494
4814
  in oklab,
4495
- var(--surface) var(--soft-bg-mix),
4496
- var(--intent, var(--neutral))
4815
+ var(--intent, var(--neutral)) calc(100% - var(--soft-bg-mix)),
4816
+ transparent
4497
4817
  );
4498
4818
  --alert-default-border: color-mix(
4499
4819
  in oklab,
4500
- var(--surface) var(--soft-border-mix),
4501
- var(--intent, var(--neutral))
4820
+ var(--intent, var(--neutral)) calc(100% - var(--soft-border-mix, 100%)),
4821
+ transparent
4502
4822
  );
4823
+ /* @sync soft-tint */
4824
+ /* @sync soft-ink */
4503
4825
  --alert-default-fg: var(
4504
4826
  --intent-soft-fg,
4505
4827
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
4506
4828
  );
4507
- /* @sync soft-recipe */
4829
+ /* @sync soft-ink */
4508
4830
  }
4509
4831
  }
4510
4832
 
4833
+ /* Soft is the alert's own default: an explicit .soft must not swap the tint
4834
+ for the shared opaque .soft recipe, so it defers to the default. */
4835
+ .alert.soft {
4836
+ --ui-bg: initial;
4837
+ --ui-fg: initial;
4838
+ --ui-border: initial;
4839
+ }
4840
+
4511
4841
  /* Decorative leading icon — use .alert-icon on the icon element. display and
4512
4842
  vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
4513
4843
  before the text); the grid anatomy below replaces them. */
@@ -4606,7 +4936,10 @@ a.badge[aria-disabled="true"] {
4606
4936
 
4607
4937
  /* Admonition — structured static box with a tinted title bar and a body
4608
4938
  that rests on the page surface. Not collapsible — use .accordion inside
4609
- the body if the content needs to be expandable. */
4939
+ the body if the content needs to be expandable. The title bar is what a
4940
+ shared treatment paints (.solid fills it, .surface/.outline clear it): the
4941
+ root's fill never shows behind the opaque title and body, and the title ink
4942
+ already follows --ui-fg, so a bar left on the soft tint is unreadable. */
4610
4943
  .alert.admonition {
4611
4944
  padding: 0;
4612
4945
  display: block;
@@ -4620,6 +4953,7 @@ a.badge[aria-disabled="true"] {
4620
4953
  gap: var(--space-20);
4621
4954
  padding-block: var(--alert-pad-block);
4622
4955
  padding-inline: var(--alert-pad-inline);
4956
+ font-weight: var(--font-weight-semibold);
4623
4957
  border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4624
4958
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4625
4959
  }
@@ -4631,18 +4965,17 @@ a.badge[aria-disabled="true"] {
4631
4965
 
4632
4966
  @supports (color: color-mix(in oklch, red, white)) {
4633
4967
  .alert.admonition > .alert-title {
4634
- background: color-mix(
4635
- in oklab,
4636
- var(--surface) var(--soft-bg-mix),
4637
- var(--intent, var(--neutral))
4968
+ background: var(
4969
+ --ui-bg,
4970
+ color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)))
4638
4971
  );
4639
4972
  }
4640
4973
  }
4641
4974
 
4642
4975
  @supports not (color: color-mix(in oklch, red, white)) {
4643
4976
  .alert.admonition > .alert-title {
4644
- background: var(--intent, var(--neutral));
4645
- color: var(--intent-fg, var(--neutral-fg));
4977
+ background: var(--ui-bg, var(--intent, var(--neutral)));
4978
+ color: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
4646
4979
  }
4647
4980
  }
4648
4981
 
@@ -4653,6 +4986,32 @@ a.badge[aria-disabled="true"] {
4653
4986
  color: var(--text);
4654
4987
  }
4655
4988
 
4989
+ /* Solid: the filled title bar already carries the intent, so the frame moves
4990
+ to the body and stays neutral. The root border goes because a transparent
4991
+ one would show the root's intent fill around the body. */
4992
+ .alert.admonition.solid {
4993
+ border: 0;
4994
+ }
4995
+
4996
+ .alert.admonition.solid > .alert-body {
4997
+ border: var(--border-width) solid var(--border);
4998
+ border-block-start: 0;
4999
+ border-end-start-radius: var(--alert-radius, var(--radius-lg));
5000
+ border-end-end-radius: var(--alert-radius, var(--radius-lg));
5001
+ }
5002
+
5003
+ /* Forced colors drop the title fill, so the bar would lose its edge: the
5004
+ frame returns to the root as one uniform system-colored border. */
5005
+ @media (forced-colors: active) {
5006
+ .alert.admonition.solid {
5007
+ border: var(--border-width) solid CanvasText;
5008
+ }
5009
+
5010
+ .alert.admonition.solid > .alert-body {
5011
+ border: 0;
5012
+ }
5013
+ }
5014
+
4656
5015
  .alert.admonition > .alert-body > :first-child {
4657
5016
  margin-block-start: 0;
4658
5017
  }
@@ -7362,11 +7721,14 @@ a.avatar[aria-disabled="true"] {
7362
7721
 
7363
7722
  /* Complete owns the accented connector that reaches the current step, so the
7364
7723
  connector leaving current goes back to neutral. Declared after
7365
- .step-complete at equal specificity: current wins on every channel. */
7724
+ .step-complete at equal specificity: current wins on every channel. The
7725
+ current marker is a regular surface, so its number and ring are selected
7726
+ ink on a surface, not a selected fill; they are held to different ratios
7727
+ (4.5:1 for the number, 3:1 for the ring) by report:theme-contrast. */
7366
7728
  .steps > [aria-current="step"] {
7367
7729
  --step-marker-bg: var(--surface-opaque);
7368
- --step-marker-border: var(--state-selected);
7369
- --step-marker-fg: var(--state-selected);
7730
+ --step-marker-border: var(--state-selected-text, var(--state-selected));
7731
+ --step-marker-fg: var(--state-selected-text, var(--state-selected));
7370
7732
  --step-line: var(--border);
7371
7733
 
7372
7734
  color: var(--text);
@@ -7667,6 +8029,7 @@ a.avatar[aria-disabled="true"] {
7667
8029
  }
7668
8030
 
7669
8031
  .app-nav > a {
8032
+ position: relative;
7670
8033
  display: grid;
7671
8034
  grid-template-rows: auto auto;
7672
8035
  place-content: center;
@@ -7707,20 +8070,27 @@ a.avatar[aria-disabled="true"] {
7707
8070
  }
7708
8071
 
7709
8072
  /* Current tile is already a full selected surface. Selection never changes
7710
- text metrics: the shared medium weight is kept, no permanent indicator is
7711
- added here. */
8073
+ text metrics: the shared medium weight is kept. The forced-colors cue is a
8074
+ transparent border on a ::before overlay, not on the tile: an element border
8075
+ shrinks the content box, and the side layout starts its content at the inline
8076
+ edge, so the icon and label shifted by --border-width when a tile became
8077
+ current. The overlay costs no layout, yet forced colors still turn its
8078
+ transparent border into a system-color frame once --state-selected flattens
8079
+ to the bar's own Canvas. No forced-colors repaint: a Highlight fill put
8080
+ HighlightText on Chromium's Canvas backplate, which hid the label. The
8081
+ positioning context is on every link so it does not move when one becomes
8082
+ current or an application places its own decoration. */
7712
8083
  .app-nav > a[aria-current="page"] {
7713
8084
  background: var(--state-selected);
7714
8085
  color: var(--state-selected-fg);
7715
8086
  }
7716
8087
 
7717
- @media (forced-colors: active) {
7718
- /* --state-selected flattens to a UA color that no longer distinguishes the
7719
- current tile from the rest of the bar, so paint the system Highlight pair. */
7720
- .app-nav > a[aria-current="page"] {
7721
- background: Highlight;
7722
- color: HighlightText;
7723
- }
8088
+ .app-nav > a[aria-current="page"]::before {
8089
+ content: "";
8090
+ position: absolute;
8091
+ inset: 0;
8092
+ border: var(--border-width) solid transparent;
8093
+ border-radius: inherit;
7724
8094
  }
7725
8095
 
7726
8096
  /*
@@ -7732,6 +8102,9 @@ a.avatar[aria-disabled="true"] {
7732
8102
  * links are a .cluster using .nav-link items. .nav-list is the vertical nav
7733
8103
  * list shared with .drawer, fed by the shared --gap token.
7734
8104
  *
8105
+ * Public hooks:
8106
+ * --state-selected-text (fallback-only: current-link ink, see tokens.css)
8107
+ *
7735
8108
  * Internal:
7736
8109
  * --gap (local value for the shared gap token)
7737
8110
  */
@@ -7809,7 +8182,7 @@ a.avatar[aria-disabled="true"] {
7809
8182
  shared with the hover rule above. */
7810
8183
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
7811
8184
  background: var(--surface-subtle);
7812
- color: var(--state-selected);
8185
+ color: var(--state-selected-text, var(--state-selected));
7813
8186
  }
7814
8187
 
7815
8188
  /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
@@ -7830,7 +8203,7 @@ a.avatar[aria-disabled="true"] {
7830
8203
 
7831
8204
  /* After :hover so a hovered current link keeps the selected accent. */
7832
8205
  .nav-link:is([aria-current="page"], [aria-current="location"]) {
7833
- color: var(--ui-fg, var(--state-selected));
8206
+ color: var(--ui-fg, var(--state-selected-text, var(--state-selected)));
7834
8207
  }
7835
8208
  }
7836
8209
 
@@ -8265,7 +8638,7 @@ a.avatar[aria-disabled="true"] {
8265
8638
  stays inert. */
8266
8639
  .tab[aria-selected="true"],
8267
8640
  .tab:is([aria-current="page"], [aria-current="true"]) {
8268
- color: var(--intent, var(--state-selected));
8641
+ color: var(--intent, var(--state-selected-text, var(--state-selected)));
8269
8642
  }
8270
8643
 
8271
8644
  /* The selected indicator lives on a positioned pseudo so that only the active
@@ -8296,6 +8669,193 @@ a.avatar[aria-disabled="true"] {
8296
8669
  inline-size: 100%;
8297
8670
  }
8298
8671
 
8672
+ /*
8673
+ * Segmented control — one compact choice among a few, on a shared track.
8674
+ *
8675
+ * Docs: docs/pages/components/segmented.md
8676
+ *
8677
+ * .segmented is the track, each .segment a direct child. The look is shared;
8678
+ * the semantics stay with the markup, and the selected state is read from it:
8679
+ * radios <label class="segment"><input type="radio"> :checked
8680
+ * tabs <button class="segment" role="tab"> aria-selected="true"
8681
+ * links <a class="segment"> aria-current="page"
8682
+ * No script of its own: radios bring their arrow keys, a tablist takes
8683
+ * data-enhance="tabs". Without :has() the radio anatomy loses its selected
8684
+ * fill and focus ring, and the native radio stays visible to carry both.
8685
+ *
8686
+ * One row of equal shares (grid-auto-columns: 1fr), at least --control-size
8687
+ * tall so it lines up with .btn and .input. Labels wrap instead of truncating;
8688
+ * a word longer than its share breaks, so the control never overflows its
8689
+ * container. A strip too long for one row is .tabs, which scrolls.
8690
+ *
8691
+ * Focus is drawn inside the segment, unlike other discrete actions
8692
+ * (focus.css): an offset ring would cross the track's padding and cover the
8693
+ * neighbouring segment.
8694
+ *
8695
+ * Public hooks:
8696
+ * --segmented-bg
8697
+ * --segmented-pad
8698
+ * --segmented-radius
8699
+ * --segmented-fg
8700
+ * --segmented-selected-bg
8701
+ * --segmented-selected-border
8702
+ */
8703
+ .segmented {
8704
+ --segmented-bg: var(--surface-subtle);
8705
+ --segmented-pad: var(--space-10);
8706
+ --segmented-radius: var(--radius);
8707
+ --segmented-fg: var(--text-muted);
8708
+ --segmented-selected-bg: var(--surface-raised);
8709
+ /* The edge is what separates the selected segment where --surface-raised
8710
+ is darker than --surface-subtle (the default dark palette). */
8711
+ --segmented-selected-border: var(--border);
8712
+
8713
+ display: inline-grid;
8714
+ grid-auto-flow: column;
8715
+ grid-auto-columns: 1fr;
8716
+ gap: var(--segmented-pad);
8717
+ /* why: margin and border also reset <fieldset>, the radio anatomy's
8718
+ element. The border is transparent, not 0: forced colors paint it, which
8719
+ outlines the track once its fill is gone. */
8720
+ margin: 0;
8721
+ padding: var(--segmented-pad);
8722
+ border: var(--border-width) solid transparent;
8723
+ border-radius: var(--segmented-radius);
8724
+ background: var(--segmented-bg);
8725
+ font-size: var(--control-font-size);
8726
+ vertical-align: middle;
8727
+ }
8728
+
8729
+ .segment {
8730
+ position: relative;
8731
+ display: flex;
8732
+ align-items: center;
8733
+ justify-content: center;
8734
+ gap: 0.375em;
8735
+ /* The track is --control-size tall through its segments, not through its
8736
+ own min-block-size: a <fieldset> lays its content in an anonymous box
8737
+ sized to the content, so a minimum on the track left the segments short.
8738
+ A minimum, not a height: a wrapped label grows the whole row. */
8739
+ min-block-size: calc(var(--control-size) - 2 * (var(--segmented-pad) + var(--border-width)));
8740
+ padding: calc(var(--space-10) / 2) var(--space-30);
8741
+ border: 0;
8742
+ border-radius: calc(var(--segmented-radius) - var(--segmented-pad));
8743
+ background: transparent;
8744
+ color: var(--segmented-fg);
8745
+ font: inherit;
8746
+ /* Constant across states: a heavier selected label would resize its share. */
8747
+ font-weight: var(--font-weight-medium);
8748
+ line-height: 1.2;
8749
+ text-align: center;
8750
+ text-decoration: none;
8751
+ /* why: also shrinks the min-content contribution, so 1fr keeps equal
8752
+ shares under constraint instead of widening one around a long word. */
8753
+ overflow-wrap: anywhere;
8754
+ cursor: pointer;
8755
+ transition:
8756
+ background-color var(--duration),
8757
+ color var(--duration),
8758
+ box-shadow var(--duration);
8759
+ }
8760
+
8761
+ .segment > input {
8762
+ margin: 0;
8763
+ }
8764
+
8765
+ /* The radio is visually hidden but stays focusable, submitted and arrow-key
8766
+ navigable (sr-only pattern). Guarded: without :has() the segment cannot
8767
+ mirror :checked, so the visible native radio is the fallback. */
8768
+ @supports selector(:has(*)) {
8769
+ .segment > input {
8770
+ position: absolute;
8771
+ inline-size: 1px;
8772
+ block-size: 1px;
8773
+ padding: 0;
8774
+ margin: -1px;
8775
+ overflow: hidden;
8776
+ clip: rect(0, 0, 0, 0);
8777
+ white-space: nowrap;
8778
+ border: 0;
8779
+ }
8780
+ }
8781
+
8782
+ .segment:hover {
8783
+ color: var(--text);
8784
+ }
8785
+
8786
+ /* Separate rules from the :has() forms below: an engine without :has() would
8787
+ discard a shared selector list, ARIA states included. */
8788
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
8789
+ background: var(--segmented-selected-bg);
8790
+ color: var(--text);
8791
+ box-shadow:
8792
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
8793
+ var(--shadow);
8794
+ }
8795
+
8796
+ .segment:has(> input:checked) {
8797
+ background: var(--segmented-selected-bg);
8798
+ color: var(--text);
8799
+ box-shadow:
8800
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
8801
+ var(--shadow);
8802
+ }
8803
+
8804
+ .segment:focus-visible {
8805
+ outline: var(--focus-ring-width) solid var(--focus);
8806
+ outline-offset: calc(var(--focus-ring-width) * -1);
8807
+ }
8808
+
8809
+ .segment:has(> input:focus-visible) {
8810
+ outline: var(--focus-ring-width) solid var(--focus);
8811
+ outline-offset: calc(var(--focus-ring-width) * -1);
8812
+ }
8813
+
8814
+ .segment:is(:disabled, [aria-disabled="true"]) {
8815
+ opacity: var(--disabled-opacity);
8816
+ cursor: not-allowed;
8817
+ }
8818
+
8819
+ .segment:has(> input:disabled) {
8820
+ opacity: var(--disabled-opacity);
8821
+ cursor: not-allowed;
8822
+ }
8823
+
8824
+ /* Forced colors drop the raised fill and its edge. The selected segment
8825
+ takes the system highlight — a background, so the outline stays the focus
8826
+ channel alone. forced-color-adjust: none is required, not cosmetic: the UA
8827
+ otherwise paints a Canvas backplate behind the label, which hid the text on
8828
+ the highlight. Opting out also stops the forcing of the rest, so the shadow
8829
+ is dropped here and the focus line takes the highlight's text color. */
8830
+ @media (forced-colors: active) {
8831
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
8832
+ --focus: HighlightText;
8833
+
8834
+ forced-color-adjust: none;
8835
+ background: Highlight;
8836
+ color: HighlightText;
8837
+ box-shadow: none;
8838
+ }
8839
+
8840
+ .segment:has(> input:checked) {
8841
+ --focus: HighlightText;
8842
+
8843
+ forced-color-adjust: none;
8844
+ background: Highlight;
8845
+ color: HighlightText;
8846
+ box-shadow: none;
8847
+ }
8848
+
8849
+ /* --disabled-opacity is 1 here (tokens.css): the dimming needs a color. */
8850
+ .segment:is(:disabled, [aria-disabled="true"]) {
8851
+ color: GrayText;
8852
+ }
8853
+
8854
+ .segment:has(> input:disabled) {
8855
+ color: GrayText;
8856
+ }
8857
+ }
8858
+
8299
8859
  /*
8300
8860
  * Scrollspy — behavior hook for navigation that marks the active section
8301
8861
  * while the page scrolls.
@@ -8421,6 +8981,9 @@ a.avatar[aria-disabled="true"] {
8421
8981
  .list > :where(.list-item),
8422
8982
  .list > li > .list-item {
8423
8983
  display: flex;
8984
+ /* A trailing control that no longer fits beside the content's longest word
8985
+ moves to its own line instead of breaking that word. */
8986
+ flex-wrap: wrap;
8424
8987
  /* Centered fits the short rows lists mostly hold. Read through
8425
8988
  --items-align so .items-start on the row or the list wins without
8426
8989
  outranking this selector — the opt-out for long supporting text. */
@@ -8467,17 +9030,29 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
8467
9030
  border-inline-start: 2px solid var(--state-selected);
8468
9031
  }
8469
9032
 
8470
- /* The content region is the one that flexes, whichever position it holds. */
9033
+ /* The content region is the one that flexes, whichever position it holds.
9034
+ Its automatic minimum is its longest word, which is what wraps the row; the
9035
+ max size caps that minimum at the row, so a long token breaks rather than
9036
+ widening the page. A zero minimum shrank it until words broke mid-way. */
8471
9037
  .list-item-content {
8472
9038
  flex: 1 1 0;
8473
- min-inline-size: 0;
9039
+ max-inline-size: 100%;
8474
9040
  }
8475
9041
 
9042
+ /* break-word, not anywhere: anywhere also lowers the min-content to a single
9043
+ character, and the row would never wrap. */
8476
9044
  .list-item-title,
8477
9045
  .list-item-text {
8478
9046
  display: block;
8479
9047
  margin: 0;
8480
- overflow-wrap: anywhere;
9048
+ overflow-wrap: break-word;
9049
+ }
9050
+
9051
+ /* Truncated text gives up its width by design: without containment its
9052
+ unbreakable line would become the content's minimum and push the trailing
9053
+ region onto a line of its own. */
9054
+ .list-item-content .truncate {
9055
+ contain: inline-size;
8481
9056
  }
8482
9057
 
8483
9058
  .list-item-text {