actual-css 0.9.2 → 0.10.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 (101) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +65 -44
  3. package/components.json +33 -13
  4. package/dist/actual.full.css +585 -499
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/app-ui.md +1 -1
  8. package/docs/design-notes/column-layout.md +103 -520
  9. package/docs/design-notes/forced-colors.md +12 -1
  10. package/docs/design-notes/naming.md +1 -1
  11. package/docs/design-notes/surface-reparenting.md +8 -9
  12. package/docs/navigation.json +2 -0
  13. package/docs/pages/components/accordion.md +55 -1
  14. package/docs/pages/components/alert.md +9 -15
  15. package/docs/pages/components/badge.md +6 -7
  16. package/docs/pages/components/bleed.md +42 -0
  17. package/docs/pages/components/button.md +30 -4
  18. package/docs/pages/components/card.md +21 -86
  19. package/docs/pages/components/close.md +48 -0
  20. package/docs/pages/components/data-list.md +26 -1
  21. package/docs/pages/components/dialog.md +16 -16
  22. package/docs/pages/components/drawer.md +9 -9
  23. package/docs/pages/components/navbar.md +5 -5
  24. package/docs/pages/components/spinner.md +3 -3
  25. package/docs/pages/enhancements/overview.md +5 -0
  26. package/docs/pages/enhancements/tooltip.md +3 -1
  27. package/docs/pages/examples/overview.md +2 -1
  28. package/docs/pages/forms/choice-cards.md +39 -0
  29. package/docs/pages/forms/combobox.md +23 -11
  30. package/docs/pages/forms/joined-controls.md +2 -5
  31. package/docs/pages/forms/overview.md +6 -0
  32. package/docs/pages/foundations/theming.md +64 -18
  33. package/docs/pages/foundations/tokens.md +28 -10
  34. package/docs/pages/guides/introduction.md +15 -23
  35. package/docs/pages/guides/modular-import.md +53 -6
  36. package/docs/pages/guides/progressive-enhancement.md +6 -3
  37. package/docs/pages/guides/tailwind.md +17 -5
  38. package/docs/pages/layout/column-layout.md +92 -54
  39. package/docs/pages/layout/media.md +1 -1
  40. package/docs/pages/layout/overview.md +1 -1
  41. package/docs/pages/utilities/overview.md +1 -1
  42. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  43. package/llms.txt +24 -4
  44. package/package.json +6 -6
  45. package/reserved-classes.json +5 -30
  46. package/skills/actual-css/PROJECT.md.example +65 -0
  47. package/skills/actual-css/README.md +64 -0
  48. package/skills/actual-css/SKILL.md +234 -0
  49. package/skills/actual-css/references/accessibility.md +44 -0
  50. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  51. package/skills/actual-css/references/enhancements.md +59 -0
  52. package/skills/actual-css/references/forms.md +49 -0
  53. package/skills/actual-css/references/layout-and-theming.md +82 -0
  54. package/skills/actual-css/references/review-checklist.md +51 -0
  55. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  56. package/src/css/components/accordion.css +44 -7
  57. package/src/css/components/alert.css +23 -43
  58. package/src/css/components/app-nav.css +9 -0
  59. package/src/css/components/badge.css +11 -39
  60. package/src/css/components/bleed.css +79 -0
  61. package/src/css/components/button.css +6 -17
  62. package/src/css/components/card.css +7 -40
  63. package/src/css/components/close.css +91 -0
  64. package/src/css/components/data-list.css +18 -0
  65. package/src/css/components/drawer.css +15 -56
  66. package/src/css/components/fab.css +10 -0
  67. package/src/css/components/flyout.css +1 -0
  68. package/src/css/components/index.css +2 -0
  69. package/src/css/components/join.css +36 -20
  70. package/src/css/components/menu.css +12 -0
  71. package/src/css/components/modal.css +10 -57
  72. package/src/css/components/navbar.css +27 -0
  73. package/src/css/core/focus.css +9 -3
  74. package/src/css/core/theme.css +25 -43
  75. package/src/css/core/tokens.css +1 -1
  76. package/src/css/core/variants.css +3 -33
  77. package/src/css/forms/choice-card.css +74 -4
  78. package/src/css/forms/choice.css +2 -3
  79. package/src/css/forms/control.css +15 -8
  80. package/src/css/forms/custom-select.css +4 -4
  81. package/src/css/forms/native.css +4 -4
  82. package/src/css/forms/range.css +23 -8
  83. package/src/css/forms/switch.css +2 -3
  84. package/src/css/layout/app-layout.css +1 -1
  85. package/src/css/layout/column-layout.css +26 -111
  86. package/src/css/utilities/extra.css +7 -6
  87. package/src/js/dialog.js +95 -24
  88. package/src/js/enhance.js +7 -3
  89. package/src/js/escape.js +1 -0
  90. package/src/js/flyout.js +10 -1
  91. package/src/js/focus-group.js +2 -1
  92. package/src/js/keys.js +14 -0
  93. package/src/js/menu.js +2 -1
  94. package/src/js/status.js +19 -0
  95. package/src/js/surface.js +12 -8
  96. package/src/js/tab.js +2 -1
  97. package/src/js/tooltip.js +18 -1
  98. package/src/js/validation.js +5 -6
  99. package/dist/actual.css +0 -876
  100. package/dist/actual.js +0 -1
  101. package/dist/actual.min.css +0 -1
