actual-css 0.9.1 → 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 (107) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +67 -45
  3. package/components.json +33 -13
  4. package/dist/actual.full.css +607 -502
  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/components/table.md +3 -2
  26. package/docs/pages/enhancements/overview.md +5 -0
  27. package/docs/pages/enhancements/tooltip.md +3 -1
  28. package/docs/pages/examples/overview.md +3 -1
  29. package/docs/pages/forms/choice-cards.md +39 -0
  30. package/docs/pages/forms/combobox.md +23 -11
  31. package/docs/pages/forms/input-policy.md +59 -0
  32. package/docs/pages/forms/joined-controls.md +2 -5
  33. package/docs/pages/forms/overview.md +6 -0
  34. package/docs/pages/forms/validation.md +37 -1
  35. package/docs/pages/foundations/theming.md +64 -18
  36. package/docs/pages/foundations/tokens.md +50 -13
  37. package/docs/pages/foundations/typography.md +10 -0
  38. package/docs/pages/guides/introduction.md +17 -24
  39. package/docs/pages/guides/modular-import.md +53 -6
  40. package/docs/pages/guides/progressive-enhancement.md +9 -5
  41. package/docs/pages/guides/tailwind.md +17 -5
  42. package/docs/pages/layout/column-layout.md +92 -54
  43. package/docs/pages/layout/media.md +1 -1
  44. package/docs/pages/layout/overview.md +1 -1
  45. package/docs/pages/utilities/overview.md +1 -1
  46. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  47. package/llms.txt +24 -4
  48. package/package.json +7 -6
  49. package/reserved-classes.json +5 -30
  50. package/skills/actual-css/PROJECT.md.example +65 -0
  51. package/skills/actual-css/README.md +64 -0
  52. package/skills/actual-css/SKILL.md +234 -0
  53. package/skills/actual-css/references/accessibility.md +44 -0
  54. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  55. package/skills/actual-css/references/enhancements.md +59 -0
  56. package/skills/actual-css/references/forms.md +49 -0
  57. package/skills/actual-css/references/layout-and-theming.md +82 -0
  58. package/skills/actual-css/references/review-checklist.md +51 -0
  59. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  60. package/src/css/components/accordion.css +44 -7
  61. package/src/css/components/alert.css +23 -43
  62. package/src/css/components/app-nav.css +9 -0
  63. package/src/css/components/badge.css +11 -39
  64. package/src/css/components/bleed.css +79 -0
  65. package/src/css/components/button.css +6 -17
  66. package/src/css/components/card.css +7 -40
  67. package/src/css/components/close.css +91 -0
  68. package/src/css/components/data-list.css +18 -0
  69. package/src/css/components/drawer.css +15 -56
  70. package/src/css/components/fab.css +10 -0
  71. package/src/css/components/flyout.css +1 -0
  72. package/src/css/components/index.css +2 -0
  73. package/src/css/components/join.css +36 -20
  74. package/src/css/components/menu.css +12 -0
  75. package/src/css/components/modal.css +10 -57
  76. package/src/css/components/navbar.css +27 -0
  77. package/src/css/core/base.css +2 -0
  78. package/src/css/core/focus.css +9 -3
  79. package/src/css/core/theme.css +25 -43
  80. package/src/css/core/tokens.css +3 -1
  81. package/src/css/core/variants.css +15 -36
  82. package/src/css/forms/choice-card.css +74 -4
  83. package/src/css/forms/choice.css +2 -3
  84. package/src/css/forms/control.css +15 -8
  85. package/src/css/forms/custom-select.css +4 -4
  86. package/src/css/forms/form.css +6 -0
  87. package/src/css/forms/native.css +4 -4
  88. package/src/css/forms/range.css +23 -8
  89. package/src/css/forms/switch.css +2 -3
  90. package/src/css/layout/app-layout.css +1 -1
  91. package/src/css/layout/column-layout.css +26 -111
  92. package/src/css/utilities/extra.css +7 -6
  93. package/src/js/dialog.js +95 -24
  94. package/src/js/enhance.js +7 -3
  95. package/src/js/escape.js +1 -0
  96. package/src/js/flyout.js +10 -1
  97. package/src/js/focus-group.js +2 -1
  98. package/src/js/keys.js +14 -0
  99. package/src/js/menu.js +2 -1
  100. package/src/js/status.js +19 -0
  101. package/src/js/surface.js +12 -8
  102. package/src/js/tab.js +2 -1
  103. package/src/js/tooltip.js +18 -1
  104. package/src/js/validation.js +36 -6
  105. package/dist/actual.css +0 -863
  106. package/dist/actual.js +0 -1
  107. package/dist/actual.min.css +0 -1
@@ -71,6 +71,8 @@ select {
71
71
  /* Typography */
72
72
  --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
73
73
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
74
+ --font-width: 100%;
75
+ --font-width-dense: 95%;
74
76
  --line-height: 1.5;
75
77
  --line-height-tight: 1.25;
76
78
  --line-height-relaxed: 1.75;
@@ -132,7 +134,7 @@ select {
132
134
  --focus-outline-color: currentColor;
133
135
  --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
134
136
  --focus-outline-offset: 2px;
135
- --focus-ring-width: 3px;
137
+ --focus-ring-width: 2px;
136
138
 
137
139
  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
138
140
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
@@ -354,10 +356,10 @@ select {
354
356
 
355
357
  /* Lines and focus */
356
358
  --border: hsl(260 15% 88%);
357
- --focus: var(--neutral);
358
- /* Pre-color-mix fallback only — superseded below by a derivation from
359
- --focus so overriding --neutral/--focus never leaves a stale ring. */
360
- --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%);
361
363
  --hover-overlay: hsl(260 20% 10% / 0.04);
362
364
 
363
365
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -380,7 +382,6 @@ select {
380
382
  --heading: var(--text);
381
383
  --selection-bg: var(--primary);
382
384
  --selection-fg: var(--primary-fg);
383
- --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
384
385
  }
385
386
 
386
387
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -389,18 +390,17 @@ select {
389
390
  default theme: a custom property bakes its var() references where it is
390
391
  declared, so these resolve against root/light/dark tokens on every boundary.
391
392
  Presets and custom islands keep their --soft-fg-mix / raw-intent ink unless
392
- they declare hooks themselves — :where() keeps this reset from outranking a
393
- theme's own declaration (e.g. brutalist's 45% soft-fg-mix). */
394
- :where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
395
- --primary-soft-fg: initial;
396
- --secondary-soft-fg: initial;
397
- --success-soft-fg: initial;
398
- --warning-soft-fg: initial;
399
- --danger-soft-fg: initial;
400
- --neutral-soft-fg: initial;
401
- }
402
-
403
- :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),
404
404
  [data-theme="light"],
