actual-css 0.9.2 → 0.11.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 (136) hide show
  1. package/CHANGELOG.md +169 -1
  2. package/README.md +65 -44
  3. package/components.json +35 -14
  4. package/dist/actual.full.css +877 -583
  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/enhancement-contract.md +16 -7
  10. package/docs/design-notes/forced-colors.md +12 -1
  11. package/docs/design-notes/naming.md +1 -1
  12. package/docs/design-notes/platform-alignment.md +8 -4
  13. package/docs/design-notes/rich-text.md +61 -0
  14. package/docs/design-notes/steps.md +2 -2
  15. package/docs/design-notes/surface-reparenting.md +8 -9
  16. package/docs/navigation.json +4 -0
  17. package/docs/pages/components/accordion.md +55 -1
  18. package/docs/pages/components/alert.md +9 -15
  19. package/docs/pages/components/aura.md +12 -13
  20. package/docs/pages/components/badge.md +16 -7
  21. package/docs/pages/components/bleed.md +42 -0
  22. package/docs/pages/components/button.md +30 -4
  23. package/docs/pages/components/card.md +21 -86
  24. package/docs/pages/components/chat.md +5 -0
  25. package/docs/pages/components/close.md +48 -0
  26. package/docs/pages/components/data-list.md +26 -1
  27. package/docs/pages/components/dialog.md +21 -18
  28. package/docs/pages/components/drawer.md +9 -9
  29. package/docs/pages/components/navbar.md +5 -5
  30. package/docs/pages/components/pagination.md +15 -15
  31. package/docs/pages/components/shimmer.md +57 -0
  32. package/docs/pages/components/skeleton.md +2 -1
  33. package/docs/pages/components/spinner.md +14 -9
  34. package/docs/pages/components/table.md +13 -0
  35. package/docs/pages/enhancements/flyout.md +2 -0
  36. package/docs/pages/enhancements/overview.md +5 -0
  37. package/docs/pages/enhancements/tabs.md +4 -0
  38. package/docs/pages/enhancements/tooltip.md +3 -1
  39. package/docs/pages/examples/overview.md +2 -1
  40. package/docs/pages/forms/choice-cards.md +116 -4
  41. package/docs/pages/forms/combobox.md +39 -12
  42. package/docs/pages/forms/joined-controls.md +2 -5
  43. package/docs/pages/forms/overview.md +10 -1
  44. package/docs/pages/forms/range.md +110 -5
  45. package/docs/pages/forms/rich-text.md +110 -0
  46. package/docs/pages/forms/validation.md +5 -1
  47. package/docs/pages/foundations/theming.md +110 -18
  48. package/docs/pages/foundations/tokens.md +41 -13
  49. package/docs/pages/guides/bootstrap.md +2 -1
  50. package/docs/pages/guides/introduction.md +15 -23
  51. package/docs/pages/guides/modular-import.md +54 -7
  52. package/docs/pages/guides/progressive-enhancement.md +19 -17
  53. package/docs/pages/guides/tailwind.md +21 -7
  54. package/docs/pages/layout/column-layout.md +92 -54
  55. package/docs/pages/layout/media.md +1 -1
  56. package/docs/pages/layout/overview.md +1 -1
  57. package/docs/pages/utilities/overview.md +1 -1
  58. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  59. package/llms.txt +25 -4
  60. package/package.json +10 -6
  61. package/reserved-classes.json +6 -30
  62. package/skills/actual-css/PROJECT.md.example +65 -0
  63. package/skills/actual-css/README.md +64 -0
  64. package/skills/actual-css/SKILL.md +234 -0
  65. package/skills/actual-css/references/accessibility.md +44 -0
  66. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  67. package/skills/actual-css/references/enhancements.md +59 -0
  68. package/skills/actual-css/references/forms.md +49 -0
  69. package/skills/actual-css/references/layout-and-theming.md +82 -0
  70. package/skills/actual-css/references/review-checklist.md +51 -0
  71. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  72. package/src/css/components/accordion.css +44 -7
  73. package/src/css/components/alert.css +23 -43
  74. package/src/css/components/app-nav.css +9 -0
  75. package/src/css/components/avatar.css +1 -1
  76. package/src/css/components/badge.css +11 -39
  77. package/src/css/components/bleed.css +79 -0
  78. package/src/css/components/button.css +6 -17
  79. package/src/css/components/card.css +7 -40
  80. package/src/css/components/close.css +91 -0
  81. package/src/css/components/data-list.css +18 -0
  82. package/src/css/components/drawer.css +15 -56
  83. package/src/css/components/fab.css +10 -0
  84. package/src/css/components/flyout.css +5 -1
  85. package/src/css/components/index.css +2 -0
  86. package/src/css/components/join.css +36 -20
  87. package/src/css/components/menu.css +12 -0
  88. package/src/css/components/meter.css +1 -1
  89. package/src/css/components/modal.css +10 -57
  90. package/src/css/components/navbar.css +27 -0
  91. package/src/css/components/pagination.css +3 -2
  92. package/src/css/components/rating.css +1 -1
  93. package/src/css/components/status-bar.css +2 -2
  94. package/src/css/components/steps.css +3 -3
  95. package/src/css/components/tab.css +3 -3
  96. package/src/css/components/table.css +6 -0
  97. package/src/css/components/tooltip.css +1 -1
  98. package/src/css/core/focus.css +19 -3
  99. package/src/css/core/theme.css +33 -43
  100. package/src/css/core/tokens.css +9 -5
  101. package/src/css/core/variants.css +5 -34
  102. package/src/css/effects/aura.css +51 -33
  103. package/src/css/effects/index.css +1 -0
  104. package/src/css/effects/shimmer.css +57 -0
  105. package/src/css/forms/choice-card.css +142 -24
  106. package/src/css/forms/choice.css +2 -3
  107. package/src/css/forms/control.css +17 -10
  108. package/src/css/forms/custom-select.css +4 -4
  109. package/src/css/forms/native.css +4 -4
  110. package/src/css/forms/otp.css +9 -1
  111. package/src/css/forms/range.css +72 -14
  112. package/src/css/forms/switch.css +2 -3
  113. package/src/css/layout/app-layout.css +1 -1
  114. package/src/css/layout/column-layout.css +26 -111
  115. package/src/css/utilities/extra.css +7 -6
  116. package/src/js/command.js +61 -32
  117. package/src/js/dialog.js +103 -47
  118. package/src/js/dismiss.js +1 -2
  119. package/src/js/enhance.js +91 -126
  120. package/src/js/escape.js +1 -0
  121. package/src/js/flyout.js +10 -1
  122. package/src/js/focus-group.js +2 -1
  123. package/src/js/full.js +1 -0
  124. package/src/js/keys.js +14 -0
  125. package/src/js/menu.js +2 -1
  126. package/src/js/password.js +1 -8
  127. package/src/js/range.js +151 -0
  128. package/src/js/scrollspy.js +4 -5
  129. package/src/js/status.js +21 -10
  130. package/src/js/surface.js +12 -8
  131. package/src/js/tab.js +2 -1
  132. package/src/js/tooltip.js +29 -2
  133. package/src/js/validation.js +8 -15
  134. package/dist/actual.css +0 -876
  135. package/dist/actual.js +0 -1
  136. package/dist/actual.min.css +0 -1