@@ -134,7 +134,7 @@ select {
134
134
  --focus-outline-color: currentColor;
135
135
  --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
136
136
  --focus-outline-offset: 2px;
137
- --focus-ring-width: 3px;
137
+ --focus-ring-width: 2px;
138
138
 
139
139
  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
140
140
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
@@ -356,10 +356,10 @@ select {
356
356
 
357
357
  /* Lines and focus */
358
358
  --border: hsl(260 15% 88%);
359
- --focus: var(--neutral);
360
- /* Pre-color-mix fallback only — superseded below by a derivation from
361
- --focus so overriding --neutral/--focus never leaves a stale ring. */
362
- --focus-ring: hsl(258 7% 43% / 0.3);
359
+ /* A primary-hued mid-tone on purpose, one value for both schemes: focus
360
+ lines must hold 3:1 against --surface and --surface-solid alike (page,
361
+ fields, a solid band), which rules out the primary itself. */
362
+ --focus: hsl(268 30% 55%);
363
363
  --hover-overlay: hsl(260 20% 10% / 0.04);
364
364
 
365
365
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -382,7 +382,6 @@ select {
382
382
  --heading: var(--text);
383
383
  --selection-bg: var(--primary);
384
384
  --selection-fg: var(--primary-fg);
385
- --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
386
385
  }
387
386
 
388
387
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -391,18 +390,17 @@ select {
391
390
  default theme: a custom property bakes its var() references where it is
392
391
  declared, so these resolve against root/light/dark tokens on every boundary.
393
392
  Presets and custom islands keep their --soft-fg-mix / raw-intent ink unless
394
- they declare hooks themselves — :where() keeps this reset from outranking a
395
- theme's own declaration (e.g. brutalist's 45% soft-fg-mix). */
396
- :where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
397
- --primary-soft-fg: initial;
398
- --secondary-soft-fg: initial;
399
- --success-soft-fg: initial;
400
- --warning-soft-fg: initial;
401
- --danger-soft-fg: initial;
402
- --neutral-soft-fg: initial;
403
- }
404
-
405
- :root,
393
+ they declare hooks themselves.
394
+
395
+ Why :where(:root) and why the reset comes second: both rules match when a
396
+ custom theme sits on <html> itself. A bare :root is (0,1,0) and would
397
+ outrank the zero-specificity reset, so the same theme resolved a calibrated
398
+ Ink & Terra ink on <html data-theme="brand"> and the raw --soft-fg-mix ink
399
+ on <div data-theme="brand"> — one attribute, two soft palettes. Levelling
400
+ both rules to zero specificity makes source order the tiebreaker, so the
401
+ reset must stay below. Zero specificity is also what lets a theme declare
402
+ its own hooks (e.g. brutalist's 45% soft-fg-mix) and win at (0,1,0). */
403
+ :where(:root),
406
404
  [data-theme="light"],
407
405
  [data-theme="dark"] {
408
406
  --secondary-soft-fg: color-mix(in oklab, var(--text) 10%, var(--secondary) 90%);
@@ -412,6 +410,15 @@ select {
412
410
  --neutral-soft-fg: color-mix(in oklab, var(--text) 20%, var(--neutral) 80%);
413
411
  }
414
412
 
413
+ :where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
414
+ --primary-soft-fg: initial;
415
+ --secondary-soft-fg: initial;
416
+ --success-soft-fg: initial;
417
+ --warning-soft-fg: initial;
418
+ --danger-soft-fg: initial;
419
+ --neutral-soft-fg: initial;
420
+ }
421
+
415
422
  /* Native platform controls (checkbox, radio, range, progress) that are not
416
423
  restyled by a component class still follow the theme accent. Declared on
417
424
  every island: accent-color inherits as a computed value, so islands must
@@ -433,20 +440,6 @@ select {
433
440
  --tone-fg: var(--text);
434
441
  }
435
442
 
436
- /* Focus ring derivation — color-mix tier. Runs whether or not light-dark()
437
- is also supported, so it covers the color-mix-without-light-dark browser
438
- tier on its own; the light-dark block below refines it further with the
439
- light/dark alpha split once both features are available. Declared on every
440
- [data-theme] island (not just the built-in light/dark pair) so custom
441
- islands recolor the ring for free; a preset that re-declares --focus-ring
442
- later in source order intentionally pins its own ring instead. */
443
- @supports (color: color-mix(in oklch, red, white)) {
444
- :root,
445
- [data-theme] {
446
- --focus-ring: color-mix(in oklch, var(--focus) 30%, transparent);
447
- }
448
- }
449
-
450
443
  /* Auto dark mode via light-dark(). Resolves based on the element's color-scheme:
451
444
  :root (light dark) follows the OS, [data-theme="light"] forces light,
452
445
  [data-theme="dark"] forces dark by overriding color-scheme.
@@ -483,22 +476,11 @@ select {
483
476
  --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));
484
477
 
485
478
  --border: light-dark(hsl(260 15% 88%), hsl(262 12% 24%));
486
- --focus-ring: light-dark(hsl(258 7% 43% / 0.3), hsl(258 10% 72% / 0.28));
487
479
  --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
488
480
  --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
489
481
  }
490
482
  }
491
483
 
492
- @supports (color: color-mix(in oklch, red, white)) and (color: light-dark(red, blue)) {
493
- :root,
494
- [data-theme] {
495
- --focus-ring: light-dark(
496
- color-mix(in oklch, var(--focus) 30%, transparent),
497
- color-mix(in oklch, var(--focus) 28%, transparent)
498
- );
499
- }
500
- }
501
-
502
484
  @media (prefers-contrast: more) {
503
485
  :root,
504
486
  /* The repeated attribute intentionally outranks single-attribute preset
@@ -506,7 +488,7 @@ select {
506
488
  [data-theme][data-theme] {
507
489
  --border-width: 2px;
508
490
  --focus-outline-offset: 3px;
509
- --focus-ring-width: 4px;
491
+ --focus-ring-width: 3px;
510
492
  --text-muted: var(--text);
511
493
  --text-subtle: var(--text);
512
494
  }
@@ -662,13 +644,9 @@ small {
662
644
  * Cross-cutting variant token mapping.
663
645
  *
664
646
  * Variant scope: .solid/.soft/.outline/.surface set --ui-* for components that
665
- * opt into shared variants. .inverted is a surface modifier, not a variant: it paints
666
- * the element directly (background/color/border-color) so it works on any bare
667
- * block, and remaps the shared --ui-* tokens so participating components
668
- * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
669
- * before components, so a component with its own explicit surface (e.g.
670
- * .accordion) naturally keeps that surface — .inverted never forces a
671
- * non-participant.
647
+ * opt into shared variants. A subtree that needs another palette or scheme is
648
+ * a data-theme island, never a variant: only an island recomputes every token
649
+ * (fields, states, intents), where a remap of a few --ui-* tokens cannot.
672
650
  * Component-specific defaults (.btn solid-by-default, .badge
673
651
  * soft-by-default, .alert soft-by-default) live with their components:
674
652
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
@@ -724,28 +702,6 @@ small {
724
702
  --ui-border: var(--border);
725
703
  }
726
704
 
727
- /* Inverse surface treatment. Paints the element directly (so it works on any
728
- bare block) and remaps the shared --ui-* tokens so opt-in components
729
- (.card, .navbar) invert through their own rules. Because this loads before
730
- component base surfaces, components with their own explicit surface
731
- (e.g. .accordion) keep it — participation is opt-in, never forced. --heading
732
- is set so global headings (var(--heading, inherit)) do not inherit an outside
733
- heading color onto the dark surface; surface-owning descendants derive
734
- their contextual aliases from their own local surface. */
735
- .inverted {
736
- --ui-bg: var(--surface-solid);
737
- --ui-fg: var(--surface);
738
- --ui-border: transparent;
739
- --ui-hover-bg: var(--surface-solid);
740
-
741
- --heading: var(--surface);
742
- --busy-overlay-bg: var(--surface-solid);
743
-
744
- background: var(--ui-bg);
745
- color: var(--ui-fg);
746
- border-color: var(--ui-border);
747
- }
748
-
749
705
  .compact {
750
706
  --gap: var(--space-20);
751
707
  --density-space: var(--space-20);
@@ -829,10 +785,6 @@ small {
829
785
  );
830
786
  /* @sync soft-recipe */
831
787
  }
832
-
833
- .inverted {
834
- --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
835
- }
836
788
  }
837
789
 
838
790
  /* Interactive hover recipes live in the components that consume them:
@@ -843,9 +795,15 @@ small {
843
795
  pointer/mouse focus and interactive components layer their ring on top.
844
796
 
845
797
  Invariant: an interactive component must never rely on box-shadow alone
846
- for its keyboard focus indicator. Components that remove the native
847
- outline on :focus-visible replace it with a transparent outline — invisible
848
- normally, forced to the system focus color under forced-colors. */
798
+ for its keyboard focus indicator; forced colors keep outlines and drop
799
+ shadows.
800
+
801
+ Component indicators are a solid --focus-ring-width line in --focus: inset
802
+ on fields (inside their border box), offset by --focus-outline-offset on
803
+ discrete actions. The color never derives from an intent or a local
804
+ surface — the theme guarantees --focus at 3:1 against both --surface and
805
+ --surface-solid, so one token reads on the page, on a solid band and on
806
+ any nested surface. */
849
807
  :focus {
850
808
  outline: var(--focus-outline);
851
809
  outline-offset: var(--focus-outline-offset);
@@ -1390,7 +1348,7 @@ small {
1390
1348
  *
1391
1349
  * Docs: docs/pages/layout/app-layout.md
1392
1350
  *
1393
- * Public hook:
1351
+ * Public hooks:
1394
1352
  * --app-nav-side-size
1395
1353
  */
1396
1354
  .app-layout {
@@ -1719,12 +1677,12 @@ small {
1719
1677
  * Docs: docs/pages/layout/column-layout.md
1720
1678
  *
1721
1679
  * Neither space-driven like .grid nor count-driven like .grid-N: this is a
1722
- * coordinate system. The author places regions with .column-span-N /
1723
- * .column-start-N and owns narrow-container recomposition. There is
1724
- * deliberately no automatic collapse — no threshold, no hidden
1725
- * .container-query behavior, no breakpoint variants. A single framework
1726
- * threshold cannot be right for arbitrary per-region compositions, and a
1727
- * hidden default would have to be undone rather than extended.
1680
+ * coordinate system. Each direct child places itself with two numbers,
1681
+ * --column-start and --column-span, and the author owns narrow-container
1682
+ * recomposition. There is deliberately no automatic collapse — no threshold,
1683
+ * no hidden .container-query behavior, no breakpoint variants. A single
1684
+ * framework threshold cannot be right for arbitrary per-region compositions,
1685
+ * and a hidden default would have to be undone rather than extended.
1728
1686
  *
1729
1687
  * See docs/design-notes/column-layout.md.
1730
1688
  *
@@ -1736,11 +1694,12 @@ small {
1736
1694
  * media + flexible content -> .media
1737
1695
  * exact custom track templates -> --grid-columns on .grid
1738
1696
  *
1739
- * Child contract: each direct child is placed on the canvas via its own
1740
- * .column-span-N / .column-start-N.
1741
- *
1742
1697
  * Unlike .grid-N, the track template does not read --gap, so the gap
1743
1698
  * utilities are exact here at every width.
1699
+ *
1700
+ * Public hooks:
1701
+ * --column-start (the start line, 1-12; unset = auto-placed)
1702
+ * --column-span (the width in canvas units, 1-12; unset = to the canvas end)
1744
1703
  */
1745
1704
  .column-layout {
1746
1705
  display: grid;
@@ -1748,116 +1707,30 @@ small {
1748
1707
  grid-template-columns: repeat(12, minmax(0, 1fr));
1749
1708
  }
1750
1709
 
1751
- /* Three zero-specificity tiers, applied in this order and only ever separated
1752
- * by source order. ORDER IS LOAD-BEARING:
1753
- *
1754
- * 1. the child reset auto-placed, spanning the whole canvas
1755
- * 2. .column-start-* a definite start line, running to the canvas end
1756
- * 3. .column-span-* the end only, so it refines either of the above
1710
+ /* One rule covers the four placements:
1711
+ * neither auto-placed, spanning the whole canvas
1712
+ * span only auto-placed over N units, so 8 + 4 flows onto one row
1713
+ * start only from line S to the canvas end (span 13 - S)
1714
+ * start and span explicit; keep S + N - 1 <= 12
1757
1715
  *
1758
- * Moving tier 3 above tier 2 makes every start+span pair run to column 12;
1759
- * moving either above tier 1 makes every placement full-span. Both failures
1760
- * are silent. tests/css-audit.test.js locks the order and
1761
- * tests/browser/column-layout.test.js locks the resulting geometry.
1716
+ * The start stays `auto` unless set: a definite start on every child would
1717
+ * take all of them out of auto-placement, and an 8 + 4 pair would stack.
1762
1718
  *
1763
- * Zero specificity is the point: an application's own container-query rules
1764
- * override placement additively, with a single-class selector and without
1765
- * having to undo framework specificity first.
1766
- */
1767
-
1768
- /* Tier 1. A child with no placement class stays a readable full-width region
1769
- * instead of collapsing to one twelfth. Expressed as a span, not as
1770
- * `1 / -1`: a definite start line would give every child a definite column
1771
- * position and disable auto-placement, so `.column-span-8` next to
1772
- * `.column-span-4` would stack instead of composing 8 + 4.
1719
+ * The reset makes the two hooks inputs of this grid item rather than an
1720
+ * inherited context: a nested .column-layout keeps the coordinates it was
1721
+ * given for its own placement, and its children start from nothing. Zero
1722
+ * specificity, so any author rule on the child wins.
1773
1723
  *
1774
1724
  * min-inline-size: 0 keeps the item box inside its track so the canvas never
1775
1725
  * grows past its container. It does not stop content from painting outside a
1776
1726
  * narrow track — that distinction is the primitive's contract, not a bug. */
1777
1727
  :where(.column-layout) > * {
1778
- grid-column-end: span 12;
1779
- min-inline-size: 0;
1780
- }
1728
+ --column-start: initial;
1729
+ --column-span: initial;
1781
1730
 
1782
- /* Tier 2. A definite start line, running to the end of the canvas unless a
1783
- * .column-span-* refines the end. `column-start-9` alone is therefore the
1784
- * last four columns rather than an implicit 21-line overflow. */
1785
- :where(.column-start-1) {
1786
- grid-column: 1 / -1;
1787
- }
1788
- :where(.column-start-2) {
1789
- grid-column: 2 / -1;
1790
- }
1791
- :where(.column-start-3) {
1792
- grid-column: 3 / -1;
1793
- }
1794
- :where(.column-start-4) {
1795
- grid-column: 4 / -1;
1796
- }
1797
- :where(.column-start-5) {
1798
- grid-column: 5 / -1;
1799
- }
1800
- :where(.column-start-6) {
1801
- grid-column: 6 / -1;
1802
- }
1803
- :where(.column-start-7) {
1804
- grid-column: 7 / -1;
1805
- }
1806
- :where(.column-start-8) {
1807
- grid-column: 8 / -1;
1808
- }
1809
- :where(.column-start-9) {
1810
- grid-column: 9 / -1;
1811
- }
1812
- :where(.column-start-10) {
1813
- grid-column: 10 / -1;
1814
- }
1815
- :where(.column-start-11) {
1816
- grid-column: 11 / -1;
1817
- }
1818
- :where(.column-start-12) {
1819
- grid-column: 12 / -1;
1820
- }
1821
-
1822
- /* Tier 3. The end only. Combined with a start, keep the placement inside the
1823
- * canvas: S + N - 1 <= 12. Exceeding it adds an auto-sized implicit column,
1824
- * which reshapes every 1fr track — the whole canvas loses its alignment, not
1825
- * just the offending region. */
1826
- :where(.column-span-1) {
1827
- grid-column-end: span 1;
1828
- }
1829
- :where(.column-span-2) {
1830
- grid-column-end: span 2;
1831
- }
1832
- :where(.column-span-3) {
1833
- grid-column-end: span 3;
1834
- }
1835
- :where(.column-span-4) {
1836
- grid-column-end: span 4;
1837
- }
1838
- :where(.column-span-5) {
1839
- grid-column-end: span 5;
1840
- }
1841
- :where(.column-span-6) {
1842
- grid-column-end: span 6;
1843
- }
1844
- :where(.column-span-7) {
1845
- grid-column-end: span 7;
1846
- }
1847
- :where(.column-span-8) {
1848
- grid-column-end: span 8;
1849
- }
1850
- :where(.column-span-9) {
1851
- grid-column-end: span 9;
1852
- }
1853
- :where(.column-span-10) {
1854
- grid-column-end: span 10;
1855
- }
1856
- :where(.column-span-11) {
1857
- grid-column-end: span 11;
1858
- }
1859
- :where(.column-span-12) {
1860
- grid-column-end: span 12;
1731
+ grid-column-start: var(--column-start, auto);
1732
+ grid-column-end: span var(--column-span, calc(13 - var(--column-start, 1)));
1733
+ min-inline-size: 0;
1861
1734
  }
1862
1735
 
1863
1736
  /*
@@ -2138,6 +2011,8 @@ small {
2138
2011
  * --control-cursor
2139
2012
  * --control-border-block-end-color
2140
2013
  * --control-border-block-end-width
2014
+ * --control-hover-border (input and textarea)
2015
+ * --control-hover-bg (select)
2141
2016
  */
2142
2017
  .input,
2143
2018
  .textarea,
@@ -2182,19 +2057,24 @@ small {
2182
2057
 
2183
2058
  .input:hover:not(:disabled),
2184
2059
  .textarea:hover:not(:disabled) {
2185
- --control-border: var(--text-subtle);
2060
+ --control-border: var(--control-hover-border, var(--text-subtle));
2186
2061
  }
2187
2062
 
2188
2063
  .select:hover:not(:disabled) {
2189
- --control-bg: var(--surface-subtle);
2190
- }
2191
-
2192
- .input:focus-visible,
2193
- .textarea:focus-visible,
2194
- .select:focus-visible {
2195
- --control-border: var(--focus);
2196
- outline: 2px solid transparent;
2197
- box-shadow: var(--focus-ring-shadow);
2064
+ --control-bg: var(--control-hover-bg, var(--surface-subtle));
2065
+ }
2066
+
2067
+ /* Field focus is an inset outline: offset = -width keeps every pixel inside
2068
+ the control's border box (any smaller offset bleeds outward), so it covers
2069
+ the border, never touches a .join neighbour or a scrollport edge, and needs
2070
+ no group treatment. Width follows --focus-ring-width, including its
2071
+ prefers-contrast step. Border = shape and validation, outline = focus. */
2072
+ .input:focus-visible:not(:disabled),
2073
+ .textarea:focus-visible:not(:disabled),
2074
+ .select:focus-visible:not(:disabled) {
2075
+ outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
2076
+ outline-offset: calc(var(--focus-ring-width) * -1);
2077
+ box-shadow: none;
2198
2078
  }
2199
2079
 
2200
2080
  .input[readonly],
@@ -2447,9 +2327,8 @@ small {
2447
2327
 
2448
2328
  .check:focus-visible,
2449
2329
  .radio:focus-visible {
2450
- --choice-border: var(--focus);
2451
- outline: 2px solid transparent;
2452
- box-shadow: var(--focus-ring-shadow);
2330
+ outline: var(--focus-ring-width) solid var(--focus);
2331
+ outline-offset: var(--focus-outline-offset);
2453
2332
  }
2454
2333
 
2455
2334
  .check:disabled,
@@ -2571,8 +2450,8 @@ small {
2571
2450
  }
2572
2451
 
2573
2452
  .file:focus-visible {
2574
- outline: 2px solid transparent;
2575
- box-shadow: var(--focus-ring-shadow);
2453
+ outline: var(--focus-ring-width) solid var(--focus);
2454
+ outline-offset: var(--focus-outline-offset);
2576
2455
  }
2577
2456
 
2578
2457
  /* Native color picker — keep the browser's dialog and selected-color UI. The
@@ -2608,8 +2487,8 @@ small {
2608
2487
  }
2609
2488
 
2610
2489
  .color:focus-visible {
2611
- outline: 2px solid transparent;
2612
- box-shadow: var(--focus-ring-shadow);
2490
+ outline: var(--focus-ring-width) solid var(--focus);
2491
+ outline-offset: var(--focus-outline-offset);
2613
2492
  }
2614
2493
 
2615
2494
  @media (forced-colors: active) {
@@ -2973,9 +2852,8 @@ small {
2973
2852
  }
2974
2853
 
2975
2854
  .switch:focus-visible {
2976
- --switch-border: var(--focus);
2977
- outline: 2px solid transparent;
2978
- box-shadow: var(--focus-ring-shadow);
2855
+ outline: var(--focus-ring-width) solid var(--focus);
2856
+ outline-offset: var(--focus-outline-offset);
2979
2857
  }
2980
2858
 
2981
2859
  .switch:disabled {
@@ -3073,20 +2951,22 @@ small {
3073
2951
  }
3074
2952
 
3075
2953
  /*
3076
- * The 1px surface-coloured separator keeps the ring visually distinct from
3077
- * the small, coloured thumb — unlike a full-size input where the same gap
3078
- * would read as an unwanted void.
2954
+ * A thumb pseudo-element takes no reliable outline, so its ring is a solid
2955
+ * --focus shadow (the transparent outline above covers forced colors). The
2956
+ * 1px surface-coloured separator keeps the ring visually distinct from the
2957
+ * small, coloured thumb — unlike a full-size input where the same gap would
2958
+ * read as an unwanted void.
3079
2959
  */
3080
2960
  .range:focus-visible::-webkit-slider-thumb {
3081
2961
  box-shadow:
3082
2962
  0 0 0 1px var(--surface),
3083
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
2963
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3084
2964
  }
3085
2965
 
3086
2966
  .range:focus-visible::-moz-range-thumb {
3087
2967
  box-shadow:
3088
2968
  0 0 0 1px var(--surface),
3089
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
2969
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3090
2970
  }
3091
2971
 
3092
2972
  .range:disabled {
@@ -3096,20 +2976,33 @@ small {
3096
2976
  }
3097
2977
 
3098
2978
  /* Forced colors: the track must stay a visible line against the page (Canvas
3099
- would merge with it) and the thumb needs a real boundary to remain
3100
- distinguishable from the track. Disabled keeps GrayText via :disabled.
3101
- Focus stays on the transparent outline of :focus-visible. */
2979
+ would merge with it) and the thumb needs a real boundary to remain
2980
+ distinguishable from the track. Disabled keeps GrayText via :disabled.
2981
+ Focus stays on the transparent outline of :focus-visible. Track and thumb
2982
+ fills are written on the properties, not only the hooks: an application
2983
+ hook override at the same specificity loads after this file and would win
2984
+ the variable, leaving a Canvas-remapped fill. */
3102
2985
  @media (forced-colors: active) {
3103
2986
  .range {
3104
2987
  --range-track-bg: CanvasText;
3105
2988
  --range-thumb-bg: ButtonFace;
3106
2989
  }
3107
2990
 
2991
+ .range::-webkit-slider-runnable-track {
2992
+ background: CanvasText;
2993
+ }
2994
+
2995
+ .range::-moz-range-track {
2996
+ background: CanvasText;
2997
+ }
2998
+
3108
2999
  .range::-webkit-slider-thumb {
3000
+ background: ButtonFace;
3109
3001
  border: var(--border-width) solid ButtonText;
3110
3002
  }
3111
3003
 
3112
3004
  .range::-moz-range-thumb {
3005
+ background: ButtonFace;
3113
3006
  border: var(--border-width) solid ButtonText;
3114
3007
  }
3115
3008
  }
@@ -3129,12 +3022,18 @@ small {
3129
3022
  * overflow: hidden will clip it. Pair with grid or flex utilities for group
3130
3023
  * layout.
3131
3024
  *
3025
+ * .chip is the compact pill form (filter chips, selectable tags): control
3026
+ * height, and a leading dot that turns into the check in a fixed slot, so
3027
+ * checking never moves the label.
3028
+ *
3132
3029
  * Public hooks:
3133
3030
  * --choice-card-pad
3134
3031
  * --choice-card-radius
3135
3032
  * --choice-card-border
3136
3033
  * --choice-card-bg
3137
3034
  * --choice-card-check-size
3035
+ * --choice-chip-dot (read-only, on .chip ::before: the check-slot centre as a
3036
+ * circle() position, for a theme that grows the dot into a fill)
3138
3037
  */
3139
3038
  :where(.choice-card) {
3140
3039
  --intent: initial;
@@ -3184,8 +3083,8 @@ small {
3184
3083
 
3185
3084
  /* Focus ring on the label follows the hidden input's focus. */
3186
3085
  .choice-card:has(:focus-visible) {
3187
- outline: 2px solid transparent;
3188
- box-shadow: var(--focus-ring-shadow);
3086
+ outline: var(--focus-ring-width) solid var(--focus);
3087
+ outline-offset: var(--focus-outline-offset);
3189
3088
  }
3190
3089
 
3191
3090
  .choice-card:has(:disabled) {
@@ -3230,11 +3129,13 @@ small {
3230
3129
  z-index: 1;
3231
3130
  }
3232
3131
 
3233
- .choice-card:has(input[type="radio"])::before {
3132
+ /* :where() keeps the shape at single-class weight, so .chip can reshape the
3133
+ layer without out-arguing the input-type test. */
3134
+ .choice-card:where(:has(input[type="radio"]))::before {
3234
3135
  border-radius: var(--radius-full);
3235
3136
  }
3236
3137
 
3237
- .choice-card:has(input[type="checkbox"])::before {
3138
+ .choice-card:where(:has(input[type="checkbox"]))::before {
3238
3139
  border-radius: var(--radius-sm);
3239
3140
  }
3240
3141
 
@@ -3251,6 +3152,54 @@ small {
3251
3152
  transform: scale(1);
3252
3153
  }
3253
3154
 
3155
+ /* Chip — the rules above stay in force; this section only relocates the
3156
+ indicator. Must follow them: several ties are won by source order. */
3157
+ .choice-card.chip {
3158
+ --choice-card-pad: 0 var(--control-pad-x);
3159
+ --choice-card-radius: var(--radius-full);
3160
+ --choice-card-check-size: 1em;
3161
+
3162
+ display: inline-flex;
3163
+ align-items: center;
3164
+ min-block-size: var(--control-size);
3165
+ line-height: 1.2;
3166
+ /* The dot layer sits under the label at z-index -1. */
3167
+ isolation: isolate;
3168
+ }
3169
+
3170
+ /* ::before is a full-bleed layer clipped to a dot centred on the check slot,
3171
+ so a theme can grow the same clip into a selected fill. The chip itself
3172
+ must not clip (its focus ring is an outline), hence the inherited radius. */
3173
+ .choice-card.chip::before {
3174
+ --choice-chip-dot: calc(var(--control-pad-x) + var(--choice-card-check-size) / 2) 50%;
3175
+
3176
+ inset: 0;
3177
+ inline-size: auto;
3178
+ block-size: auto;
3179
+ border-radius: inherit;
3180
+ box-shadow: none;
3181
+ background: var(--text-subtle);
3182
+ transform: none;
3183
+ clip-path: circle(0.1875em at var(--choice-chip-dot));
3184
+ z-index: -1;
3185
+ transition: clip-path var(--duration);
3186
+ }
3187
+
3188
+ .choice-card.chip:has(:checked)::before {
3189
+ clip-path: circle(0 at var(--choice-chip-dot));
3190
+ }
3191
+
3192
+ /* The check keeps the card's scale-in, in flow ahead of the label. */
3193
+ .choice-card.chip::after {
3194
+ position: static;
3195
+ order: -1;
3196
+ flex: none;
3197
+ margin-inline-end: 0.375em;
3198
+ background: var(--intent, var(--state-selected));
3199
+ -webkit-mask-size: contain;
3200
+ mask-size: contain;
3201
+ }
3202
+
3254
3203
  /* The native input is hidden and the checked marker is custom-drawn: without
3255
3204
  this repair, forced colors pushes disc and check to the same system color
3256
3205
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
@@ -3265,6 +3214,20 @@ small {
3265
3214
  .choice-card:has(:checked)::after {
3266
3215
  background: HighlightText;
3267
3216
  }
3217
+
3218
+ /* A chip has no disc behind its check: the check carries the state alone,
3219
+ and dropping the checked layer also drops any theme fill. */
3220
+ .choice-card.chip::before {
3221
+ background: CanvasText;
3222
+ }
3223
+
3224
+ .choice-card.chip:has(:checked)::before {
3225
+ content: none;
3226
+ }
3227
+
3228
+ .choice-card.chip:has(:checked)::after {
3229
+ background: CanvasText;
3230
+ }
3268
3231
  }
3269
3232
 
3270
3233
  /*
@@ -3278,7 +3241,6 @@ small {
3278
3241
  * Arbitrary menu and popover behavior belongs elsewhere.
3279
3242
  *
3280
3243
  * Internal:
3281
- * --control-border
3282
3244
  * --control-bg
3283
3245
  */
3284
3246
  /* This enhancement is excluded from forced-colors, where the native/shared
@@ -3303,11 +3265,12 @@ small {
3303
3265
  box-shadow: var(--shadow-popout);
3304
3266
  }
3305
3267
 
3268
+ /* Same inset outline as a focused field (control.css). */
3306
3269
  &:open {
3307
- --control-border: var(--border);
3308
3270
  --control-bg: var(--surface-raised);
3309
- outline: none;
3310
- box-shadow: var(--focus-ring-shadow);
3271
+ outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
3272
+ outline-offset: calc(var(--focus-ring-width) * -1);
3273
+ box-shadow: none;
3311
3274
  }
3312
3275
 
3313
3276
  &::picker-icon {
@@ -3636,7 +3599,6 @@ small {
3636
3599
  * --btn-min-size
3637
3600
  * --btn-pad-x
3638
3601
  * --btn-radius
3639
- * --btn-focus-color
3640
3602
  * --btn-gap
3641
3603
  * --btn-hover-overlay
3642
3604
  * --btn-default-bg
@@ -3649,7 +3611,6 @@ small {
3649
3611
  * --btn-fg
3650
3612
  * --btn-border
3651
3613
  * --btn-default-hover-bg
3652
- * --btn-focus-ring-color
3653
3614
  * --btn-border-block-end-color
3654
3615
  * --btn-border-block-end-width
3655
3616
  * --spinner-width
@@ -3676,7 +3637,6 @@ small {
3676
3637
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
3677
3638
  --btn-default-hover-bg: var(--btn-bg);
3678
3639
  --btn-radius: var(--radius);
3679
- --btn-focus-color: var(--intent, var(--focus));
3680
3640
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
3681
3641
 
3682
3642
  /* Positioning context for indicators, overlays, and grouped states. */
@@ -3718,12 +3678,6 @@ small {
3718
3678
  padding: 0;
3719
3679
  }
3720
3680
 
3721
- @supports (color: color-mix(in oklch, red, white)) {
3722
- .btn {
3723
- --btn-focus-ring-color: color-mix(in oklch, var(--btn-focus-color) 28%, transparent);
3724
- }
3725
- }
3726
-
3727
3681
  /* .solid/.soft/.outline are shared via variants.css. The baseline --btn-*
3728
3682
  fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
3729
3683
  .btn.ghost {
@@ -3811,15 +3765,13 @@ small {
3811
3765
  text-decoration-thickness: 0.12em;
3812
3766
  }
3813
3767
 
3768
+ /* why: the ring must contrast with the surface around the button, not with
3769
+ the button, so it is --focus for every intent and variant — the theme
3770
+ guarantees --focus against --surface and --surface-solid, which covers
3771
+ the page, a solid band and nested surfaces without tracking any context. */
3814
3772
  .btn:focus-visible {
3815
- outline: 2px solid transparent;
3816
- box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
3817
- }
3818
-
3819
- .btn.link:focus-visible {
3820
- outline: var(--focus-outline);
3821
- outline-offset: 0.2em;
3822
- box-shadow: none;
3773
+ outline: var(--focus-ring-width) solid var(--focus);
3774
+ outline-offset: var(--focus-outline-offset);
3823
3775
  }
3824
3776
 
3825
3777
  .btn:disabled {
@@ -3865,6 +3817,98 @@ a.btn[aria-disabled="true"] {
3865
3817
  }
3866
3818
  }
3867
3819
 
3820
+ /*
3821
+ * Close button — the shared X that removes or closes the thing it sits in.
3822
+ *
3823
+ * Docs: docs/pages/components/close.md
3824
+ *
3825
+ * .close is always the X, painted from the --icon-close mask: an empty
3826
+ * <button class="close" aria-label="…"> needs no icon font and no text glyph.
3827
+ * The mechanism stays with the container (command="request-close",
3828
+ * method="dialog", command="--dismiss", application logic); the class only
3829
+ * names the visual action.
3830
+ *
3831
+ * The containers that host one (alert, badge) tune it from their own files by
3832
+ * declaring the size hooks and their geometry; this file owns the control
3833
+ * and the dialog-surface corner, shared by .modal and .drawer.
3834
+ *
3835
+ * Public hooks:
3836
+ * --close-size
3837
+ * --close-icon-size
3838
+ */
3839
+ .close {
3840
+ display: inline-grid;
3841
+ place-items: center;
3842
+ flex: none;
3843
+ inline-size: var(--close-size, var(--control-size));
3844
+ block-size: var(--close-size, var(--control-size));
3845
+ padding: 0;
3846
+ border: 0;
3847
+ border-radius: var(--radius);
3848
+ background: transparent;
3849
+ color: inherit;
3850
+ cursor: pointer;
3851
+ }
3852
+
3853
+ .close::before {
3854
+ content: "";
3855
+ inline-size: var(--close-icon-size, 1rem);
3856
+ block-size: var(--close-icon-size, 1rem);
3857
+ background: currentColor;
3858
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
3859
+ mask: var(--icon-close) center / contain no-repeat;
3860
+ }
3861
+
3862
+ /* An inset overlay, not a background: it tints whatever fill the context gave
3863
+ the control (the dialog corner's --surface-subtle) instead of replacing it. */
3864
+ @media (hover: hover) {
3865
+ .close:hover {
3866
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
3867
+ }
3868
+ }
3869
+
3870
+ .close:active {
3871
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
3872
+ }
3873
+
3874
+ .close:focus-visible {
3875
+ outline: var(--focus-ring-width) solid var(--focus);
3876
+ outline-offset: var(--focus-outline-offset);
3877
+ }
3878
+
3879
+ /* Dialog corner — out of the content flow, anchored to the positioned dialog
3880
+ surface rather than to the header or a scroll container inside it. Matched
3881
+ on the documented positions (the dialog, its form, its header, or a form
3882
+ inside that header) so a .close inside content — an alert in the body —
3883
+ keeps its own place. */
3884
+ :where(dialog):is(.modal, .drawer) > .close,
3885
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > .close,
3886
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close {
3887
+ position: absolute;
3888
+ z-index: 1;
3889
+ inset-block-start: var(--space-20);
3890
+ inset-inline-end: var(--space-20);
3891
+ background: var(--surface-subtle);
3892
+ color: var(--text-muted);
3893
+ }
3894
+
3895
+ @media (hover: hover) {
3896
+ :where(dialog):is(.modal, .drawer) > .close:hover,
3897
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > .close:hover,
3898
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close:hover {
3899
+ color: var(--text);
3900
+ }
3901
+ }
3902
+
3903
+ @media (forced-colors: active) {
3904
+ /* The X is a currentColor mask fill: without this the system remaps the
3905
+ background to Canvas and the glyph disappears. Kept on the pseudo so the
3906
+ host keeps its native color and focus adaptation. */
3907
+ .close::before {
3908
+ forced-color-adjust: none;
3909
+ }
3910
+ }
3911
+
3868
3912
  /*
3869
3913
  * Badges for compact labels, counts, and state hints.
3870
3914
  *
@@ -3881,7 +3925,6 @@ a.btn[aria-disabled="true"] {
3881
3925
  * --badge-icon-size
3882
3926
  * --badge-dot-size
3883
3927
  * --badge-radius
3884
- * --badge-dismiss-icon-size
3885
3928
  *
3886
3929
  * Internal:
3887
3930
  * --badge-default-bg
@@ -3912,7 +3955,10 @@ a.btn[aria-disabled="true"] {
3912
3955
  --badge-font-size: var(--font-size-sm);
3913
3956
  --badge-icon-size: 1em;
3914
3957
  --badge-dot-size: 0.5rem;
3915
- --badge-dismiss-icon-size: 0.875em;
3958
+ /* Declared on the badge, not the button, so an author can still set them
3959
+ on either. Same element as --badge-size, so .sm/.lg carry through. */
3960
+ --close-size: max(var(--badge-size), 24px);
3961
+ --close-icon-size: 0.875em;
3916
3962
  --badge-default-bg: var(--surface-subtle);
3917
3963
  --badge-default-fg: var(--intent, var(--text));
3918
3964
  --badge-default-border: var(--border);
@@ -3962,8 +4008,8 @@ a.btn[aria-disabled="true"] {
3962
4008
  --badge-dot-size: 0.625rem;
3963
4009
  }
3964
4010
 
3965
- /* Decorative children follow the badge's optical size. Text and interactive
3966
- dismiss buttons retain their own boxes. */
4011
+ /* Decorative children follow the badge's optical size. Text and the .close
4012
+ button retain their own boxes. */
3967
4013
  .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
3968
4014
  flex: none;
3969
4015
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
@@ -4016,45 +4062,15 @@ a.btn[aria-disabled="true"] {
4016
4062
  background-color: currentColor;
4017
4063
  }
4018
4064
 
4019
- .badge > button {
4020
- display: inline-grid;
4021
- place-items: center;
4022
- flex: 0 0 auto;
4023
- /* WCAG 2.5.8 floors pointer targets at 24 CSS px. Density and local size may
4024
- keep the badge visually compact through the negative margins, but never
4025
- shrink its action target below that literal floor. */
4026
- min-inline-size: max(var(--badge-size), 24px);
4027
- min-block-size: max(var(--badge-size), 24px);
4065
+ /* Removable tag — a .close (components/close.css) inside the pill. WCAG 2.5.8
4066
+ floors pointer targets at 24 CSS px: density and local size may keep the
4067
+ badge visually compact through the negative margins, but never shrink the
4068
+ target below that literal floor. */
4069
+ .badge > .close {
4028
4070
  margin-block: -0.25rem;
4029
4071
  margin-inline-start: -0.15em;
4030
4072
  margin-inline-end: -0.45em;
4031
- padding: 0.25rem;
4032
- border: 0;
4033
4073
  border-radius: var(--radius-full);
4034
- background: transparent;
4035
- color: inherit;
4036
- font: inherit;
4037
- line-height: 1;
4038
- cursor: pointer;
4039
- }
4040
-
4041
- /* Dismiss mark — an empty dismiss button paints its own X from the shared
4042
- --icon-close mask, so no icon font or text glyph is required in the markup
4043
- (same contract as .alert-dismiss and .dialog-close). A mask box is a block
4044
- box, so place-items centers the ink itself; a text glyph would only center
4045
- its line box and land the ink low, because a lowercase x has no descender
4046
- and only x-height of ascent. A button with its own content keeps it. */
4047
- .badge > button:empty::before {
4048
- content: "";
4049
- inline-size: var(--badge-dismiss-icon-size);
4050
- block-size: var(--badge-dismiss-icon-size);
4051
- background: currentColor;
4052
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4053
- mask: var(--icon-close) center / contain no-repeat;
4054
- }
4055
-
4056
- .badge > button:not(:disabled):hover {
4057
- background: var(--hover-overlay);
4058
4074
  }
4059
4075
 
4060
4076
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
@@ -4145,7 +4161,6 @@ a.badge[aria-disabled="true"] {
4145
4161
  * --alert-font-size
4146
4162
  * --alert-radius
4147
4163
  * --alert-icon-size
4148
- * --alert-dismiss-size
4149
4164
  * --alert-border-inline-start-color
4150
4165
  * --alert-border-inline-start-width
4151
4166
  *
@@ -4156,7 +4171,6 @@ a.badge[aria-disabled="true"] {
4156
4171
  * --alert-bg
4157
4172
  * --alert-fg
4158
4173
  * --alert-border
4159
- * --alert-dismiss-icon-size
4160
4174
  */
4161
4175
  /* Intent boundary — inherited intents do not tint this component.
4162
4176
  Local intent classes still win because :where() has zero specificity. */
@@ -4205,13 +4219,13 @@ a.badge[aria-disabled="true"] {
4205
4219
  The slots are excluded because they are anatomy, not content: an
4206
4220
  admonition's title bar has to stay flush against its body. The list follows
4207
4221
  the anatomy, so it grows with it. */
4208
- .alert > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4222
+ .alert > :not(:where(.alert-icon, .close, .alert-title, .alert-body)) {
4209
4223
  margin-block: 0;
4210
4224
  }
4211
4225
 
4212
4226
  .alert
4213
- > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body))
4214
- + :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4227
+ > :not(:where(.alert-icon, .close, .alert-title, .alert-body))
4228
+ + :not(:where(.alert-icon, .close, .alert-title, .alert-body)) {
4215
4229
  margin-block-start: var(--space-10);
4216
4230
  }
4217
4231
 
@@ -4249,42 +4263,24 @@ a.badge[aria-disabled="true"] {
4249
4263
  vertical-align: -0.125em;
4250
4264
  }
4251
4265
 
4252
- /* Dismiss — compact close button for inline alerts. Transparent at rest with
4253
- a reduced opacity glyph so it stays secondary inside an already-colored
4254
- alert; hover reinforces it via the shared overlay. Uses the shared
4255
- --icon-close mask, so no icon font or text glyph is required in the markup.
4256
- Standard alerts place it as a direct trailing child; admonitions place it
4257
- inside .alert-title (see below). */
4258
- .alert-dismiss {
4259
- --alert-dismiss-size: 1.75rem;
4260
- --alert-dismiss-icon-size: 0.875rem;
4261
-
4262
- display: inline-grid;
4263
- place-items: center;
4264
- inline-size: var(--alert-dismiss-size);
4265
- block-size: var(--alert-dismiss-size);
4266
- flex: none;
4267
- padding: 0;
4268
- border: 0;
4269
- border-radius: var(--radius);
4270
- background: transparent;
4271
- color: currentColor;
4272
- opacity: 0.65;
4273
- cursor: pointer;
4266
+ /* Close (components/close.css) — compact, and at reduced opacity so it stays
4267
+ secondary inside an already-colored alert. Standard alerts place it as a
4268
+ direct trailing child; admonitions place it inside .alert-title (see
4269
+ below). The sizes are declared on the alert so an author can still set
4270
+ them on the alert or on the button. */
4271
+ .alert {
4272
+ --close-size: 1.75rem;
4273
+ --close-icon-size: 0.875rem;
4274
4274
  }
4275
4275
 
4276
- .alert-dismiss::before {
4277
- content: "";
4278
- inline-size: var(--alert-dismiss-icon-size);
4279
- block-size: var(--alert-dismiss-icon-size);
4280
- background: currentColor;
4281
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4282
- mask: var(--icon-close) center / contain no-repeat;
4276
+ .alert > .close,
4277
+ .alert > .alert-title > .close {
4278
+ opacity: 0.65;
4283
4279
  }
4284
4280
 
4285
4281
  @media (hover: hover) {
4286
- .alert-dismiss:hover {
4287
- background: var(--hover-overlay);
4282
+ .alert > .close:hover,
4283
+ .alert > .alert-title > .close:hover {
4288
4284
  opacity: 1;
4289
4285
  }
4290
4286
  }
@@ -4293,7 +4289,7 @@ a.badge[aria-disabled="true"] {
4293
4289
  both. Icon and dismiss are pinned to the outer columns; anything else (the
4294
4290
  message content) flows into the remaining text column. */
4295
4291
  @supports selector(.alert:has(> *)) {
4296
- .alert:has(> .alert-icon, > .alert-dismiss) {
4292
+ .alert:has(> .alert-icon, > .close) {
4297
4293
  display: grid;
4298
4294
  grid-template-columns: minmax(0, 1fr);
4299
4295
  align-items: var(--items-align, start);
@@ -4312,19 +4308,19 @@ a.badge[aria-disabled="true"] {
4312
4308
  margin-block-start: 0.125em;
4313
4309
  }
4314
4310
 
4315
- .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
4311
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4316
4312
  grid-column: 2;
4317
4313
  }
4318
4314
 
4319
- .alert:has(> .alert-dismiss) {
4315
+ .alert:has(> .close) {
4320
4316
  grid-template-columns: minmax(0, 1fr) auto;
4321
4317
  }
4322
4318
 
4323
- .alert:has(> .alert-icon):has(> .alert-dismiss) {
4319
+ .alert:has(> .alert-icon):has(> .close) {
4324
4320
  grid-template-columns: auto minmax(0, 1fr) auto;
4325
4321
  }
4326
4322
 
4327
- .alert:has(> .alert-dismiss) > .alert-dismiss {
4323
+ .alert:has(> .close) > .close {
4328
4324
  grid-column: -2 / -1;
4329
4325
  grid-row: 1;
4330
4326
  }
@@ -4372,8 +4368,8 @@ a.badge[aria-disabled="true"] {
4372
4368
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4373
4369
  }
4374
4370
 
4375
- /* Dismiss inside an admonition title bar rides the inline end. */
4376
- .alert.admonition > .alert-title > .alert-dismiss {
4371
+ /* Close inside an admonition title bar rides the inline end. */
4372
+ .alert.admonition > .alert-title > .close {
4377
4373
  margin-inline-start: auto;
4378
4374
  }
4379
4375
 
@@ -4466,7 +4462,9 @@ a.badge[aria-disabled="true"] {
4466
4462
  *
4467
4463
  * Child contract:
4468
4464
  * Direct .bleed children escape --card-pad (negative inset, wider media);
4469
- * edge children inherit the card corners. .bleed must be a direct child.
4465
+ * edge children inherit the card corners. .bleed must be a direct child, and
4466
+ * its rules live in components/bleed.css — the card relays --card-pad to the
4467
+ * shared --surface-pad that they read.
4470
4468
  *
4471
4469
  * Internal:
4472
4470
  * --card-bg
@@ -4497,6 +4495,10 @@ a.badge[aria-disabled="true"] {
4497
4495
  --card-bg: var(--ui-bg, var(--surface-raised));
4498
4496
  --card-fg: var(--ui-fg, var(--text));
4499
4497
 
4498
+ /* The inset a direct .bleed child cancels (components/bleed.css). Relayed
4499
+ from --card-pad so .compact and an author override both reach it. */
4500
+ --surface-pad: var(--card-pad);
4501
+
4500
4502
  /* Keep contextual descendants tied to the surface this card actually owns. */
4501
4503
  --heading: var(--card-fg);
4502
4504
  --busy-overlay-bg: var(--card-bg);
@@ -4562,45 +4564,6 @@ a.badge[aria-disabled="true"] {
4562
4564
  --card-pad: var(--space-20);
4563
4565
  }
4564
4566
 
4565
- /* Full-bleed children */
4566
- .card > .bleed {
4567
- margin-inline: calc(var(--card-pad) * -1);
4568
- max-inline-size: none;
4569
- }
4570
-
4571
- .card > .bleed:first-child {
4572
- margin-block-start: calc(var(--card-pad) * -1);
4573
- border-start-start-radius: inherit;
4574
- border-start-end-radius: inherit;
4575
- }
4576
-
4577
- .card > .bleed:last-child {
4578
- margin-block-end: calc(var(--card-pad) * -1);
4579
- border-end-start-radius: inherit;
4580
- border-end-end-radius: inherit;
4581
- }
4582
-
4583
- /* Media bleed */
4584
- .card > :where(img, picture, video, canvas, svg).bleed {
4585
- display: block;
4586
- inline-size: calc(100% + var(--card-pad) * 2);
4587
- max-inline-size: none;
4588
- }
4589
-
4590
- .card > picture.bleed {
4591
- overflow: hidden;
4592
- }
4593
-
4594
- .card > picture.bleed > img {
4595
- display: block;
4596
- inline-size: 100%;
4597
- block-size: auto;
4598
- }
4599
-
4600
- /* Padded structural bleed */
4601
- .card > :where(header, footer, figure).bleed {
4602
- padding: var(--card-pad);
4603
- }
4604
4567
  @media print {
4605
4568
  .card {
4606
4569
  border: var(--border-width) solid CanvasText;
@@ -4622,8 +4585,9 @@ a.badge[aria-disabled="true"] {
4622
4585
  * --dialog-icon-size
4623
4586
  * --dialog-icon-glyph-size
4624
4587
  *
4588
+ * The corner close control is .close (components/close.css).
4589
+ *
4625
4590
  * Internal:
4626
- * --dialog-close-icon-size
4627
4591
  * --dialog-focus-bleed
4628
4592
  * --dialog-viewport-block
4629
4593
  * --dialog-viewport-gap-default
@@ -4709,11 +4673,16 @@ a.badge[aria-disabled="true"] {
4709
4673
 
4710
4674
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4711
4675
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
4712
- --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--border-width) * 2);
4676
+ --dialog-focus-bleed: calc(
4677
+ var(--focus-outline-offset) +
4678
+ max(var(--focus-ring-width), var(--border-width) * 2)
4679
+ );
4713
4680
 
4714
4681
  min-block-size: 0;
4715
4682
  /* Keep focus paint inside the clip without moving the body off the fixed
4716
- header and footer edges. */
4683
+ header and footer edges. The reserve covers the thicker of the two
4684
+ outside indicators: the :focus baseline (2 × border) and the component
4685
+ line (--focus-ring-width, 3px in bootstrap-v6). */
4717
4686
  margin-inline: calc(var(--dialog-focus-bleed) * -1);
4718
4687
  padding-inline: var(--dialog-focus-bleed);
4719
4688
  overflow: auto;
@@ -4736,7 +4705,7 @@ a.badge[aria-disabled="true"] {
4736
4705
  The form/.stack anatomy is structural, so it stays in :where() and never
4737
4706
  lends .stack's specificity to a bare <form>. */
4738
4707
  :where(dialog).modal > :where(form, .stack) > header {
4739
- padding-inline-end: calc(var(--control-size) + var(--space-30));
4708
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
4740
4709
  }
4741
4710
 
4742
4711
  :where(dialog).modal > :where(form, .stack) > footer {
@@ -4900,59 +4869,6 @@ html.has-modal-open.had-scrollbar {
4900
4869
  }
4901
4870
  }
4902
4871
 
4903
- /* Close control. A close button is an action attached to a surface, so it
4904
- never participates in the content flow — absolute at the panel's top
4905
- inline-end, inside the surface. The glyph comes from --icon-close, masked to
4906
- currentColor, so no icon font or text glyph is required in the markup. */
4907
- .dialog-close {
4908
- --dialog-close-icon-size: 1rem;
4909
-
4910
- display: inline-grid;
4911
- place-items: center;
4912
- padding: 0;
4913
- border: 0;
4914
- border-radius: var(--radius);
4915
- color: var(--text-muted);
4916
- cursor: pointer;
4917
-
4918
- /* Out of flow, anchored to the positioned dialog surface, not the header or
4919
- any scroll container inside it. */
4920
- position: absolute;
4921
- z-index: 1;
4922
- inset-block-start: var(--space-20);
4923
- inset-inline-end: var(--space-20);
4924
-
4925
- inline-size: var(--control-size);
4926
- block-size: var(--control-size);
4927
-
4928
- background: var(--surface-subtle);
4929
- }
4930
-
4931
- .dialog-close::before {
4932
- content: "";
4933
- inline-size: var(--dialog-close-icon-size);
4934
- block-size: var(--dialog-close-icon-size);
4935
- background: currentColor;
4936
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4937
- mask: var(--icon-close) center / contain no-repeat;
4938
- }
4939
-
4940
- @media (hover: hover) {
4941
- .dialog-close:hover {
4942
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4943
- color: var(--text);
4944
- }
4945
- }
4946
-
4947
- .dialog-close:active {
4948
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4949
- }
4950
-
4951
- .dialog-close:focus-visible {
4952
- outline: 2px solid transparent;
4953
- box-shadow: var(--focus-ring-shadow);
4954
- }
4955
-
4956
4872
  /*
4957
4873
  * Drawer — modal side-sheet built on the native dialog element.
4958
4874
  *
@@ -4972,6 +4888,8 @@ html.has-modal-open.had-scrollbar {
4972
4888
  * --drawer-size
4973
4889
  * --drawer-pad
4974
4890
  *
4891
+ * The corner close control is .close (components/close.css).
4892
+ *
4975
4893
  * Internal:
4976
4894
  * --drawer-closed-translate (side/RTL positioning state)
4977
4895
  * --drawer-focus-bleed
@@ -4981,6 +4899,8 @@ html.has-modal-open.had-scrollbar {
4981
4899
  :where(dialog).drawer {
4982
4900
  --drawer-size: 20rem;
4983
4901
  --drawer-pad: var(--space-40);
4902
+ /* The inset a direct .bleed child cancels (components/bleed.css). */
4903
+ --surface-pad: var(--drawer-pad);
4984
4904
  --drawer-closed-translate: -100%;
4985
4905
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
4986
4906
  the same variable through inheritance and never lags the panel. */
@@ -5080,15 +5000,20 @@ html.has-modal-open.had-scrollbar {
5080
5000
  /* The body region absorbs the panel's height and scrolls. A drawer is a
5081
5001
  full-height shell by definition, so it always scrolls its body — unlike the
5082
5002
  modal, which sizes to its content and opts in with .scrollable. Header and
5083
- footer keep their own tracks; .drawer-close is named because a generic
5003
+ footer keep their own tracks; .close is named because a generic
5084
5004
  exclusion list is easier to trust than its `position: absolute`. */
5085
- :where(dialog).drawer > :not(:is(header, footer, form, .drawer-close)),
5086
- :where(dialog).drawer > form > :not(:is(header, footer, .drawer-close)) {
5087
- --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--border-width) * 2);
5005
+ :where(dialog).drawer > :not(:is(header, footer, form, .close)),
5006
+ :where(dialog).drawer > form > :not(:is(header, footer, .close)) {
5007
+ --drawer-focus-bleed: calc(
5008
+ var(--focus-outline-offset) +
5009
+ max(var(--focus-ring-width), var(--border-width) * 2)
5010
+ );
5088
5011
 
5089
5012
  min-block-size: 0;
5090
5013
  /* Keep focus paint inside the clip without moving the body off the fixed
5091
- header and footer edges. */
5014
+ header and footer edges. The reserve covers the thicker of the two
5015
+ outside indicators: the :focus baseline (2 × border) and the component
5016
+ line (--focus-ring-width, 3px in bootstrap-v6). */
5092
5017
  margin-inline: calc(var(--drawer-focus-bleed) * -1);
5093
5018
  padding-inline: var(--drawer-focus-bleed);
5094
5019
  overflow: auto;
@@ -5160,57 +5085,87 @@ html.has-modal-open.had-scrollbar {
5160
5085
  control. */
5161
5086
  :where(dialog).drawer > header,
5162
5087
  :where(dialog).drawer > form > header {
5163
- padding-inline-end: calc(var(--control-size) + var(--space-30));
5088
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
5164
5089
  }
5165
5090
 
5166
- /* Close control, owned here so a drawer-only build does not depend on
5167
- modal.css. Mirrors the modal's .dialog-close placement: absolute at the
5168
- panel's top inline-end, out of the content flow. */
5169
- .drawer-close {
5170
- --drawer-close-icon-size: 1rem;
5091
+ /*
5092
+ * Full-bleed children — the shared escape hatch for a padded surface.
5093
+ *
5094
+ * Docs: docs/pages/components/bleed.md
5095
+ *
5096
+ * A surface that insets its direct children relays that inset to them as
5097
+ * --surface-pad; a direct .bleed child cancels it and reaches the surface
5098
+ * edge. Card and drawer pad at the container level, so they own the token
5099
+ * there; the accordion pads its summary and its panel instead, so the panel
5100
+ * owns it and the item does not.
5101
+ *
5102
+ * Why the token is handed to the children rather than declared on the surface:
5103
+ * custom properties inherit, so a surface-level declaration would also reach a
5104
+ * grandchild, which sits at its wrapper's edge rather than the surface's and
5105
+ * would pull a negative margin out of the wrong box. Relaying it one level and
5106
+ * clearing it below keeps the child combinator's guarantee while leaving the
5107
+ * .bleed rules themselves free of the surface list.
5108
+ *
5109
+ * Why the clearing rule comes first: both rules are zero-specificity, so
5110
+ * source order decides where they overlap, and they overlap on exactly one
5111
+ * element — a direct child of a surface nested inside another surface. That
5112
+ * child must keep the inner surface's inset, so the relay has to win.
5113
+ *
5114
+ * Public hooks:
5115
+ * --bleed-pad (read-only: the inset relayed to a surface's direct children;
5116
+ * pad a band that is not a header/footer/figure with it)
5117
+ *
5118
+ * Framework plumbing:
5119
+ * --surface-pad (declared by the surface)
5120
+ */
5121
+ :where(.card, .drawer, .accordion details > summary + *) > * * {
5122
+ --bleed-pad: initial;
5123
+ }
5171
5124
 
5172
- display: inline-grid;
5173
- place-items: center;
5174
- padding: 0;
5175
- border: 0;
5176
- border-radius: var(--radius);
5177
- color: var(--text-muted);
5178
- cursor: pointer;
5125
+ :where(.card, .drawer, .accordion details > summary + *) > * {
5126
+ --bleed-pad: var(--surface-pad);
5127
+ }
5179
5128
 
5180
- position: absolute;
5181
- z-index: 1;
5182
- inset-block-start: var(--space-20);
5183
- inset-inline-end: var(--space-20);
5129
+ /* Inert without a surface: the fallback resolves the negative margin to zero
5130
+ rather than dropping the declaration and inheriting a stray inset. */
5131
+ .bleed {
5132
+ margin-inline: calc(var(--bleed-pad, 0px) * -1);
5133
+ max-inline-size: none;
5134
+ }
5184
5135
 
5185
- inline-size: var(--control-size);
5186
- block-size: var(--control-size);
5136
+ .bleed:first-child {
5137
+ margin-block-start: calc(var(--bleed-pad, 0px) * -1);
5138
+ border-start-start-radius: inherit;
5139
+ border-start-end-radius: inherit;
5140
+ }
5187
5141
 
5188
- background: var(--surface-subtle);
5142
+ .bleed:last-child {
5143
+ margin-block-end: calc(var(--bleed-pad, 0px) * -1);
5144
+ border-end-start-radius: inherit;
5145
+ border-end-end-radius: inherit;
5189
5146
  }
5190
5147
 
5191
- .drawer-close::before {
5192
- content: "";
5193
- inline-size: var(--drawer-close-icon-size);
5194
- block-size: var(--drawer-close-icon-size);
5195
- background: currentColor;
5196
- -webkit-mask: var(--icon-close) center / contain no-repeat;
5197
- mask: var(--icon-close) center / contain no-repeat;
5148
+ /* Media bleed */
5149
+ :where(img, picture, video, canvas, svg).bleed {
5150
+ display: block;
5151
+ inline-size: calc(100% + var(--bleed-pad, 0px) * 2);
5152
+ max-inline-size: none;
5198
5153
  }
5199
5154
 
5200
- @media (hover: hover) {
5201
- .drawer-close:hover {
5202
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
5203
- color: var(--text);
5204
- }
5155
+ picture.bleed {
5156
+ overflow: hidden;
5205
5157
  }
5206
5158
 
5207
- .drawer-close:active {
5208
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
5159
+ picture.bleed > img {
5160
+ display: block;
5161
+ inline-size: 100%;
5162
+ block-size: auto;
5209
5163
  }
5210
5164
 
5211
- .drawer-close:focus-visible {
5212
- outline: 2px solid transparent;
5213
- box-shadow: var(--focus-ring-shadow);
5165
+ /* Padded structural bleed — the band keeps the surface's own rhythm while its
5166
+ background reaches the edge. */
5167
+ :where(header, footer, figure).bleed {
5168
+ padding: var(--bleed-pad, 0px);
5214
5169
  }
5215
5170
 
5216
5171
 
@@ -5287,6 +5242,7 @@ html.has-modal-open.had-scrollbar {
5287
5242
  border: var(--border-width) solid var(--border);
5288
5243
  border-radius: var(--radius);
5289
5244
  background: var(--surface-raised);
5245
+ color: var(--text);
5290
5246
  box-shadow: var(--shadow-popout);
5291
5247
  list-style: none;
5292
5248
  opacity: 1;
@@ -5519,6 +5475,18 @@ html.has-modal-open.had-scrollbar {
5519
5475
  border-block-start: var(--border-width) solid var(--border);
5520
5476
  }
5521
5477
 
5478
+ @media (forced-colors: active) {
5479
+ /* The check mark is a currentColor mask fill and the mixed/radio marks are
5480
+ currentColor gradients: without this the system remaps them to Canvas or
5481
+ drops the image and the state disappears. Kept on the pseudo so the host
5482
+ keeps its native color and focus adaptation. */
5483
+ .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before,
5484
+ .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before,
5485
+ .menu-item[role="menuitemradio"]::before {
5486
+ forced-color-adjust: none;
5487
+ }
5488
+ }
5489
+
5522
5490
  /*
5523
5491
  * Tooltip — supplemental label overlaid near a trigger.
5524
5492
  *
@@ -5680,11 +5648,15 @@ html.has-modal-open.had-scrollbar {
5680
5648
  *
5681
5649
  * Public hooks:
5682
5650
  * --accordion-radius
5651
+ * --accordion-pad
5683
5652
  * --accordion-marker-color
5653
+ * --accordion-marker-size
5684
5654
  */
5685
5655
  .accordion {
5686
5656
  --accordion-radius: var(--radius);
5657
+ --accordion-pad: var(--space-40);
5687
5658
  --accordion-marker-color: var(--text-muted);
5659
+ --accordion-marker-size: 1rem;
5688
5660
 
5689
5661
  border: var(--border-width) solid var(--border);
5690
5662
  border-radius: var(--accordion-radius);
@@ -5701,7 +5673,7 @@ html.has-modal-open.had-scrollbar {
5701
5673
  gap: var(--space-30);
5702
5674
  cursor: pointer;
5703
5675
  list-style: none;
5704
- padding: var(--space-40);
5676
+ padding: var(--accordion-pad);
5705
5677
  color: var(--text);
5706
5678
  font-weight: var(--font-weight-medium);
5707
5679
  }
@@ -5717,12 +5689,17 @@ html.has-modal-open.had-scrollbar {
5717
5689
  display: none;
5718
5690
  }
5719
5691
 
5692
+ /* The marker is the chevron itself: the box is filled with
5693
+ --accordion-marker-color and then clipped to the glyph by the mask. Anything
5694
+ painted on this pseudo-element — a background, a ring, a shadow — is clipped
5695
+ with it, so a decorated marker (a chevron in a pill) needs a real element;
5696
+ see the accordion docs for the opt-out. */
5720
5697
  .accordion summary::after {
5721
5698
  content: "";
5722
5699
  flex: 0 0 auto;
5723
5700
  margin-inline-start: auto;
5724
- inline-size: 1rem;
5725
- block-size: 1rem;
5701
+ inline-size: var(--accordion-marker-size);
5702
+ block-size: var(--accordion-marker-size);
5726
5703
  background-color: var(--accordion-marker-color);
5727
5704
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
5728
5705
  mask: var(--icon-chevron) center / contain no-repeat;
@@ -5733,9 +5710,14 @@ html.has-modal-open.had-scrollbar {
5733
5710
  transform: rotate(180deg);
5734
5711
  }
5735
5712
 
5713
+ /* The panel, not the item, owns the inset here — .separated paints a card on
5714
+ <details> but leaves it unpadded — so the panel is what a .bleed child
5715
+ escapes (components/bleed.css). */
5736
5716
  .accordion details > summary + * {
5737
- padding-block: 0 var(--space-40);
5738
- padding-inline: var(--space-40);
5717
+ --surface-pad: var(--accordion-pad);
5718
+
5719
+ padding-block: 0 var(--accordion-pad);
5720
+ padding-inline: var(--accordion-pad);
5739
5721
  color: var(--text-muted);
5740
5722
  margin-block: 0;
5741
5723
  }
@@ -5744,6 +5726,9 @@ html.has-modal-open.had-scrollbar {
5744
5726
  padding-block-start: var(--space-10);
5745
5727
  }
5746
5728
 
5729
+ /* Also cancels the lift of a first .bleed child (bleed.css, lower
5730
+ specificity): the panel's top edge is the summary, not the item edge, so a
5731
+ bleed reaches only the sides and the bottom. */
5747
5732
  .accordion details > summary + * > :first-child {
5748
5733
  margin-block-start: 0;
5749
5734
  }
@@ -5768,6 +5753,12 @@ html.has-modal-open.had-scrollbar {
5768
5753
  padding-inline: 0;
5769
5754
  }
5770
5755
 
5756
+ /* Flush already sits at the embedding surface's edge, so there is no inline
5757
+ inset left for a .bleed child to cancel. */
5758
+ .accordion.flush details > summary + * {
5759
+ --surface-pad: 0px;
5760
+ }
5761
+
5771
5762
  /* Separated — each item owns a full card. The item's own border beats the
5772
5763
  shared details + details separator on specificity alone, so nothing needs
5773
5764
  undoing for the second item onwards. */
@@ -5784,9 +5775,23 @@ html.has-modal-open.had-scrollbar {
5784
5775
  background: var(--surface-raised);
5785
5776
  }
5786
5777
 
5778
+ /* A last .bleed child inherits its bottom corners from the panel, but the
5779
+ rounded box is the item (.separated) or the group (last item) — relay the
5780
+ radius to the panel. Explicit rather than inherit: in browsers with
5781
+ ::details-content the panel's flat-tree parent is that slot, not
5782
+ <details>. */
5783
+ .accordion.separated > details > summary + *,
5784
+ .accordion:not(.flush) > details:last-child > summary + * {
5785
+ border-end-start-radius: var(--accordion-radius);
5786
+ border-end-end-radius: var(--accordion-radius);
5787
+ }
5788
+
5787
5789
  @media (forced-colors: active) {
5788
- .accordion {
5789
- --accordion-marker-color: CanvasText;
5790
+ /* Written on the property, not the hook: an application hook override at
5791
+ the same specificity loads after this file and would win the variable,
5792
+ leaving a Canvas-remapped fill. The property keeps CanvasText. */
5793
+ .accordion summary::after {
5794
+ background-color: CanvasText;
5790
5795
  }
5791
5796
  }
5792
5797
 
@@ -6781,6 +6786,16 @@ a.avatar[aria-disabled="true"] {
6781
6786
  transform: none;
6782
6787
  }
6783
6788
 
6789
+ @media (forced-colors: active) {
6790
+ /* Both toggle markers are currentColor mask fills: without this the system
6791
+ remaps the backgrounds to Canvas and the glyphs disappear. Kept on the
6792
+ pseudos so the host keeps its native color and focus adaptation. */
6793
+ .fab > summary::before,
6794
+ .fab > summary::after {
6795
+ forced-color-adjust: none;
6796
+ }
6797
+ }
6798
+
6784
6799
  /*
6785
6800
  * Rating — integer star rating drawn on native radio inputs.
6786
6801
  *
@@ -7305,6 +7320,15 @@ a.avatar[aria-disabled="true"] {
7305
7320
  color: var(--text);
7306
7321
  }
7307
7322
 
7323
+ /* Same contextual hover as .nav-link (navbar.css): a tint of the tile's ink
7324
+ reads on any --ui-* bar; the baseline keeps a legible light chip. */
7325
+ @supports (color: color-mix(in oklch, red, white)) {
7326
+ .app-nav > a:hover {
7327
+ background: color-mix(in oklch, currentColor 10%, transparent);
7328
+ color: var(--ui-fg, var(--text));
7329
+ }
7330
+ }
7331
+
7308
7332
  /* Current tile is already a full selected surface. Selection never changes
7309
7333
  text metrics: the shared medium weight is kept, no permanent indicator is
7310
7334
  added here. */
@@ -7392,11 +7416,16 @@ a.avatar[aria-disabled="true"] {
7392
7416
  display: inline-flex;
7393
7417
  align-items: center;
7394
7418
  min-block-size: var(--control-size-sm);
7419
+ padding-block: 0;
7395
7420
  padding-inline: var(--space-20);
7421
+ border: 0;
7396
7422
  border-radius: var(--radius);
7423
+ background: transparent;
7397
7424
  color: var(--ui-fg, var(--text-muted));
7425
+ font: inherit;
7398
7426
  font-weight: var(--font-weight-medium);
7399
7427
  text-decoration: none;
7428
+ cursor: pointer;
7400
7429
  }
7401
7430
 
7402
7431
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
@@ -7421,6 +7450,28 @@ a.avatar[aria-disabled="true"] {
7421
7450
  color: var(--state-selected);
7422
7451
  }
7423
7452
 
7453
+ /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
7454
+ ink, the .btn.ghost hover recipe, so it reads on light and dark bars alike
7455
+ instead of snapping back to --surface-subtle. Not --ui-hover-bg: that is the
7456
+ bar's own hover and can equal its fill. The ink follows --ui-fg too — the
7457
+ selected accent has no contrast pair with an arbitrary bar. The baseline
7458
+ above keeps the light chip where color-mix is missing, with ink that is
7459
+ legible on it. */
7460
+ @supports (color: color-mix(in oklch, red, white)) {
7461
+ .nav-link:is(:hover, [aria-current]) {
7462
+ background: color-mix(in oklch, currentColor 10%, transparent);
7463
+ }
7464
+
7465
+ .nav-link:hover {
7466
+ color: var(--ui-fg, var(--text));
7467
+ }
7468
+
7469
+ .nav-link[aria-current],
7470
+ .nav-link[aria-current]:hover {
7471
+ color: var(--ui-fg, var(--state-selected));
7472
+ }
7473
+ }
7474
+
7424
7475
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links.
7425
7476
  Links fill each row so the whole line is an interactive target; the
7426
7477
  horizontal .navbar-nav links keep their intrinsic size. */
@@ -7716,6 +7767,9 @@ a.avatar[aria-disabled="true"] {
7716
7767
  * A plain <dl> handles richer relationships (several terms or descriptions)
7717
7768
  * and stays untouched by this component.
7718
7769
  *
7770
+ * Variants:
7771
+ * .stacked — value under its term rather than beside it.
7772
+ *
7719
7773
  * Why margin resets: neutralizes .prose list margins if .data-list appears
7720
7774
  * inside an authored rich-text scope; the grid owns row/column rhythm.
7721
7775
  */
@@ -7737,6 +7791,21 @@ a.avatar[aria-disabled="true"] {
7737
7791
  margin: 0;
7738
7792
  }
7739
7793
 
7794
+ /* Stacked — the value sits under its term instead of beside it, for narrow
7795
+ columns and for values too long to sit in a second column.
7796
+ Why the pair separation rides on the term's margin: the children alternate
7797
+ dt/dd with no element around a pair, so the grid has no row boundary to give
7798
+ a larger gap to. The tight gap is the row gap; the term reopens the space. */
7799
+ .data-list.stacked {
7800
+ grid-template-columns: minmax(0, 1fr);
7801
+ gap: var(--space-10);
7802
+ }
7803
+
7804
+ .data-list.stacked > dt + dt,
7805
+ .data-list.stacked > dd + dt {
7806
+ margin-block-start: var(--space-30);
7807
+ }
7808
+
7740
7809
  /*
7741
7810
  * Application list — repeated rows with optional leading and trailing regions.
7742
7811
  * Content determines the number of lines; the component only owns the row
@@ -8090,6 +8159,11 @@ a.list-item[aria-current]::before {
8090
8159
  min-block-size: var(--control-size);
8091
8160
  padding-inline: var(--space-30);
8092
8161
  border: var(--border-width) solid var(--control-border);
8162
+ /* An addon is a field segment: it honours the same lower-edge extension
8163
+ points as text controls (control.css), so a theme's underline or accent
8164
+ edge reaches it instead of stopping at the field. */
8165
+ border-block-end-color: var(--control-border-block-end-color, var(--control-border));
8166
+ border-block-end-width: var(--control-border-block-end-width, var(--border-width));
8093
8167
  border-radius: var(--radius);
8094
8168
  background: var(--control-bg);
8095
8169
  color: var(--control-fg);
@@ -8137,38 +8211,49 @@ a.list-item[aria-current]::before {
8137
8211
  z-index: 1;
8138
8212
  }
8139
8213
 
8140
- /* Keep an active focus ring above an adjacent hovered control. This matters
8141
- on platforms where clicking a button does not move focus from the input. */
8142
- .join > :is(:focus, :focus-within) {
8143
- z-index: 4;
8144
- }
8145
-
8146
8214
  /* An input's border must read as the "primary" edge against an attached
8147
8215
  action (e.g. a password-reveal button) at all times, not only while
8148
8216
  hovered or focused — otherwise the shared edge flips ownership as the
8149
- user's pointer moves, which reads as a rendering glitch. */
8150
- .join > :is(.input, .textarea, .select) {
8217
+ user's pointer moves, which reads as a rendering glitch. The :has()
8218
+ variant covers a control nested one wrapper level deep (e.g. a combobox
8219
+ element): the wrapper is the flex item, so it carries the z-index — a
8220
+ z-index on the inner control would be a no-op on a static grandchild. */
8221
+ .join > :is(.input, .textarea, .select),
8222
+ .join > :has(> :is(.input, .textarea, .select)) {
8151
8223
  z-index: 3;
8152
8224
  }
8153
8225
 
8154
- /* An input/select with an attached action reads as one composite field. Move
8155
- its outer focus treatment to the join; button-only groups keep per-button
8156
- focus, and unsupported :has() browsers retain the control's normal ring. */
8157
- .join:has(> :is(.input, .textarea, .select):focus-visible) {
8158
- border-radius: var(--join-radius);
8159
- outline: 2px solid transparent;
8160
- box-shadow: var(--focus-ring-shadow);
8226
+ /* Keep an active focus line above every neighbour, including an adjacent
8227
+ field and a hovered control (some platforms keep focus on the input while
8228
+ a button is clicked). It must follow the primary-edge rule above: both are
8229
+ (0,2,0), and when it came first a focused field stayed at 3 and the next
8230
+ field, painted later, covered the 1px shared edge of its line. */
8231
+ .join > :is(:focus, :focus-within) {
8232
+ z-index: 4;
8161
8233
  }
8162
8234
 
8163
- .join:has(> :is(.input, .textarea, .select)) > :is(.input, .textarea, .select):focus-visible {
8164
- outline: none;
8165
- box-shadow: none;
8235
+ /* One wrapper level (e.g. a combobox element around the native control) is
8236
+ supported generically — no widget is named here. The wrapper is the flex
8237
+ item that receives the corner longhands above; the inner control inherits
8238
+ them. The wrapper is also what align-items stretches: block-size: 100%
8239
+ resolves against that stretched size, so the field fills it. :hover and
8240
+ :focus-within already propagate to the wrapper, so the z-index rules above
8241
+ need no duplication. */
8242
+ .join > * > :is(.input, .textarea, .select) {
8243
+ block-size: 100%;
8244
+ border-radius: inherit;
8166
8245
  }
8167
8246
 
8247
+ /* Text controls paint their own inset focus outline (control.css), so a
8248
+ .join never moves the ring to the group: button-only groups and wrapped
8249
+ inputs alike keep per-control focus, with no :has() group treatment. */
8250
+
8168
8251
  /* A default outline action belongs to the composite field border. An explicit
8169
8252
  intent still provides its own outline color — --intent is guaranteed-invalid
8170
- for a neutral button (the :where(.btn) boundary), so the fallback applies. */
8171
- .join:has(> :is(.input, .textarea, .select)) > .btn.outline {
8253
+ for a neutral button (the :where(.btn) boundary), so the fallback applies.
8254
+ The second :has() covers a control nested one wrapper level deep. */
8255
+ .join:has(> :is(.input, .textarea, .select)) > .btn.outline,
8256
+ .join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
8172
8257
  --btn-border-color: var(--intent, var(--border));
8173
8258
  }
8174
8259
 
@@ -8537,9 +8622,9 @@ a.list-item[aria-current]::before {
8537
8622
  }
8538
8623
 
8539
8624
  /*
8540
- * Optional utility helpers.
8541
- *
8542
- * These classes are intentionally not part of the core API.
8625
+ * Extra utility helpers — explicit property/value names beside the curated
8626
+ * shortcuts of base.css. Both are imported by utilities/index.css and are
8627
+ * equally public (docs/design-notes/naming.md § Utilities).
8543
8628
  * They are escape hatches for layout nudges, not a full atomic scale.
8544
8629
  *
8545
8630
  * Core principle:
@@ -8715,8 +8800,9 @@ a.list-item[aria-current]::before {
8715
8800
  overflow-y: auto;
8716
8801
  }
8717
8802
 
8718
- /* Text */
8719
- .white-space-normal {
8803
+ /* Text. .text-wrap is the counterpart of base's .text-nowrap: it restores
8804
+ wrapping inside a component that sets nowrap (.badge). */
8805
+ .text-wrap {
8720
8806
  white-space: normal;
8721
8807
  }
8722
8808
 
@@ -8789,7 +8875,7 @@ a.list-item[aria-current]::before {
8789
8875
  /* Surface. These are semantic surfaces, not arbitrary paint: each owns its
8790
8876
  matching ink and rebinds --heading to that ink, so it stays readable when it
8791
8877
  recreates a light surface inside a context that installed a dark one
8792
- (.inverted). Intent ink is not inherited through a surface utility; use
8878
+ (e.g. a --surface-solid band). Intent ink is not inherited through a surface utility; use
8793
8879
  .intent-color for that. */
8794
8880
  .background-surface {
8795
8881
  --heading: currentColor;