405
405
  [data-theme="dark"] {
406
406
  --secondary-soft-fg: color-mix(in oklab, var(--text) 10%, var(--secondary) 90%);
@@ -410,6 +410,15 @@ select {
410
410
  --neutral-soft-fg: color-mix(in oklab, var(--text) 20%, var(--neutral) 80%);
411
411
  }
412
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
+
413
422
  /* Native platform controls (checkbox, radio, range, progress) that are not
414
423
  restyled by a component class still follow the theme accent. Declared on
415
424
  every island: accent-color inherits as a computed value, so islands must
@@ -431,20 +440,6 @@ select {
431
440
  --tone-fg: var(--text);
432
441
  }
433
442
 
434
- /* Focus ring derivation — color-mix tier. Runs whether or not light-dark()
435
- is also supported, so it covers the color-mix-without-light-dark browser
436
- tier on its own; the light-dark block below refines it further with the
437
- light/dark alpha split once both features are available. Declared on every
438
- [data-theme] island (not just the built-in light/dark pair) so custom
439
- islands recolor the ring for free; a preset that re-declares --focus-ring
440
- later in source order intentionally pins its own ring instead. */
441
- @supports (color: color-mix(in oklch, red, white)) {
442
- :root,
443
- [data-theme] {
444
- --focus-ring: color-mix(in oklch, var(--focus) 30%, transparent);
445
- }
446
- }
447
-
448
443
  /* Auto dark mode via light-dark(). Resolves based on the element's color-scheme:
449
444
  :root (light dark) follows the OS, [data-theme="light"] forces light,
450
445
  [data-theme="dark"] forces dark by overriding color-scheme.
@@ -481,22 +476,11 @@ select {
481
476
  --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));
482
477
 
483
478
  --border: light-dark(hsl(260 15% 88%), hsl(262 12% 24%));
484
- --focus-ring: light-dark(hsl(258 7% 43% / 0.3), hsl(258 10% 72% / 0.28));
485
479
  --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
486
480
  --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
487
481
  }
488
482
  }
489
483
 
490
- @supports (color: color-mix(in oklch, red, white)) and (color: light-dark(red, blue)) {
491
- :root,
492
- [data-theme] {
493
- --focus-ring: light-dark(
494
- color-mix(in oklch, var(--focus) 30%, transparent),
495
- color-mix(in oklch, var(--focus) 28%, transparent)
496
- );
497
- }
498
- }
499
-
500
484
  @media (prefers-contrast: more) {
501
485
  :root,
502
486
  /* The repeated attribute intentionally outranks single-attribute preset
@@ -504,7 +488,7 @@ select {
504
488
  [data-theme][data-theme] {
505
489
  --border-width: 2px;
506
490
  --focus-outline-offset: 3px;
507
- --focus-ring-width: 4px;
491
+ --focus-ring-width: 3px;
508
492
  --text-muted: var(--text);
509
493
  --text-subtle: var(--text);
510
494
  }
@@ -519,12 +503,14 @@ select {
519
503
  * Public hooks:
520
504
  * --link
521
505
  * --hr-space
506
+ * --font-width / --font-width-dense (see variants.css for the dense application)
522
507
  */
523
508
 
524
509
  body {
525
510
  background: var(--surface);
526
511
  color: var(--text);
527
512
  font-family: var(--font-sans);
513
+ font-stretch: var(--font-width);
528
514
  line-height: var(--line-height);
529
515
  }
530
516
 
@@ -658,13 +644,9 @@ small {
658
644
  * Cross-cutting variant token mapping.
659
645
  *
660
646
  * Variant scope: .solid/.soft/.outline/.surface set --ui-* for components that
661
- * opt into shared variants. .inverted is a surface modifier, not a variant: it paints
662
- * the element directly (background/color/border-color) so it works on any bare
663
- * block, and remaps the shared --ui-* tokens so participating components
664
- * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
665
- * before components, so a component with its own explicit surface (e.g.
666
- * .accordion) naturally keeps that surface — .inverted never forces a
667
- * 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.
668
650
  * Component-specific defaults (.btn solid-by-default, .badge
669
651
  * soft-by-default, .alert soft-by-default) live with their components:
670
652
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
@@ -672,9 +654,16 @@ small {
672
654
  * components/alert.css — .alert default soft behind explicit --ui-*
673
655
  *
674
656
  * .compact/.spacious set inherited density tokens: spacing rhythm and geometry
675
- * only. Density never changes typography or icon size. Controls consume
676
- * --control-size; non-control components may consume --density-* when the
677
- * context should affect their local rhythm.
657
+ * only, plus a slight typographic width bonus. Density never changes font size
658
+ * or icon size. Controls consume --control-size; non-control components may
659
+ * consume --density-* when the context should affect their local rhythm.
660
+ * .compact applies --font-width-dense through font-stretch (a variable wdth
661
+ * axis when available, otherwise normal width-face matching); layout must
662
+ * never depend on the narrowing being available. .spacious restores
663
+ * --font-width, so a spacious island inside a compact context returns to the
664
+ * normal width exactly as it restores its own gap, spacing, and control size.
665
+ * Themes whose typeface has no useful width axis opt out with
666
+ * --font-width-dense: 100%.
678
667
  *
679
668
  * .sm/.lg are local size roles, not shared measurements. Shared control
680
669
  * participants below map both typography and geometry; components with
@@ -713,38 +702,18 @@ small {
713
702
  --ui-border: var(--border);
714
703
  }
715
704
 
716
- /* Inverse surface treatment. Paints the element directly (so it works on any
717
- bare block) and remaps the shared --ui-* tokens so opt-in components
718
- (.card, .navbar) invert through their own rules. Because this loads before
719
- component base surfaces, components with their own explicit surface
720
- (e.g. .accordion) keep it — participation is opt-in, never forced. --heading
721
- is set so global headings (var(--heading, inherit)) do not inherit an outside
722
- heading color onto the dark surface; surface-owning descendants derive
723
- their contextual aliases from their own local surface. */
724
- .inverted {
725
- --ui-bg: var(--surface-solid);
726
- --ui-fg: var(--surface);
727
- --ui-border: transparent;
728
- --ui-hover-bg: var(--surface-solid);
729
-
730
- --heading: var(--surface);
731
- --busy-overlay-bg: var(--surface-solid);
732
-
733
- background: var(--ui-bg);
734
- color: var(--ui-fg);
735
- border-color: var(--ui-border);
736
- }
737
-
738
705
  .compact {
739
706
  --gap: var(--space-20);
740
707
  --density-space: var(--space-20);
741
708
  --control-size: var(--control-size-sm);
709
+ font-stretch: var(--font-width-dense);
742
710
  }
743
711
 
744
712
  .spacious {
745
713
  --gap: var(--space-50);
746
714
  --density-space: var(--space-50);
747
715
  --control-size: var(--control-size-lg);
716
+ font-stretch: var(--font-width);
748
717
  }
749
718
 
750
719
  /* Shared control scale. Wrappers are listed when their descendants form one
@@ -816,10 +785,6 @@ small {
816
785
  );
817
786
  /* @sync soft-recipe */
818
787
  }
819
-
820
- .inverted {
821
- --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
822
- }
823
788
  }
824
789
 
825
790
  /* Interactive hover recipes live in the components that consume them:
@@ -830,9 +795,15 @@ small {
830
795
  pointer/mouse focus and interactive components layer their ring on top.
831
796
 
832
797
  Invariant: an interactive component must never rely on box-shadow alone
833
- for its keyboard focus indicator. Components that remove the native
834
- outline on :focus-visible replace it with a transparent outline — invisible
835
- 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. */
836
807
  :focus {
837
808
  outline: var(--focus-outline);
838
809
  outline-offset: var(--focus-outline-offset);
@@ -1377,7 +1348,7 @@ small {
1377
1348
  *
1378
1349
  * Docs: docs/pages/layout/app-layout.md
1379
1350
  *
1380
- * Public hook:
1351
+ * Public hooks:
1381
1352
  * --app-nav-side-size
1382
1353
  */
1383
1354
  .app-layout {
@@ -1706,12 +1677,12 @@ small {
1706
1677
  * Docs: docs/pages/layout/column-layout.md
1707
1678
  *
1708
1679
  * Neither space-driven like .grid nor count-driven like .grid-N: this is a
1709
- * coordinate system. The author places regions with .column-span-N /
1710
- * .column-start-N and owns narrow-container recomposition. There is
1711
- * deliberately no automatic collapse — no threshold, no hidden
1712
- * .container-query behavior, no breakpoint variants. A single framework
1713
- * threshold cannot be right for arbitrary per-region compositions, and a
1714
- * 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.
1715
1686
  *
1716
1687
  * See docs/design-notes/column-layout.md.
1717
1688
  *
@@ -1723,11 +1694,12 @@ small {
1723
1694
  * media + flexible content -> .media
1724
1695
  * exact custom track templates -> --grid-columns on .grid
1725
1696
  *
1726
- * Child contract: each direct child is placed on the canvas via its own
1727
- * .column-span-N / .column-start-N.
1728
- *
1729
1697
  * Unlike .grid-N, the track template does not read --gap, so the gap
1730
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)
1731
1703
  */
1732
1704
  .column-layout {
1733
1705
  display: grid;
@@ -1735,116 +1707,30 @@ small {
1735
1707
  grid-template-columns: repeat(12, minmax(0, 1fr));
1736
1708
  }
1737
1709
 
1738
- /* Three zero-specificity tiers, applied in this order and only ever separated
1739
- * by source order. ORDER IS LOAD-BEARING:
1740
- *
1741
- * 1. the child reset auto-placed, spanning the whole canvas
1742
- * 2. .column-start-* a definite start line, running to the canvas end
1743
- * 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
1744
1715
  *
1745
- * Moving tier 3 above tier 2 makes every start+span pair run to column 12;
1746
- * moving either above tier 1 makes every placement full-span. Both failures
1747
- * are silent. tests/css-audit.test.js locks the order and
1748
- * 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.
1749
1718
  *
1750
- * Zero specificity is the point: an application's own container-query rules
1751
- * override placement additively, with a single-class selector and without
1752
- * having to undo framework specificity first.
1753
- */
1754
-
1755
- /* Tier 1. A child with no placement class stays a readable full-width region
1756
- * instead of collapsing to one twelfth. Expressed as a span, not as
1757
- * `1 / -1`: a definite start line would give every child a definite column
1758
- * position and disable auto-placement, so `.column-span-8` next to
1759
- * `.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.
1760
1723
  *
1761
1724
  * min-inline-size: 0 keeps the item box inside its track so the canvas never
1762
1725
  * grows past its container. It does not stop content from painting outside a
1763
1726
  * narrow track — that distinction is the primitive's contract, not a bug. */
1764
1727
  :where(.column-layout) > * {
1765
- grid-column-end: span 12;
1766
- min-inline-size: 0;
1767
- }
1768
-
1769
- /* Tier 2. A definite start line, running to the end of the canvas unless a
1770
- * .column-span-* refines the end. `column-start-9` alone is therefore the
1771
- * last four columns rather than an implicit 21-line overflow. */
1772
- :where(.column-start-1) {
1773
- grid-column: 1 / -1;
1774
- }
1775
- :where(.column-start-2) {
1776
- grid-column: 2 / -1;
1777
- }
1778
- :where(.column-start-3) {
1779
- grid-column: 3 / -1;
1780
- }
1781
- :where(.column-start-4) {
1782
- grid-column: 4 / -1;
1783
- }
1784
- :where(.column-start-5) {
1785
- grid-column: 5 / -1;
1786
- }
1787
- :where(.column-start-6) {
1788
- grid-column: 6 / -1;
1789
- }
1790
- :where(.column-start-7) {
1791
- grid-column: 7 / -1;
1792
- }
1793
- :where(.column-start-8) {
1794
- grid-column: 8 / -1;
1795
- }
1796
- :where(.column-start-9) {
1797
- grid-column: 9 / -1;
1798
- }
1799
- :where(.column-start-10) {
1800
- grid-column: 10 / -1;
1801
- }
1802
- :where(.column-start-11) {
1803
- grid-column: 11 / -1;
1804
- }
1805
- :where(.column-start-12) {
1806
- grid-column: 12 / -1;
1807
- }
1728
+ --column-start: initial;
1729
+ --column-span: initial;
1808
1730
 
1809
- /* Tier 3. The end only. Combined with a start, keep the placement inside the
1810
- * canvas: S + N - 1 <= 12. Exceeding it adds an auto-sized implicit column,
1811
- * which reshapes every 1fr track — the whole canvas loses its alignment, not
1812
- * just the offending region. */
1813
- :where(.column-span-1) {
1814
- grid-column-end: span 1;
1815
- }
1816
- :where(.column-span-2) {
1817
- grid-column-end: span 2;
1818
- }
1819
- :where(.column-span-3) {
1820
- grid-column-end: span 3;
1821
- }
1822
- :where(.column-span-4) {
1823
- grid-column-end: span 4;
1824
- }
1825
- :where(.column-span-5) {
1826
- grid-column-end: span 5;
1827
- }
1828
- :where(.column-span-6) {
1829
- grid-column-end: span 6;
1830
- }
1831
- :where(.column-span-7) {
1832
- grid-column-end: span 7;
1833
- }
1834
- :where(.column-span-8) {
1835
- grid-column-end: span 8;
1836
- }
1837
- :where(.column-span-9) {
1838
- grid-column-end: span 9;
1839
- }
1840
- :where(.column-span-10) {
1841
- grid-column-end: span 10;
1842
- }
1843
- :where(.column-span-11) {
1844
- grid-column-end: span 11;
1845
- }
1846
- :where(.column-span-12) {
1847
- 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;
1848
1734
  }
1849
1735
 
1850
1736
  /*
@@ -2049,6 +1935,12 @@ small {
2049
1935
  */
2050
1936
  .field {
2051
1937
  display: grid;
1938
+
1939
+ /* why: a field stretched by a taller row sibling — the classic case is a
1940
+ neighbour revealing its .field-error — would otherwise hand the extra
1941
+ block size to its auto rows and inflate the control. Rows keep their
1942
+ natural height; the slack stays at the end. */
1943
+ align-content: start;
2052
1944
  gap: var(--space-10);
2053
1945
  color: var(--text);
2054
1946
  font-size: var(--control-font-size);
@@ -2119,6 +2011,8 @@ small {
2119
2011
  * --control-cursor
2120
2012
  * --control-border-block-end-color
2121
2013
  * --control-border-block-end-width
2014
+ * --control-hover-border (input and textarea)
2015
+ * --control-hover-bg (select)
2122
2016
  */
2123
2017
  .input,
2124
2018
  .textarea,
@@ -2163,19 +2057,24 @@ small {
2163
2057
 
2164
2058
  .input:hover:not(:disabled),
2165
2059
  .textarea:hover:not(:disabled) {
2166
- --control-border: var(--text-subtle);
2060
+ --control-border: var(--control-hover-border, var(--text-subtle));
2167
2061
  }
2168
2062
 
2169
2063
  .select:hover:not(:disabled) {
2170
- --control-bg: var(--surface-subtle);
2171
- }
2172
-
2173
- .input:focus-visible,
2174
- .textarea:focus-visible,
2175
- .select:focus-visible {
2176
- --control-border: var(--focus);
2177
- outline: 2px solid transparent;
2178
- 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;
2179
2078
  }
2180
2079
 
2181
2080
  .input[readonly],
@@ -2428,9 +2327,8 @@ small {
2428
2327
 
2429
2328
  .check:focus-visible,
2430
2329
  .radio:focus-visible {
2431
- --choice-border: var(--focus);
2432
- outline: 2px solid transparent;
2433
- box-shadow: var(--focus-ring-shadow);
2330
+ outline: var(--focus-ring-width) solid var(--focus);
2331
+ outline-offset: var(--focus-outline-offset);
2434
2332
  }
2435
2333
 
2436
2334
  .check:disabled,
@@ -2552,8 +2450,8 @@ small {
2552
2450
  }
2553
2451
 
2554
2452
  .file:focus-visible {
2555
- outline: 2px solid transparent;
2556
- box-shadow: var(--focus-ring-shadow);
2453
+ outline: var(--focus-ring-width) solid var(--focus);
2454
+ outline-offset: var(--focus-outline-offset);
2557
2455
  }
2558
2456
 
2559
2457
  /* Native color picker — keep the browser's dialog and selected-color UI. The
@@ -2589,8 +2487,8 @@ small {
2589
2487
  }
2590
2488
 
2591
2489
  .color:focus-visible {
2592
- outline: 2px solid transparent;
2593
- box-shadow: var(--focus-ring-shadow);
2490
+ outline: var(--focus-ring-width) solid var(--focus);
2491
+ outline-offset: var(--focus-outline-offset);
2594
2492
  }
2595
2493
 
2596
2494
  @media (forced-colors: active) {
@@ -2954,9 +2852,8 @@ small {
2954
2852
  }
2955
2853
 
2956
2854
  .switch:focus-visible {
2957
- --switch-border: var(--focus);
2958
- outline: 2px solid transparent;
2959
- box-shadow: var(--focus-ring-shadow);
2855
+ outline: var(--focus-ring-width) solid var(--focus);
2856
+ outline-offset: var(--focus-outline-offset);
2960
2857
  }
2961
2858
 
2962
2859
  .switch:disabled {
@@ -3054,20 +2951,22 @@ small {
3054
2951
  }
3055
2952
 
3056
2953
  /*
3057
- * The 1px surface-coloured separator keeps the ring visually distinct from
3058
- * the small, coloured thumb — unlike a full-size input where the same gap
3059
- * 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.
3060
2959
  */
3061
2960
  .range:focus-visible::-webkit-slider-thumb {
3062
2961
  box-shadow:
3063
2962
  0 0 0 1px var(--surface),
3064
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
2963
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3065
2964
  }
3066
2965
 
3067
2966
  .range:focus-visible::-moz-range-thumb {
3068
2967
  box-shadow:
3069
2968
  0 0 0 1px var(--surface),
3070
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
2969
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3071
2970
  }
3072
2971
 
3073
2972
  .range:disabled {
@@ -3077,20 +2976,33 @@ small {
3077
2976
  }
3078
2977
 
3079
2978
  /* Forced colors: the track must stay a visible line against the page (Canvas
3080
- would merge with it) and the thumb needs a real boundary to remain
3081
- distinguishable from the track. Disabled keeps GrayText via :disabled.
3082
- 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. */
3083
2985
  @media (forced-colors: active) {
3084
2986
  .range {
3085
2987
  --range-track-bg: CanvasText;
3086
2988
  --range-thumb-bg: ButtonFace;
3087
2989
  }
3088
2990
 
2991
+ .range::-webkit-slider-runnable-track {
2992
+ background: CanvasText;
2993
+ }
2994
+
2995
+ .range::-moz-range-track {
2996
+ background: CanvasText;
2997
+ }
2998
+
3089
2999
  .range::-webkit-slider-thumb {
3000
+ background: ButtonFace;
3090
3001
  border: var(--border-width) solid ButtonText;
3091
3002
  }
3092
3003
 
3093
3004
  .range::-moz-range-thumb {
3005
+ background: ButtonFace;
3094
3006
  border: var(--border-width) solid ButtonText;
3095
3007
  }
3096
3008
  }
@@ -3110,12 +3022,18 @@ small {
3110
3022
  * overflow: hidden will clip it. Pair with grid or flex utilities for group
3111
3023
  * layout.
3112
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
+ *
3113
3029
  * Public hooks:
3114
3030
  * --choice-card-pad
3115
3031
  * --choice-card-radius
3116
3032
  * --choice-card-border
3117
3033
  * --choice-card-bg
3118
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)
3119
3037
  */
3120
3038
  :where(.choice-card) {
3121
3039
  --intent: initial;
@@ -3165,8 +3083,8 @@ small {
3165
3083
 
3166
3084
  /* Focus ring on the label follows the hidden input's focus. */
3167
3085
  .choice-card:has(:focus-visible) {
3168
- outline: 2px solid transparent;
3169
- box-shadow: var(--focus-ring-shadow);
3086
+ outline: var(--focus-ring-width) solid var(--focus);
3087
+ outline-offset: var(--focus-outline-offset);
3170
3088
  }
3171
3089
 
3172
3090
  .choice-card:has(:disabled) {
@@ -3211,11 +3129,13 @@ small {
3211
3129
  z-index: 1;
3212
3130
  }
3213
3131
 
3214
- .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 {
3215
3135
  border-radius: var(--radius-full);
3216
3136
  }
3217
3137
 
3218
- .choice-card:has(input[type="checkbox"])::before {
3138
+ .choice-card:where(:has(input[type="checkbox"]))::before {
3219
3139
  border-radius: var(--radius-sm);
3220
3140
  }
3221
3141
 
@@ -3232,6 +3152,54 @@ small {
3232
3152
  transform: scale(1);
3233
3153
  }
3234
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
+
3235
3203
  /* The native input is hidden and the checked marker is custom-drawn: without
3236
3204
  this repair, forced colors pushes disc and check to the same system color
3237
3205
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
@@ -3246,6 +3214,20 @@ small {
3246
3214
  .choice-card:has(:checked)::after {
3247
3215
  background: HighlightText;
3248
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
+ }
3249
3231
  }
3250
3232
 
3251
3233
  /*
@@ -3259,7 +3241,6 @@ small {
3259
3241
  * Arbitrary menu and popover behavior belongs elsewhere.
3260
3242
  *
3261
3243
  * Internal:
3262
- * --control-border
3263
3244
  * --control-bg
3264
3245
  */
3265
3246
  /* This enhancement is excluded from forced-colors, where the native/shared
@@ -3284,11 +3265,12 @@ small {
3284
3265
  box-shadow: var(--shadow-popout);
3285
3266
  }
3286
3267
 
3268
+ /* Same inset outline as a focused field (control.css). */
3287
3269
  &:open {
3288
- --control-border: var(--border);
3289
3270
  --control-bg: var(--surface-raised);
3290
- outline: none;
3291
- 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;
3292
3274
  }
3293
3275
 
3294
3276
  &::picker-icon {
@@ -3617,7 +3599,6 @@ small {
3617
3599
  * --btn-min-size
3618
3600
  * --btn-pad-x
3619
3601
  * --btn-radius
3620
- * --btn-focus-color
3621
3602
  * --btn-gap
3622
3603
  * --btn-hover-overlay
3623
3604
  * --btn-default-bg
@@ -3630,7 +3611,6 @@ small {
3630
3611
  * --btn-fg
3631
3612
  * --btn-border
3632
3613
  * --btn-default-hover-bg
3633
- * --btn-focus-ring-color
3634
3614
  * --btn-border-block-end-color
3635
3615
  * --btn-border-block-end-width
3636
3616
  * --spinner-width
@@ -3657,7 +3637,6 @@ small {
3657
3637
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
3658
3638
  --btn-default-hover-bg: var(--btn-bg);
3659
3639
  --btn-radius: var(--radius);
3660
- --btn-focus-color: var(--intent, var(--focus));
3661
3640
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
3662
3641
 
3663
3642
  /* Positioning context for indicators, overlays, and grouped states. */
@@ -3699,12 +3678,6 @@ small {
3699
3678
  padding: 0;
3700
3679
  }
3701
3680
 
3702
- @supports (color: color-mix(in oklch, red, white)) {
3703
- .btn {
3704
- --btn-focus-ring-color: color-mix(in oklch, var(--btn-focus-color) 28%, transparent);
3705
- }
3706
- }
3707
-
3708
3681
  /* .solid/.soft/.outline are shared via variants.css. The baseline --btn-*
3709
3682
  fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
3710
3683
  .btn.ghost {
@@ -3792,15 +3765,13 @@ small {
3792
3765
  text-decoration-thickness: 0.12em;
3793
3766
  }
3794
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. */
3795
3772
  .btn:focus-visible {
3796
- outline: 2px solid transparent;
3797
- box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
3798
- }
3799
-
3800
- .btn.link:focus-visible {
3801
- outline: var(--focus-outline);
3802
- outline-offset: 0.2em;
3803
- box-shadow: none;
3773
+ outline: var(--focus-ring-width) solid var(--focus);
3774
+ outline-offset: var(--focus-outline-offset);
3804
3775
  }
3805
3776
 
3806
3777
  .btn:disabled {
@@ -3846,6 +3817,98 @@ a.btn[aria-disabled="true"] {
3846
3817
  }
3847
3818
  }
3848
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
+
3849
3912
  /*
3850
3913
  * Badges for compact labels, counts, and state hints.
3851
3914
  *
@@ -3862,7 +3925,6 @@ a.btn[aria-disabled="true"] {
3862
3925
  * --badge-icon-size
3863
3926
  * --badge-dot-size
3864
3927
  * --badge-radius
3865
- * --badge-dismiss-icon-size
3866
3928
  *
3867
3929
  * Internal:
3868
3930
  * --badge-default-bg
@@ -3893,7 +3955,10 @@ a.btn[aria-disabled="true"] {
3893
3955
  --badge-font-size: var(--font-size-sm);
3894
3956
  --badge-icon-size: 1em;
3895
3957
  --badge-dot-size: 0.5rem;
3896
- --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;
3897
3962
  --badge-default-bg: var(--surface-subtle);
3898
3963
  --badge-default-fg: var(--intent, var(--text));
3899
3964
  --badge-default-border: var(--border);
@@ -3943,8 +4008,8 @@ a.btn[aria-disabled="true"] {
3943
4008
  --badge-dot-size: 0.625rem;
3944
4009
  }
3945
4010
 
3946
- /* Decorative children follow the badge's optical size. Text and interactive
3947
- 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. */
3948
4013
  .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
3949
4014
  flex: none;
3950
4015
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
@@ -3997,45 +4062,15 @@ a.btn[aria-disabled="true"] {
3997
4062
  background-color: currentColor;
3998
4063
  }
3999
4064
 
4000
- .badge > button {
4001
- display: inline-grid;
4002
- place-items: center;
4003
- flex: 0 0 auto;
4004
- /* WCAG 2.5.8 floors pointer targets at 24 CSS px. Density and local size may
4005
- keep the badge visually compact through the negative margins, but never
4006
- shrink its action target below that literal floor. */
4007
- min-inline-size: max(var(--badge-size), 24px);
4008
- 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 {
4009
4070
  margin-block: -0.25rem;
4010
4071
  margin-inline-start: -0.15em;
4011
4072
  margin-inline-end: -0.45em;
4012
- padding: 0.25rem;
4013
- border: 0;
4014
4073
  border-radius: var(--radius-full);
4015
- background: transparent;
4016
- color: inherit;
4017
- font: inherit;
4018
- line-height: 1;
4019
- cursor: pointer;
4020
- }
4021
-
4022
- /* Dismiss mark — an empty dismiss button paints its own X from the shared
4023
- --icon-close mask, so no icon font or text glyph is required in the markup
4024
- (same contract as .alert-dismiss and .dialog-close). A mask box is a block
4025
- box, so place-items centers the ink itself; a text glyph would only center
4026
- its line box and land the ink low, because a lowercase x has no descender
4027
- and only x-height of ascent. A button with its own content keeps it. */
4028
- .badge > button:empty::before {
4029
- content: "";
4030
- inline-size: var(--badge-dismiss-icon-size);
4031
- block-size: var(--badge-dismiss-icon-size);
4032
- background: currentColor;
4033
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4034
- mask: var(--icon-close) center / contain no-repeat;
4035
- }
4036
-
4037
- .badge > button:not(:disabled):hover {
4038
- background: var(--hover-overlay);
4039
4074
  }
4040
4075
 
4041
4076
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
@@ -4126,7 +4161,6 @@ a.badge[aria-disabled="true"] {
4126
4161
  * --alert-font-size
4127
4162
  * --alert-radius
4128
4163
  * --alert-icon-size
4129
- * --alert-dismiss-size
4130
4164
  * --alert-border-inline-start-color
4131
4165
  * --alert-border-inline-start-width
4132
4166
  *
@@ -4137,7 +4171,6 @@ a.badge[aria-disabled="true"] {
4137
4171
  * --alert-bg
4138
4172
  * --alert-fg
4139
4173
  * --alert-border
4140
- * --alert-dismiss-icon-size
4141
4174
  */
4142
4175
  /* Intent boundary — inherited intents do not tint this component.
4143
4176
  Local intent classes still win because :where() has zero specificity. */
@@ -4186,13 +4219,13 @@ a.badge[aria-disabled="true"] {
4186
4219
  The slots are excluded because they are anatomy, not content: an
4187
4220
  admonition's title bar has to stay flush against its body. The list follows
4188
4221
  the anatomy, so it grows with it. */
4189
- .alert > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4222
+ .alert > :not(:where(.alert-icon, .close, .alert-title, .alert-body)) {
4190
4223
  margin-block: 0;
4191
4224
  }
4192
4225
 
4193
4226
  .alert
4194
- > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body))
4195
- + :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)) {
4196
4229
  margin-block-start: var(--space-10);
4197
4230
  }
4198
4231
 
@@ -4230,42 +4263,24 @@ a.badge[aria-disabled="true"] {
4230
4263
  vertical-align: -0.125em;
4231
4264
  }
4232
4265
 
4233
- /* Dismiss — compact close button for inline alerts. Transparent at rest with
4234
- a reduced opacity glyph so it stays secondary inside an already-colored
4235
- alert; hover reinforces it via the shared overlay. Uses the shared
4236
- --icon-close mask, so no icon font or text glyph is required in the markup.
4237
- Standard alerts place it as a direct trailing child; admonitions place it
4238
- inside .alert-title (see below). */
4239
- .alert-dismiss {
4240
- --alert-dismiss-size: 1.75rem;
4241
- --alert-dismiss-icon-size: 0.875rem;
4242
-
4243
- display: inline-grid;
4244
- place-items: center;
4245
- inline-size: var(--alert-dismiss-size);
4246
- block-size: var(--alert-dismiss-size);
4247
- flex: none;
4248
- padding: 0;
4249
- border: 0;
4250
- border-radius: var(--radius);
4251
- background: transparent;
4252
- color: currentColor;
4253
- opacity: 0.65;
4254
- 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;
4255
4274
  }
4256
4275
 
4257
- .alert-dismiss::before {
4258
- content: "";
4259
- inline-size: var(--alert-dismiss-icon-size);
4260
- block-size: var(--alert-dismiss-icon-size);
4261
- background: currentColor;
4262
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4263
- mask: var(--icon-close) center / contain no-repeat;
4276
+ .alert > .close,
4277
+ .alert > .alert-title > .close {
4278
+ opacity: 0.65;
4264
4279
  }
4265
4280
 
4266
4281
  @media (hover: hover) {
4267
- .alert-dismiss:hover {
4268
- background: var(--hover-overlay);
4282
+ .alert > .close:hover,
4283
+ .alert > .alert-title > .close:hover {
4269
4284
  opacity: 1;
4270
4285
  }
4271
4286
  }
@@ -4274,7 +4289,7 @@ a.badge[aria-disabled="true"] {
4274
4289
  both. Icon and dismiss are pinned to the outer columns; anything else (the
4275
4290
  message content) flows into the remaining text column. */
4276
4291
  @supports selector(.alert:has(> *)) {
4277
- .alert:has(> .alert-icon, > .alert-dismiss) {
4292
+ .alert:has(> .alert-icon, > .close) {
4278
4293
  display: grid;
4279
4294
  grid-template-columns: minmax(0, 1fr);
4280
4295
  align-items: var(--items-align, start);
@@ -4293,19 +4308,19 @@ a.badge[aria-disabled="true"] {
4293
4308
  margin-block-start: 0.125em;
4294
4309
  }
4295
4310
 
4296
- .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
4311
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4297
4312
  grid-column: 2;
4298
4313
  }
4299
4314
 
4300
- .alert:has(> .alert-dismiss) {
4315
+ .alert:has(> .close) {
4301
4316
  grid-template-columns: minmax(0, 1fr) auto;
4302
4317
  }
4303
4318
 
4304
- .alert:has(> .alert-icon):has(> .alert-dismiss) {
4319
+ .alert:has(> .alert-icon):has(> .close) {
4305
4320
  grid-template-columns: auto minmax(0, 1fr) auto;
4306
4321
  }
4307
4322
 
4308
- .alert:has(> .alert-dismiss) > .alert-dismiss {
4323
+ .alert:has(> .close) > .close {
4309
4324
  grid-column: -2 / -1;
4310
4325
  grid-row: 1;
4311
4326
  }
@@ -4353,8 +4368,8 @@ a.badge[aria-disabled="true"] {
4353
4368
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4354
4369
  }
4355
4370
 
4356
- /* Dismiss inside an admonition title bar rides the inline end. */
4357
- .alert.admonition > .alert-title > .alert-dismiss {
4371
+ /* Close inside an admonition title bar rides the inline end. */
4372
+ .alert.admonition > .alert-title > .close {
4358
4373
  margin-inline-start: auto;
4359
4374
  }
4360
4375
 
@@ -4447,7 +4462,9 @@ a.badge[aria-disabled="true"] {
4447
4462
  *
4448
4463
  * Child contract:
4449
4464
  * Direct .bleed children escape --card-pad (negative inset, wider media);
4450
- * 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.
4451
4468
  *
4452
4469
  * Internal:
4453
4470
  * --card-bg
@@ -4478,6 +4495,10 @@ a.badge[aria-disabled="true"] {
4478
4495
  --card-bg: var(--ui-bg, var(--surface-raised));
4479
4496
  --card-fg: var(--ui-fg, var(--text));
4480
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
+
4481
4502
  /* Keep contextual descendants tied to the surface this card actually owns. */
4482
4503
  --heading: var(--card-fg);
4483
4504
  --busy-overlay-bg: var(--card-bg);
@@ -4543,45 +4564,6 @@ a.badge[aria-disabled="true"] {
4543
4564
  --card-pad: var(--space-20);
4544
4565
  }
4545
4566
 
4546
- /* Full-bleed children */
4547
- .card > .bleed {
4548
- margin-inline: calc(var(--card-pad) * -1);
4549
- max-inline-size: none;
4550
- }
4551
-
4552
- .card > .bleed:first-child {
4553
- margin-block-start: calc(var(--card-pad) * -1);
4554
- border-start-start-radius: inherit;
4555
- border-start-end-radius: inherit;
4556
- }
4557
-
4558
- .card > .bleed:last-child {
4559
- margin-block-end: calc(var(--card-pad) * -1);
4560
- border-end-start-radius: inherit;
4561
- border-end-end-radius: inherit;
4562
- }
4563
-
4564
- /* Media bleed */
4565
- .card > :where(img, picture, video, canvas, svg).bleed {
4566
- display: block;
4567
- inline-size: calc(100% + var(--card-pad) * 2);
4568
- max-inline-size: none;
4569
- }
4570
-
4571
- .card > picture.bleed {
4572
- overflow: hidden;
4573
- }
4574
-
4575
- .card > picture.bleed > img {
4576
- display: block;
4577
- inline-size: 100%;
4578
- block-size: auto;
4579
- }
4580
-
4581
- /* Padded structural bleed */
4582
- .card > :where(header, footer, figure).bleed {
4583
- padding: var(--card-pad);
4584
- }
4585
4567
  @media print {
4586
4568
  .card {
4587
4569
  border: var(--border-width) solid CanvasText;
@@ -4603,8 +4585,9 @@ a.badge[aria-disabled="true"] {
4603
4585
  * --dialog-icon-size
4604
4586
  * --dialog-icon-glyph-size
4605
4587
  *
4588
+ * The corner close control is .close (components/close.css).
4589
+ *
4606
4590
  * Internal:
4607
- * --dialog-close-icon-size
4608
4591
  * --dialog-focus-bleed
4609
4592
  * --dialog-viewport-block
4610
4593
  * --dialog-viewport-gap-default
@@ -4690,11 +4673,16 @@ a.badge[aria-disabled="true"] {
4690
4673
 
4691
4674
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4692
4675
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
4693
- --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
+ );
4694
4680
 
4695
4681
  min-block-size: 0;
4696
4682
  /* Keep focus paint inside the clip without moving the body off the fixed
4697
- 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). */
4698
4686
  margin-inline: calc(var(--dialog-focus-bleed) * -1);
4699
4687
  padding-inline: var(--dialog-focus-bleed);
4700
4688
  overflow: auto;
@@ -4717,7 +4705,7 @@ a.badge[aria-disabled="true"] {
4717
4705
  The form/.stack anatomy is structural, so it stays in :where() and never
4718
4706
  lends .stack's specificity to a bare <form>. */
4719
4707
  :where(dialog).modal > :where(form, .stack) > header {
4720
- padding-inline-end: calc(var(--control-size) + var(--space-30));
4708
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
4721
4709
  }
4722
4710
 
4723
4711
  :where(dialog).modal > :where(form, .stack) > footer {
@@ -4881,59 +4869,6 @@ html.has-modal-open.had-scrollbar {
4881
4869
  }
4882
4870
  }
4883
4871
 
4884
- /* Close control. A close button is an action attached to a surface, so it
4885
- never participates in the content flow — absolute at the panel's top
4886
- inline-end, inside the surface. The glyph comes from --icon-close, masked to
4887
- currentColor, so no icon font or text glyph is required in the markup. */
4888
- .dialog-close {
4889
- --dialog-close-icon-size: 1rem;
4890
-
4891
- display: inline-grid;
4892
- place-items: center;
4893
- padding: 0;
4894
- border: 0;
4895
- border-radius: var(--radius);
4896
- color: var(--text-muted);
4897
- cursor: pointer;
4898
-
4899
- /* Out of flow, anchored to the positioned dialog surface, not the header or
4900
- any scroll container inside it. */
4901
- position: absolute;
4902
- z-index: 1;
4903
- inset-block-start: var(--space-20);
4904
- inset-inline-end: var(--space-20);
4905
-
4906
- inline-size: var(--control-size);
4907
- block-size: var(--control-size);
4908
-
4909
- background: var(--surface-subtle);
4910
- }
4911
-
4912
- .dialog-close::before {
4913
- content: "";
4914
- inline-size: var(--dialog-close-icon-size);
4915
- block-size: var(--dialog-close-icon-size);
4916
- background: currentColor;
4917
- -webkit-mask: var(--icon-close) center / contain no-repeat;
4918
- mask: var(--icon-close) center / contain no-repeat;
4919
- }
4920
-
4921
- @media (hover: hover) {
4922
- .dialog-close:hover {
4923
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4924
- color: var(--text);
4925
- }
4926
- }
4927
-
4928
- .dialog-close:active {
4929
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4930
- }
4931
-
4932
- .dialog-close:focus-visible {
4933
- outline: 2px solid transparent;
4934
- box-shadow: var(--focus-ring-shadow);
4935
- }
4936
-
4937
4872
  /*
4938
4873
  * Drawer — modal side-sheet built on the native dialog element.
4939
4874
  *
@@ -4953,6 +4888,8 @@ html.has-modal-open.had-scrollbar {
4953
4888
  * --drawer-size
4954
4889
  * --drawer-pad
4955
4890
  *
4891
+ * The corner close control is .close (components/close.css).
4892
+ *
4956
4893
  * Internal:
4957
4894
  * --drawer-closed-translate (side/RTL positioning state)
4958
4895
  * --drawer-focus-bleed
@@ -4962,6 +4899,8 @@ html.has-modal-open.had-scrollbar {
4962
4899
  :where(dialog).drawer {
4963
4900
  --drawer-size: 20rem;
4964
4901
  --drawer-pad: var(--space-40);
4902
+ /* The inset a direct .bleed child cancels (components/bleed.css). */
4903
+ --surface-pad: var(--drawer-pad);
4965
4904
  --drawer-closed-translate: -100%;
4966
4905
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
4967
4906
  the same variable through inheritance and never lags the panel. */
@@ -5061,15 +5000,20 @@ html.has-modal-open.had-scrollbar {
5061
5000
  /* The body region absorbs the panel's height and scrolls. A drawer is a
5062
5001
  full-height shell by definition, so it always scrolls its body — unlike the
5063
5002
  modal, which sizes to its content and opts in with .scrollable. Header and
5064
- footer keep their own tracks; .drawer-close is named because a generic
5003
+ footer keep their own tracks; .close is named because a generic
5065
5004
  exclusion list is easier to trust than its `position: absolute`. */
5066
- :where(dialog).drawer > :not(:is(header, footer, form, .drawer-close)),
5067
- :where(dialog).drawer > form > :not(:is(header, footer, .drawer-close)) {
5068
- --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
+ );
5069
5011
 
5070
5012
  min-block-size: 0;
5071
5013
  /* Keep focus paint inside the clip without moving the body off the fixed
5072
- 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). */
5073
5017
  margin-inline: calc(var(--drawer-focus-bleed) * -1);
5074
5018
  padding-inline: var(--drawer-focus-bleed);
5075
5019
  overflow: auto;
@@ -5141,57 +5085,87 @@ html.has-modal-open.had-scrollbar {
5141
5085
  control. */
5142
5086
  :where(dialog).drawer > header,
5143
5087
  :where(dialog).drawer > form > header {
5144
- padding-inline-end: calc(var(--control-size) + var(--space-30));
5088
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
5145
5089
  }
5146
5090
 
5147
- /* Close control, owned here so a drawer-only build does not depend on
5148
- modal.css. Mirrors the modal's .dialog-close placement: absolute at the
5149
- panel's top inline-end, out of the content flow. */
5150
- .drawer-close {
5151
- --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
+ }
5152
5124
 
5153
- display: inline-grid;
5154
- place-items: center;
5155
- padding: 0;
5156
- border: 0;
5157
- border-radius: var(--radius);
5158
- color: var(--text-muted);
5159
- cursor: pointer;
5125
+ :where(.card, .drawer, .accordion details > summary + *) > * {
5126
+ --bleed-pad: var(--surface-pad);
5127
+ }
5160
5128
 
5161
- position: absolute;
5162
- z-index: 1;
5163
- inset-block-start: var(--space-20);
5164
- 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
+ }
5165
5135
 
5166
- inline-size: var(--control-size);
5167
- 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
+ }
5168
5141
 
5169
- 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;
5170
5146
  }
5171
5147
 
5172
- .drawer-close::before {
5173
- content: "";
5174
- inline-size: var(--drawer-close-icon-size);
5175
- block-size: var(--drawer-close-icon-size);
5176
- background: currentColor;
5177
- -webkit-mask: var(--icon-close) center / contain no-repeat;
5178
- 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;
5179
5153
  }
5180
5154
 
5181
- @media (hover: hover) {
5182
- .drawer-close:hover {
5183
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
5184
- color: var(--text);
5185
- }
5155
+ picture.bleed {
5156
+ overflow: hidden;
5186
5157
  }
5187
5158
 
5188
- .drawer-close:active {
5189
- 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;
5190
5163
  }
5191
5164
 
5192
- .drawer-close:focus-visible {
5193
- outline: 2px solid transparent;
5194
- 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);
5195
5169
  }
5196
5170
 
5197
5171
 
@@ -5268,6 +5242,7 @@ html.has-modal-open.had-scrollbar {
5268
5242
  border: var(--border-width) solid var(--border);
5269
5243
  border-radius: var(--radius);
5270
5244
  background: var(--surface-raised);
5245
+ color: var(--text);
5271
5246
  box-shadow: var(--shadow-popout);
5272
5247
  list-style: none;
5273
5248
  opacity: 1;
@@ -5500,6 +5475,18 @@ html.has-modal-open.had-scrollbar {
5500
5475
  border-block-start: var(--border-width) solid var(--border);
5501
5476
  }
5502
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
+
5503
5490
  /*
5504
5491
  * Tooltip — supplemental label overlaid near a trigger.
5505
5492
  *
@@ -5661,11 +5648,15 @@ html.has-modal-open.had-scrollbar {
5661
5648
  *
5662
5649
  * Public hooks:
5663
5650
  * --accordion-radius
5651
+ * --accordion-pad
5664
5652
  * --accordion-marker-color
5653
+ * --accordion-marker-size
5665
5654
  */
5666
5655
  .accordion {
5667
5656
  --accordion-radius: var(--radius);
5657
+ --accordion-pad: var(--space-40);
5668
5658
  --accordion-marker-color: var(--text-muted);
5659
+ --accordion-marker-size: 1rem;
5669
5660
 
5670
5661
  border: var(--border-width) solid var(--border);
5671
5662
  border-radius: var(--accordion-radius);
@@ -5682,7 +5673,7 @@ html.has-modal-open.had-scrollbar {
5682
5673
  gap: var(--space-30);
5683
5674
  cursor: pointer;
5684
5675
  list-style: none;
5685
- padding: var(--space-40);
5676
+ padding: var(--accordion-pad);
5686
5677
  color: var(--text);
5687
5678
  font-weight: var(--font-weight-medium);
5688
5679
  }
@@ -5698,12 +5689,17 @@ html.has-modal-open.had-scrollbar {
5698
5689
  display: none;
5699
5690
  }
5700
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. */
5701
5697
  .accordion summary::after {
5702
5698
  content: "";
5703
5699
  flex: 0 0 auto;
5704
5700
  margin-inline-start: auto;
5705
- inline-size: 1rem;
5706
- block-size: 1rem;
5701
+ inline-size: var(--accordion-marker-size);
5702
+ block-size: var(--accordion-marker-size);
5707
5703
  background-color: var(--accordion-marker-color);
5708
5704
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
5709
5705
  mask: var(--icon-chevron) center / contain no-repeat;
@@ -5714,9 +5710,14 @@ html.has-modal-open.had-scrollbar {
5714
5710
  transform: rotate(180deg);
5715
5711
  }
5716
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). */
5717
5716
  .accordion details > summary + * {
5718
- padding-block: 0 var(--space-40);
5719
- 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);
5720
5721
  color: var(--text-muted);
5721
5722
  margin-block: 0;
5722
5723
  }
@@ -5725,6 +5726,9 @@ html.has-modal-open.had-scrollbar {
5725
5726
  padding-block-start: var(--space-10);
5726
5727
  }
5727
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. */
5728
5732
  .accordion details > summary + * > :first-child {
5729
5733
  margin-block-start: 0;
5730
5734
  }
@@ -5749,6 +5753,12 @@ html.has-modal-open.had-scrollbar {
5749
5753
  padding-inline: 0;
5750
5754
  }
5751
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
+
5752
5762
  /* Separated — each item owns a full card. The item's own border beats the
5753
5763
  shared details + details separator on specificity alone, so nothing needs
5754
5764
  undoing for the second item onwards. */
@@ -5765,9 +5775,23 @@ html.has-modal-open.had-scrollbar {
5765
5775
  background: var(--surface-raised);
5766
5776
  }
5767
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
+
5768
5789
  @media (forced-colors: active) {
5769
- .accordion {
5770
- --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;
5771
5795
  }
5772
5796
  }
5773
5797
 
@@ -6762,6 +6786,16 @@ a.avatar[aria-disabled="true"] {
6762
6786
  transform: none;
6763
6787
  }
6764
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
+
6765
6799
  /*
6766
6800
  * Rating — integer star rating drawn on native radio inputs.
6767
6801
  *
@@ -7286,6 +7320,15 @@ a.avatar[aria-disabled="true"] {
7286
7320
  color: var(--text);
7287
7321
  }
7288
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
+
7289
7332
  /* Current tile is already a full selected surface. Selection never changes
7290
7333
  text metrics: the shared medium weight is kept, no permanent indicator is
7291
7334
  added here. */
@@ -7373,11 +7416,16 @@ a.avatar[aria-disabled="true"] {
7373
7416
  display: inline-flex;
7374
7417
  align-items: center;
7375
7418
  min-block-size: var(--control-size-sm);
7419
+ padding-block: 0;
7376
7420
  padding-inline: var(--space-20);
7421
+ border: 0;
7377
7422
  border-radius: var(--radius);
7423
+ background: transparent;
7378
7424
  color: var(--ui-fg, var(--text-muted));
7425
+ font: inherit;
7379
7426
  font-weight: var(--font-weight-medium);
7380
7427
  text-decoration: none;
7428
+ cursor: pointer;
7381
7429
  }
7382
7430
 
7383
7431
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
@@ -7402,6 +7450,28 @@ a.avatar[aria-disabled="true"] {
7402
7450
  color: var(--state-selected);
7403
7451
  }
7404
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
+
7405
7475
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links.
7406
7476
  Links fill each row so the whole line is an interactive target; the
7407
7477
  horizontal .navbar-nav links keep their intrinsic size. */
@@ -7697,6 +7767,9 @@ a.avatar[aria-disabled="true"] {
7697
7767
  * A plain <dl> handles richer relationships (several terms or descriptions)
7698
7768
  * and stays untouched by this component.
7699
7769
  *
7770
+ * Variants:
7771
+ * .stacked — value under its term rather than beside it.
7772
+ *
7700
7773
  * Why margin resets: neutralizes .prose list margins if .data-list appears
7701
7774
  * inside an authored rich-text scope; the grid owns row/column rhythm.
7702
7775
  */
@@ -7718,6 +7791,21 @@ a.avatar[aria-disabled="true"] {
7718
7791
  margin: 0;
7719
7792
  }
7720
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
+
7721
7809
  /*
7722
7810
  * Application list — repeated rows with optional leading and trailing regions.
7723
7811
  * Content determines the number of lines; the component only owns the row
@@ -8071,6 +8159,11 @@ a.list-item[aria-current]::before {
8071
8159
  min-block-size: var(--control-size);
8072
8160
  padding-inline: var(--space-30);
8073
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));
8074
8167
  border-radius: var(--radius);
8075
8168
  background: var(--control-bg);
8076
8169
  color: var(--control-fg);
@@ -8118,38 +8211,49 @@ a.list-item[aria-current]::before {
8118
8211
  z-index: 1;
8119
8212
  }
8120
8213
 
8121
- /* Keep an active focus ring above an adjacent hovered control. This matters
8122
- on platforms where clicking a button does not move focus from the input. */
8123
- .join > :is(:focus, :focus-within) {
8124
- z-index: 4;
8125
- }
8126
-
8127
8214
  /* An input's border must read as the "primary" edge against an attached
8128
8215
  action (e.g. a password-reveal button) at all times, not only while
8129
8216
  hovered or focused — otherwise the shared edge flips ownership as the
8130
- user's pointer moves, which reads as a rendering glitch. */
8131
- .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)) {
8132
8223
  z-index: 3;
8133
8224
  }
8134
8225
 
8135
- /* An input/select with an attached action reads as one composite field. Move
8136
- its outer focus treatment to the join; button-only groups keep per-button
8137
- focus, and unsupported :has() browsers retain the control's normal ring. */
8138
- .join:has(> :is(.input, .textarea, .select):focus-visible) {
8139
- border-radius: var(--join-radius);
8140
- outline: 2px solid transparent;
8141
- 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;
8142
8233
  }
8143
8234
 
8144
- .join:has(> :is(.input, .textarea, .select)) > :is(.input, .textarea, .select):focus-visible {
8145
- outline: none;
8146
- 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;
8147
8245
  }
8148
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
+
8149
8251
  /* A default outline action belongs to the composite field border. An explicit
8150
8252
  intent still provides its own outline color — --intent is guaranteed-invalid
8151
- for a neutral button (the :where(.btn) boundary), so the fallback applies. */
8152
- .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 {
8153
8257
  --btn-border-color: var(--intent, var(--border));
8154
8258
  }
8155
8259
 
@@ -8518,9 +8622,9 @@ a.list-item[aria-current]::before {
8518
8622
  }
8519
8623
 
8520
8624
  /*
8521
- * Optional utility helpers.
8522
- *
8523
- * 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).
8524
8628
  * They are escape hatches for layout nudges, not a full atomic scale.
8525
8629
  *
8526
8630
  * Core principle:
@@ -8696,8 +8800,9 @@ a.list-item[aria-current]::before {
8696
8800
  overflow-y: auto;
8697
8801
  }
8698
8802
 
8699
- /* Text */
8700
- .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 {
8701
8806
  white-space: normal;
8702
8807
  }
8703
8808
 
@@ -8770,7 +8875,7 @@ a.list-item[aria-current]::before {
8770
8875
  /* Surface. These are semantic surfaces, not arbitrary paint: each owns its
8771
8876
  matching ink and rebinds --heading to that ink, so it stays readable when it
8772
8877
  recreates a light surface inside a context that installed a dark one
8773
- (.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
8774
8879
  .intent-color for that. */
8775
8880
  .background-surface {
8776
8881
  --heading: currentColor;