@@ -130,11 +130,15 @@ select {
130
130
  --ease-enter: cubic-bezier(0.2, 0, 0, 1);
131
131
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
132
132
 
133
- /* Focus ring — shared by every interactive component's :focus-visible */
133
+ /* Generic focus fallback — an adaptive currentColor line for elements
134
+ without a component ring (links, summary, plain items). Components draw
135
+ the themed ring instead: --focus-ring-width solid --focus. See focus.css
136
+ for the two contracts. Both share --focus-ring-width: they differ in
137
+ color only. */
134
138
  --focus-outline-color: currentColor;
135
- --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
139
+ --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
136
140
  --focus-outline-offset: 2px;
137
- --focus-ring-width: 3px;
141
+ --focus-ring-width: 2px;
138
142
 
139
143
  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
140
144
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
@@ -195,8 +199,8 @@ select {
195
199
  --state-selected-fg: var(--primary-fg);
196
200
  --state-disabled: var(--text-subtle);
197
201
 
198
- /* Status dot ring — follows the island's surface. */
199
- --indicator-ring: var(--surface);
202
+ /* Status dot ring — cuts the dot out of what it overlaps. */
203
+ --indicator-ring: var(--surface-opaque);
200
204
  }
201
205
 
202
206
  :root {
@@ -291,6 +295,8 @@ select {
291
295
  * Shadow color, Focus ring derivation.
292
296
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
293
297
  * other tokens; hard-coded overrides on a custom theme will break the intent.
298
+ * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
299
+ * translucent surfaces overrides them with opaque values.
294
300
  * Internal: --hgroup-gap.
295
301
  */
296
302
  :root {
@@ -356,10 +362,10 @@ select {
356
362
 
357
363
  /* Lines and focus */
358
364
  --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);
365
+ /* A primary-hued mid-tone on purpose, one value for both schemes: focus
366
+ lines must hold 3:1 against --surface and --surface-solid alike (page,
367
+ fields, a solid band), which rules out the primary itself. */
368
+ --focus: hsl(268 30% 55%);
363
369
  --hover-overlay: hsl(260 20% 10% / 0.04);
364
370
 
365
371
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -382,7 +388,12 @@ select {
382
388
  --heading: var(--text);
383
389
  --selection-bg: var(--primary);
384
390
  --selection-fg: var(--primary-fg);
385
- --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
391
+ /* Surface roles that must stay opaque when a theme paints translucent
392
+ surfaces; both equal --surface on an opaque palette. --surface-opaque
393
+ hides or separates what is painted beneath a ring, separator or marker —
394
+ never a fill. --surface-solid-fg is the ink on --surface-solid. */
395
+ --surface-opaque: var(--surface);
396
+ --surface-solid-fg: var(--surface);
386
397
  }
387
398
 
388
399
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -391,18 +402,17 @@ select {
391
402
  default theme: a custom property bakes its var() references where it is
392
403
  declared, so these resolve against root/light/dark tokens on every boundary.
393
404
  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,
405
+ they declare hooks themselves.
406
+
407
+ Why :where(:root) and why the reset comes second: both rules match when a
408
+ custom theme sits on <html> itself. A bare :root is (0,1,0) and would
409
+ outrank the zero-specificity reset, so the same theme resolved a calibrated
410
+ Ink & Terra ink on <html data-theme="brand"> and the raw --soft-fg-mix ink
411
+ on <div data-theme="brand"> — one attribute, two soft palettes. Levelling
412
+ both rules to zero specificity makes source order the tiebreaker, so the
413
+ reset must stay below. Zero specificity is also what lets a theme declare
414
+ its own hooks (e.g. brutalist's 45% soft-fg-mix) and win at (0,1,0). */
415
+ :where(:root),
406
416
  [data-theme="light"],
407
417
  [data-theme="dark"] {
408
418
  --secondary-soft-fg: color-mix(in oklab, var(--text) 10%, var(--secondary) 90%);
@@ -412,6 +422,15 @@ select {
412
422
  --neutral-soft-fg: color-mix(in oklab, var(--text) 20%, var(--neutral) 80%);
413
423
  }
414
424
 
425
+ :where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
426
+ --primary-soft-fg: initial;
427
+ --secondary-soft-fg: initial;
428
+ --success-soft-fg: initial;
429
+ --warning-soft-fg: initial;
430
+ --danger-soft-fg: initial;
431
+ --neutral-soft-fg: initial;
432
+ }
433
+
415
434
  /* Native platform controls (checkbox, radio, range, progress) that are not
416
435
  restyled by a component class still follow the theme accent. Declared on
417
436
  every island: accent-color inherits as a computed value, so islands must
@@ -433,20 +452,6 @@ select {
433
452
  --tone-fg: var(--text);
434
453
  }
435
454
 
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
455
  /* Auto dark mode via light-dark(). Resolves based on the element's color-scheme:
451
456
  :root (light dark) follows the OS, [data-theme="light"] forces light,
452
457
  [data-theme="dark"] forces dark by overriding color-scheme.
@@ -483,22 +488,11 @@ select {
483
488
  --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));
484
489
 
485
490
  --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
491
  --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
488
492
  --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
489
493
  }
490
494
  }
491
495
 
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
496
  @media (prefers-contrast: more) {
503
497
  :root,
504
498
  /* The repeated attribute intentionally outranks single-attribute preset
@@ -506,7 +500,7 @@ select {
506
500
  [data-theme][data-theme] {
507
501
  --border-width: 2px;
508
502
  --focus-outline-offset: 3px;
509
- --focus-ring-width: 4px;
503
+ --focus-ring-width: 3px;
510
504
  --text-muted: var(--text);
511
505
  --text-subtle: var(--text);
512
506
  }
@@ -662,13 +656,9 @@ small {
662
656
  * Cross-cutting variant token mapping.
663
657
  *
664
658
  * 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.
659
+ * opt into shared variants. A subtree that needs another palette or scheme is
660
+ * a data-theme island, never a variant: only an island recomputes every token
661
+ * (fields, states, intents), where a remap of a few --ui-* tokens cannot.
672
662
  * Component-specific defaults (.btn solid-by-default, .badge
673
663
  * soft-by-default, .alert soft-by-default) live with their components:
674
664
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
@@ -724,28 +714,6 @@ small {
724
714
  --ui-border: var(--border);
725
715
  }
726
716
 
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
717
  .compact {
750
718
  --gap: var(--space-20);
751
719
  --density-space: var(--space-20);
@@ -763,7 +731,8 @@ small {
763
731
  /* Shared control scale. Wrappers are listed when their descendants form one
764
732
  component (field, choice-card, floating-field, input-icon, join, pagination,
765
733
  tabs, flyout). A local size declaration beats inherited density because it
766
- is declared on the sized component itself. */
734
+ is declared on the sized component itself. Guarded by check:sync
735
+ (size-scale-participants): .sm and .lg must list the same components. */
767
736
  :where(
768
737
  .btn,
769
738
  .input,
@@ -829,10 +798,6 @@ small {
829
798
  );
830
799
  /* @sync soft-recipe */
831
800
  }
832
-
833
- .inverted {
834
- --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
835
- }
836
801
  }
837
802
 
838
803
  /* Interactive hover recipes live in the components that consume them:
@@ -843,9 +808,25 @@ small {
843
808
  pointer/mouse focus and interactive components layer their ring on top.
844
809
 
845
810
  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. */
811
+ for its keyboard focus indicator; forced colors keep outlines and drop
812
+ shadows.
813
+
814
+ Two contracts share the job, both --focus-ring-width wide. Text-like
815
+ elements, whose ink is the thing being focused (links, summary, .nav-link,
816
+ .menu-item, breadcrumb links, .navbar-brand), declare no outline and take
817
+ the generic fallback below: a currentColor line that follows whatever the
818
+ application painted. Boxed components, with a fill or border of their own,
819
+ draw the themed ring instead — a solid line in --focus: inset on fields
820
+ (inside their border box), offset by --focus-outline-offset on discrete
821
+ actions. The color never derives from an intent or a local
822
+ surface — the theme guarantees --focus at 3:1 against both --surface and
823
+ --surface-solid, so one token reads on the page, on a solid band and on
824
+ any nested surface.
825
+
826
+ Documented exception: an invalid field focuses in --form-invalid-border
827
+ (danger), so the error stays visible at the point of correction. The border
828
+ carries the same hook; report:theme-contrast measures that line at 3:1
829
+ against the field's background alongside --focus. */
849
830
  :focus {
850
831
  outline: var(--focus-outline);
851
832
  outline-offset: var(--focus-outline-offset);
@@ -1390,7 +1371,7 @@ small {
1390
1371
  *
1391
1372
  * Docs: docs/pages/layout/app-layout.md
1392
1373
  *
1393
- * Public hook:
1374
+ * Public hooks:
1394
1375
  * --app-nav-side-size
1395
1376
  */
1396
1377
  .app-layout {
@@ -1719,12 +1700,12 @@ small {
1719
1700
  * Docs: docs/pages/layout/column-layout.md
1720
1701
  *
1721
1702
  * 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.
1703
+ * coordinate system. Each direct child places itself with two numbers,
1704
+ * --column-start and --column-span, and the author owns narrow-container
1705
+ * recomposition. There is deliberately no automatic collapse — no threshold,
1706
+ * no hidden .container-query behavior, no breakpoint variants. A single
1707
+ * framework threshold cannot be right for arbitrary per-region compositions,
1708
+ * and a hidden default would have to be undone rather than extended.
1728
1709
  *
1729
1710
  * See docs/design-notes/column-layout.md.
1730
1711
  *
@@ -1736,11 +1717,12 @@ small {
1736
1717
  * media + flexible content -> .media
1737
1718
  * exact custom track templates -> --grid-columns on .grid
1738
1719
  *
1739
- * Child contract: each direct child is placed on the canvas via its own
1740
- * .column-span-N / .column-start-N.
1741
- *
1742
1720
  * Unlike .grid-N, the track template does not read --gap, so the gap
1743
1721
  * utilities are exact here at every width.
1722
+ *
1723
+ * Public hooks:
1724
+ * --column-start (the start line, 1-12; unset = auto-placed)
1725
+ * --column-span (the width in canvas units, 1-12; unset = to the canvas end)
1744
1726
  */
1745
1727
  .column-layout {
1746
1728
  display: grid;
@@ -1748,116 +1730,30 @@ small {
1748
1730
  grid-template-columns: repeat(12, minmax(0, 1fr));
1749
1731
  }
1750
1732
 
1751
- /* Three zero-specificity tiers, applied in this order and only ever separated
1752
- * by source order. ORDER IS LOAD-BEARING:
1733
+ /* One rule covers the four placements:
1734
+ * neither auto-placed, spanning the whole canvas
1735
+ * span only auto-placed over N units, so 8 + 4 flows onto one row
1736
+ * start only from line S to the canvas end (span 13 - S)
1737
+ * start and span explicit; keep S + N - 1 <= 12
1753
1738
  *
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
1739
+ * The start stays `auto` unless set: a definite start on every child would
1740
+ * take all of them out of auto-placement, and an 8 + 4 pair would stack.
1757
1741
  *
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.
1762
- *
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.
1742
+ * The reset makes the two hooks inputs of this grid item rather than an
1743
+ * inherited context: a nested .column-layout keeps the coordinates it was
1744
+ * given for its own placement, and its children start from nothing. Zero
1745
+ * specificity, so any author rule on the child wins.
1773
1746
  *
1774
1747
  * min-inline-size: 0 keeps the item box inside its track so the canvas never
1775
1748
  * grows past its container. It does not stop content from painting outside a
1776
1749
  * narrow track — that distinction is the primitive's contract, not a bug. */
1777
1750
  :where(.column-layout) > * {
1778
- grid-column-end: span 12;
1779
- min-inline-size: 0;
1780
- }
1781
-
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
- }
1751
+ --column-start: initial;
1752
+ --column-span: initial;
1821
1753
 
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;
1754
+ grid-column-start: var(--column-start, auto);
1755
+ grid-column-end: span var(--column-span, calc(13 - var(--column-start, 1)));
1756
+ min-inline-size: 0;
1861
1757
  }
1862
1758
 
1863
1759
  /*
@@ -2138,6 +2034,8 @@ small {
2138
2034
  * --control-cursor
2139
2035
  * --control-border-block-end-color
2140
2036
  * --control-border-block-end-width
2037
+ * --control-hover-border (input and textarea)
2038
+ * --control-hover-bg (select)
2141
2039
  */
2142
2040
  .input,
2143
2041
  .textarea,
@@ -2165,8 +2063,8 @@ small {
2165
2063
  cursor: var(--control-cursor);
2166
2064
  /* No outline: none here — the core :focus fallback must survive for browsers
2167
2065
  without :focus-visible. Pointer focus is hidden by core's
2168
- :focus:not(:focus-visible); keyboard focus swaps in the transparent
2169
- outline + ring below. */
2066
+ :focus:not(:focus-visible); keyboard focus draws the inset outline below
2067
+ (invalid fields take --form-invalid-border, the documented exception). */
2170
2068
  transition:
2171
2069
  border-color var(--duration),
2172
2070
  box-shadow var(--duration),
@@ -2182,19 +2080,24 @@ small {
2182
2080
 
2183
2081
  .input:hover:not(:disabled),
2184
2082
  .textarea:hover:not(:disabled) {
2185
- --control-border: var(--text-subtle);
2083
+ --control-border: var(--control-hover-border, var(--text-subtle));
2186
2084
  }
2187
2085
 
2188
2086
  .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);
2087
+ --control-bg: var(--control-hover-bg, var(--surface-subtle));
2088
+ }
2089
+
2090
+ /* Field focus is an inset outline: offset = -width keeps every pixel inside
2091
+ the control's border box (any smaller offset bleeds outward), so it covers
2092
+ the border, never touches a .join neighbour or a scrollport edge, and needs
2093
+ no group treatment. Width follows --focus-ring-width, including its
2094
+ prefers-contrast step. Border = shape and validation, outline = focus. */
2095
+ .input:focus-visible:not(:disabled),
2096
+ .textarea:focus-visible:not(:disabled),
2097
+ .select:focus-visible:not(:disabled) {
2098
+ outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
2099
+ outline-offset: calc(var(--focus-ring-width) * -1);
2100
+ box-shadow: none;
2198
2101
  }
2199
2102
 
2200
2103
  .input[readonly],
@@ -2447,9 +2350,8 @@ small {
2447
2350
 
2448
2351
  .check:focus-visible,
2449
2352
  .radio:focus-visible {
2450
- --choice-border: var(--focus);
2451
- outline: 2px solid transparent;
2452
- box-shadow: var(--focus-ring-shadow);
2353
+ outline: var(--focus-ring-width) solid var(--focus);
2354
+ outline-offset: var(--focus-outline-offset);
2453
2355
  }
2454
2356
 
2455
2357
  .check:disabled,
@@ -2571,8 +2473,8 @@ small {
2571
2473
  }
2572
2474
 
2573
2475
  .file:focus-visible {
2574
- outline: 2px solid transparent;
2575
- box-shadow: var(--focus-ring-shadow);
2476
+ outline: var(--focus-ring-width) solid var(--focus);
2477
+ outline-offset: var(--focus-outline-offset);
2576
2478
  }
2577
2479
 
2578
2480
  /* Native color picker — keep the browser's dialog and selected-color UI. The
@@ -2608,8 +2510,8 @@ small {
2608
2510
  }
2609
2511
 
2610
2512
  .color:focus-visible {
2611
- outline: 2px solid transparent;
2612
- box-shadow: var(--focus-ring-shadow);
2513
+ outline: var(--focus-ring-width) solid var(--focus);
2514
+ outline-offset: var(--focus-outline-offset);
2613
2515
  }
2614
2516
 
2615
2517
  @media (forced-colors: active) {
@@ -2973,9 +2875,8 @@ small {
2973
2875
  }
2974
2876
 
2975
2877
  .switch:focus-visible {
2976
- --switch-border: var(--focus);
2977
- outline: 2px solid transparent;
2978
- box-shadow: var(--focus-ring-shadow);
2878
+ outline: var(--focus-ring-width) solid var(--focus);
2879
+ outline-offset: var(--focus-outline-offset);
2979
2880
  }
2980
2881
 
2981
2882
  .switch:disabled {
@@ -3011,6 +2912,10 @@ small {
3011
2912
  * --range-track-height
3012
2913
  * --range-thumb-bg
3013
2914
  * --range-track-bg
2915
+ * --range-fill-bg
2916
+ *
2917
+ * Runtime-written (by actual-css/js/range, never set by hand):
2918
+ * --range-progress
3014
2919
  */
3015
2920
 
3016
2921
  /* Intent boundary — inherited intents do not tint this component.
@@ -3020,10 +2925,17 @@ small {
3020
2925
  }
3021
2926
 
3022
2927
  .range {
3023
- --range-thumb-size: 1.25rem;
3024
- --range-track-height: 0.25rem;
2928
+ --range-thumb-size: 1.125rem;
2929
+ --range-track-height: 0.625rem;
3025
2930
  --range-thumb-bg: var(--intent, var(--primary));
3026
2931
  --range-track-bg: var(--border);
2932
+ --range-fill-bg: var(--intent, var(--primary));
2933
+ /* Fill share, written by actual-css/js/range. 0% with no runtime means a
2934
+ plain track — the control stays fully usable without JavaScript. */
2935
+ --range-progress: 0%;
2936
+ /* Logical fill direction: the gradient below cannot use flow-relative
2937
+ keywords, so RTL flips the angle through this hook instead. */
2938
+ --range-fill-from: to right;
3027
2939
 
3028
2940
  appearance: none;
3029
2941
  -webkit-appearance: none;
@@ -3033,16 +2945,29 @@ small {
3033
2945
  cursor: pointer;
3034
2946
  }
3035
2947
 
2948
+ :where([dir="rtl"]) .range,
2949
+ .range:dir(rtl) {
2950
+ --range-fill-from: to left;
2951
+ }
2952
+
3036
2953
  .range::-webkit-slider-runnable-track {
3037
2954
  block-size: var(--range-track-height);
3038
2955
  border-radius: var(--radius-full);
3039
- background: var(--range-track-bg);
2956
+ background: linear-gradient(
2957
+ var(--range-fill-from),
2958
+ var(--range-fill-bg) var(--range-progress),
2959
+ var(--range-track-bg) var(--range-progress)
2960
+ );
3040
2961
  }
3041
2962
 
3042
2963
  .range::-moz-range-track {
3043
2964
  block-size: var(--range-track-height);
3044
2965
  border-radius: var(--radius-full);
3045
- background: var(--range-track-bg);
2966
+ background: linear-gradient(
2967
+ var(--range-fill-from),
2968
+ var(--range-fill-bg) var(--range-progress),
2969
+ var(--range-track-bg) var(--range-progress)
2970
+ );
3046
2971
  }
3047
2972
 
3048
2973
  .range::-webkit-slider-thumb {
@@ -3055,6 +2980,10 @@ small {
3055
2980
  background: var(--range-thumb-bg);
3056
2981
  margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
3057
2982
  cursor: inherit;
2983
+ /* Permanent surface separator: the fill defaults to the thumb color, so
2984
+ without this the thumb would melt into the filled part of the track.
2985
+ Focus-visible keeps this ring and adds the --focus ring outside it. */
2986
+ box-shadow: 0 0 0 2px var(--surface-opaque);
3058
2987
  transition: box-shadow var(--duration);
3059
2988
  }
3060
2989
 
@@ -3065,6 +2994,7 @@ small {
3065
2994
  border: 0;
3066
2995
  background: var(--range-thumb-bg);
3067
2996
  cursor: inherit;
2997
+ box-shadow: 0 0 0 2px var(--surface-opaque);
3068
2998
  transition: box-shadow var(--duration);
3069
2999
  }
3070
3000
 
@@ -3073,20 +3003,23 @@ small {
3073
3003
  }
3074
3004
 
3075
3005
  /*
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.
3006
+ * A thumb pseudo-element takes no reliable outline, so its ring is a solid
3007
+ * --focus shadow (the transparent outline above covers forced colors). The
3008
+ * surface-coloured separator keeps the ring visually distinct from the
3009
+ * coloured thumb — unlike a full-size input where the same gap would
3010
+ * read as an unwanted void. It doubles as the resting separator between
3011
+ * the thumb and the same-coloured fill.
3079
3012
  */
3080
3013
  .range:focus-visible::-webkit-slider-thumb {
3081
3014
  box-shadow:
3082
- 0 0 0 1px var(--surface),
3083
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
3015
+ 0 0 0 2px var(--surface-opaque),
3016
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
3084
3017
  }
3085
3018
 
3086
3019
  .range:focus-visible::-moz-range-thumb {
3087
3020
  box-shadow:
3088
- 0 0 0 1px var(--surface),
3089
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
3021
+ 0 0 0 2px var(--surface-opaque),
3022
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
3090
3023
  }
3091
3024
 
3092
3025
  .range:disabled {
@@ -3094,22 +3027,48 @@ small {
3094
3027
  cursor: not-allowed;
3095
3028
  opacity: var(--disabled-opacity);
3096
3029
  }
3097
-
3098
3030
  /* Forced colors: the track must stay a visible line against the page (Canvas
3099
3031
  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. */
3032
+ distinguishable from the track. The fill keeps its two-tone split with
3033
+ system colors so the value stays readable. Disabled keeps GrayText via
3034
+ :disabled. Focus stays on the transparent outline of :focus-visible. Track
3035
+ and thumb fills are written on the properties, not only the hooks: an
3036
+ application hook override at the same specificity loads after this file
3037
+ and would win the variable, leaving a Canvas-remapped fill.
3038
+ The split lives in a background-image, which forced-color-adjust: auto
3039
+ drops — so the tracks opt out locally. The colors stay system keywords,
3040
+ keeping native contrast adaptation; only the host keeps adjusting. */
3102
3041
  @media (forced-colors: active) {
3103
3042
  .range {
3104
3043
  --range-track-bg: CanvasText;
3105
3044
  --range-thumb-bg: ButtonFace;
3045
+ --range-fill-bg: Highlight;
3046
+ }
3047
+
3048
+ .range::-webkit-slider-runnable-track {
3049
+ forced-color-adjust: none;
3050
+ background: linear-gradient(
3051
+ var(--range-fill-from),
3052
+ Highlight var(--range-progress),
3053
+ CanvasText var(--range-progress)
3054
+ );
3106
3055
  }
3107
3056
 
3057
+ .range::-moz-range-track {
3058
+ forced-color-adjust: none;
3059
+ background: linear-gradient(
3060
+ var(--range-fill-from),
3061
+ Highlight var(--range-progress),
3062
+ CanvasText var(--range-progress)
3063
+ );
3064
+ }
3108
3065
  .range::-webkit-slider-thumb {
3066
+ background: ButtonFace;
3109
3067
  border: var(--border-width) solid ButtonText;
3110
3068
  }
3111
3069
 
3112
3070
  .range::-moz-range-thumb {
3071
+ background: ButtonFace;
3113
3072
  border: var(--border-width) solid ButtonText;
3114
3073
  }
3115
3074
  }
@@ -3121,20 +3080,29 @@ small {
3121
3080
  *
3122
3081
  * Each .choice-card is a <label> wrapping a native radio or checkbox. When
3123
3082
  * :has() is supported the input is visually hidden and the card mirrors its
3124
- * state: checked border and tint, checkmark badge (round for radio, rounded
3125
- * square for checkbox), focus ring, disabled dimming. Without :has() the
3126
- * native input stays visible so selection state never disappears.
3083
+ * state: an indicator on the leading edge (a ring for radio, a box for
3084
+ * checkbox) that fills with a check, checked border and tint, focus ring,
3085
+ * disabled dimming. Without :has() the native input stays visible in the
3086
+ * indicator's slot, so selection state never disappears.
3087
+ *
3088
+ * The card is a two-column grid: the indicator in the first column, level
3089
+ * with the first row, and each child element of the label on its own row in
3090
+ * the second. Wrap loose text in an element. A corner badge for tiles is a
3091
+ * documented recipe on these same layers, not a variant.
3127
3092
  *
3128
- * The badge overhangs the top-right corner by 0.375em; ancestors with
3129
- * overflow: hidden will clip it. Pair with grid or flex utilities for group
3130
- * layout.
3093
+ * .chip is the compact pill form (filter chips, selectable tags): control
3094
+ * height, and a leading dot that turns into the check in a fixed slot, so
3095
+ * checking never moves the label.
3131
3096
  *
3132
3097
  * Public hooks:
3133
3098
  * --choice-card-pad
3099
+ * --choice-card-gap
3134
3100
  * --choice-card-radius
3135
3101
  * --choice-card-border
3136
3102
  * --choice-card-bg
3137
3103
  * --choice-card-check-size
3104
+ * --choice-chip-dot (read-only, on .chip ::before: the check-slot centre as a
3105
+ * circle() position, for a theme that grows the dot into a fill)
3138
3106
  */
3139
3107
  :where(.choice-card) {
3140
3108
  --intent: initial;
@@ -3143,19 +3111,25 @@ small {
3143
3111
 
3144
3112
  .choice-card {
3145
3113
  --choice-card-pad: var(--density-space, var(--space-40));
3114
+ --choice-card-gap: var(--space-30);
3146
3115
  --choice-card-radius: var(--radius);
3147
3116
  --choice-card-border: var(--border);
3148
3117
  --choice-card-bg: var(--surface);
3149
- --choice-card-check-size: 1.5em;
3118
+ --choice-card-check-size: 1.25em;
3150
3119
 
3151
3120
  position: relative;
3152
- display: block;
3121
+ display: grid;
3122
+ grid-template-columns: var(--choice-card-check-size) minmax(0, 1fr);
3123
+ /* why: a card stretched by a taller grid neighbour keeps its rows at the
3124
+ top instead of spreading them. */
3125
+ align-content: start;
3126
+ column-gap: var(--choice-card-gap);
3153
3127
  padding: var(--choice-card-pad);
3154
3128
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
3155
3129
  border-radius: var(--choice-card-radius);
3156
3130
  background: var(--choice-card-bg);
3157
3131
  color: var(--text);
3158
- /* Consumed so .sm/.lg reach the card; the badge's em sizing follows. */
3132
+ /* Consumed so .sm/.lg reach the card; the indicator's em sizing follows. */
3159
3133
  font-size: var(--control-font-size);
3160
3134
  cursor: pointer;
3161
3135
  transition:
@@ -3164,6 +3138,18 @@ small {
3164
3138
  box-shadow var(--duration);
3165
3139
  }
3166
3140
 
3141
+ .choice-card > * {
3142
+ grid-column: 2;
3143
+ }
3144
+
3145
+ /* The input owns the indicator's cell: visible there without :has(), hidden
3146
+ in it with :has(). */
3147
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
3148
+ grid-area: 1 / 1;
3149
+ place-self: center;
3150
+ margin: 0;
3151
+ }
3152
+
3167
3153
  /* The native input is visually hidden but stays focusable and participates in
3168
3154
  form submission and keyboard navigation (sr-only pattern). Guarded: without
3169
3155
  :has() the card cannot mirror the input's state, so the visible native
@@ -3184,8 +3170,8 @@ small {
3184
3170
 
3185
3171
  /* Focus ring on the label follows the hidden input's focus. */
3186
3172
  .choice-card:has(:focus-visible) {
3187
- outline: 2px solid transparent;
3188
- box-shadow: var(--focus-ring-shadow);
3173
+ outline: var(--focus-ring-width) solid var(--focus);
3174
+ outline-offset: var(--focus-outline-offset);
3189
3175
  }
3190
3176
 
3191
3177
  .choice-card:has(:disabled) {
@@ -3209,32 +3195,41 @@ small {
3209
3195
  }
3210
3196
  }
3211
3197
 
3212
- /* Checkmark badge — always rendered at scale(0) and scaled in on check, so
3213
- the entrance can transition (a pseudo created on :checked could not). */
3198
+ /* Indicator: ::before is the ring or box, ::after the check. Both share the
3199
+ input's cell, centred on the first line — not the first row, which a long
3200
+ label wraps — and stay hidden until :has() can mirror the input. The em
3201
+ margin is the lh-less fallback at the default 1.5 line-height. */
3214
3202
  .choice-card::before,
3215
3203
  .choice-card::after {
3216
3204
  content: "";
3217
- position: absolute;
3218
- inset-inline-end: -0.375em;
3219
- inset-block-start: -0.375em;
3205
+ grid-area: 1 / 1;
3206
+ align-self: start;
3207
+ margin-block-start: calc((1.5em - var(--choice-card-check-size)) / 2);
3208
+ margin-block-start: calc((1lh - var(--choice-card-check-size)) / 2);
3220
3209
  inline-size: var(--choice-card-check-size);
3221
3210
  block-size: var(--choice-card-check-size);
3222
3211
  transform: scale(0);
3223
- transition: transform var(--duration);
3212
+ transition:
3213
+ transform var(--duration),
3214
+ background-color var(--duration),
3215
+ border-color var(--duration);
3224
3216
  }
3225
3217
 
3226
- /* Badge disc — circle for radio, rounded square for checkbox */
3218
+ /* A border, not an inset shadow: forced colors drop shadows, and the resting
3219
+ ring is the only sign of an unchecked option. The invalid border reaches it
3220
+ through the same hook as the card's. */
3227
3221
  .choice-card::before {
3228
- background: var(--intent, var(--state-selected));
3229
- box-shadow: 0 0 0 2px var(--surface);
3222
+ border: max(var(--border-width), 1.5px) solid var(--form-invalid-border, var(--text-muted));
3230
3223
  z-index: 1;
3231
3224
  }
3232
3225
 
3233
- .choice-card:has(input[type="radio"])::before {
3226
+ /* :where() keeps the shape at single-class weight, so .chip can reshape the
3227
+ layer without out-arguing the input-type test. */
3228
+ .choice-card:where(:has(input[type="radio"]))::before {
3234
3229
  border-radius: var(--radius-full);
3235
3230
  }
3236
3231
 
3237
- .choice-card:has(input[type="checkbox"])::before {
3232
+ .choice-card:where(:has(input[type="checkbox"]))::before {
3238
3233
  border-radius: var(--radius-sm);
3239
3234
  }
3240
3235
 
@@ -3246,18 +3241,86 @@ small {
3246
3241
  z-index: 2;
3247
3242
  }
3248
3243
 
3249
- .choice-card:has(:checked)::before,
3244
+ /* Guarded with the input's hiding above: a resting ring beside a visible
3245
+ native input would draw the indicator twice. */
3246
+ @supports selector(:has(*)) {
3247
+ .choice-card::before {
3248
+ transform: none;
3249
+ }
3250
+ }
3251
+
3252
+ .choice-card:has(:checked)::before {
3253
+ border-color: var(--intent, var(--state-selected));
3254
+ background: var(--intent, var(--state-selected));
3255
+ }
3256
+
3250
3257
  .choice-card:has(:checked)::after {
3251
3258
  transform: scale(1);
3252
3259
  }
3253
3260
 
3261
+ /* Chip — the rules above stay in force; this section only relocates the
3262
+ indicator. Must follow them: several ties are won by source order. */
3263
+ .choice-card.chip {
3264
+ --choice-card-pad: 0 var(--control-pad-x);
3265
+ --choice-card-radius: var(--radius-full);
3266
+ --choice-card-check-size: 1em;
3267
+
3268
+ display: inline-flex;
3269
+ align-items: center;
3270
+ min-block-size: var(--control-size);
3271
+ line-height: 1.2;
3272
+ /* The dot layer sits under the label at z-index -1. */
3273
+ isolation: isolate;
3274
+ }
3275
+
3276
+ /* ::before is a full-bleed layer clipped to a dot centred on the check slot,
3277
+ so a theme can grow the same clip into a selected fill. The chip itself
3278
+ must not clip (its focus ring is an outline), hence the inherited radius. */
3279
+ .choice-card.chip::before {
3280
+ --choice-chip-dot: calc(var(--control-pad-x) + var(--choice-card-check-size) / 2) 50%;
3281
+
3282
+ position: absolute;
3283
+ inset: 0;
3284
+ /* why: an absolute box honours self-alignment — the card's start would
3285
+ shrink this layer to 0 height and the dot with it. */
3286
+ align-self: auto;
3287
+ margin: 0;
3288
+ inline-size: auto;
3289
+ block-size: auto;
3290
+ border: 0;
3291
+ border-radius: inherit;
3292
+ background: var(--text-subtle);
3293
+ transform: none;
3294
+ clip-path: circle(0.1875em at var(--choice-chip-dot));
3295
+ z-index: -1;
3296
+ transition: clip-path var(--duration);
3297
+ }
3298
+
3299
+ .choice-card.chip:has(:checked)::before {
3300
+ clip-path: circle(0 at var(--choice-chip-dot));
3301
+ }
3302
+
3303
+ /* The check keeps the card's scale-in, in flow ahead of the label. */
3304
+ .choice-card.chip::after {
3305
+ order: -1;
3306
+ flex: none;
3307
+ align-self: center;
3308
+ margin-block: 0;
3309
+ margin-inline: 0 0.375em;
3310
+ background: var(--intent, var(--state-selected));
3311
+ -webkit-mask-size: contain;
3312
+ mask-size: contain;
3313
+ }
3314
+
3254
3315
  /* The native input is hidden and the checked marker is custom-drawn: without
3255
3316
  this repair, forced colors pushes disc and check to the same system color
3256
3317
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
3257
3318
  so no forced-color-adjust is needed. The pseudo is not focusable — the
3258
- outline is a separator against the card, not a focus channel. */
3319
+ outline separates the disc from whatever it overlaps (the card's border,
3320
+ for a corner-badge recipe), not a focus channel. */
3259
3321
  @media (forced-colors: active) {
3260
3322
  .choice-card:has(:checked)::before {
3323
+ border-color: Highlight;
3261
3324
  background: Highlight;
3262
3325
  outline: 2px solid Canvas;
3263
3326
  }
@@ -3265,6 +3328,20 @@ small {
3265
3328
  .choice-card:has(:checked)::after {
3266
3329
  background: HighlightText;
3267
3330
  }
3331
+
3332
+ /* A chip has no disc behind its check: the check carries the state alone,
3333
+ and dropping the checked layer also drops any theme fill. */
3334
+ .choice-card.chip::before {
3335
+ background: CanvasText;
3336
+ }
3337
+
3338
+ .choice-card.chip:has(:checked)::before {
3339
+ content: none;
3340
+ }
3341
+
3342
+ .choice-card.chip:has(:checked)::after {
3343
+ background: CanvasText;
3344
+ }
3268
3345
  }
3269
3346
 
3270
3347
  /*
@@ -3278,7 +3355,6 @@ small {
3278
3355
  * Arbitrary menu and popover behavior belongs elsewhere.
3279
3356
  *
3280
3357
  * Internal:
3281
- * --control-border
3282
3358
  * --control-bg
3283
3359
  */
3284
3360
  /* This enhancement is excluded from forced-colors, where the native/shared
@@ -3303,11 +3379,12 @@ small {
3303
3379
  box-shadow: var(--shadow-popout);
3304
3380
  }
3305
3381
 
3382
+ /* Same inset outline as a focused field (control.css). */
3306
3383
  &:open {
3307
- --control-border: var(--border);
3308
3384
  --control-bg: var(--surface-raised);
3309
- outline: none;
3310
- box-shadow: var(--focus-ring-shadow);
3385
+ outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
3386
+ outline-offset: calc(var(--focus-ring-width) * -1);
3387
+ box-shadow: none;
3311
3388
  }
3312
3389
 
3313
3390
  &::picker-icon {
@@ -3539,7 +3616,7 @@ small {
3539
3616
  padding-block: 0;
3540
3617
  padding-inline: calc((var(--otp-cell-size) - 1ch) / 2) 0;
3541
3618
  border: 0;
3542
- outline: 2px solid transparent;
3619
+ outline: none;
3543
3620
  box-shadow: none;
3544
3621
  background: transparent;
3545
3622
  color: var(--text);
@@ -3564,6 +3641,14 @@ small {
3564
3641
  character after it: the state rules select the cells as following siblings
3565
3642
  (input:focus ~ span, input:disabled ~ span, …), so that order is part of the
3566
3643
  contract. */
3644
+ /* The documented markup puts .input on the native input, so the field ring
3645
+ (.input:focus-visible:not(:disabled), control.css) would outrank the reset
3646
+ above and draw that group ring; this selector must stay one step above it.
3647
+ No transparent outline either: forced colors would paint it as a ring. */
3648
+ .otp > input:focus-visible:not(:disabled) {
3649
+ outline: none;
3650
+ }
3651
+
3567
3652
  .otp > input:focus ~ span {
3568
3653
  --otp-cell-border: var(--focus);
3569
3654
  --otp-cell-border-width: calc(var(--border-width) * 2);
@@ -3636,7 +3721,6 @@ small {
3636
3721
  * --btn-min-size
3637
3722
  * --btn-pad-x
3638
3723
  * --btn-radius
3639
- * --btn-focus-color
3640
3724
  * --btn-gap
3641
3725
  * --btn-hover-overlay
3642
3726
  * --btn-default-bg
@@ -3649,7 +3733,6 @@ small {
3649
3733
  * --btn-fg
3650
3734
  * --btn-border
3651
3735
  * --btn-default-hover-bg
3652
- * --btn-focus-ring-color
3653
3736
  * --btn-border-block-end-color
3654
3737
  * --btn-border-block-end-width
3655
3738
  * --spinner-width
@@ -3676,7 +3759,6 @@ small {
3676
3759
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
3677
3760
  --btn-default-hover-bg: var(--btn-bg);
3678
3761
  --btn-radius: var(--radius);
3679
- --btn-focus-color: var(--intent, var(--focus));
3680
3762
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
3681
3763
 
3682
3764
  /* Positioning context for indicators, overlays, and grouped states. */
@@ -3718,12 +3800,6 @@ small {
3718
3800
  padding: 0;
3719
3801
  }
3720
3802
 
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
3803
  /* .solid/.soft/.outline are shared via variants.css. The baseline --btn-*
3728
3804
  fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
3729
3805
  .btn.ghost {
@@ -3811,15 +3887,13 @@ small {
3811
3887
  text-decoration-thickness: 0.12em;
3812
3888
  }
3813
3889
 
3890
+ /* why: the ring must contrast with the surface around the button, not with
3891
+ the button, so it is --focus for every intent and variant — the theme
3892
+ guarantees --focus against --surface and --surface-solid, which covers
3893
+ the page, a solid band and nested surfaces without tracking any context. */
3814
3894
  .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;
3895
+ outline: var(--focus-ring-width) solid var(--focus);
3896
+ outline-offset: var(--focus-outline-offset);
3823
3897
  }
3824
3898
 
3825
3899
  .btn:disabled {
@@ -3865,6 +3939,98 @@ a.btn[aria-disabled="true"] {
3865
3939
  }
3866
3940
  }
3867
3941
 
3942
+ /*
3943
+ * Close button — the shared X that removes or closes the thing it sits in.
3944
+ *
3945
+ * Docs: docs/pages/components/close.md
3946
+ *
3947
+ * .close is always the X, painted from the --icon-close mask: an empty
3948
+ * <button class="close" aria-label="…"> needs no icon font and no text glyph.
3949
+ * The mechanism stays with the container (command="request-close",
3950
+ * method="dialog", command="--dismiss", application logic); the class only
3951
+ * names the visual action.
3952
+ *
3953
+ * The containers that host one (alert, badge) tune it from their own files by
3954
+ * declaring the size hooks and their geometry; this file owns the control
3955
+ * and the dialog-surface corner, shared by .modal and .drawer.
3956
+ *
3957
+ * Public hooks:
3958
+ * --close-size
3959
+ * --close-icon-size
3960
+ */
3961
+ .close {
3962
+ display: inline-grid;
3963
+ place-items: center;
3964
+ flex: none;
3965
+ inline-size: var(--close-size, var(--control-size));
3966
+ block-size: var(--close-size, var(--control-size));
3967
+ padding: 0;
3968
+ border: 0;
3969
+ border-radius: var(--radius);
3970
+ background: transparent;
3971
+ color: inherit;
3972
+ cursor: pointer;
3973
+ }
3974
+
3975
+ .close::before {
3976
+ content: "";
3977
+ inline-size: var(--close-icon-size, 1rem);
3978
+ block-size: var(--close-icon-size, 1rem);
3979
+ background: currentColor;
3980
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
3981
+ mask: var(--icon-close) center / contain no-repeat;
3982
+ }
3983
+
3984
+ /* An inset overlay, not a background: it tints whatever fill the context gave
3985
+ the control (the dialog corner's --surface-subtle) instead of replacing it. */
3986
+ @media (hover: hover) {
3987
+ .close:hover {
3988
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
3989
+ }
3990
+ }
3991
+
3992
+ .close:active {
3993
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
3994
+ }
3995
+
3996
+ .close:focus-visible {
3997
+ outline: var(--focus-ring-width) solid var(--focus);
3998
+ outline-offset: var(--focus-outline-offset);
3999
+ }
4000
+
4001
+ /* Dialog corner — out of the content flow, anchored to the positioned dialog
4002
+ surface rather than to the header or a scroll container inside it. Matched
4003
+ on the documented positions (the dialog, its form, its header, or a form
4004
+ inside that header) so a .close inside content — an alert in the body —
4005
+ keeps its own place. */
4006
+ :where(dialog):is(.modal, .drawer) > .close,
4007
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > .close,
4008
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close {
4009
+ position: absolute;
4010
+ z-index: 1;
4011
+ inset-block-start: var(--space-20);
4012
+ inset-inline-end: var(--space-20);
4013
+ background: var(--surface-subtle);
4014
+ color: var(--text-muted);
4015
+ }
4016
+
4017
+ @media (hover: hover) {
4018
+ :where(dialog):is(.modal, .drawer) > .close:hover,
4019
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > .close:hover,
4020
+ :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close:hover {
4021
+ color: var(--text);
4022
+ }
4023
+ }
4024
+
4025
+ @media (forced-colors: active) {
4026
+ /* The X is a currentColor mask fill: without this the system remaps the
4027
+ background to Canvas and the glyph disappears. Kept on the pseudo so the
4028
+ host keeps its native color and focus adaptation. */
4029
+ .close::before {
4030
+ forced-color-adjust: none;
4031
+ }
4032
+ }
4033
+
3868
4034
  /*
3869
4035
  * Badges for compact labels, counts, and state hints.
3870
4036
  *
@@ -3881,7 +4047,6 @@ a.btn[aria-disabled="true"] {
3881
4047
  * --badge-icon-size
3882
4048
  * --badge-dot-size
3883
4049
  * --badge-radius
3884
- * --badge-dismiss-icon-size
3885
4050
  *
3886
4051
  * Internal:
3887
4052
  * --badge-default-bg
@@ -3912,7 +4077,10 @@ a.btn[aria-disabled="true"] {
3912
4077
  --badge-font-size: var(--font-size-sm);
3913
4078
  --badge-icon-size: 1em;
3914
4079
  --badge-dot-size: 0.5rem;
3915
- --badge-dismiss-icon-size: 0.875em;
4080
+ /* Declared on the badge, not the button, so an author can still set them
4081
+ on either. Same element as --badge-size, so .sm/.lg carry through. */
4082
+ --close-size: max(var(--badge-size), 24px);
4083
+ --close-icon-size: 0.875em;
3916
4084
  --badge-default-bg: var(--surface-subtle);
3917
4085
  --badge-default-fg: var(--intent, var(--text));
3918
4086
  --badge-default-border: var(--border);
@@ -3962,8 +4130,8 @@ a.btn[aria-disabled="true"] {
3962
4130
  --badge-dot-size: 0.625rem;
3963
4131
  }
3964
4132
 
3965
- /* Decorative children follow the badge's optical size. Text and interactive
3966
- dismiss buttons retain their own boxes. */
4133
+ /* Decorative children follow the badge's optical size. Text and the .close
4134
+ button retain their own boxes. */
3967
4135
  .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
3968
4136
  flex: none;
3969
4137
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
@@ -4016,45 +4184,15 @@ a.btn[aria-disabled="true"] {
4016
4184
  background-color: currentColor;
4017
4185
  }
4018
4186
 
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);
4187
+ /* Removable tag — a .close (components/close.css) inside the pill. WCAG 2.5.8
4188
+ floors pointer targets at 24 CSS px: density and local size may keep the
4189
+ badge visually compact through the negative margins, but never shrink the
4190
+ target below that literal floor. */
4191
+ .badge > .close {
4028
4192
  margin-block: -0.25rem;
4029
4193
  margin-inline-start: -0.15em;
4030
4194
  margin-inline-end: -0.45em;
4031
- padding: 0.25rem;
4032
- border: 0;
4033
4195
  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
4196
  }
4059
4197
 
4060
4198
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
@@ -4145,7 +4283,6 @@ a.badge[aria-disabled="true"] {
4145
4283
  * --alert-font-size
4146
4284
  * --alert-radius
4147
4285
  * --alert-icon-size
4148
- * --alert-dismiss-size
4149
4286
  * --alert-border-inline-start-color
4150
4287
  * --alert-border-inline-start-width
4151
4288
  *
@@ -4156,7 +4293,6 @@ a.badge[aria-disabled="true"] {
4156
4293
  * --alert-bg
4157
4294
  * --alert-fg
4158
4295
  * --alert-border
4159
- * --alert-dismiss-icon-size
4160
4296
  */
4161
4297
  /* Intent boundary — inherited intents do not tint this component.
4162
4298
  Local intent classes still win because :where() has zero specificity. */
@@ -4205,13 +4341,13 @@ a.badge[aria-disabled="true"] {
4205
4341
  The slots are excluded because they are anatomy, not content: an
4206
4342
  admonition's title bar has to stay flush against its body. The list follows
4207
4343
  the anatomy, so it grows with it. */
4208
- .alert > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4344
+ .alert > :not(:where(.alert-icon, .close, .alert-title, .alert-body)) {
4209
4345
  margin-block: 0;
4210
4346
  }
4211
4347
 
4212
4348
  .alert
4213
- > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body))
4214
- + :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4349
+ > :not(:where(.alert-icon, .close, .alert-title, .alert-body))
4350
+ + :not(:where(.alert-icon, .close, .alert-title, .alert-body)) {
4215
4351
  margin-block-start: var(--space-10);
4216
4352
  }
4217
4353
 
@@ -4249,42 +4385,24 @@ a.badge[aria-disabled="true"] {
4249
4385
  vertical-align: -0.125em;
4250
4386
  }
4251
4387
 
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;
4388
+ /* Close (components/close.css) — compact, and at reduced opacity so it stays
4389
+ secondary inside an already-colored alert. Standard alerts place it as a
4390
+ direct trailing child; admonitions place it inside .alert-title (see
4391
+ below). The sizes are declared on the alert so an author can still set
4392
+ them on the alert or on the button. */
4393
+ .alert {
4394
+ --close-size: 1.75rem;
4395
+ --close-icon-size: 0.875rem;
4274
4396
  }
4275
4397
 
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;
4398
+ .alert > .close,
4399
+ .alert > .alert-title > .close {
4400
+ opacity: 0.65;
4283
4401
  }
4284
4402
 
4285
4403
  @media (hover: hover) {
4286
- .alert-dismiss:hover {
4287
- background: var(--hover-overlay);
4404
+ .alert > .close:hover,
4405
+ .alert > .alert-title > .close:hover {
4288
4406
  opacity: 1;
4289
4407
  }
4290
4408
  }
@@ -4293,7 +4411,7 @@ a.badge[aria-disabled="true"] {
4293
4411
  both. Icon and dismiss are pinned to the outer columns; anything else (the
4294
4412
  message content) flows into the remaining text column. */
4295
4413
  @supports selector(.alert:has(> *)) {
4296
- .alert:has(> .alert-icon, > .alert-dismiss) {
4414
+ .alert:has(> .alert-icon, > .close) {
4297
4415
  display: grid;
4298
4416
  grid-template-columns: minmax(0, 1fr);
4299
4417
  align-items: var(--items-align, start);
@@ -4312,19 +4430,19 @@ a.badge[aria-disabled="true"] {
4312
4430
  margin-block-start: 0.125em;
4313
4431
  }
4314
4432
 
4315
- .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
4433
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
4316
4434
  grid-column: 2;
4317
4435
  }
4318
4436
 
4319
- .alert:has(> .alert-dismiss) {
4437
+ .alert:has(> .close) {
4320
4438
  grid-template-columns: minmax(0, 1fr) auto;
4321
4439
  }
4322
4440
 
4323
- .alert:has(> .alert-icon):has(> .alert-dismiss) {
4441
+ .alert:has(> .alert-icon):has(> .close) {
4324
4442
  grid-template-columns: auto minmax(0, 1fr) auto;
4325
4443
  }
4326
4444
 
4327
- .alert:has(> .alert-dismiss) > .alert-dismiss {
4445
+ .alert:has(> .close) > .close {
4328
4446
  grid-column: -2 / -1;
4329
4447
  grid-row: 1;
4330
4448
  }
@@ -4372,8 +4490,8 @@ a.badge[aria-disabled="true"] {
4372
4490
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
4373
4491
  }
4374
4492
 
4375
- /* Dismiss inside an admonition title bar rides the inline end. */
4376
- .alert.admonition > .alert-title > .alert-dismiss {
4493
+ /* Close inside an admonition title bar rides the inline end. */
4494
+ .alert.admonition > .alert-title > .close {
4377
4495
  margin-inline-start: auto;
4378
4496
  }
4379
4497
 
@@ -4466,7 +4584,9 @@ a.badge[aria-disabled="true"] {
4466
4584
  *
4467
4585
  * Child contract:
4468
4586
  * Direct .bleed children escape --card-pad (negative inset, wider media);
4469
- * edge children inherit the card corners. .bleed must be a direct child.
4587
+ * edge children inherit the card corners. .bleed must be a direct child, and
4588
+ * its rules live in components/bleed.css — the card relays --card-pad to the
4589
+ * shared --surface-pad that they read.
4470
4590
  *
4471
4591
  * Internal:
4472
4592
  * --card-bg
@@ -4497,6 +4617,10 @@ a.badge[aria-disabled="true"] {
4497
4617
  --card-bg: var(--ui-bg, var(--surface-raised));
4498
4618
  --card-fg: var(--ui-fg, var(--text));
4499
4619
 
4620
+ /* The inset a direct .bleed child cancels (components/bleed.css). Relayed
4621
+ from --card-pad so .compact and an author override both reach it. */
4622
+ --surface-pad: var(--card-pad);
4623
+
4500
4624
  /* Keep contextual descendants tied to the surface this card actually owns. */
4501
4625
  --heading: var(--card-fg);
4502
4626
  --busy-overlay-bg: var(--card-bg);
@@ -4562,45 +4686,6 @@ a.badge[aria-disabled="true"] {
4562
4686
  --card-pad: var(--space-20);
4563
4687
  }
4564
4688
 
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
4689
  @media print {
4605
4690
  .card {
4606
4691
  border: var(--border-width) solid CanvasText;
@@ -4622,8 +4707,9 @@ a.badge[aria-disabled="true"] {
4622
4707
  * --dialog-icon-size
4623
4708
  * --dialog-icon-glyph-size
4624
4709
  *
4710
+ * The corner close control is .close (components/close.css).
4711
+ *
4625
4712
  * Internal:
4626
- * --dialog-close-icon-size
4627
4713
  * --dialog-focus-bleed
4628
4714
  * --dialog-viewport-block
4629
4715
  * --dialog-viewport-gap-default
@@ -4709,11 +4795,16 @@ a.badge[aria-disabled="true"] {
4709
4795
 
4710
4796
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
4711
4797
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
4712
- --dialog-focus-bleed: calc(var(--focus-outline-offset) + var(--border-width) * 2);
4798
+ --dialog-focus-bleed: calc(
4799
+ var(--focus-outline-offset) +
4800
+ max(var(--focus-ring-width), var(--border-width) * 2)
4801
+ );
4713
4802
 
4714
4803
  min-block-size: 0;
4715
4804
  /* Keep focus paint inside the clip without moving the body off the fixed
4716
- header and footer edges. */
4805
+ header and footer edges. The reserve covers the thicker of the two
4806
+ outside indicators: the :focus baseline (2 × border) and the component
4807
+ line (--focus-ring-width, 3px in bootstrap-v6). */
4717
4808
  margin-inline: calc(var(--dialog-focus-bleed) * -1);
4718
4809
  padding-inline: var(--dialog-focus-bleed);
4719
4810
  overflow: auto;
@@ -4736,7 +4827,7 @@ a.badge[aria-disabled="true"] {
4736
4827
  The form/.stack anatomy is structural, so it stays in :where() and never
4737
4828
  lends .stack's specificity to a bare <form>. */
4738
4829
  :where(dialog).modal > :where(form, .stack) > header {
4739
- padding-inline-end: calc(var(--control-size) + var(--space-30));
4830
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
4740
4831
  }
4741
4832
 
4742
4833
  :where(dialog).modal > :where(form, .stack) > footer {
@@ -4900,59 +4991,6 @@ html.has-modal-open.had-scrollbar {
4900
4991
  }
4901
4992
  }
4902
4993
 
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
4994
  /*
4957
4995
  * Drawer — modal side-sheet built on the native dialog element.
4958
4996
  *
@@ -4972,6 +5010,8 @@ html.has-modal-open.had-scrollbar {
4972
5010
  * --drawer-size
4973
5011
  * --drawer-pad
4974
5012
  *
5013
+ * The corner close control is .close (components/close.css).
5014
+ *
4975
5015
  * Internal:
4976
5016
  * --drawer-closed-translate (side/RTL positioning state)
4977
5017
  * --drawer-focus-bleed
@@ -4981,6 +5021,8 @@ html.has-modal-open.had-scrollbar {
4981
5021
  :where(dialog).drawer {
4982
5022
  --drawer-size: 20rem;
4983
5023
  --drawer-pad: var(--space-40);
5024
+ /* The inset a direct .bleed child cancels (components/bleed.css). */
5025
+ --surface-pad: var(--drawer-pad);
4984
5026
  --drawer-closed-translate: -100%;
4985
5027
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
4986
5028
  the same variable through inheritance and never lags the panel. */
@@ -5080,15 +5122,20 @@ html.has-modal-open.had-scrollbar {
5080
5122
  /* The body region absorbs the panel's height and scrolls. A drawer is a
5081
5123
  full-height shell by definition, so it always scrolls its body — unlike the
5082
5124
  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
5125
+ footer keep their own tracks; .close is named because a generic
5084
5126
  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);
5127
+ :where(dialog).drawer > :not(:is(header, footer, form, .close)),
5128
+ :where(dialog).drawer > form > :not(:is(header, footer, .close)) {
5129
+ --drawer-focus-bleed: calc(
5130
+ var(--focus-outline-offset) +
5131
+ max(var(--focus-ring-width), var(--border-width) * 2)
5132
+ );
5088
5133
 
5089
5134
  min-block-size: 0;
5090
5135
  /* Keep focus paint inside the clip without moving the body off the fixed
5091
- header and footer edges. */
5136
+ header and footer edges. The reserve covers the thicker of the two
5137
+ outside indicators: the :focus baseline (2 × border) and the component
5138
+ line (--focus-ring-width, 3px in bootstrap-v6). */
5092
5139
  margin-inline: calc(var(--drawer-focus-bleed) * -1);
5093
5140
  padding-inline: var(--drawer-focus-bleed);
5094
5141
  overflow: auto;
@@ -5160,57 +5207,87 @@ html.has-modal-open.had-scrollbar {
5160
5207
  control. */
5161
5208
  :where(dialog).drawer > header,
5162
5209
  :where(dialog).drawer > form > header {
5163
- padding-inline-end: calc(var(--control-size) + var(--space-30));
5210
+ padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
5164
5211
  }
5165
5212
 
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;
5213
+ /*
5214
+ * Full-bleed children — the shared escape hatch for a padded surface.
5215
+ *
5216
+ * Docs: docs/pages/components/bleed.md
5217
+ *
5218
+ * A surface that insets its direct children relays that inset to them as
5219
+ * --surface-pad; a direct .bleed child cancels it and reaches the surface
5220
+ * edge. Card and drawer pad at the container level, so they own the token
5221
+ * there; the accordion pads its summary and its panel instead, so the panel
5222
+ * owns it and the item does not.
5223
+ *
5224
+ * Why the token is handed to the children rather than declared on the surface:
5225
+ * custom properties inherit, so a surface-level declaration would also reach a
5226
+ * grandchild, which sits at its wrapper's edge rather than the surface's and
5227
+ * would pull a negative margin out of the wrong box. Relaying it one level and
5228
+ * clearing it below keeps the child combinator's guarantee while leaving the
5229
+ * .bleed rules themselves free of the surface list.
5230
+ *
5231
+ * Why the clearing rule comes first: both rules are zero-specificity, so
5232
+ * source order decides where they overlap, and they overlap on exactly one
5233
+ * element — a direct child of a surface nested inside another surface. That
5234
+ * child must keep the inner surface's inset, so the relay has to win.
5235
+ *
5236
+ * Public hooks:
5237
+ * --bleed-pad (read-only: the inset relayed to a surface's direct children;
5238
+ * pad a band that is not a header/footer/figure with it)
5239
+ *
5240
+ * Framework plumbing:
5241
+ * --surface-pad (declared by the surface)
5242
+ */
5243
+ :where(.card, .drawer, .accordion details > summary + *) > * * {
5244
+ --bleed-pad: initial;
5245
+ }
5171
5246
 
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;
5247
+ :where(.card, .drawer, .accordion details > summary + *) > * {
5248
+ --bleed-pad: var(--surface-pad);
5249
+ }
5179
5250
 
5180
- position: absolute;
5181
- z-index: 1;
5182
- inset-block-start: var(--space-20);
5183
- inset-inline-end: var(--space-20);
5251
+ /* Inert without a surface: the fallback resolves the negative margin to zero
5252
+ rather than dropping the declaration and inheriting a stray inset. */
5253
+ .bleed {
5254
+ margin-inline: calc(var(--bleed-pad, 0px) * -1);
5255
+ max-inline-size: none;
5256
+ }
5184
5257
 
5185
- inline-size: var(--control-size);
5186
- block-size: var(--control-size);
5258
+ .bleed:first-child {
5259
+ margin-block-start: calc(var(--bleed-pad, 0px) * -1);
5260
+ border-start-start-radius: inherit;
5261
+ border-start-end-radius: inherit;
5262
+ }
5187
5263
 
5188
- background: var(--surface-subtle);
5264
+ .bleed:last-child {
5265
+ margin-block-end: calc(var(--bleed-pad, 0px) * -1);
5266
+ border-end-start-radius: inherit;
5267
+ border-end-end-radius: inherit;
5189
5268
  }
5190
5269
 
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;
5270
+ /* Media bleed */
5271
+ :where(img, picture, video, canvas, svg).bleed {
5272
+ display: block;
5273
+ inline-size: calc(100% + var(--bleed-pad, 0px) * 2);
5274
+ max-inline-size: none;
5198
5275
  }
5199
5276
 
5200
- @media (hover: hover) {
5201
- .drawer-close:hover {
5202
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
5203
- color: var(--text);
5204
- }
5277
+ picture.bleed {
5278
+ overflow: hidden;
5205
5279
  }
5206
5280
 
5207
- .drawer-close:active {
5208
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
5281
+ picture.bleed > img {
5282
+ display: block;
5283
+ inline-size: 100%;
5284
+ block-size: auto;
5209
5285
  }
5210
5286
 
5211
- .drawer-close:focus-visible {
5212
- outline: 2px solid transparent;
5213
- box-shadow: var(--focus-ring-shadow);
5287
+ /* Padded structural bleed — the band keeps the surface's own rhythm while its
5288
+ background reaches the edge. */
5289
+ :where(header, footer, figure).bleed {
5290
+ padding: var(--bleed-pad, 0px);
5214
5291
  }
5215
5292
 
5216
5293
 
@@ -5231,6 +5308,7 @@ html.has-modal-open.had-scrollbar {
5231
5308
  * Public hooks:
5232
5309
  * --flyout-inline-size
5233
5310
  * --flyout-max-inline-size
5311
+ * --flyout-pad
5234
5312
  *
5235
5313
  * Internal:
5236
5314
  * --menu-item-size (derived from --control-size for menu rows)
@@ -5283,10 +5361,13 @@ html.has-modal-open.had-scrollbar {
5283
5361
  );
5284
5362
  overflow: auto;
5285
5363
  margin: 0;
5286
- padding: var(--space-10);
5364
+ /* Tight by default for action menus, whose items carry their own padding.
5365
+ Rich panels (forms, pickers) opt into dialog air per instance. */
5366
+ padding: var(--flyout-pad, var(--space-10));
5287
5367
  border: var(--border-width) solid var(--border);
5288
5368
  border-radius: var(--radius);
5289
5369
  background: var(--surface-raised);
5370
+ color: var(--text);
5290
5371
  box-shadow: var(--shadow-popout);
5291
5372
  list-style: none;
5292
5373
  opacity: 1;
@@ -5519,6 +5600,18 @@ html.has-modal-open.had-scrollbar {
5519
5600
  border-block-start: var(--border-width) solid var(--border);
5520
5601
  }
5521
5602
 
5603
+ @media (forced-colors: active) {
5604
+ /* The check mark is a currentColor mask fill and the mixed/radio marks are
5605
+ currentColor gradients: without this the system remaps them to Canvas or
5606
+ drops the image and the state disappears. Kept on the pseudo so the host
5607
+ keeps its native color and focus adaptation. */
5608
+ .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before,
5609
+ .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before,
5610
+ .menu-item[role="menuitemradio"]::before {
5611
+ forced-color-adjust: none;
5612
+ }
5613
+ }
5614
+
5522
5615
  /*
5523
5616
  * Tooltip — supplemental label overlaid near a trigger.
5524
5617
  *
@@ -5543,7 +5636,7 @@ html.has-modal-open.had-scrollbar {
5543
5636
  */
5544
5637
  .tooltip {
5545
5638
  --tooltip-bg: var(--surface-solid);
5546
- --tooltip-fg: var(--surface);
5639
+ --tooltip-fg: var(--surface-solid-fg);
5547
5640
  --tooltip-arrow-size: 5px;
5548
5641
 
5549
5642
  position: fixed;
@@ -5680,11 +5773,15 @@ html.has-modal-open.had-scrollbar {
5680
5773
  *
5681
5774
  * Public hooks:
5682
5775
  * --accordion-radius
5776
+ * --accordion-pad
5683
5777
  * --accordion-marker-color
5778
+ * --accordion-marker-size
5684
5779
  */
5685
5780
  .accordion {
5686
5781
  --accordion-radius: var(--radius);
5782
+ --accordion-pad: var(--space-40);
5687
5783
  --accordion-marker-color: var(--text-muted);
5784
+ --accordion-marker-size: 1rem;
5688
5785
 
5689
5786
  border: var(--border-width) solid var(--border);
5690
5787
  border-radius: var(--accordion-radius);
@@ -5701,7 +5798,7 @@ html.has-modal-open.had-scrollbar {
5701
5798
  gap: var(--space-30);
5702
5799
  cursor: pointer;
5703
5800
  list-style: none;
5704
- padding: var(--space-40);
5801
+ padding: var(--accordion-pad);
5705
5802
  color: var(--text);
5706
5803
  font-weight: var(--font-weight-medium);
5707
5804
  }
@@ -5717,12 +5814,17 @@ html.has-modal-open.had-scrollbar {
5717
5814
  display: none;
5718
5815
  }
5719
5816
 
5817
+ /* The marker is the chevron itself: the box is filled with
5818
+ --accordion-marker-color and then clipped to the glyph by the mask. Anything
5819
+ painted on this pseudo-element — a background, a ring, a shadow — is clipped
5820
+ with it, so a decorated marker (a chevron in a pill) needs a real element;
5821
+ see the accordion docs for the opt-out. */
5720
5822
  .accordion summary::after {
5721
5823
  content: "";
5722
5824
  flex: 0 0 auto;
5723
5825
  margin-inline-start: auto;
5724
- inline-size: 1rem;
5725
- block-size: 1rem;
5826
+ inline-size: var(--accordion-marker-size);
5827
+ block-size: var(--accordion-marker-size);
5726
5828
  background-color: var(--accordion-marker-color);
5727
5829
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
5728
5830
  mask: var(--icon-chevron) center / contain no-repeat;
@@ -5733,9 +5835,14 @@ html.has-modal-open.had-scrollbar {
5733
5835
  transform: rotate(180deg);
5734
5836
  }
5735
5837
 
5838
+ /* The panel, not the item, owns the inset here — .separated paints a card on
5839
+ <details> but leaves it unpadded — so the panel is what a .bleed child
5840
+ escapes (components/bleed.css). */
5736
5841
  .accordion details > summary + * {
5737
- padding-block: 0 var(--space-40);
5738
- padding-inline: var(--space-40);
5842
+ --surface-pad: var(--accordion-pad);
5843
+
5844
+ padding-block: 0 var(--accordion-pad);
5845
+ padding-inline: var(--accordion-pad);
5739
5846
  color: var(--text-muted);
5740
5847
  margin-block: 0;
5741
5848
  }
@@ -5744,6 +5851,9 @@ html.has-modal-open.had-scrollbar {
5744
5851
  padding-block-start: var(--space-10);
5745
5852
  }
5746
5853
 
5854
+ /* Also cancels the lift of a first .bleed child (bleed.css, lower
5855
+ specificity): the panel's top edge is the summary, not the item edge, so a
5856
+ bleed reaches only the sides and the bottom. */
5747
5857
  .accordion details > summary + * > :first-child {
5748
5858
  margin-block-start: 0;
5749
5859
  }
@@ -5768,6 +5878,12 @@ html.has-modal-open.had-scrollbar {
5768
5878
  padding-inline: 0;
5769
5879
  }
5770
5880
 
5881
+ /* Flush already sits at the embedding surface's edge, so there is no inline
5882
+ inset left for a .bleed child to cancel. */
5883
+ .accordion.flush details > summary + * {
5884
+ --surface-pad: 0px;
5885
+ }
5886
+
5771
5887
  /* Separated — each item owns a full card. The item's own border beats the
5772
5888
  shared details + details separator on specificity alone, so nothing needs
5773
5889
  undoing for the second item onwards. */
@@ -5784,9 +5900,23 @@ html.has-modal-open.had-scrollbar {
5784
5900
  background: var(--surface-raised);
5785
5901
  }
5786
5902
 
5903
+ /* A last .bleed child inherits its bottom corners from the panel, but the
5904
+ rounded box is the item (.separated) or the group (last item) — relay the
5905
+ radius to the panel. Explicit rather than inherit: in browsers with
5906
+ ::details-content the panel's flat-tree parent is that slot, not
5907
+ <details>. */
5908
+ .accordion.separated > details > summary + *,
5909
+ .accordion:not(.flush) > details:last-child > summary + * {
5910
+ border-end-start-radius: var(--accordion-radius);
5911
+ border-end-end-radius: var(--accordion-radius);
5912
+ }
5913
+
5787
5914
  @media (forced-colors: active) {
5788
- .accordion {
5789
- --accordion-marker-color: CanvasText;
5915
+ /* Written on the property, not the hook: an application hook override at
5916
+ the same specificity loads after this file and would win the variable,
5917
+ leaving a Canvas-remapped fill. The property keeps CanvasText. */
5918
+ .accordion summary::after {
5919
+ background-color: CanvasText;
5790
5920
  }
5791
5921
  }
5792
5922
 
@@ -6152,8 +6282,8 @@ html.has-modal-open.had-scrollbar {
6152
6282
  }
6153
6283
 
6154
6284
  .status-bar {
6155
- --status-bg: var(--intent, var(--text));
6156
- --status-fg: var(--intent-fg, var(--surface));
6285
+ --status-bg: var(--intent, var(--surface-solid));
6286
+ --status-fg: var(--intent-fg, var(--surface-solid-fg));
6157
6287
 
6158
6288
  position: fixed;
6159
6289
  /* Fixed positioning tracks the layout viewport, which the mobile software
@@ -6423,7 +6553,7 @@ a.avatar[aria-disabled="true"] {
6423
6553
  }
6424
6554
 
6425
6555
  .avatar-stack > .avatar {
6426
- box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface);
6556
+ box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface-opaque);
6427
6557
  }
6428
6558
 
6429
6559
  .avatar-stack > .avatar + .avatar {
@@ -6781,6 +6911,16 @@ a.avatar[aria-disabled="true"] {
6781
6911
  transform: none;
6782
6912
  }
6783
6913
 
6914
+ @media (forced-colors: active) {
6915
+ /* Both toggle markers are currentColor mask fills: without this the system
6916
+ remaps the backgrounds to Canvas and the glyphs disappear. Kept on the
6917
+ pseudos so the host keeps its native color and focus adaptation. */
6918
+ .fab > summary::before,
6919
+ .fab > summary::after {
6920
+ forced-color-adjust: none;
6921
+ }
6922
+ }
6923
+
6784
6924
  /*
6785
6925
  * Rating — integer star rating drawn on native radio inputs.
6786
6926
  *
@@ -6839,7 +6979,7 @@ a.avatar[aria-disabled="true"] {
6839
6979
  }
6840
6980
 
6841
6981
  .rating > input[type="radio"]:focus-visible {
6842
- outline: var(--focus-outline);
6982
+ outline: var(--focus-ring-width) solid var(--focus);
6843
6983
  outline-offset: var(--focus-outline-offset);
6844
6984
  background: var(--rating-color);
6845
6985
  }
@@ -6945,7 +7085,7 @@ a.avatar[aria-disabled="true"] {
6945
7085
  --step-size: 1.75rem;
6946
7086
  --step-inline-gap: var(--space-20);
6947
7087
  --step-line-size: var(--border-width);
6948
- --step-marker-bg: var(--surface);
7088
+ --step-marker-bg: var(--surface-opaque);
6949
7089
  --step-marker-border: var(--border);
6950
7090
  --step-marker-fg: var(--text-muted);
6951
7091
  --step-marker-radius: var(--radius-full);
@@ -7025,7 +7165,7 @@ a.avatar[aria-disabled="true"] {
7025
7165
  connector leaving current goes back to neutral. Declared after
7026
7166
  .step-complete at equal specificity: current wins on every channel. */
7027
7167
  .steps > [aria-current="step"] {
7028
- --step-marker-bg: var(--surface);
7168
+ --step-marker-bg: var(--surface-opaque);
7029
7169
  --step-marker-border: var(--state-selected);
7030
7170
  --step-marker-fg: var(--state-selected);
7031
7171
  --step-line: var(--border);
@@ -7064,7 +7204,7 @@ a.avatar[aria-disabled="true"] {
7064
7204
  focus ring's own width and offset. Only this composition scrolls, and only a
7065
7205
  link has a ring to protect. */
7066
7206
  .steps-horizontal:has(a[href]) {
7067
- padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
7207
+ padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7068
7208
  }
7069
7209
 
7070
7210
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -7305,6 +7445,15 @@ a.avatar[aria-disabled="true"] {
7305
7445
  color: var(--text);
7306
7446
  }
7307
7447
 
7448
+ /* Same contextual hover as .nav-link (navbar.css): a tint of the tile's ink
7449
+ reads on any --ui-* bar; the baseline keeps a legible light chip. */
7450
+ @supports (color: color-mix(in oklch, red, white)) {
7451
+ .app-nav > a:hover {
7452
+ background: color-mix(in oklch, currentColor 10%, transparent);
7453
+ color: var(--ui-fg, var(--text));
7454
+ }
7455
+ }
7456
+
7308
7457
  /* Current tile is already a full selected surface. Selection never changes
7309
7458
  text metrics: the shared medium weight is kept, no permanent indicator is
7310
7459
  added here. */
@@ -7392,11 +7541,16 @@ a.avatar[aria-disabled="true"] {
7392
7541
  display: inline-flex;
7393
7542
  align-items: center;
7394
7543
  min-block-size: var(--control-size-sm);
7544
+ padding-block: 0;
7395
7545
  padding-inline: var(--space-20);
7546
+ border: 0;
7396
7547
  border-radius: var(--radius);
7548
+ background: transparent;
7397
7549
  color: var(--ui-fg, var(--text-muted));
7550
+ font: inherit;
7398
7551
  font-weight: var(--font-weight-medium);
7399
7552
  text-decoration: none;
7553
+ cursor: pointer;
7400
7554
  }
7401
7555
 
7402
7556
  /* nav-link serves two contracts: plain page navigation (aria-current="page")
@@ -7421,6 +7575,28 @@ a.avatar[aria-disabled="true"] {
7421
7575
  color: var(--state-selected);
7422
7576
  }
7423
7577
 
7578
+ /* On a bar with its own --ui-bg/--ui-fg the fill is a tint of the link's own
7579
+ ink, the .btn.ghost hover recipe, so it reads on light and dark bars alike
7580
+ instead of snapping back to --surface-subtle. Not --ui-hover-bg: that is the
7581
+ bar's own hover and can equal its fill. The ink follows --ui-fg too — the
7582
+ selected accent has no contrast pair with an arbitrary bar. The baseline
7583
+ above keeps the light chip where color-mix is missing, with ink that is
7584
+ legible on it. */
7585
+ @supports (color: color-mix(in oklch, red, white)) {
7586
+ .nav-link:is(:hover, [aria-current]) {
7587
+ background: color-mix(in oklch, currentColor 10%, transparent);
7588
+ }
7589
+
7590
+ .nav-link:hover {
7591
+ color: var(--ui-fg, var(--text));
7592
+ }
7593
+
7594
+ .nav-link[aria-current],
7595
+ .nav-link[aria-current]:hover {
7596
+ color: var(--ui-fg, var(--state-selected));
7597
+ }
7598
+ }
7599
+
7424
7600
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links.
7425
7601
  Links fill each row so the whole line is an interactive target; the
7426
7602
  horizontal .navbar-nav links keep their intrinsic size. */
@@ -7512,8 +7688,9 @@ a.avatar[aria-disabled="true"] {
7512
7688
  *
7513
7689
  * Docs: docs/pages/components/pagination.md
7514
7690
  *
7515
- * Render .btn elements inside a .pagination list. The active page is marked
7516
- * with aria-current="page"; .btn owns the active state styling.
7691
+ * Render .btn.ghost elements inside a .pagination list. The active page is
7692
+ * marked with aria-current="page"; .btn owns the active state styling, so the
7693
+ * current page is the only filled item.
7517
7694
  *
7518
7695
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
7519
7696
  * — they are decorative, not actionable. They still get the same box sizing
@@ -7631,10 +7808,10 @@ a.avatar[aria-disabled="true"] {
7631
7808
  }
7632
7809
 
7633
7810
  /* The ring is square on the edge the tab is attached to, so it reads as part
7634
- of the strip rather than a floating chip. */
7811
+ of the strip rather than a floating chip. Color and width follow the
7812
+ component ring contract (focus.css); only the geometry is tab-specific. */
7635
7813
  .tab:focus-visible {
7636
- outline: var(--focus-outline);
7637
- outline-color: var(--focus);
7814
+ outline: var(--focus-ring-width) solid var(--focus);
7638
7815
  outline-offset: -2px;
7639
7816
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
7640
7817
  }
@@ -7716,6 +7893,9 @@ a.avatar[aria-disabled="true"] {
7716
7893
  * A plain <dl> handles richer relationships (several terms or descriptions)
7717
7894
  * and stays untouched by this component.
7718
7895
  *
7896
+ * Variants:
7897
+ * .stacked — value under its term rather than beside it.
7898
+ *
7719
7899
  * Why margin resets: neutralizes .prose list margins if .data-list appears
7720
7900
  * inside an authored rich-text scope; the grid owns row/column rhythm.
7721
7901
  */
@@ -7737,6 +7917,21 @@ a.avatar[aria-disabled="true"] {
7737
7917
  margin: 0;
7738
7918
  }
7739
7919
 
7920
+ /* Stacked — the value sits under its term instead of beside it, for narrow
7921
+ columns and for values too long to sit in a second column.
7922
+ Why the pair separation rides on the term's margin: the children alternate
7923
+ dt/dd with no element around a pair, so the grid has no row boundary to give
7924
+ a larger gap to. The tight gap is the row gap; the term reopens the space. */
7925
+ .data-list.stacked {
7926
+ grid-template-columns: minmax(0, 1fr);
7927
+ gap: var(--space-10);
7928
+ }
7929
+
7930
+ .data-list.stacked > dt + dt,
7931
+ .data-list.stacked > dd + dt {
7932
+ margin-block-start: var(--space-30);
7933
+ }
7934
+
7740
7935
  /*
7741
7936
  * Application list — repeated rows with optional leading and trailing regions.
7742
7937
  * Content determines the number of lines; the component only owns the row
@@ -7869,6 +8064,12 @@ a.list-item[aria-current]::before {
7869
8064
  * .table-wrap provides the scroll container and outer chrome; .table owns row
7870
8065
  * and cell rules. Tokens are sourced from the shared scale.
7871
8066
  *
8067
+ * Responsive strategy is horizontal overflow: the wrap scrolls, the table
8068
+ * keeps its columns. Restructuring rows into cards would destroy the
8069
+ * column scanability tables exist for, and needs data-label duplication
8070
+ * because CSS cannot join a cell to its header — that representation choice
8071
+ * belongs to the application, not the framework.
8072
+ *
7872
8073
  * Public hooks:
7873
8074
  * --table-min
7874
8075
  * --table-cell-pad
@@ -7989,7 +8190,7 @@ a.list-item[aria-current]::before {
7989
8190
  }
7990
8191
 
7991
8192
  .meter:not(meter) > * + * {
7992
- border-inline-start: var(--border-width) solid var(--surface);
8193
+ border-inline-start: var(--border-width) solid var(--surface-opaque);
7993
8194
  }
7994
8195
 
7995
8196
  .meter::-webkit-meter-bar {
@@ -8090,6 +8291,11 @@ a.list-item[aria-current]::before {
8090
8291
  min-block-size: var(--control-size);
8091
8292
  padding-inline: var(--space-30);
8092
8293
  border: var(--border-width) solid var(--control-border);
8294
+ /* An addon is a field segment: it honours the same lower-edge extension
8295
+ points as text controls (control.css), so a theme's underline or accent
8296
+ edge reaches it instead of stopping at the field. */
8297
+ border-block-end-color: var(--control-border-block-end-color, var(--control-border));
8298
+ border-block-end-width: var(--control-border-block-end-width, var(--border-width));
8093
8299
  border-radius: var(--radius);
8094
8300
  background: var(--control-bg);
8095
8301
  color: var(--control-fg);
@@ -8137,38 +8343,49 @@ a.list-item[aria-current]::before {
8137
8343
  z-index: 1;
8138
8344
  }
8139
8345
 
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
8346
  /* An input's border must read as the "primary" edge against an attached
8147
8347
  action (e.g. a password-reveal button) at all times, not only while
8148
8348
  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) {
8349
+ user's pointer moves, which reads as a rendering glitch. The :has()
8350
+ variant covers a control nested one wrapper level deep (e.g. a combobox
8351
+ element): the wrapper is the flex item, so it carries the z-index — a
8352
+ z-index on the inner control would be a no-op on a static grandchild. */
8353
+ .join > :is(.input, .textarea, .select),
8354
+ .join > :has(> :is(.input, .textarea, .select)) {
8151
8355
  z-index: 3;
8152
8356
  }
8153
8357
 
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);
8358
+ /* Keep an active focus line above every neighbour, including an adjacent
8359
+ field and a hovered control (some platforms keep focus on the input while
8360
+ a button is clicked). It must follow the primary-edge rule above: both are
8361
+ (0,2,0), and when it came first a focused field stayed at 3 and the next
8362
+ field, painted later, covered the 1px shared edge of its line. */
8363
+ .join > :is(:focus, :focus-within) {
8364
+ z-index: 4;
8161
8365
  }
8162
8366
 
8163
- .join:has(> :is(.input, .textarea, .select)) > :is(.input, .textarea, .select):focus-visible {
8164
- outline: none;
8165
- box-shadow: none;
8367
+ /* One wrapper level (e.g. a combobox element around the native control) is
8368
+ supported generically — no widget is named here. The wrapper is the flex
8369
+ item that receives the corner longhands above; the inner control inherits
8370
+ them. The wrapper is also what align-items stretches: block-size: 100%
8371
+ resolves against that stretched size, so the field fills it. :hover and
8372
+ :focus-within already propagate to the wrapper, so the z-index rules above
8373
+ need no duplication. */
8374
+ .join > * > :is(.input, .textarea, .select) {
8375
+ block-size: 100%;
8376
+ border-radius: inherit;
8166
8377
  }
8167
8378
 
8379
+ /* Text controls paint their own inset focus outline (control.css), so a
8380
+ .join never moves the ring to the group: button-only groups and wrapped
8381
+ inputs alike keep per-control focus, with no :has() group treatment. */
8382
+
8168
8383
  /* A default outline action belongs to the composite field border. An explicit
8169
8384
  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 {
8385
+ for a neutral button (the :where(.btn) boundary), so the fallback applies.
8386
+ The second :has() covers a control nested one wrapper level deep. */
8387
+ .join:has(> :is(.input, .textarea, .select)) > .btn.outline,
8388
+ .join:has(> * > :is(.input, .textarea, .select)) > .btn.outline {
8172
8389
  --btn-border-color: var(--intent, var(--border));
8173
8390
  }
8174
8391
 
@@ -8210,36 +8427,55 @@ a.list-item[aria-current]::before {
8210
8427
 
8211
8428
  .aura {
8212
8429
  --aura-color: var(--intent, var(--primary));
8213
- --aura-width: 2px;
8430
+ --aura-width: 1px;
8214
8431
  --aura-blur: 0.25rem;
8215
- --aura-intensity: 1;
8432
+ --aura-intensity: 0.4;
8216
8433
  --aura-radius: var(--radius-lg);
8217
8434
  --aura-duration: 6s;
8218
8435
 
8219
8436
  position: relative;
8220
8437
  isolation: isolate;
8221
8438
  display: inline-grid;
8222
- padding: var(--aura-width);
8223
8439
  border-radius: calc(var(--aura-radius) + var(--aura-width));
8224
- border: var(--border-width) solid transparent;
8440
+
8441
+ /* The frame is the border itself, so --aura-width is the painted thickness.
8442
+ A transparent border stays for forced colors, where it becomes the visible
8443
+ boundary; a padding frame inside it would paint both, doubling the line. */
8444
+ border: var(--aura-width) solid transparent;
8225
8445
  background: var(--aura-color);
8226
8446
 
8227
8447
  /* A short feathered accent rather than a long wedge with a hard edge, which
8228
8448
  reads as one lit side. Nothing tints the frame outside it: a constant tint
8229
- across 2px draws an outline, the opposite of the effect. */
8449
+ across the frame draws an outline, the opposite of the effect. `border-box`
8450
+ sizes the gradient to the border box: the default padding-box origin would
8451
+ tile a neighbouring copy of the gradient into the border, which is now the
8452
+ whole frame. */
8230
8453
  background: conic-gradient(
8231
- from var(--aura-angle),
8232
- transparent 0 78%,
8233
- var(--aura-color) 93%,
8234
- transparent
8235
- );
8236
- }
8237
-
8238
- /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
8454
+ from var(--aura-angle),
8455
+ transparent 0 78%,
8456
+ var(--aura-color) 93%,
8457
+ transparent
8458
+ )
8459
+ border-box;
8460
+
8461
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
8462
+ legible, so there is nothing to gain from animating a second wide blurred
8463
+ layer. This is the layer that stays lit at all times, so it — not the
8464
+ frame — is what keeps the object present between two passes of the
8465
+ accent. An outer shadow never paints under the box, so it stays out of a
8466
+ translucent child. min() because a color-mix() share above 100% is
8467
+ invalid, where the opacity it replaces clamped. */
8468
+ box-shadow: 0 0 calc(var(--aura-blur) * 8) var(--aura-blur)
8469
+ color-mix(in oklab, var(--aura-color) min(100%, calc(15% * var(--aura-intensity))), transparent);
8470
+ }
8471
+
8472
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. An
8473
+ absolute inset starts at the padding edge, inside the border that is the
8474
+ frame, so the negative inset puts the halo back on the frame. */
8239
8475
  .aura::before {
8240
8476
  position: absolute;
8241
8477
  z-index: -1;
8242
- inset: 0;
8478
+ inset: calc(-1 * var(--aura-width));
8243
8479
  border-radius: inherit;
8244
8480
  background: inherit;
8245
8481
  filter: blur(var(--aura-blur));
@@ -8248,29 +8484,22 @@ a.list-item[aria-current]::before {
8248
8484
  content: "";
8249
8485
  }
8250
8486
 
8251
- /* Far halo — flat and static. At this blur the rotating detail is no longer
8252
- legible, so there is nothing to gain from animating a second wide blurred
8253
- layer. It outgrows the frame, otherwise the child covers it and the whole
8254
- effect reads as a drop shadow. This is the layer that stays lit at all
8255
- times, so it — not the frame — is what keeps the object present between two
8256
- passes of the accent. */
8487
+ /* Occlusion — the frame and the near halo paint across the whole box, and only
8488
+ their edge is meant to show. This mask sits above both, under the child, so
8489
+ a transparent or translucent child (an .outline alert, a glass surface) is
8490
+ framed rather than filled with the frame color. It follows the child's
8491
+ shape through --aura-radius. */
8257
8492
  .aura::after {
8258
8493
  position: absolute;
8259
8494
  z-index: -1;
8260
- inset: calc(-1 * var(--aura-blur));
8261
- border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
8262
- background: var(--aura-color);
8263
- filter: blur(calc(var(--aura-blur) * 4));
8264
- opacity: calc(0.15 * var(--aura-intensity));
8495
+ inset: 0;
8496
+ border-radius: var(--aura-radius);
8497
+ background: var(--surface-opaque);
8265
8498
  pointer-events: none;
8266
8499
  content: "";
8267
8500
  }
8268
8501
 
8269
- /* Lifts the child above both halos. It also has to be opaque: the frame paints
8270
- across the whole box and only its edge is meant to show, so a transparent
8271
- child (an .outline alert, a .ghost button) is filled with the frame color
8272
- rather than framed by it. Documented as a constraint, not worked around —
8273
- masking a ring would cost more than the case is worth. */
8502
+ /* Lifts the child above the halo and the mask. */
8274
8503
  .aura > * {
8275
8504
  position: relative;
8276
8505
  }
@@ -8293,7 +8522,7 @@ a.list-item[aria-current]::before {
8293
8522
  animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
8294
8523
  }
8295
8524
 
8296
- .aura-glow::after {
8525
+ .aura-glow {
8297
8526
  animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
8298
8527
  }
8299
8528
  }
@@ -8305,8 +8534,8 @@ a.list-item[aria-current]::before {
8305
8534
  @media (prefers-reduced-motion: reduce) {
8306
8535
  .aura:not(.aura-glow) {
8307
8536
  background:
8308
- radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
8309
- radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
8537
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%) border-box,
8538
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%) border-box;
8310
8539
  }
8311
8540
  }
8312
8541
 
@@ -8327,10 +8556,16 @@ a.list-item[aria-current]::before {
8327
8556
  }
8328
8557
  }
8329
8558
 
8559
+ /* The far halo is the element's own shadow: opening its spread and share
8560
+ breathes it without scaling the child. */
8330
8561
  @keyframes aura-breathe-far {
8331
8562
  50% {
8332
- opacity: calc(0.28 * var(--aura-intensity));
8333
- transform: scale(1.07);
8563
+ box-shadow: 0 0 calc(var(--aura-blur) * 10) calc(var(--aura-blur) * 2)
8564
+ color-mix(
8565
+ in oklab,
8566
+ var(--aura-color) min(100%, calc(28% * var(--aura-intensity))),
8567
+ transparent
8568
+ );
8334
8569
  }
8335
8570
  }
8336
8571
 
@@ -8350,6 +8585,64 @@ a.list-item[aria-current]::before {
8350
8585
  --aura-radius: var(--radius-full);
8351
8586
  }
8352
8587
 
8588
+ /*
8589
+ * Highlight sweeping across a text label — an activity in progress.
8590
+ *
8591
+ * Docs: docs/pages/components/shimmer.md
8592
+ *
8593
+ * Presentation only: it carries no state and selects no ARIA attribute, so the
8594
+ * label stays whatever the author makes it (a role="status" message, a list
8595
+ * item, a heading).
8596
+ *
8597
+ * Public hooks:
8598
+ * --shimmer-color
8599
+ * --shimmer-highlight
8600
+ */
8601
+ .shimmer {
8602
+ --shimmer-color: var(--text-muted);
8603
+ --shimmer-highlight: var(--text);
8604
+
8605
+ color: var(--shimmer-color);
8606
+ }
8607
+
8608
+ /* The sweep paints the glyphs through a clipped background, which needs the
8609
+ glyph fill transparent. Without background-clip: text that would erase the
8610
+ label, so the transparency lives under the same query as the clip. It is
8611
+ the fill, not `color`: a label class that sets its own color (.muted, a
8612
+ caption style) would otherwise win the cascade and paint opaque glyphs over
8613
+ the sweep. Reduced motion keeps the plain label — a frozen band would read
8614
+ as a random tint. */
8615
+ @supports (background-clip: text) {
8616
+ @media (prefers-reduced-motion: no-preference) {
8617
+ .shimmer {
8618
+ -webkit-text-fill-color: transparent;
8619
+ background:
8620
+ linear-gradient(
8621
+ 90deg,
8622
+ var(--shimmer-color) 40%,
8623
+ var(--shimmer-highlight) 50%,
8624
+ var(--shimmer-color) 60%
8625
+ )
8626
+ 0 0 / 300% 100%;
8627
+ background-clip: text;
8628
+ animation: actual-shimmer var(--duration-shimmer) linear infinite;
8629
+ }
8630
+ }
8631
+ }
8632
+
8633
+ /* The band occupies the middle third of a background three times the label's
8634
+ width, so each pass leaves the end of the label before the next one enters
8635
+ at the start. */
8636
+ @keyframes actual-shimmer {
8637
+ from {
8638
+ background-position: 100% 0;
8639
+ }
8640
+
8641
+ to {
8642
+ background-position: 0% 0;
8643
+ }
8644
+ }
8645
+
8353
8646
  /* Utilities — small, single-purpose escape hatches. */
8354
8647
  /*
8355
8648
  * Small, single-purpose utilities for common escape hatches.
@@ -8537,9 +8830,9 @@ a.list-item[aria-current]::before {
8537
8830
  }
8538
8831
 
8539
8832
  /*
8540
- * Optional utility helpers.
8541
- *
8542
- * These classes are intentionally not part of the core API.
8833
+ * Extra utility helpers — explicit property/value names beside the curated
8834
+ * shortcuts of base.css. Both are imported by utilities/index.css and are
8835
+ * equally public (docs/design-notes/naming.md § Utilities).
8543
8836
  * They are escape hatches for layout nudges, not a full atomic scale.
8544
8837
  *
8545
8838
  * Core principle:
@@ -8715,8 +9008,9 @@ a.list-item[aria-current]::before {
8715
9008
  overflow-y: auto;
8716
9009
  }
8717
9010
 
8718
- /* Text */
8719
- .white-space-normal {
9011
+ /* Text. .text-wrap is the counterpart of base's .text-nowrap: it restores
9012
+ wrapping inside a component that sets nowrap (.badge). */
9013
+ .text-wrap {
8720
9014
  white-space: normal;
8721
9015
  }
8722
9016
 
@@ -8789,7 +9083,7 @@ a.list-item[aria-current]::before {
8789
9083
  /* Surface. These are semantic surfaces, not arbitrary paint: each owns its
8790
9084
  matching ink and rebinds --heading to that ink, so it stays readable when it
8791
9085
  recreates a light surface inside a context that installed a dark one
8792
- (.inverted). Intent ink is not inherited through a surface utility; use
9086
+ (e.g. a --surface-solid band). Intent ink is not inherited through a surface utility; use
8793
9087
  .intent-color for that. */
8794
9088
  .background-surface {
8795
9089
  --heading: currentColor;