actual-css 0.7.0 → 0.8.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 (141) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +13 -11
  3. package/components.json +668 -0
  4. package/dist/actual.css +166 -37
  5. package/dist/actual.full.css +963 -603
  6. package/dist/actual.full.js +1 -1
  7. package/dist/actual.full.min.css +1 -1
  8. package/dist/actual.js +1 -1
  9. package/dist/actual.min.css +1 -1
  10. package/docs/design-notes/accessibility-decisions.md +33 -0
  11. package/docs/design-notes/browser-support.md +62 -30
  12. package/docs/design-notes/column-layout.md +3 -3
  13. package/docs/design-notes/css-anchor.md +75 -21
  14. package/docs/design-notes/enhancement-contract.md +14 -6
  15. package/docs/design-notes/grid.md +4 -4
  16. package/docs/design-notes/naming.md +6 -1
  17. package/docs/design-notes/platform-alignment.md +53 -7
  18. package/docs/design-notes/popover-manual-poc.md +105 -25
  19. package/docs/design-notes/surface-reparenting.md +56 -47
  20. package/docs/design-notes/tooltip-coordinate-space.md +132 -0
  21. package/docs/design-notes/widget-primitives.md +7 -4
  22. package/docs/pages/components/accordion.md +41 -2
  23. package/docs/pages/components/alert.md +80 -30
  24. package/docs/pages/components/app-navigation.md +6 -0
  25. package/docs/pages/components/avatar.md +6 -6
  26. package/docs/pages/components/badge.md +101 -16
  27. package/docs/pages/components/button.md +16 -8
  28. package/docs/pages/components/card.md +10 -10
  29. package/docs/pages/components/dialog.md +16 -6
  30. package/docs/pages/components/fab.md +4 -3
  31. package/docs/pages/components/list.md +42 -4
  32. package/docs/pages/components/navbar.md +17 -1
  33. package/docs/pages/components/pagination.md +2 -2
  34. package/docs/pages/components/steps.md +19 -0
  35. package/docs/pages/components/table.md +3 -2
  36. package/docs/pages/enhancements/flyout.md +66 -36
  37. package/docs/pages/enhancements/overview.md +25 -0
  38. package/docs/pages/enhancements/status-bar.md +1 -1
  39. package/docs/pages/enhancements/tabs.md +45 -37
  40. package/docs/pages/enhancements/tooltip.md +36 -10
  41. package/docs/pages/examples/overview.md +38 -5
  42. package/docs/pages/forms/choice-cards.md +6 -2
  43. package/docs/pages/forms/combobox.md +1 -1
  44. package/docs/pages/forms/floating-field.md +4 -3
  45. package/docs/pages/forms/joined-controls.md +22 -0
  46. package/docs/pages/forms/otp.md +2 -1
  47. package/docs/pages/forms/overview.md +1 -1
  48. package/docs/pages/forms/states-and-sizes.md +16 -18
  49. package/docs/pages/forms/sticky-actions.md +2 -2
  50. package/docs/pages/forms/switch.md +1 -1
  51. package/docs/pages/foundations/philosophy.md +57 -26
  52. package/docs/pages/foundations/theming.md +2 -2
  53. package/docs/pages/foundations/tokens.md +118 -21
  54. package/docs/pages/foundations/typography.md +35 -14
  55. package/docs/pages/guides/introduction.md +11 -9
  56. package/docs/pages/guides/progressive-enhancement.md +115 -30
  57. package/docs/pages/guides/tailwind.md +3 -2
  58. package/docs/pages/layout/media.md +1 -1
  59. package/docs/pages/layout/overview.md +11 -1
  60. package/docs/pages/layout/scroll-snap.md +3 -3
  61. package/docs/pages/layout/stack.md +17 -9
  62. package/docs/pages/patterns/icon-input.md +4 -2
  63. package/docs/pages/patterns/icon-slot.md +2 -0
  64. package/docs/pages/patterns/intent-nesting.md +1 -1
  65. package/docs/pages/utilities/overview.md +23 -2
  66. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  67. package/docs/pages/utilities/spacing.md +10 -8
  68. package/llms.txt +2 -1
  69. package/package.json +17 -8
  70. package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
  71. package/src/css/components/accordion.css +42 -3
  72. package/src/css/components/alert.css +25 -13
  73. package/src/css/components/app-nav.css +18 -8
  74. package/src/css/components/avatar.css +13 -1
  75. package/src/css/components/badge.css +78 -12
  76. package/src/css/components/breadcrumb.css +2 -3
  77. package/src/css/components/button.css +9 -16
  78. package/src/css/components/card.css +9 -2
  79. package/src/css/components/chat.css +1 -0
  80. package/src/css/components/data-list.css +1 -1
  81. package/src/css/components/drawer.css +79 -25
  82. package/src/css/components/fab.css +0 -6
  83. package/src/css/components/flyout.css +35 -98
  84. package/src/css/components/index.css +0 -1
  85. package/src/css/components/indicator.css +21 -0
  86. package/src/css/components/key.css +10 -0
  87. package/src/css/components/list.css +40 -4
  88. package/src/css/components/menu.css +2 -1
  89. package/src/css/components/modal.css +48 -33
  90. package/src/css/components/navbar.css +37 -4
  91. package/src/css/components/pagination.css +1 -0
  92. package/src/css/components/spinner.css +6 -6
  93. package/src/css/components/status-bar.css +0 -5
  94. package/src/css/components/steps.css +45 -59
  95. package/src/css/components/tab.css +29 -9
  96. package/src/css/components/table.css +5 -5
  97. package/src/css/components/tooltip.css +36 -40
  98. package/src/css/core/base.css +23 -1
  99. package/src/css/core/intents.css +17 -6
  100. package/src/css/core/theme.css +27 -0
  101. package/src/css/core/tokens.css +35 -15
  102. package/src/css/core/variants.css +64 -15
  103. package/src/css/forms/choice-card.css +4 -4
  104. package/src/css/forms/choice.css +6 -3
  105. package/src/css/forms/control.css +2 -1
  106. package/src/css/forms/floating-field.css +3 -3
  107. package/src/css/forms/form.css +4 -3
  108. package/src/css/forms/input-icon.css +20 -14
  109. package/src/css/forms/native.css +6 -1
  110. package/src/css/forms/otp.css +9 -1
  111. package/src/css/forms/range.css +2 -0
  112. package/src/css/forms/select.css +29 -9
  113. package/src/css/forms/switch.css +19 -5
  114. package/src/css/layout/app-layout.css +1 -1
  115. package/src/css/layout/grid.css +50 -76
  116. package/src/css/layout/scroll-snap.css +11 -7
  117. package/src/css/layout/scroller.css +3 -4
  118. package/src/css/layout/stack.css +2 -2
  119. package/src/css/typography/fluid.css +4 -3
  120. package/src/css/typography/prose.css +6 -5
  121. package/src/css/utilities/base.css +2 -1
  122. package/src/css/utilities/extra.css +20 -17
  123. package/src/js/context-menu.js +27 -26
  124. package/src/js/dialog.js +2 -1
  125. package/src/js/dismiss.js +5 -0
  126. package/src/js/enhance.js +34 -2
  127. package/src/js/enhancement-loader.js +36 -43
  128. package/src/js/events.js +0 -3
  129. package/src/js/filter.js +0 -1
  130. package/src/js/floating-space.js +42 -0
  131. package/src/js/flyout.js +9 -44
  132. package/src/js/focus-group.js +7 -0
  133. package/src/js/focus.js +6 -0
  134. package/src/js/menu.js +10 -1
  135. package/src/js/selectors.js +5 -9
  136. package/src/js/status.js +6 -5
  137. package/src/js/surface.js +97 -139
  138. package/src/js/tab.js +6 -1
  139. package/src/js/tooltip.js +150 -51
  140. package/src/js/validation.js +1 -1
  141. package/src/css/components/surface.css +0 -49
@@ -74,9 +74,9 @@ select {
74
74
  --line-height: 1.5;
75
75
  --line-height-tight: 1.25;
76
76
  --line-height-relaxed: 1.75;
77
- --font-weight: 400;
77
+ --font-weight-normal: 400;
78
78
  --font-weight-medium: 500;
79
- --font-weight-strong: 650;
79
+ --font-weight-semibold: 600;
80
80
  --font-weight-bold: 700;
81
81
 
82
82
  /* Typography scale */
@@ -91,7 +91,6 @@ select {
91
91
  applications, and no future framework step can collide with them. */
92
92
  --gap: 0.75rem;
93
93
  --density-space: var(--space-40);
94
- --density-compact-size: 1.5rem;
95
94
  --space-10: 0.25rem;
96
95
  --space-20: 0.5rem;
97
96
  --space-30: 0.75rem;
@@ -117,7 +116,10 @@ select {
117
116
  beyond this vocabulary. Toggles that share one transition list at
118
117
  --duration-fast (flyout, tooltip) and pure-opacity backdrops stay on
119
118
  `ease`: at 100ms the curve has almost no resolving time, and a scrim is
120
- not traveling anywhere. */
119
+ not traveling anywhere. Animation policy: reset.css is the global safety
120
+ net; every meaningful @keyframes is explicitly gated behind
121
+ prefers-reduced-motion: no-preference; ordinary transitions rely on the
122
+ reset alone. */
121
123
  --duration: 150ms;
122
124
  --duration-fast: 100ms;
123
125
  --duration-slow: 200ms;
@@ -148,20 +150,29 @@ select {
148
150
  --bar-height: 0.65rem;
149
151
 
150
152
  /* Controls — inputs and buttons share --control-size so heights always match.
151
- .sm/.lg (variants.css) switch --control-size; --control-font-size stays
152
- at its baseline because density does not change typography.
153
+ .compact/.spacious (variants.css) switch geometry without changing
154
+ --control-font-size. Local .sm/.lg component sizes switch all three.
153
155
 
154
156
  Inline choices (checkbox, radio, switch) are typographic, not field-sized:
155
157
  they sit on a line of text, so they follow --control-font-size through
156
158
  --choice-control-size instead of --control-size. Being an em value in
157
159
  :root is deliberate — an unregistered custom property resolves its units
158
160
  at the element that consumes it, so one declaration tracks whatever font
159
- size the choice actually renders at. */
161
+ size the choice actually renders at.
162
+
163
+ --control-pad-x is the opposite kind of token: a resolved geometry length,
164
+ not an em ratio. Every part of one control must see the same horizontal
165
+ distance, so it is stated in the typographic tokens rather than an em
166
+ whose value would change on a descendant with its own font size (the
167
+ input-icon glyph, made larger by --input-icon-size). It mirrors
168
+ --control-font-size and is rebound beside it at the .sm/.lg boundaries
169
+ (variants.css); declaring it only in :root would freeze the md value,
170
+ because a var() inside a custom property resolves where it is declared. */
160
171
  --control-size-sm: 2rem;
161
172
  --control-size-md: 2.375rem;
162
173
  --control-size-lg: 2.75rem;
163
174
  --control-size: var(--control-size-md);
164
- --control-pad-x: 1em;
175
+ --control-pad-x: var(--font-size-md);
165
176
  --control-font-size: var(--font-size-md);
166
177
  --choice-control-size: 1.125em;
167
178
  --disabled-opacity: 0.65;
@@ -194,7 +205,10 @@ select {
194
205
  --soft-hover-alpha: 12%;
195
206
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
196
207
  pure intent color; lower values rebate it toward --text, which vivid or
197
- light intent palettes need to stay legible on their own soft surface. */
208
+ light intent palettes need to stay legible on their own soft surface.
209
+ The default palette ships per-role --*-soft-fg hooks calibrated against
210
+ the hovered soft fill instead (theme.css); this mix is the fallback a
211
+ theme without hooks falls back to. */
198
212
  --soft-fg-mix: 100%;
199
213
 
200
214
  /* Shared icon assets. Override these URLs when a strict CSP disallows data:. */
@@ -210,13 +224,14 @@ select {
210
224
  --viewport-block: 100vh;
211
225
  --viewport-inline: 100vw;
212
226
 
213
- /* Z-index scale — used ONLY by non-dialog overlays (see the layering
214
- note below for the full model). Actual CSS reserves multiples of 10 for
215
- framework layers; applications may use intermediate values for local
216
- layering. */
227
+ /* Z-index scale — orders document-layer components only. Native top-layer
228
+ elements, dialogs and popovers alike, do not participate in this ordering:
229
+ they always paint above the document, and their order among themselves is
230
+ their top-layer promotion order. No z-index value here can reach them.
231
+ Actual CSS reserves multiples of 10 for framework layers; applications may
232
+ use intermediate values for local layering. */
217
233
  --z-sticky: 10;
218
234
  --z-menu: 20;
219
- --z-tooltip: 50;
220
235
  --z-status: 60;
221
236
  }
222
237
 
@@ -251,7 +266,12 @@ select {
251
266
  --shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
252
267
  }
253
268
 
254
- :root {
269
+ /* Theme-derived like the shadows above, so it is declared on the same
270
+ boundaries: an alias resolved only on :root is inherited already composed,
271
+ and a [data-theme] island overriding --backdrop-color or
272
+ --backdrop-opacity would not change the scrim of a dialog inside it. */
273
+ :root,
274
+ [data-theme] {
255
275
  --backdrop-fill: color-mix(
256
276
  in oklch,
257
277
  var(--backdrop-color) calc(var(--backdrop-opacity) * 100%),
@@ -363,6 +383,33 @@ select {
363
383
  --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
364
384
  }
365
385
 
386
+ /* Soft foreground hooks — the soft ink is the intent rebated toward the local
387
+ text so it keeps 4.5:1 against the soft hover fill (the resting fill carries
388
+ the extra margin; variants.css soft recipe). Deliberately scoped to the
389
+ default theme: a custom property bakes its var() references where it is
390
+ declared, so these resolve against root/light/dark tokens on every boundary.
391
+ Presets and custom islands keep their --soft-fg-mix / raw-intent ink unless
392
+ they declare hooks themselves — :where() keeps this reset from outranking a
393
+ theme's own declaration (e.g. brutalist's 45% soft-fg-mix). */
394
+ :where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
395
+ --primary-soft-fg: initial;
396
+ --secondary-soft-fg: initial;
397
+ --success-soft-fg: initial;
398
+ --warning-soft-fg: initial;
399
+ --danger-soft-fg: initial;
400
+ --neutral-soft-fg: initial;
401
+ }
402
+
403
+ :root,
404
+ [data-theme="light"],
405
+ [data-theme="dark"] {
406
+ --secondary-soft-fg: color-mix(in oklab, var(--text) 10%, var(--secondary) 90%);
407
+ --success-soft-fg: color-mix(in oklab, var(--text) 15%, var(--success) 85%);
408
+ --warning-soft-fg: color-mix(in oklab, var(--text) 25%, var(--warning) 75%);
409
+ --danger-soft-fg: color-mix(in oklab, var(--text) 15%, var(--danger) 85%);
410
+ --neutral-soft-fg: color-mix(in oklab, var(--text) 20%, var(--neutral) 80%);
411
+ }
412
+
366
413
  /* Native platform controls (checkbox, radio, range, progress) that are not
367
414
  restyled by a component class still follow the theme accent. Declared on
368
415
  every island: accent-color inherits as a computed value, so islands must
@@ -471,6 +518,7 @@ select {
471
518
  *
472
519
  * Public hooks:
473
520
  * --link
521
+ * --hr-space
474
522
  */
475
523
 
476
524
  body {
@@ -512,10 +560,23 @@ hgroup > * {
512
560
  margin-block: 0;
513
561
  }
514
562
 
563
+ /* Section break.
564
+
565
+ margin-inline: 0 overrides the UA's `margin: 0.5em auto`. In block flow the
566
+ auto margins resolve to zero and nothing changes, but as a flex or grid item
567
+ an inline auto margin replaces the stretch — an <hr> in a .stack computed to
568
+ width 0 and vanished. .stack deliberately leaves the inline axis free, so
569
+ the reset belongs here.
570
+
571
+ --hr-space tunes the block rhythm on the instance: a rule closing a page
572
+ section and a rule separating two rows in a panel are the same element at
573
+ very different scales. It applies to an hr in document flow; inside .stack
574
+ or .prose the parent owns the spacing between siblings and wins. */
515
575
  hr {
516
576
  border: 0;
517
577
  border-block-start: var(--border-width) solid var(--border);
518
- margin-block: var(--space-60);
578
+ margin-block: var(--hr-space, var(--space-60));
579
+ margin-inline: 0;
519
580
  }
520
581
 
521
582
  code,
@@ -525,6 +586,14 @@ pre {
525
586
  font-family: var(--font-mono);
526
587
  }
527
588
 
589
+ /* Overflow containment only. One unbreakable line — a URL, a minified token, a
590
+ wide ASCII table — otherwise widens the whole page, and an author cannot see
591
+ it coming from the markup. Background, padding and radius stay out: those
592
+ belong to .prose or to an application code-block component. */
593
+ pre {
594
+ overflow-x: auto;
595
+ }
596
+
528
597
  small {
529
598
  font-size: 0.875em;
530
599
  }
@@ -536,36 +605,47 @@ small {
536
605
 
537
606
  /*
538
607
  * Intent classes do not style elements directly.
539
- * They only expose --intent and --intent-fg.
540
- * Components and variants decide how to consume them. Each component that
541
- * consumes --intent or --ui-* opens a local intent boundary in a :where()
542
- * block that resets exactly the tokens it consumes to initial. Parent intents
543
- * do not leak into nested components. Local intent classes (.primary, .danger,
544
- * etc.) still apply because they have higher specificity than :where().
608
+ * They only expose --intent, --intent-fg, and the optional per-intent soft
609
+ * foreground --intent-soft-fg, relayed from the per-role --*-soft-fg hooks.
610
+ * The default theme declares those hooks for its soft ink (see theme.css);
611
+ * a theme without them lets the relay resolve to an invalid value, so the soft
612
+ * recipe falls back to its --soft-fg-mix derivation and a theme opts in one
613
+ * role at a time (docs/pages/foundations/tokens.md). Components and variants
614
+ * decide how to consume them. Each component that consumes --intent,
615
+ * --intent-soft-fg or --ui-* opens a local intent boundary in a :where() block
616
+ * that resets exactly the tokens it consumes to initial. Parent intents do not
617
+ * leak into nested components. Local intent classes (.primary, .danger, etc.)
618
+ * still apply because they have higher specificity than :where().
545
619
  */
546
620
  .primary {
547
621
  --intent: var(--primary);
548
622
  --intent-fg: var(--primary-fg);
623
+ --intent-soft-fg: var(--primary-soft-fg);
549
624
  }
550
625
  .secondary {
551
626
  --intent: var(--secondary);
552
627
  --intent-fg: var(--secondary-fg);
628
+ --intent-soft-fg: var(--secondary-soft-fg);
553
629
  }
554
630
  .success {
555
631
  --intent: var(--success);
556
632
  --intent-fg: var(--success-fg);
633
+ --intent-soft-fg: var(--success-soft-fg);
557
634
  }
558
635
  .warning {
559
636
  --intent: var(--warning);
560
637
  --intent-fg: var(--warning-fg);
638
+ --intent-soft-fg: var(--warning-soft-fg);
561
639
  }
562
640
  .danger {
563
641
  --intent: var(--danger);
564
642
  --intent-fg: var(--danger-fg);
643
+ --intent-soft-fg: var(--danger-soft-fg);
565
644
  }
566
645
  .neutral {
567
646
  --intent: var(--neutral);
568
647
  --intent-fg: var(--neutral-fg);
648
+ --intent-soft-fg: var(--neutral-soft-fg);
569
649
  }
570
650
 
571
651
  /* Paint an inherited or local intent on a simple foreground element without
@@ -591,16 +671,15 @@ small {
591
671
  * components/badge.css — .badge default soft behind explicit --ui-*
592
672
  * components/alert.css — .alert default soft behind explicit --ui-*
593
673
  *
594
- * .sm/.lg set shared density tokens: spacing rhythm and geometry only.
595
- * Density never changes typography — controls keep their font-size and
596
- * content components keep theirs. Controls consume --control-size; non-control
597
- * components may consume --density-* when the density context should affect
598
- * their local rhythm. Compact components may consume --density-compact-size.
599
- * Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
600
- * applying .sm/.lg to card, dialog, accordion, navbar, table, progress, meter
601
- * is a no-op. Progress and meter are deliberate: a bar keeps its inline size
602
- * and only changes thickness, which --bar-height expresses better than a
603
- * three-step scale.
674
+ * .compact/.spacious set inherited density tokens: spacing rhythm and geometry
675
+ * only. Density never changes typography or icon size. Controls consume
676
+ * --control-size; non-control components may consume --density-* when the
677
+ * context should affect their local rhythm.
678
+ *
679
+ * .sm/.lg are local size roles, not shared measurements. Shared control
680
+ * participants below map both typography and geometry; components with
681
+ * bespoke geometry (badge, spinner, avatar, rating, key, prose) own those
682
+ * mappings locally. Components without an explicit mapping do not participate.
604
683
  *
605
684
  * Intent list source of truth: intents.css. Components consume --intent* tokens
606
685
  * instead of re-listing intent classes.
@@ -656,17 +735,64 @@ small {
656
735
  border-color: var(--ui-border);
657
736
  }
658
737
 
659
- .sm {
738
+ .compact {
660
739
  --gap: var(--space-20);
661
740
  --density-space: var(--space-20);
662
741
  --control-size: var(--control-size-sm);
663
- --density-compact-size: 1.25rem;
664
742
  }
665
- .lg {
743
+
744
+ .spacious {
666
745
  --gap: var(--space-50);
667
746
  --density-space: var(--space-50);
668
747
  --control-size: var(--control-size-lg);
669
- --density-compact-size: 1.875rem;
748
+ }
749
+
750
+ /* Shared control scale. Wrappers are listed when their descendants form one
751
+ component (field, input-icon, join, pagination, tabs, flyout). A local size
752
+ declaration beats inherited density because it is declared on the sized
753
+ component itself. */
754
+ :where(
755
+ .btn,
756
+ .input,
757
+ .textarea,
758
+ .select,
759
+ .color,
760
+ .file,
761
+ .field,
762
+ .choice,
763
+ .input-icon,
764
+ .otp,
765
+ .join,
766
+ .pagination,
767
+ .tabs,
768
+ .tab,
769
+ .flyout
770
+ ).sm {
771
+ --control-size: var(--control-size-sm);
772
+ --control-font-size: var(--font-size-sm);
773
+ --control-pad-x: var(--font-size-sm);
774
+ }
775
+
776
+ :where(
777
+ .btn,
778
+ .input,
779
+ .textarea,
780
+ .select,
781
+ .color,
782
+ .file,
783
+ .field,
784
+ .choice,
785
+ .input-icon,
786
+ .otp,
787
+ .join,
788
+ .pagination,
789
+ .tabs,
790
+ .tab,
791
+ .flyout
792
+ ).lg {
793
+ --control-size: var(--control-size-lg);
794
+ --control-font-size: var(--font-size-lg);
795
+ --control-pad-x: var(--font-size-lg);
670
796
  }
671
797
 
672
798
  /* Modern color enhancement. Baseline variant values above remain usable
@@ -680,7 +806,10 @@ small {
680
806
  var(--surface) var(--soft-border-mix),
681
807
  var(--intent, var(--neutral))
682
808
  );
683
- --ui-fg: color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix));
809
+ --ui-fg: var(
810
+ --intent-soft-fg,
811
+ color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
812
+ );
684
813
  /* @sync soft-recipe */
685
814
  }
686
815
 
@@ -784,7 +913,7 @@ small {
784
913
  .prose :where(h1, h2, h3, h4, h5, h6) {
785
914
  margin-block: 1.5em 0;
786
915
  color: var(--heading, var(--text));
787
- font-weight: var(--font-weight-strong);
916
+ font-weight: var(--font-weight-semibold);
788
917
  line-height: var(--prose-heading-line-height);
789
918
  }
790
919
 
@@ -825,12 +954,12 @@ small {
825
954
  text-underline-offset: 0.18em;
826
955
  }
827
956
 
828
- .prose :where(a:hover) {
957
+ .prose :where(a:hover, a:focus-visible) {
829
958
  text-decoration-thickness: 0.12em;
830
959
  }
831
960
 
832
961
  @supports (color: color-mix(in oklch, red, white)) {
833
- .prose :where(a:hover) {
962
+ .prose :where(a:hover, a:focus-visible) {
834
963
  color: color-mix(in oklab, var(--link, var(--primary)), var(--text) 15%);
835
964
  }
836
965
  }
@@ -848,7 +977,7 @@ small {
848
977
  /* Description lists keep document flow: a valid <dl> may pair several <dt>
849
978
  with one <dd> or the reverse, so no grid layout is imposed here. */
850
979
  .prose :where(dt) {
851
- font-weight: var(--font-weight-strong);
980
+ font-weight: var(--font-weight-semibold);
852
981
  }
853
982
 
854
983
  .prose :where(dd) {
@@ -890,8 +1019,8 @@ small {
890
1019
  }
891
1020
  /* @sync key-recipe */
892
1021
 
1022
+ /* Overflow containment is global on `pre` (base.css); only the box is prose's. */
893
1023
  .prose :where(pre) {
894
- overflow-x: auto;
895
1024
  padding: 1em;
896
1025
  border: var(--border-width) solid var(--border);
897
1026
  border-radius: var(--radius);
@@ -904,6 +1033,7 @@ small {
904
1033
  }
905
1034
 
906
1035
  .prose :where(blockquote) {
1036
+ margin-inline: 0;
907
1037
  padding-inline-start: 1em;
908
1038
  border-inline-start: var(--border-width) solid var(--border);
909
1039
  color: var(--text-muted);
@@ -1007,9 +1137,10 @@ small {
1007
1137
  /*
1008
1138
  * Optional fluid typography module.
1009
1139
  *
1010
- * Not imported by actual.css. Import this file manually after actual.css when
1011
- * you need display, title, and lead sizes that scale with the viewport. Typical
1012
- * use cases: marketing pages, landing pages, hero sections, documentation
1140
+ * Part of the typography family (typography/index.css) and the full bundle; it
1141
+ * is not in the minimal core, so a core-only import adds it explicitly when
1142
+ * display, title, and lead sizes must scale with the viewport. Typical use
1143
+ * cases: marketing pages, landing pages, hero sections, documentation
1013
1144
  * homepages.
1014
1145
  *
1015
1146
  * Do not use this for body text, app UI, or the default prose scale. Fluid
@@ -1134,8 +1265,8 @@ small {
1134
1265
  --gap is inherited, so a container that composes other primitives sets its
1135
1266
  own rhythm on the `gap` property, not on the token: setting --gap on a
1136
1267
  region re-spaces every nested stack, cluster and grid at any depth. Setting
1137
- the token stays correct on the element that consumes it, and .sm/.lg set it
1138
- deliberately so a density context reaches nested layouts. See
1268
+ the token stays correct on the element that consumes it, and
1269
+ .compact/.spacious set it deliberately so a density context reaches nested layouts. See
1139
1270
  docs/pages/layout/stack.md. */
1140
1271
  .stack {
1141
1272
  display: flex;
@@ -1297,7 +1428,7 @@ small {
1297
1428
  text-align: start;
1298
1429
  }
1299
1430
 
1300
- /* Side navigation no longer occupies the viewport's bottom safe area. */
1431
+ /* In the side layout the FAB owns the viewport's bottom safe-area inset. */
1301
1432
  .app-layout > .fab {
1302
1433
  margin-block-end: max(var(--fab-offset), env(safe-area-inset-bottom));
1303
1434
  }
@@ -1410,7 +1541,7 @@ small {
1410
1541
  *
1411
1542
  * .grid is space-driven: it fits as many tracks as --grid-min allows.
1412
1543
  * .grid-N is count-driven: it declares a structural density of N and collapses
1413
- * through natural divisors of N (see the subdivision block below).
1544
+ * through natural divisors of N.
1414
1545
  *
1415
1546
  * Public hooks:
1416
1547
  * --grid-min
@@ -1421,22 +1552,15 @@ small {
1421
1552
  *
1422
1553
  * Child contract: each direct child is a grid item placed in the tracks the
1423
1554
  * recipe generates.
1555
+ *
1556
+ * Choosing between these and the other layout primitives:
1557
+ * docs/pages/layout/grid.md. Why divisors, why a *named* container, and how
1558
+ * the thresholds were arrived at: docs/design-notes/grid.md.
1424
1559
  */
1425
1560
 
1426
- /* --grid-columns replaces the intrinsic auto-fit template: once set, the
1427
- grid no longer collapses on its own — the author owns narrow-container
1428
- collapse. Prefer .sidebar-layout for content + aside layouts; reserve
1429
- --grid-columns for exact custom track templates. */
1430
-
1431
- /* Recipe:
1432
- - collection whose item width is the contract -> .grid, tune --grid-min
1433
- - collection whose column density is the contract -> .grid-2/3/4/6
1434
- - balanced subdivision of that density -> place it inside any size query
1435
- container (.container-query is the convenience helper for that context)
1436
- - peer regions that switch as a group -> .switcher
1437
- - content + aside -> .sidebar-layout
1438
- - exact custom track templates -> --grid-columns with an explicit collapse
1439
- rule of your own */
1561
+ /* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
1562
+ no longer collapses on its own and the author owns narrow-container
1563
+ collapse. */
1440
1564
  .grid {
1441
1565
  display: grid;
1442
1566
  gap: var(--gap);
@@ -1446,16 +1570,14 @@ small {
1446
1570
  );
1447
1571
  }
1448
1572
 
1449
- /* Structural density presets. The baseline is an auto-fill grid bounded at N
1450
- tracks: responsive and overflow-safe on its own, so a preset used without a
1451
- query container is never a trap — it simply reaches its balanced states less
1452
- precisely. auto-fill rather than auto-fit keeps item width stable when a
1453
- partial final page holds fewer items than the grid's capacity.
1573
+ /* Baseline: auto-fill bounded at N, so a preset is responsive and
1574
+ overflow-safe with no wrapper at all. auto-fill rather than auto-fit keeps
1575
+ item width stable when a partial final page holds fewer items than the
1576
+ grid's capacity.
1454
1577
 
1455
- --grid-min is deliberately NOT read here. The subdivision below runs on
1456
- @container thresholds, which cannot resolve a custom property, so a hook
1457
- honored in the baseline would fall silent the moment a query container is
1458
- added. Item width is .grid's contract; column density is .grid-N's. */
1578
+ --grid-min is deliberately NOT read here: @container thresholds cannot
1579
+ resolve a custom property, so a hook honored in the baseline would fall
1580
+ silent the moment a query container is added. */
1459
1581
  :where(.grid-2, .grid-3, .grid-4, .grid-6) {
1460
1582
  display: grid;
1461
1583
  gap: var(--gap);
@@ -1484,80 +1606,63 @@ small {
1484
1606
  --grid-count: 6;
1485
1607
  }
1486
1608
 
1487
- /* Grid items keep an automatic minimum inline size (min-content), which can
1488
- overflow a track sized minmax(0, 1fr). Layout primitives remove that
1489
- intrinsic floor so their overflow-safe track sizing holds; authors only
1490
- need .min-inline-size-0 outside primitives that do not already remove this
1491
- floor. */
1609
+ /* Grid items keep an automatic min-content floor that can overflow a
1610
+ minmax(0, 1fr) track. Layout primitives remove it so their overflow-safe
1611
+ track sizing holds; .min-inline-size-0 is for the ones that do not. */
1492
1612
  :where(.grid, .grid-2, .grid-3, .grid-4, .grid-6) > * {
1493
1613
  min-inline-size: 0;
1494
1614
  }
1495
1615
 
1496
- /* Balanced subdivision, activated by an ancestor container named actual-container.
1497
- * A .grid-N inside such a container enters only divisor states of N. The name
1498
- * is deliberate: a grid must not key off the nearest anonymous query container,
1499
- * or a region that sets container-type: inline-size for an unrelated reason
1500
- * would silently change the grid. The author opts the grid in by naming the
1501
- * context — .container-query is the official helper, or declare
1502
- * container: actual-container / inline-size on your own region.
1616
+ /* Balanced subdivision, keyed on an ancestor container *named*
1617
+ * actual-container — never an anonymous one, so a region that sets
1618
+ * container-type: inline-size for an unrelated reason cannot change a grid.
1503
1619
  *
1504
1620
  * .grid-2 2 -> 1
1505
1621
  * .grid-3 3 -> 1
1506
1622
  * .grid-4 4 -> 2 -> 1
1507
1623
  * .grid-6 6 -> 3 -> 2 -> 1
1508
1624
  *
1509
- * Every state is a divisor of N, so all items keep the same width and no
1510
- * state produces a visibly uneven split such as 5 + 1 or 4 + 2. Intermediate
1511
- * counts are skipped on purpose: do not "complete" these chains with the
1512
- * missing integers, and do not replace them with an unbounded auto-fit.
1513
- *
1514
- * The query container must be an ancestor. An element cannot query its own
1515
- * size without taking inline-size containment, which destroys its intrinsic
1516
- * inline contribution and collapses it inside .cluster, .topbar, floats, and
1517
- * any shrink-to-fit box. See docs/design-notes/grid.md.
1518
- *
1519
- * Without an actual-container ancestor none of these @container rules resolve, so
1520
- * the auto-fill baseline above stays. The min-width: 0 rule is the lowest
1521
- * step: it pins the grid to one column inside a container before any larger
1522
- * threshold fires (a bare grid would otherwise show a 2 + 1 orphan at 28-48rem).
1523
- *
1524
- * Three shared thresholds, not one calibrated per preset: 28rem opens the
1525
- * first horizontal density, 48rem opens three columns, 64rem opens the dense
1526
- * 4 and 6. N already carries the author's statement about item size, so
1527
- * tuning every step to land the same item width would erase that signal —
1528
- * .grid-6 at 64rem gives ~161px per tile, which is the point of choosing it.
1529
- * Probed with the content each preset targets: large cards for .grid-2 down
1530
- * to stats, avatars, and thumbnails for .grid-6.
1625
+ * Every state is a divisor of N. Do not complete these chains with the
1626
+ * missing integers and do not replace them with an unbounded auto-fit: both
1627
+ * bring back uneven splits such as 5 + 1.
1628
+ *
1629
+ * The container must be an ANCESTOR. An element cannot query its own size
1630
+ * without inline-size containment, which destroys its intrinsic inline
1631
+ * contribution and collapses it inside .cluster, .topbar, floats and any
1632
+ * shrink-to-fit box.
1633
+ *
1634
+ * With no such ancestor none of these resolve and the baseline above stays.
1635
+ * min-width: 0 is the lowest step: it pins the grid to one column before any
1636
+ * larger threshold fires, which a bare grid would otherwise show as a 2 + 1
1637
+ * orphan between 28 and 48rem.
1531
1638
  *
1532
1639
  * Ascending min-width only: the last matching step wins by cascade order.
1533
1640
  */
1534
- @supports (container-type: inline-size) {
1535
- @container actual-container (min-width: 0px) {
1536
- :where(.grid-2, .grid-3, .grid-4, .grid-6) {
1537
- grid-template-columns: 1fr;
1538
- }
1641
+ @container actual-container (min-width: 0px) {
1642
+ :where(.grid-2, .grid-3, .grid-4, .grid-6) {
1643
+ grid-template-columns: 1fr;
1539
1644
  }
1645
+ }
1540
1646
 
1541
- @container actual-container (min-width: 28rem) {
1542
- :where(.grid-2, .grid-4, .grid-6) {
1543
- grid-template-columns: repeat(2, minmax(0, 1fr));
1544
- }
1647
+ @container actual-container (min-width: 28rem) {
1648
+ :where(.grid-2, .grid-4, .grid-6) {
1649
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1545
1650
  }
1651
+ }
1546
1652
 
1547
- @container actual-container (min-width: 48rem) {
1548
- :where(.grid-3, .grid-6) {
1549
- grid-template-columns: repeat(3, minmax(0, 1fr));
1550
- }
1653
+ @container actual-container (min-width: 48rem) {
1654
+ :where(.grid-3, .grid-6) {
1655
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1551
1656
  }
1657
+ }
1552
1658
 
1553
- @container actual-container (min-width: 64rem) {
1554
- :where(.grid-4) {
1555
- grid-template-columns: repeat(4, minmax(0, 1fr));
1556
- }
1659
+ @container actual-container (min-width: 64rem) {
1660
+ :where(.grid-4) {
1661
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1662
+ }
1557
1663
 
1558
- :where(.grid-6) {
1559
- grid-template-columns: repeat(6, minmax(0, 1fr));
1560
- }
1664
+ :where(.grid-6) {
1665
+ grid-template-columns: repeat(6, minmax(0, 1fr));
1561
1666
  }
1562
1667
  }
1563
1668
 
@@ -1716,10 +1821,9 @@ small {
1716
1821
  * the light/dark adaptation that color-scheme already gives for free, and the
1717
1822
  * two never match anyway.
1718
1823
  *
1719
- * Above the capability floor: scrollbar-color lands in Chromium 121 and
1720
- * Safari 18.2, the floor is Chromium 99 / Safari 15.4. Older engines ignore
1721
- * both declarations and draw their native scrollbar — the intended fallback,
1722
- * not a broken state.
1824
+ * Above every tier: scrollbar-color lands in Chromium 121 and Safari 18.2, so
1825
+ * even a Recommended engine may lack it. Engines that ignore both declarations
1826
+ * draw their native scrollbar — the intended fallback, not a broken state.
1723
1827
  *
1724
1828
  * Styles the scrollbar only; it does not create the overflow. Pair it with
1725
1829
  * .overflow-auto or a component that already scrolls.
@@ -1781,24 +1885,28 @@ small {
1781
1885
  .scroll-snap {
1782
1886
  overscroll-behavior-inline: contain;
1783
1887
  scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
1888
+ /* x then inline: an engine that lacks the logical keyword keeps x.
1889
+ proximity is the default, so a short rail cannot trap the pointer. */
1890
+ scroll-snap-type: x proximity;
1891
+ scroll-snap-type: inline proximity;
1892
+ }
1893
+
1894
+ /* Strict snapping is opt-in; proximity keeps a rail from fighting the
1895
+ pointer on a short strip. */
1896
+ .scroll-snap[data-snap="mandatory"] {
1784
1897
  scroll-snap-type: x mandatory;
1898
+ scroll-snap-type: inline mandatory;
1785
1899
  }
1786
1900
 
1787
1901
  .scroll-snap > * {
1788
1902
  scroll-snap-align: var(--scroll-snap-align);
1789
1903
  }
1790
1904
 
1791
- .scroll-snap[data-snap="center"] {
1905
+ .scroll-snap[data-snap-align="center"] {
1792
1906
  --scroll-snap-align: center;
1793
1907
  }
1794
1908
  }
1795
1909
 
1796
- @supports (scroll-snap-type: inline mandatory) {
1797
- .scroll-snap {
1798
- scroll-snap-type: inline mandatory;
1799
- }
1800
- }
1801
-
1802
1910
  .scroll-snap[data-scrollbar="hidden"] {
1803
1911
  scrollbar-width: none;
1804
1912
  }
@@ -1899,6 +2007,7 @@ small {
1899
2007
  display: grid;
1900
2008
  gap: var(--space-10);
1901
2009
  color: var(--text);
2010
+ font-size: var(--control-font-size);
1902
2011
  }
1903
2012
 
1904
2013
  .field-group {
@@ -1912,7 +2021,7 @@ small {
1912
2021
  .field-label,
1913
2022
  .field-group > legend {
1914
2023
  color: var(--text);
1915
- font-weight: var(--font-weight-strong);
2024
+ font-weight: var(--font-weight-semibold);
1916
2025
  }
1917
2026
 
1918
2027
  .field-group > legend {
@@ -1923,13 +2032,13 @@ small {
1923
2032
 
1924
2033
  .field-help {
1925
2034
  color: var(--text-muted);
1926
- font-size: var(--font-size-sm);
2035
+ font-size: 0.875em;
1927
2036
  }
1928
2037
 
1929
2038
  .field-error {
1930
2039
  display: none;
1931
2040
  color: var(--danger);
1932
- font-size: var(--font-size-sm);
2041
+ font-size: 0.875em;
1933
2042
  }
1934
2043
 
1935
2044
  .required-mark {
@@ -1974,9 +2083,10 @@ small {
1974
2083
  --control-cursor: auto;
1975
2084
 
1976
2085
  appearance: none;
2086
+ -webkit-appearance: none;
1977
2087
  inline-size: 100%;
1978
2088
  min-block-size: var(--control-size);
1979
- padding-inline: var(--space-30);
2089
+ padding-inline: var(--control-pad-x);
1980
2090
  border: var(--border-width) solid var(--form-invalid-border, var(--control-border));
1981
2091
  border-block-end-color: var(
1982
2092
  --form-invalid-border,
@@ -2159,6 +2269,7 @@ small {
2159
2269
  background: var(--choice-bg);
2160
2270
  color: var(--choice-mark);
2161
2271
  appearance: none;
2272
+ -webkit-appearance: none;
2162
2273
  cursor: pointer;
2163
2274
  }
2164
2275
 
@@ -2197,9 +2308,9 @@ small {
2197
2308
 
2198
2309
  .check:is(:checked, :indeterminate),
2199
2310
  .radio:checked {
2200
- --choice-border: var(--intent, var(--primary));
2201
- --choice-bg: var(--intent, var(--primary));
2202
- --choice-mark: var(--intent-fg, var(--primary-fg));
2311
+ --choice-border: var(--intent, var(--state-selected));
2312
+ --choice-bg: var(--intent, var(--state-selected));
2313
+ --choice-mark: var(--intent-fg, var(--state-selected-fg));
2203
2314
  }
2204
2315
 
2205
2316
  .check:checked::before {
@@ -2218,6 +2329,8 @@ small {
2218
2329
  transform: scale(1);
2219
2330
  }
2220
2331
 
2332
+ /* Progressive: without :not() selector lists, the normal unchecked border
2333
+ remains visible and the control stays usable. */
2221
2334
  .check:hover:not(:checked, :indeterminate, :disabled),
2222
2335
  .radio:hover:not(:checked, :indeterminate, :disabled) {
2223
2336
  --choice-border: var(--text-subtle);
@@ -2253,20 +2366,40 @@ small {
2253
2366
  * the CSS chevron, so the picker (appearance: base-select) enhancement in
2254
2367
  * custom-select.css can be opted out independently. Add .native to a specific
2255
2368
  * select to opt out of the enhancement.
2369
+ *
2370
+ * Public hooks:
2371
+ * --select-icon-size
2256
2372
  */
2257
- .select:not([multiple], [size]:not([size="1"])) {
2373
+ /* --select-icon-size is typographic (em), so the reserved slot and the
2374
+ pictogram scale with the control's font size, not a fixed rem sliver. */
2375
+ .select:not(:is([multiple], [size]:not([size="1"]))) {
2376
+ --select-icon-size: 1em;
2377
+
2258
2378
  appearance: none;
2259
- padding-inline-end: calc(var(--space-30) + var(--space-60));
2379
+ -webkit-appearance: none;
2380
+ padding-inline-end: calc(var(--control-pad-x) * 2 + var(--select-icon-size));
2260
2381
  background-image: var(--icon-chevron);
2261
- background-position: center right var(--space-30);
2262
- background-size: 1rem auto;
2382
+ background-position: center right var(--control-pad-x);
2383
+ background-size: var(--select-icon-size) auto;
2263
2384
  background-repeat: no-repeat;
2264
2385
  }
2265
2386
 
2266
2387
  /* The chevron sits on the inline-end side; flip it in RTL so it follows
2267
- the padding-inline-end space reserved for it. */
2268
- .select:not([multiple], [size]:not([size="1"])):dir(rtl) {
2269
- background-position: center left var(--space-30);
2388
+ the padding-inline-end space reserved for it. Safari before 16.4 has no
2389
+ :dir(); the ancestor-attribute path is the Degraded-tier fallback and the
2390
+ :dir() rules are dropped on their own there. :dir() follows inherited
2391
+ directionality, so the explicit ltr rule neutralizes an outer RTL fallback
2392
+ for a nested direction change. */
2393
+ [dir="rtl"] .select:not(:is([multiple], [size]:not([size="1"]))) {
2394
+ background-position: center left var(--control-pad-x);
2395
+ }
2396
+
2397
+ .select:not(:is([multiple], [size]:not([size="1"]))):dir(ltr) {
2398
+ background-position: center right var(--control-pad-x);
2399
+ }
2400
+
2401
+ .select:not(:is([multiple], [size]:not([size="1"]))):dir(rtl) {
2402
+ background-position: center left var(--control-pad-x);
2270
2403
  }
2271
2404
 
2272
2405
  /* Both forced-colors and prefers-contrast hand the arrow back to the
@@ -2274,10 +2407,10 @@ small {
2274
2407
  simpler than maintaining contrast variants, and native rendering already
2275
2408
  matches whatever contrast mode the user picked. */
2276
2409
  @media (forced-colors: active), (prefers-contrast: more) {
2277
- .select:not([multiple], [size]:not([size="1"])) {
2410
+ .select:not(:is([multiple], [size]:not([size="1"]))) {
2278
2411
  appearance: auto;
2279
2412
  background-image: none;
2280
- padding-inline-end: var(--space-30);
2413
+ padding-inline-end: var(--control-pad-x);
2281
2414
  }
2282
2415
  }
2283
2416
 
@@ -2291,18 +2424,23 @@ small {
2291
2424
  */
2292
2425
  .file {
2293
2426
  color: var(--text-muted);
2427
+ font-size: var(--control-font-size);
2294
2428
  }
2295
2429
 
2296
2430
  .file::file-selector-button {
2297
2431
  margin-inline-end: var(--space-30);
2432
+ /* Consume the shared control scale like .btn, so .sm/.lg and density resize
2433
+ the whole control, not just the filename text. */
2434
+ min-block-size: var(--control-size);
2298
2435
  padding-block: var(--space-10);
2436
+ padding-block: max(var(--space-10), calc((var(--control-size) - 1lh) / 2));
2299
2437
  padding-inline: var(--space-20);
2300
2438
  border: var(--border-width) solid var(--border);
2301
2439
  border-radius: var(--radius-sm);
2302
2440
  background: var(--surface-subtle);
2303
2441
  color: var(--text);
2304
2442
  font: inherit;
2305
- font-size: var(--font-size-sm);
2443
+ font-size: var(--control-font-size);
2306
2444
  font-weight: var(--font-weight-medium);
2307
2445
  cursor: pointer;
2308
2446
  transition:
@@ -2529,18 +2667,19 @@ small {
2529
2667
  * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
2530
2668
  *
2531
2669
  * Icon-library agnostic: detects position by DOM order. First child that is
2532
- * not .input = leading icon; icon after .input = trailing. The icon keeps a
2533
- * fixed size; density contexts shrink the control geometry around it, not
2534
- * the pictogram.
2670
+ * not .input = leading icon; icon after .input = trailing. The icon follows
2671
+ * local typography; density contexts shrink the control geometry around it
2672
+ * without changing the pictogram.
2535
2673
  *
2536
2674
  * Public hooks:
2537
2675
  * --input-icon-size
2538
2676
  */
2539
2677
  .input-icon {
2540
- --input-icon-size: 1.25rem;
2678
+ --input-icon-size: 1.25em;
2541
2679
 
2542
2680
  display: block;
2543
2681
  position: relative;
2682
+ font-size: var(--control-font-size);
2544
2683
  }
2545
2684
 
2546
2685
  /* Icon — shared by leading and trailing */
@@ -2555,29 +2694,34 @@ small {
2555
2694
 
2556
2695
  /* Leading: icon is the first child */
2557
2696
  .input-icon > :first-child:where(:not(.input)) {
2558
- inset-inline-start: var(--space-30);
2697
+ inset-inline-start: var(--control-pad-x);
2559
2698
  }
2560
2699
 
2561
- /* Trailing: input is the first child, icon follows */
2562
- .input-icon > .input:first-child ~ :where(:not(.input)) {
2563
- inset-inline-end: var(--space-30);
2700
+ /* Trailing: an icon follows the input, with or without a leading icon. */
2701
+ .input-icon > .input ~ :where(:not(.input)) {
2702
+ inset-inline-end: var(--control-pad-x);
2564
2703
  }
2565
2704
 
2566
2705
  /* font-size only sizes text-metric icons (webfont <i>, <span>). An inline
2567
2706
  <svg> with just a viewBox ignores it, so give it explicit dimensions. */
2568
2707
  .input-icon > svg:where(:not(.input)) {
2569
- inline-size: var(--input-icon-size);
2570
- block-size: var(--input-icon-size);
2708
+ /* The shared rule already maps --input-icon-size onto font-size. One em is
2709
+ therefore the requested optical size; reusing the em-valued hook here
2710
+ would apply its factor a second time. */
2711
+ inline-size: 1em;
2712
+ block-size: 1em;
2571
2713
  }
2572
2714
 
2573
2715
  /* Leading: input gets start padding to clear the icon */
2574
2716
  .input-icon > :first-child:where(:not(.input)) ~ .input {
2575
- padding-inline-start: calc(var(--space-30) * 2 + var(--input-icon-size));
2717
+ padding-inline-start: calc(var(--control-pad-x) * 2 + var(--input-icon-size));
2576
2718
  }
2577
2719
 
2578
- /* Trailing: input is first child and has a sibling → end padding */
2579
- .input-icon > .input:first-child:where(:not(:last-child)) {
2580
- padding-inline-end: calc(var(--space-30) * 2 + var(--input-icon-size));
2720
+ /* Trailing: the input has a following sibling → end padding. Not tied to the
2721
+ input being first child, so a leading icon does not disable the trailing
2722
+ icon's reserved space. */
2723
+ .input-icon > .input:where(:not(:last-child)) {
2724
+ padding-inline-end: calc(var(--control-pad-x) * 2 + var(--input-icon-size));
2581
2725
  }
2582
2726
 
2583
2727
  /*
@@ -2647,6 +2791,7 @@ small {
2647
2791
  border-radius: var(--radius-full);
2648
2792
  background: var(--switch-bg);
2649
2793
  appearance: none;
2794
+ -webkit-appearance: none;
2650
2795
  cursor: pointer;
2651
2796
  }
2652
2797
 
@@ -2667,14 +2812,17 @@ small {
2667
2812
  background-color var(--duration);
2668
2813
  }
2669
2814
 
2670
- .switch:hover:not(:disabled) {
2815
+ /* Hover is not selected: an unchecked switch keeps the brand fallback on hover,
2816
+ so a theme that separates --primary from --state-selected never tints an
2817
+ OFF control with the selection accent before it is checked. */
2818
+ .switch:not(:checked):hover:not(:disabled) {
2671
2819
  --switch-border: var(--intent, var(--primary));
2672
2820
  }
2673
2821
 
2674
2822
  .switch:checked {
2675
- --switch-bg: var(--intent, var(--primary));
2676
- --switch-border: var(--intent, var(--primary));
2677
- --switch-knob: var(--intent-fg, var(--primary-fg));
2823
+ --switch-bg: var(--intent, var(--state-selected));
2824
+ --switch-border: var(--intent, var(--state-selected));
2825
+ --switch-knob: var(--intent-fg, var(--state-selected-fg));
2678
2826
  }
2679
2827
 
2680
2828
  .switch:checked::before {
@@ -2682,7 +2830,17 @@ small {
2682
2830
  }
2683
2831
 
2684
2832
  /* The knob travels toward inline-end; in RTL that is leftward, so the
2685
- translateX sign flips. translateX is physical, not logical. */
2833
+ translateX sign flips. translateX is physical, not logical. Safari before
2834
+ 16.4 has no :dir(); the ancestor-attribute path is the Degraded-tier
2835
+ fallback and the :dir() rules are dropped on their own there. */
2836
+ [dir="rtl"] .switch:checked::before {
2837
+ transform: translateX(calc(var(--switch-travel) * -1));
2838
+ }
2839
+
2840
+ .switch:checked:dir(ltr)::before {
2841
+ transform: translateX(var(--switch-travel));
2842
+ }
2843
+
2686
2844
  .switch:checked:dir(rtl)::before {
2687
2845
  transform: translateX(calc(var(--switch-travel) * -1));
2688
2846
  }
@@ -2739,6 +2897,7 @@ small {
2739
2897
  --range-track-bg: var(--border);
2740
2898
 
2741
2899
  appearance: none;
2900
+ -webkit-appearance: none;
2742
2901
  inline-size: 100%;
2743
2902
  block-size: var(--range-thumb-size);
2744
2903
  background: transparent;
@@ -2759,6 +2918,7 @@ small {
2759
2918
 
2760
2919
  .range::-webkit-slider-thumb {
2761
2920
  appearance: none;
2921
+ -webkit-appearance: none;
2762
2922
  inline-size: var(--range-thumb-size);
2763
2923
  block-size: var(--range-thumb-size);
2764
2924
  border-radius: var(--radius-full);
@@ -2906,13 +3066,13 @@ small {
2906
3066
 
2907
3067
  /* Checked border — shared by radio and checkbox */
2908
3068
  .choice-card:has(:checked) {
2909
- --choice-card-border: var(--intent, var(--primary));
3069
+ --choice-card-border: var(--intent, var(--state-selected));
2910
3070
  }
2911
3071
 
2912
3072
  /* Subtle background tint on checked — modern browsers only */
2913
3073
  @supports (color: color-mix(in oklch, red, white)) {
2914
3074
  .choice-card:has(:checked) {
2915
- --choice-card-bg: color-mix(in oklab, var(--surface) 96%, var(--intent, var(--primary)));
3075
+ --choice-card-bg: color-mix(in oklab, var(--surface) 96%, var(--intent, var(--state-selected)));
2916
3076
  }
2917
3077
  }
2918
3078
 
@@ -2932,7 +3092,7 @@ small {
2932
3092
 
2933
3093
  /* Badge disc — circle for radio, rounded square for checkbox */
2934
3094
  .choice-card::before {
2935
- background: var(--intent, var(--primary));
3095
+ background: var(--intent, var(--state-selected));
2936
3096
  box-shadow: 0 0 0 2px var(--surface);
2937
3097
  z-index: 1;
2938
3098
  }
@@ -2947,7 +3107,7 @@ small {
2947
3107
 
2948
3108
  /* Check mark */
2949
3109
  .choice-card::after {
2950
- background: var(--intent-fg, var(--primary-fg));
3110
+ background: var(--intent-fg, var(--state-selected-fg));
2951
3111
  -webkit-mask: var(--icon-check) center / 60% no-repeat;
2952
3112
  mask: var(--icon-check) center / 60% no-repeat;
2953
3113
  z-index: 2;
@@ -3084,8 +3244,8 @@ small {
3084
3244
  * placeholder=" " to expose a reliable empty state — without it the label
3085
3245
  * only reacts to focus.
3086
3246
  *
3087
- * Density flows through --control-size: .sm/.lg shrink the control geometry
3088
- * and the reserved headroom with it, leaving typography untouched.
3247
+ * Density flows through --control-size: .compact/.spacious change the control
3248
+ * geometry and the reserved headroom with it, leaving typography untouched.
3089
3249
  *
3090
3250
  * Public hooks:
3091
3251
  * --floating-label-top
@@ -3116,7 +3276,7 @@ small {
3116
3276
  inset-inline-start: calc(var(--space-30) + var(--border-width));
3117
3277
  color: var(--text-muted);
3118
3278
  font-size: var(--control-font-size);
3119
- font-weight: var(--font-weight);
3279
+ font-weight: var(--font-weight-normal);
3120
3280
  opacity: var(--floating-label-opacity);
3121
3281
  pointer-events: none;
3122
3282
  transition:
@@ -3196,7 +3356,7 @@ small {
3196
3356
  .otp {
3197
3357
  --otp-cell-size: var(--control-size);
3198
3358
  --otp-gap: var(--space-10);
3199
- --otp-font-size: 1.5rem;
3359
+ --otp-font-size: 1.5em;
3200
3360
  --otp-cell-border: var(--border);
3201
3361
  --otp-cell-bg: var(--surface);
3202
3362
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
@@ -3209,6 +3369,7 @@ small {
3209
3369
  max-inline-size: 100%;
3210
3370
  gap: var(--otp-gap);
3211
3371
  border-radius: var(--radius);
3372
+ font-size: var(--control-font-size);
3212
3373
  /* Unlike hidden, clip cannot become a scroll container when the caret moves
3213
3374
  into the input extension. */
3214
3375
  overflow: clip;
@@ -3250,6 +3411,7 @@ small {
3250
3411
  letter-spacing: calc(var(--otp-cell-size) + var(--otp-gap) - 1ch);
3251
3412
  text-align: start;
3252
3413
  appearance: none;
3414
+ -webkit-appearance: none;
3253
3415
  }
3254
3416
 
3255
3417
  /* Deliberately no group ring: a box around all the cells reads as a second
@@ -3284,6 +3446,12 @@ small {
3284
3446
  cursor: not-allowed;
3285
3447
  }
3286
3448
 
3449
+ /* biome-ignore lint/style/noDescendingSpecificity: disabled must come last of
3450
+ the cell states, and the reorder the rule asks for would invert that. A
3451
+ disabled input is barred from constraint validation, so `:invalid` and
3452
+ `:focus` cannot reach it at all; the only overlap left is an authored
3453
+ aria-invalid, where equal specificity and source order are what make
3454
+ disabled — not danger — the colour a dead field reads as. */
3287
3455
  .otp > input:disabled ~ span {
3288
3456
  --otp-cell-bg: var(--surface-subtle);
3289
3457
  --otp-cell-border: var(--state-disabled);
@@ -3343,6 +3511,7 @@ small {
3343
3511
  :where(.btn) {
3344
3512
  --intent: initial;
3345
3513
  --intent-fg: initial;
3514
+ --intent-soft-fg: initial;
3346
3515
  --ui-bg: initial;
3347
3516
  --ui-fg: initial;
3348
3517
  --ui-border: initial;
@@ -3414,13 +3583,16 @@ small {
3414
3583
  --ui-border: transparent;
3415
3584
  }
3416
3585
 
3417
- /* Solid/soft get the overlay darkening on hover; outline/ghost/link stay flat.
3418
- Routed through a variable instead of a :not(.outline, .ghost, .link)
3419
- exclusion so the hover rule below only has to state the interactive
3420
- element list once. */
3586
+ /* Solid keeps the overlay darkening on hover; soft/outline/ghost/link stay
3587
+ flat. The soft hover is its own fill change (--soft-hover-alpha) — a second
3588
+ overlay on top would double the fill step and drag its ink below the resting
3589
+ contrast on every intent. Routed through a variable instead of a
3590
+ :not(.outline, .ghost, .link) exclusion so the hover rule below only has to
3591
+ state the interactive element list once. */
3421
3592
  .btn.outline,
3422
3593
  .btn.ghost,
3423
- .btn.link {
3594
+ .btn.link,
3595
+ .btn.soft {
3424
3596
  --btn-hover-overlay: transparent;
3425
3597
  }
3426
3598
 
@@ -3544,17 +3716,6 @@ a.btn[aria-disabled="true"] {
3544
3716
  }
3545
3717
  }
3546
3718
 
3547
- @media print {
3548
- :is(button, input[type="button"], input[type="submit"], input[type="reset"]).btn {
3549
- display: none !important;
3550
- }
3551
-
3552
- a.btn[href^="http"]::after {
3553
- content: " (" attr(href) ")";
3554
- font-size: 0.875em;
3555
- }
3556
- }
3557
-
3558
3719
  /*
3559
3720
  * Badges for compact labels, counts, and state hints.
3560
3721
  *
@@ -3566,8 +3727,10 @@ a.btn[aria-disabled="true"] {
3566
3727
  * --badge-size
3567
3728
  * --badge-pad-x
3568
3729
  * --badge-font-size
3730
+ * --badge-icon-size
3569
3731
  * --badge-dot-size
3570
3732
  * --badge-radius
3733
+ * --badge-dismiss-icon-size
3571
3734
  *
3572
3735
  * Internal:
3573
3736
  * --badge-default-bg
@@ -3583,6 +3746,7 @@ a.btn[aria-disabled="true"] {
3583
3746
  :where(.badge) {
3584
3747
  --intent: initial;
3585
3748
  --intent-fg: initial;
3749
+ --intent-soft-fg: initial;
3586
3750
  --ui-bg: initial;
3587
3751
  --ui-fg: initial;
3588
3752
  --ui-border: initial;
@@ -3590,10 +3754,14 @@ a.btn[aria-disabled="true"] {
3590
3754
  }
3591
3755
 
3592
3756
  .badge {
3593
- --badge-size: var(--density-compact-size);
3594
- --badge-pad-x: 0.55em;
3595
- --badge-font-size: var(--font-size-xs);
3757
+ --badge-size: 1.5rem;
3758
+ /* A third of the height keeps the pill proportional across local sizes
3759
+ without coupling it to the label typography. */
3760
+ --badge-pad-x: calc(var(--badge-size) / 3);
3761
+ --badge-font-size: var(--font-size-sm);
3762
+ --badge-icon-size: 1em;
3596
3763
  --badge-dot-size: 0.5rem;
3764
+ --badge-dismiss-icon-size: 0.875em;
3597
3765
  --badge-default-bg: var(--surface-subtle);
3598
3766
  --badge-default-fg: var(--intent, var(--text));
3599
3767
  --badge-default-border: var(--border);
@@ -3606,7 +3774,11 @@ a.btn[aria-disabled="true"] {
3606
3774
  display: inline-flex;
3607
3775
  align-items: center;
3608
3776
  justify-content: center;
3777
+ vertical-align: middle;
3609
3778
  gap: 0.35em;
3779
+ /* Content-sized: a flex parent must not squeeze the pill and push its
3780
+ nowrap label outside the background. */
3781
+ flex: none;
3610
3782
  inline-size: fit-content;
3611
3783
  max-inline-size: 100%;
3612
3784
  min-block-size: var(--badge-size);
@@ -3616,7 +3788,7 @@ a.btn[aria-disabled="true"] {
3616
3788
  background: var(--badge-bg);
3617
3789
  color: var(--badge-fg);
3618
3790
  font-size: var(--badge-font-size);
3619
- font-weight: var(--font-weight-strong);
3791
+ font-weight: var(--font-weight-medium);
3620
3792
  line-height: 1;
3621
3793
  text-decoration: none;
3622
3794
  white-space: nowrap;
@@ -3627,6 +3799,31 @@ a.btn[aria-disabled="true"] {
3627
3799
  box-shadow var(--duration);
3628
3800
  }
3629
3801
 
3802
+ .badge.sm {
3803
+ --badge-size: 1.25rem;
3804
+ --badge-font-size: var(--font-size-xs);
3805
+ --badge-dot-size: 0.375rem;
3806
+ }
3807
+
3808
+ .badge.lg {
3809
+ --badge-size: 1.875rem;
3810
+ --badge-font-size: var(--font-size-md);
3811
+ --badge-dot-size: 0.625rem;
3812
+ }
3813
+
3814
+ /* Decorative children follow the badge's optical size. Text and interactive
3815
+ dismiss buttons retain their own boxes. */
3816
+ .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
3817
+ flex: none;
3818
+ /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
3819
+ the requested optical size; reusing the em-valued hook here would apply its
3820
+ factor a second time. */
3821
+ inline-size: 1em;
3822
+ block-size: 1em;
3823
+ font-size: var(--badge-icon-size);
3824
+ line-height: 1;
3825
+ }
3826
+
3630
3827
  /* Soft by default. Local defaults remain behind --ui-* in the resolution
3631
3828
  chain, so explicit shared treatments compose without selector exclusions. */
3632
3829
  @supports (color: color-mix(in oklch, red, white)) {
@@ -3642,10 +3839,9 @@ a.btn[aria-disabled="true"] {
3642
3839
  var(--surface) var(--soft-border-mix),
3643
3840
  var(--intent, var(--neutral))
3644
3841
  );
3645
- --badge-default-fg: color-mix(
3646
- in oklab,
3647
- var(--text),
3648
- var(--intent, var(--text)) var(--soft-fg-mix)
3842
+ --badge-default-fg: var(
3843
+ --intent-soft-fg,
3844
+ color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
3649
3845
  );
3650
3846
  /* @sync soft-recipe */
3651
3847
  }
@@ -3662,12 +3858,22 @@ a.btn[aria-disabled="true"] {
3662
3858
  block-size: var(--badge-size);
3663
3859
  }
3664
3860
 
3861
+ /* The shared .dot separator doubles as a status dot inside a badge.
3862
+ currentColor follows the badge's resolved ink, so the dot matches a soft
3863
+ intent without repeating the intent class on it. */
3864
+ .badge > .dot {
3865
+ background-color: currentColor;
3866
+ }
3867
+
3665
3868
  .badge > button {
3666
3869
  display: inline-grid;
3667
3870
  place-items: center;
3668
3871
  flex: 0 0 auto;
3669
- min-inline-size: var(--density-compact-size);
3670
- min-block-size: var(--density-compact-size);
3872
+ /* WCAG 2.5.8 floors pointer targets at 24 CSS px. Density and local size may
3873
+ keep the badge visually compact through the negative margins, but never
3874
+ shrink its action target below that literal floor. */
3875
+ min-inline-size: max(var(--badge-size), 24px);
3876
+ min-block-size: max(var(--badge-size), 24px);
3671
3877
  margin-block: -0.25rem;
3672
3878
  margin-inline-start: -0.15em;
3673
3879
  margin-inline-end: -0.45em;
@@ -3681,6 +3887,21 @@ a.btn[aria-disabled="true"] {
3681
3887
  cursor: pointer;
3682
3888
  }
3683
3889
 
3890
+ /* Dismiss mark — an empty dismiss button paints its own X from the shared
3891
+ --icon-close mask, so no icon font or text glyph is required in the markup
3892
+ (same contract as .alert-dismiss and .dialog-close). A mask box is a block
3893
+ box, so place-items centers the ink itself; a text glyph would only center
3894
+ its line box and land the ink low, because a lowercase x has no descender
3895
+ and only x-height of ascent. A button with its own content keeps it. */
3896
+ .badge > button:empty::before {
3897
+ content: "";
3898
+ inline-size: var(--badge-dismiss-icon-size);
3899
+ block-size: var(--badge-dismiss-icon-size);
3900
+ background: currentColor;
3901
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
3902
+ mask: var(--icon-close) center / contain no-repeat;
3903
+ }
3904
+
3684
3905
  .badge > button:not(:disabled):hover {
3685
3906
  background: var(--hover-overlay);
3686
3907
  }
@@ -3713,7 +3934,14 @@ a.btn[aria-disabled="true"] {
3713
3934
  background: var(--ui-hover-bg, var(--badge-default-hover-bg));
3714
3935
  }
3715
3936
 
3716
- :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
3937
+ /* Solid and empty badges keep the overlay darkening on hover; soft badges do
3938
+ not — their hover fill (--soft-hover-alpha) is already the state change, and
3939
+ stacking a second darkening drags the soft ink below its resting contrast.
3940
+ Soft here means :not(.solid, .outline, :empty), mirroring the soft hover-bg
3941
+ recipe above. */
3942
+ :where(a.badge, button.badge):is(.solid, :empty):not(
3943
+ :where(:disabled, [aria-disabled="true"])
3944
+ ):hover {
3717
3945
  /* Huge inset spread paints the overlay over the pill radius (see button.css). */
3718
3946
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
3719
3947
  }
@@ -3745,7 +3973,6 @@ a.badge[aria-disabled="true"] {
3745
3973
  @media print {
3746
3974
  .badge {
3747
3975
  border: var(--border-width) solid CanvasText;
3748
- break-inside: avoid;
3749
3976
  }
3750
3977
  }
3751
3978
 
@@ -3783,6 +4010,7 @@ a.badge[aria-disabled="true"] {
3783
4010
  :where(.alert) {
3784
4011
  --intent: initial;
3785
4012
  --intent-fg: initial;
4013
+ --intent-soft-fg: initial;
3786
4014
  --ui-bg: initial;
3787
4015
  --ui-fg: initial;
3788
4016
  --ui-border: initial;
@@ -3815,12 +4043,23 @@ a.badge[aria-disabled="true"] {
3815
4043
  min-inline-size: 0;
3816
4044
  }
3817
4045
 
3818
- .alert > :first-child {
3819
- margin-block-start: 0;
4046
+ /* The alert owns the block spacing between its direct content blocks, the way
4047
+ .stack and .prose own theirs, so a title and a description are plain
4048
+ siblings instead of a wrapper carrying an inline --gap. What made that pair
4049
+ read as detached is the UA paragraph margin: trimming only the outer edges
4050
+ left it in place between them. Content keeps its own internal rhythm — a
4051
+ nested list keeps its bullets and padding, a .prose child keeps its flow.
4052
+ The slots are excluded because they are anatomy, not content: an
4053
+ admonition's title bar has to stay flush against its body. The list follows
4054
+ the anatomy, so it grows with it. */
4055
+ .alert > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4056
+ margin-block: 0;
3820
4057
  }
3821
4058
 
3822
- .alert > :last-child {
3823
- margin-block-end: 0;
4059
+ .alert
4060
+ > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body))
4061
+ + :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
4062
+ margin-block-start: var(--space-10);
3824
4063
  }
3825
4064
 
3826
4065
  /* Alerts are soft by default. The recipe writes local defaults rather than
@@ -3838,10 +4077,9 @@ a.badge[aria-disabled="true"] {
3838
4077
  var(--surface) var(--soft-border-mix),
3839
4078
  var(--intent, var(--neutral))
3840
4079
  );
3841
- --alert-default-fg: color-mix(
3842
- in oklab,
3843
- var(--text),
3844
- var(--intent, var(--text)) var(--soft-fg-mix)
4080
+ --alert-default-fg: var(
4081
+ --intent-soft-fg,
4082
+ color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
3845
4083
  );
3846
4084
  /* @sync soft-recipe */
3847
4085
  }
@@ -3942,7 +4180,7 @@ a.badge[aria-disabled="true"] {
3942
4180
  /* Links */
3943
4181
  .alert :where(a:not(.btn)) {
3944
4182
  color: inherit;
3945
- font-weight: var(--font-weight-strong);
4183
+ font-weight: var(--font-weight-semibold);
3946
4184
  }
3947
4185
 
3948
4186
  /* Forced colors: no override needed. The always-on border gets a forced
@@ -3950,11 +4188,12 @@ a.badge[aria-disabled="true"] {
3950
4188
  Canvas backplate (unreadable) — so .solid intentionally falls back to the
3951
4189
  default look. */
3952
4190
 
3953
- /* Callout — neutral surface with a thick accent border on the leading edge.
3954
- The thickness follows the shared --alert-border-inline-start-width hook. */
4191
+ /* Callout — leading-edge flag treatment. It owns the border geometry only, so
4192
+ the surface and the ink stay with the alert's own intent and shared
4193
+ treatment: bare is the soft panel, .surface the page surface, .solid a
4194
+ filled one, and .outline has no fill and no other side. Thickness and color
4195
+ follow the --alert-border-inline-start-* hooks. */
3955
4196
  .alert.callout {
3956
- --alert-bg: var(--surface-subtle);
3957
- --alert-fg: var(--text);
3958
4197
  border: 0;
3959
4198
  border-inline-start: var(--alert-border-inline-start-width, 4px) solid
3960
4199
  var(--alert-border-inline-start-color, var(--intent, var(--neutral)));
@@ -4079,8 +4318,15 @@ a.badge[aria-disabled="true"] {
4079
4318
  * --busy-overlay-bg
4080
4319
  */
4081
4320
  /* Intent boundary — inherited intents do not tint this component.
4082
- Local intent classes still win because :where() has zero specificity. */
4321
+ Local intent classes still win because :where() has zero specificity.
4322
+ The intent tokens are reset alongside --ui-*: the shared variants
4323
+ (.soft/.solid/.outline/.surface) read --intent / --intent-fg / --intent-soft-fg
4324
+ when they paint this surface, so an ancestor intent would otherwise leak
4325
+ through a variant that has no local intent class. */
4083
4326
  :where(.card) {
4327
+ --intent: initial;
4328
+ --intent-fg: initial;
4329
+ --intent-soft-fg: initial;
4084
4330
  --ui-bg: initial;
4085
4331
  --ui-fg: initial;
4086
4332
  --ui-border: initial;
@@ -4153,7 +4399,7 @@ a.badge[aria-disabled="true"] {
4153
4399
  box-shadow: var(--shadow);
4154
4400
  }
4155
4401
  .card.subtle {
4156
- background: var(--surface-subtle);
4402
+ --card-bg: var(--surface-subtle);
4157
4403
  }
4158
4404
  .card.compact {
4159
4405
  --card-pad: var(--space-20);
@@ -4205,56 +4451,6 @@ a.badge[aria-disabled="true"] {
4205
4451
  }
4206
4452
  }
4207
4453
 
4208
- /*
4209
- * Surface — shared modal scrim and backdrop for flyout and drawer.
4210
- *
4211
- * Drawn by .drawer::backdrop (native dialog top-layer) and .surface-backdrop
4212
- * (custom fixed div for flyout sheets on coarse pointers). The native
4213
- * .modal::backdrop uses the same fill and animates opacity from 0 to 1
4214
- * (modal.css), so the scrim strength stays consistent across the dialog
4215
- * family.
4216
- *
4217
- * .surface-backdrop, .is-sheet, and .is-open are runtime markers set by
4218
- * surface.js. Authors opt in via [data-flyout-mobile="sheet|auto|none|anchored"]
4219
- * — the JS resolves "auto" into .is-sheet at run time.
4220
- */
4221
- /* Custom backdrop for overlays that do not use the native dialog top-layer.
4222
- * Its z-index sits just below the flyout it accompanies. */
4223
- .surface-backdrop {
4224
- position: fixed;
4225
- inset: 0;
4226
- z-index: calc(var(--z-menu) - 1);
4227
- background: var(--backdrop-fill);
4228
- opacity: 1;
4229
- /* A scrim never travels; it fades on the neutral `ease`. Duration matches
4230
- the flyout sheet it accompanies so it never lags the panel. */
4231
- transition: opacity var(--duration-fast) ease;
4232
- }
4233
-
4234
- .surface-backdrop[hidden] {
4235
- display: none;
4236
- opacity: 0;
4237
- }
4238
-
4239
- @starting-style {
4240
- .surface-backdrop:not([hidden]) {
4241
- opacity: 0;
4242
- }
4243
- }
4244
-
4245
- @supports (transition-behavior: allow-discrete) {
4246
- .surface-backdrop {
4247
- transition:
4248
- opacity var(--duration-fast) ease,
4249
- display var(--duration-fast) allow-discrete;
4250
- }
4251
- }
4252
- @media print {
4253
- .surface-backdrop {
4254
- display: none !important;
4255
- }
4256
- }
4257
-
4258
4454
  /*
4259
4455
  * Modal shell styling for native dialog elements.
4260
4456
  *
@@ -4269,21 +4465,40 @@ a.badge[aria-disabled="true"] {
4269
4465
  *
4270
4466
  * Internal:
4271
4467
  * --dialog-close-icon-size
4468
+ * --dialog-viewport-block
4469
+ * --dialog-viewport-gap-default
4272
4470
  * --dialog-viewport-gap-2x
4273
4471
  */
4274
4472
  dialog.modal {
4275
- --modal-size: 32rem;
4276
- --dialog-viewport-gap: var(--space-40);
4277
- /* calc() multiplication is not in the supported browser range, so the
4473
+ /* --dialog-viewport-gap stays fallback-only for the same reason as
4474
+ --modal-size below: declaring the public hook here would give it
4475
+ type+class specificity that a single author class cannot outrank. The
4476
+ responsive default lives in an internal token instead, so a media query
4477
+ can still move it without closing the hook.
4478
+
4479
+ calc() multiplication is not in the supported browser range, so the
4278
4480
  doubled gap is spelled out with an additive calc. */
4279
- --dialog-viewport-gap-2x: calc(var(--dialog-viewport-gap) + var(--dialog-viewport-gap));
4481
+ --dialog-viewport-gap-default: var(--space-40);
4482
+ --dialog-viewport-gap-2x: calc(
4483
+ var(--dialog-viewport-gap, var(--dialog-viewport-gap-default)) +
4484
+ var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
4485
+ );
4280
4486
 
4281
4487
  /* Keep the surface anchored to the viewport in the top layer. This also
4282
4488
  provides the containing block for the absolutely positioned close button. */
4283
4489
  position: fixed;
4284
4490
 
4285
- max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size));
4286
- max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
4491
+ /* --modal-size stays fallback-only: declaring it on dialog.modal gives it
4492
+ type+class specificity, which a single author class cannot outrank, so the
4493
+ public hook would silently keep this default. Same reason choice.css never
4494
+ declares --choice-control-offset. */
4495
+ max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size, 32rem));
4496
+
4497
+ /* Stable viewport cap for the panel: dvh tracks the dynamic browser toolbar
4498
+ and makes the dialog reflow on mobile, svh does not. App shells keep the
4499
+ dynamic token; only the modal pins the small viewport. */
4500
+ --dialog-viewport-block: var(--viewport-block);
4501
+ max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4287
4502
  border: var(--border-width) solid var(--border);
4288
4503
  border-radius: var(--radius-lg);
4289
4504
  background: var(--surface-raised);
@@ -4293,7 +4508,7 @@ dialog.modal {
4293
4508
  overflow: visible;
4294
4509
  opacity: 0;
4295
4510
  /*
4296
- * Keep the open dialog root untransformed. Fixed overlays mounted inside a
4511
+ * Keep the open dialog root untransformed. Fixed overlays authored inside a
4297
4512
  * modal dialog use viewport coordinates; transform/translate/scale on this
4298
4513
  * element would make them position against the dialog instead. The closed
4299
4514
  * state can still carry a small transform for enter/exit motion. The lift
@@ -4310,16 +4525,23 @@ dialog.modal > form,
4310
4525
  dialog.modal > .stack {
4311
4526
  display: grid;
4312
4527
  gap: var(--space-40);
4313
- max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
4528
+ max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4314
4529
  overflow: auto;
4315
4530
  overscroll-behavior: contain;
4316
4531
  padding: var(--space-40);
4317
4532
  }
4318
4533
 
4319
- /* Keep the dialog off the viewport edges on small screens. */
4534
+ @supports (block-size: 100svh) {
4535
+ dialog.modal {
4536
+ --dialog-viewport-block: 100svh;
4537
+ }
4538
+ }
4539
+
4540
+ /* Keep the dialog off the viewport edges on small screens. Moves the internal
4541
+ default, not the public hook, so an author override still wins here too. */
4320
4542
  @media (max-width: 30rem) {
4321
4543
  dialog.modal {
4322
- --dialog-viewport-gap: var(--space-30);
4544
+ --dialog-viewport-gap-default: var(--space-30);
4323
4545
  }
4324
4546
  }
4325
4547
 
@@ -4329,8 +4551,8 @@ dialog.modal.scrollable > .stack {
4329
4551
  overflow: hidden;
4330
4552
  }
4331
4553
 
4332
- dialog.modal.scrollable > form > :not(header, footer),
4333
- dialog.modal.scrollable > .stack > :not(header, footer) {
4554
+ dialog.modal.scrollable > form > :not(:is(header, footer)),
4555
+ dialog.modal.scrollable > .stack > :not(:is(header, footer)) {
4334
4556
  min-block-size: 0;
4335
4557
  overflow: auto;
4336
4558
  overscroll-behavior: contain;
@@ -4385,6 +4607,7 @@ dialog.modal.dialog-confirmation > form > footer {
4385
4607
  :where(.dialog-icon) {
4386
4608
  --intent: initial;
4387
4609
  --intent-fg: initial;
4610
+ --intent-soft-fg: initial;
4388
4611
  --ui-bg: initial;
4389
4612
  --ui-fg: initial;
4390
4613
  }
@@ -4513,14 +4736,11 @@ html.has-modal-open.had-scrollbar {
4513
4736
  }
4514
4737
  }
4515
4738
 
4516
- /* Close controls. A close button is an action attached to a surface, so it
4517
- never participates in the content flow. The modal and the drawer share the
4518
- same simple placement — absolute at the panel's top inline-end, inside the
4519
- surface — so both close buttons look and behave identically. The glyph comes
4520
- from --icon-close, masked to currentColor, so no icon font or text glyph is
4521
- required in the markup. */
4522
- .dialog-close,
4523
- .drawer-close {
4739
+ /* Close control. A close button is an action attached to a surface, so it
4740
+ never participates in the content flow — absolute at the panel's top
4741
+ inline-end, inside the surface. The glyph comes from --icon-close, masked to
4742
+ currentColor, so no icon font or text glyph is required in the markup. */
4743
+ .dialog-close {
4524
4744
  --dialog-close-icon-size: 1rem;
4525
4745
 
4526
4746
  display: inline-grid;
@@ -4544,8 +4764,7 @@ html.has-modal-open.had-scrollbar {
4544
4764
  background: var(--surface-subtle);
4545
4765
  }
4546
4766
 
4547
- .dialog-close::before,
4548
- .drawer-close::before {
4767
+ .dialog-close::before {
4549
4768
  content: "";
4550
4769
  inline-size: var(--dialog-close-icon-size);
4551
4770
  block-size: var(--dialog-close-icon-size);
@@ -4555,28 +4774,20 @@ html.has-modal-open.had-scrollbar {
4555
4774
  }
4556
4775
 
4557
4776
  @media (hover: hover) {
4558
- .dialog-close:hover,
4559
- .drawer-close:hover {
4777
+ .dialog-close:hover {
4560
4778
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
4561
4779
  color: var(--text);
4562
4780
  }
4563
4781
  }
4564
4782
 
4565
- .dialog-close:active,
4566
- .drawer-close:active {
4783
+ .dialog-close:active {
4567
4784
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
4568
4785
  }
4569
4786
 
4570
- .dialog-close:focus-visible,
4571
- .drawer-close:focus-visible {
4787
+ .dialog-close:focus-visible {
4572
4788
  outline: 2px solid transparent;
4573
4789
  box-shadow: var(--focus-ring-shadow);
4574
4790
  }
4575
- @media print {
4576
- dialog.modal[open] {
4577
- display: none !important;
4578
- }
4579
- }
4580
4791
 
4581
4792
  /*
4582
4793
  * Drawer — modal side-sheet built on the native dialog element.
@@ -4642,8 +4853,18 @@ dialog.drawer[open] {
4642
4853
  transform var(--drawer-motion-duration) var(--ease-enter);
4643
4854
  }
4644
4855
 
4645
- /* Backdrop color sourced from components/surface.css so it matches .modal
4646
- and .surface-backdrop across the dialog family. */
4856
+ /* A form is the drawer's content wrapper when actions need method="dialog".
4857
+ Span the drawer tracks so the form can keep the header at the top, let the
4858
+ body absorb the available height, and keep the footer at the bottom. */
4859
+ dialog.drawer > form {
4860
+ display: grid;
4861
+ grid-row: 1 / -1;
4862
+ grid-template-rows: auto minmax(0, 1fr) auto;
4863
+ gap: var(--space-40);
4864
+ min-block-size: 100%;
4865
+ }
4866
+
4867
+ /* The shared backdrop token keeps drawer and modal scrims consistent. */
4647
4868
  dialog.drawer::backdrop {
4648
4869
  background: var(--backdrop-fill);
4649
4870
  opacity: 0;
@@ -4708,33 +4929,33 @@ dialog.drawer nav {
4708
4929
  }
4709
4930
 
4710
4931
  /* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
4711
- path as the Minimal-tier fallback. */
4932
+ path as the Degraded-tier fallback. The :dir() rules are dropped on their
4933
+ own there, so they need no wrapper. :dir() follows inherited HTML
4934
+ directionality and handles nested direction changes; the explicit ltr rules
4935
+ neutralize an outer RTL fallback selector. Each specificity group keeps the
4936
+ fallback before the :dir() rules. */
4712
4937
  [dir="rtl"] dialog.drawer {
4713
4938
  --drawer-closed-translate: 100%;
4714
4939
  }
4715
4940
 
4716
- [dir="rtl"] dialog.drawer[data-side="end"] {
4941
+ dialog.drawer:dir(ltr) {
4717
4942
  --drawer-closed-translate: -100%;
4718
4943
  }
4719
4944
 
4720
- /* :dir() follows inherited HTML directionality and handles nested direction
4721
- changes. Explicit ltr rules neutralize an outer RTL fallback selector. */
4722
- @supports selector(:dir(rtl)) {
4723
- dialog.drawer:dir(ltr) {
4724
- --drawer-closed-translate: -100%;
4725
- }
4945
+ dialog.drawer:dir(rtl) {
4946
+ --drawer-closed-translate: 100%;
4947
+ }
4726
4948
 
4727
- dialog.drawer[data-side="end"]:dir(ltr) {
4728
- --drawer-closed-translate: 100%;
4729
- }
4949
+ [dir="rtl"] dialog.drawer[data-side="end"] {
4950
+ --drawer-closed-translate: -100%;
4951
+ }
4730
4952
 
4731
- dialog.drawer:dir(rtl) {
4732
- --drawer-closed-translate: 100%;
4733
- }
4953
+ dialog.drawer[data-side="end"]:dir(ltr) {
4954
+ --drawer-closed-translate: 100%;
4955
+ }
4734
4956
 
4735
- dialog.drawer[data-side="end"]:dir(rtl) {
4736
- --drawer-closed-translate: -100%;
4737
- }
4957
+ dialog.drawer[data-side="end"]:dir(rtl) {
4958
+ --drawer-closed-translate: -100%;
4738
4959
  }
4739
4960
 
4740
4961
  @media (prefers-reduced-motion: no-preference) {
@@ -4743,32 +4964,74 @@ dialog.drawer nav {
4743
4964
  }
4744
4965
  }
4745
4966
 
4746
- /* The close button shares the modal's interior placement (modal.css): absolute
4747
- at the panel's top inline-end, out of the content flow. The header reserves
4748
- inline room so the title never runs under it. */
4967
+ /* The header reserves inline room so the title never runs under the close
4968
+ control. */
4749
4969
  dialog.drawer > header,
4750
4970
  dialog.drawer > form > header {
4751
4971
  padding-inline-end: calc(var(--control-size) + var(--space-30));
4752
4972
  }
4753
- @media print {
4754
- dialog.drawer[open] {
4755
- display: none !important;
4973
+
4974
+ /* Close control, owned here so a drawer-only build does not depend on
4975
+ modal.css. Mirrors the modal's .dialog-close placement: absolute at the
4976
+ panel's top inline-end, out of the content flow. */
4977
+ .drawer-close {
4978
+ --drawer-close-icon-size: 1rem;
4979
+
4980
+ display: inline-grid;
4981
+ place-items: center;
4982
+ padding: 0;
4983
+ border: 0;
4984
+ border-radius: var(--radius);
4985
+ color: var(--text-muted);
4986
+ cursor: pointer;
4987
+
4988
+ position: absolute;
4989
+ z-index: 1;
4990
+ inset-block-start: var(--space-20);
4991
+ inset-inline-end: var(--space-20);
4992
+
4993
+ inline-size: var(--control-size);
4994
+ block-size: var(--control-size);
4995
+
4996
+ background: var(--surface-subtle);
4997
+ }
4998
+
4999
+ .drawer-close::before {
5000
+ content: "";
5001
+ inline-size: var(--drawer-close-icon-size);
5002
+ block-size: var(--drawer-close-icon-size);
5003
+ background: currentColor;
5004
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
5005
+ mask: var(--icon-close) center / contain no-repeat;
5006
+ }
5007
+
5008
+ @media (hover: hover) {
5009
+ .drawer-close:hover {
5010
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
5011
+ color: var(--text);
4756
5012
  }
4757
5013
  }
4758
5014
 
5015
+ .drawer-close:active {
5016
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
5017
+ }
5018
+
5019
+ .drawer-close:focus-visible {
5020
+ outline: 2px solid transparent;
5021
+ box-shadow: var(--focus-ring-shadow);
5022
+ }
5023
+
4759
5024
 
4760
5025
  /* Overlays */
4761
5026
  /*
4762
5027
  * Flyout — floating panel for actions and links.
4763
5028
  *
4764
- * JavaScript owns positioning (position: fixed + left/top), state
4765
- * (hidden + .is-open), focus, and keyboard behavior. CSS owns the surfaced
4766
- * panel appearance, item styles, and animation.
5029
+ * JavaScript promotes the panel to the top layer and chooses viewport or
5030
+ * document coordinates from its anchor. It also owns state (.is-open), focus,
5031
+ * and keyboard behavior. CSS owns the surfaced panel appearance, item styles,
5032
+ * and animation.
4767
5033
  *
4768
- * Runtime markers (managed by surface.js): .is-open marks an open flyout,
4769
- * .is-sheet marks "auto" resolved to bottom-sheet presentation on coarse
4770
- * pointers. Authors opt into mobile behavior with
4771
- * [data-flyout-mobile="sheet|auto|none|anchored"].
5034
+ * Runtime marker (managed by surface.js): .is-open marks an open flyout.
4772
5035
  * --surface-anchor-width is set by surface.js while an open flyout is positioned.
4773
5036
  *
4774
5037
  * Public hooks:
@@ -4777,7 +5040,7 @@ dialog.drawer > form > header {
4777
5040
  *
4778
5041
  * Internal:
4779
5042
  * --menu-item-size (derived from --control-size for menu rows)
4780
- * --available-height (measured by floating.js)
5043
+ * --available-height (measured by the positioner)
4781
5044
  * --surface-anchor-width (measured by surface.js)
4782
5045
  */
4783
5046
 
@@ -4804,8 +5067,8 @@ dialog.drawer > form > header {
4804
5067
  --menu-item-size: var(--control-size);
4805
5068
  z-index: var(--z-menu);
4806
5069
  /* No-JS fallback: below the trigger inside the relative .flyout-trigger.
4807
- surface.js switches an open flyout to position: fixed with measured
4808
- coordinates; this only covers a flyout revealed without the runtime. */
5070
+ surface.js replaces this with the anchor's coupled position/coordinate
5071
+ mode; this only covers a flyout revealed without the runtime. */
4809
5072
  position: absolute;
4810
5073
  inset-block-start: calc(100% + var(--space-10));
4811
5074
  inset-inline-start: 0;
@@ -4833,101 +5096,53 @@ dialog.drawer > form > header {
4833
5096
  box-shadow: var(--shadow-popout);
4834
5097
  list-style: none;
4835
5098
  opacity: 1;
4836
- /* Anchored flyouts fade in and out; scaling around the center would detach
4837
- the anchored edge from its trigger on wide panels. The sheet presentation
4838
- below adds its own translate motion. */
5099
+ /* Flyouts fade in and out; scaling around the center would detach the
5100
+ anchored edge from its trigger on wide panels. */
4839
5101
  transition: opacity var(--duration-fast) ease;
4840
5102
  }
4841
5103
 
4842
- /* Native popovers enter the top layer with UA geometry that conflicts with
4843
- anchored positioning. The reset is gated on real Popover support because an
4844
- attribute selector matches whether or not the engine implements Popover:
4845
- without it the UA neither hides nor promotes the element, so an ungated
4846
- reset would strand a permanently visible panel at inset: auto. Popover is
4847
- not part of Actual's Minimal contract — engines inside the Minimal range
4848
- may lack it — so the gate is about the capability, not the floor. Kept
4849
- scoped to Actual surfaces so bare [popover] elements retain their platform
5104
+ /* A popover enters the top layer with UA geometry that conflicts with anchored
5105
+ positioning. surface.js sets popover="manual" on every surface it manages,
5106
+ so this is the transport's own geometry rather than an opt-in. Kept scoped to
5107
+ Actual surfaces so bare [popover] elements retain their platform
4850
5108
  presentation. */
4851
- @supports selector(:popover-open) {
4852
- .flyout[popover] {
4853
- position: fixed;
4854
- inset: auto;
4855
- margin: 0;
4856
- }
4857
-
4858
- /* A presentation class may own its *visible* display type, but must not
4859
- override the platform's hidden state. The UA closes a popover with
4860
- display: none, and an author declaration beats the UA origin whatever the
4861
- specificity — so .flyout's own display: grid would leave a closed popover
4862
- painted at its static position. Restoring both properties here also gives
4863
- the native path the exit target the runtime path already has in
4864
- .flyout[hidden]: without an opacity to animate, the overlay/display
4865
- allow-discrete transition below would hold a fully opaque panel in the top
4866
- layer and then snap it away. */
4867
- .flyout[popover]:not(:popover-open) {
4868
- display: none;
4869
- opacity: 0;
4870
- }
5109
+ .flyout[popover] {
5110
+ position: fixed;
5111
+ inset: auto;
5112
+ margin: 0;
5113
+ }
5114
+
5115
+ /* A presentation class may own its *visible* display type, but must not
5116
+ override the platform's hidden state. The UA closes a popover with
5117
+ display: none, and an author declaration beats the UA origin whatever the
5118
+ specificity — so .flyout's own display: grid would leave a closed popover
5119
+ painted at its static position. The opacity is the exit target: with nothing
5120
+ to animate, the overlay/display allow-discrete transition below would hold a
5121
+ fully opaque panel in the top layer and then snap it away. */
5122
+ .flyout[popover]:not(:popover-open) {
5123
+ display: none;
5124
+ opacity: 0;
4871
5125
  }
4872
5126
 
4873
- /* compat-ok: :popover-open rides in a forgiving :is(), where an engine without
4874
- Popover discards just that member and .is-open alone keeps driving the open
4875
- state. This is a property of the forgiving list, not of the pseudo-class: in
4876
- a plain selector list an unsupported member would invalidate the whole rule.
4877
- Keep it inside :is()/:where() or gate it. */
4878
- .flyout:is(.is-open, :popover-open):not(.is-sheet) {
5127
+ /* Both lifecycles in one rule: .is-open for a surface.js panel, :popover-open
5128
+ for one an author drives natively with popovertarget. */
5129
+ .flyout:is(.is-open, :popover-open) {
4879
5130
  inset: auto;
4880
5131
  }
4881
5132
 
4882
- /* compat-ok: forgiving :is() again, and @starting-style is itself unsupported
4883
- across part of the Minimal range, so the rule simply does not exist there. */
5133
+ /* @starting-style is above the floor (Safari 17.5), so where it is missing the
5134
+ entry simply has no animated origin and the panel appears at full opacity. */
4884
5135
  @starting-style {
4885
5136
  .flyout:is(.is-open, :popover-open) {
4886
5137
  opacity: 0;
4887
5138
  }
4888
5139
  }
4889
5140
 
4890
- /* Bottom-sheet presentation. surface.js toggles .is-sheet when the flyout is
4891
- opened on coarse pointer / narrow viewports. */
4892
- .flyout.is-sheet {
4893
- --menu-item-size: var(--control-size-lg);
4894
- position: fixed;
4895
- inset-inline: 0;
4896
- inset-block-start: auto;
4897
- inset-block-end: 0;
4898
- inline-size: auto;
4899
- min-inline-size: 0;
4900
- max-inline-size: none;
4901
- max-block-size: min(calc(var(--viewport-block) * 0.75), 32rem);
4902
- overflow: auto;
4903
- grid-template-columns: 1fr;
4904
- padding: max(var(--space-30), env(safe-area-inset-top)) var(--space-30)
4905
- max(var(--space-30), env(safe-area-inset-bottom));
4906
- border-end-start-radius: 0;
4907
- border-end-end-radius: 0;
4908
- /* Sheet motion is a directional slide from the viewport edge; it replaces
4909
- the base fade-only recipe. */
4910
- transition:
4911
- opacity var(--duration-fast) ease,
4912
- translate var(--duration-fast) ease;
4913
- }
4914
-
4915
5141
  .flyout[hidden] {
4916
5142
  display: none;
4917
5143
  opacity: 0;
4918
5144
  }
4919
5145
 
4920
- .flyout.is-sheet[hidden] {
4921
- translate: 0 var(--space-30);
4922
- }
4923
-
4924
- /* compat-ok: forgiving :is() inside @starting-style, as above. */
4925
- @starting-style {
4926
- .flyout.is-sheet:is(.is-open, :popover-open) {
4927
- translate: 0 var(--space-30);
4928
- }
4929
- }
4930
-
4931
5146
  /* display keeps the exit transition alive for the [hidden] path; overlay does
4932
5147
  the same for the native popover path, where hidePopover() would otherwise
4933
5148
  drop the panel out of the top layer on the first frame and let a parent
@@ -4940,14 +5155,6 @@ dialog.drawer > form > header {
4940
5155
  overlay var(--duration-fast) allow-discrete,
4941
5156
  display var(--duration-fast) allow-discrete;
4942
5157
  }
4943
-
4944
- .flyout.is-sheet {
4945
- transition:
4946
- opacity var(--duration-fast) ease,
4947
- translate var(--duration-fast) ease,
4948
- overlay var(--duration-fast) allow-discrete,
4949
- display var(--duration-fast) allow-discrete;
4950
- }
4951
5158
  }
4952
5159
 
4953
5160
  .flyout > li {
@@ -4963,7 +5170,7 @@ dialog.drawer > form > header {
4963
5170
  margin: 0;
4964
5171
  color: var(--text-muted);
4965
5172
  font-size: var(--font-size-xs);
4966
- font-weight: var(--font-weight-strong);
5173
+ font-weight: var(--font-weight-semibold);
4967
5174
  }
4968
5175
 
4969
5176
  .flyout > section > ul {
@@ -4981,11 +5188,6 @@ dialog.drawer > form > header {
4981
5188
  /* Vertical dividers — .menu-separator (menu.css) is the canonical way to
4982
5189
  separate menu items. Flyout panels that are not menus use auto grid gap
4983
5190
  declared on .flyout itself. */
4984
- @media print {
4985
- .flyout {
4986
- display: none !important;
4987
- }
4988
- }
4989
5191
 
4990
5192
  /*
4991
5193
  * Menu — action list primitive consumed by flyout and context-menu.
@@ -5033,6 +5235,7 @@ dialog.drawer > form > header {
5033
5235
  background: transparent;
5034
5236
  color: var(--intent, var(--text));
5035
5237
  font: inherit;
5238
+ font-size: var(--control-font-size);
5036
5239
  text-align: start;
5037
5240
  text-decoration: none;
5038
5241
  white-space: nowrap;
@@ -5113,7 +5316,7 @@ dialog.drawer > form > header {
5113
5316
  padding: var(--space-20);
5114
5317
  color: var(--text-muted);
5115
5318
  font-size: var(--font-size-xs);
5116
- font-weight: var(--font-weight-strong);
5319
+ font-weight: var(--font-weight-semibold);
5117
5320
  }
5118
5321
 
5119
5322
  .menu-separator {
@@ -5125,9 +5328,13 @@ dialog.drawer > form > header {
5125
5328
  /*
5126
5329
  * Tooltip — supplemental label overlaid near a trigger.
5127
5330
  *
5128
- * Position, placement, and arrow position are managed by JavaScript
5129
- * via position: fixed, left, top, data-placement, and --arrow-x.
5130
- * Visibility is managed with hidden; CSS owns entry and exit animation.
5331
+ * Position, placement, and arrow position are managed by JavaScript via left,
5332
+ * top, data-placement, and --arrow-x. The runtime also owns `position`: it
5333
+ * writes `absolute` for a tip whose trigger scrolls with the page, so the
5334
+ * browser carries the tip along instead of the positioner correcting it a
5335
+ * frame later, and `fixed` — the declaration below — for every other trigger.
5336
+ * Visibility is the Popover lifecycle: tooltip.js promotes the tip to the top
5337
+ * layer with popover="manual". CSS owns entry and exit animation.
5131
5338
  *
5132
5339
  * Public hooks:
5133
5340
  * --tooltip-bg
@@ -5135,8 +5342,8 @@ dialog.drawer > form > header {
5135
5342
  * --tooltip-arrow-size
5136
5343
  *
5137
5344
  * Internal:
5138
- * --arrow-x (arrow position, written by floating.js)
5139
- * --arrow-y (arrow position, written by floating.js)
5345
+ * --arrow-x (arrow position, written by the positioner)
5346
+ * --arrow-y (arrow position, written by the positioner)
5140
5347
  */
5141
5348
  .tooltip {
5142
5349
  --tooltip-bg: var(--surface-solid);
@@ -5144,7 +5351,6 @@ dialog.drawer > form > header {
5144
5351
  --tooltip-arrow-size: 5px;
5145
5352
 
5146
5353
  position: fixed;
5147
- z-index: var(--z-tooltip);
5148
5354
  max-inline-size: min(24rem, calc(var(--viewport-inline) - 2rem));
5149
5355
  padding: 0.25em 0.6em;
5150
5356
  border-radius: var(--radius-sm);
@@ -5165,43 +5371,42 @@ dialog.drawer > form > header {
5165
5371
 
5166
5372
  /* Neutralize only the native geometry that conflicts with a positioned
5167
5373
  tooltip. In particular, the UA overflow would turn the arrow into scroll
5168
- overflow instead of letting it project beyond the bubble. Gated on real
5169
- Popover support: the attribute selector matches whether or not the engine
5170
- implements Popover, and without it nothing hides the element, so an ungated
5171
- reset would leave a stray bubble parked at inset: auto. Popover sits outside
5172
- Actual's Minimal contract, so the gate tracks the capability rather than the
5173
- floor. */
5174
- @supports selector(:popover-open) {
5175
- .tooltip[popover] {
5176
- inset: auto;
5177
- margin: 0;
5178
- overflow: visible;
5179
- }
5374
+ overflow instead of letting it project beyond the bubble.
5180
5375
 
5181
- /* .tooltip declares no display of its own, so the UA already hides a closed
5182
- popover here. The rule is written anyway: both components advertised as
5183
- Popover-compatible should cede the hidden state the same way, and the
5184
- opacity target is what actually makes the native exit fade — see the
5185
- overlay note below. */
5186
- .tooltip[popover]:not(:popover-open) {
5187
- display: none;
5188
- opacity: 0;
5189
- }
5376
+ One rule for both lifecycles: the runtime writes popover="manual" on the
5377
+ tips it manages, and an application driving a .tooltip[popover] itself lands
5378
+ on the same declarations. Who owns which is in
5379
+ docs/pages/enhancements/overview.md. */
5380
+ .tooltip[popover] {
5381
+ inset: auto;
5382
+ margin: 0;
5383
+ overflow: visible;
5384
+ }
5385
+
5386
+ /* The closed state, for either lifecycle. .tooltip declares no display of its
5387
+ own so the UA would already hide it, but the opacity target is what makes
5388
+ the exit fade at all — see the overlay note below. */
5389
+ .tooltip[popover]:not(:popover-open) {
5390
+ display: none;
5391
+ opacity: 0;
5190
5392
  }
5191
5393
 
5394
+ /* The state before enhancement, and the author's to write — not the runtime's.
5395
+ Wiring is lazy: tooltip.js adopts an explicit tip on its trigger's first
5396
+ hover, so a tooltip nobody hovers keeps [hidden] for the whole life of the
5397
+ page. Without this rule it would sit on screen at full opacity until then.
5398
+ The runtime removes the attribute at the moment it takes the lifecycle over,
5399
+ because an author declaration outranks the platform's own hidden state. */
5192
5400
  .tooltip[hidden] {
5193
5401
  display: none;
5194
5402
  opacity: 0;
5195
5403
  }
5196
5404
 
5197
- /* Entry opacity for both lifecycles: a runtime-driven tip is visible when it
5198
- is neither a popover nor [hidden], a native one when it is :popover-open.
5199
- compat-ok: [popover] appears here only under negation, which every engine
5200
- evaluates correctly whether or not it implements Popover, :popover-open
5201
- rides in a forgiving :is(), and @starting-style is itself unsupported across
5202
- part of the Minimal range — so the rule does not exist there at all. */
5405
+ /* Entry opacity, for either lifecycle — both are :popover-open.
5406
+ @starting-style is above the floor (Safari 17.5); where it is missing the
5407
+ tip simply appears at full opacity. */
5203
5408
  @starting-style {
5204
- .tooltip:is(:not([popover]):not([hidden]), :popover-open) {
5409
+ .tooltip:popover-open {
5205
5410
  opacity: 0;
5206
5411
  }
5207
5412
  }
@@ -5260,12 +5465,6 @@ dialog.drawer > form > header {
5260
5465
  clip-path: polygon(0 50%, 100% 0, 100% 100%);
5261
5466
  }
5262
5467
 
5263
- @media print {
5264
- .tooltip {
5265
- display: none !important;
5266
- }
5267
- }
5268
-
5269
5468
 
5270
5469
  /* Disclosure */
5271
5470
  /*
@@ -5275,6 +5474,12 @@ dialog.drawer > form > header {
5275
5474
  * or demo styles. Decorative palettes and custom markers belong outside the
5276
5475
  * base component.
5277
5476
  *
5477
+ * Container treatments (structural chrome, not skins):
5478
+ * - default — the common raised shell;
5479
+ * - .flush — the shell removed, only the item separators remain, so the group
5480
+ * can sit directly inside an existing surface (card, drawer, page section);
5481
+ * - .separated — each item becomes its own card.
5482
+ *
5278
5483
  * Public hooks:
5279
5484
  * --accordion-radius
5280
5485
  * --accordion-marker-color
@@ -5300,11 +5505,14 @@ dialog.drawer > form > header {
5300
5505
  list-style: none;
5301
5506
  padding: var(--space-40);
5302
5507
  color: var(--text);
5303
- font-weight: var(--font-weight-bold);
5508
+ font-weight: var(--font-weight-medium);
5304
5509
  }
5305
5510
 
5306
- .accordion summary:hover {
5307
- color: var(--primary);
5511
+ .accordion summary:hover::after {
5512
+ /* The whole row is one focus target; reinforcing only the end marker keeps
5513
+ the affordance on the disclosure control instead of making the whole
5514
+ label read as a link. */
5515
+ --accordion-marker-color: currentColor;
5308
5516
  }
5309
5517
 
5310
5518
  .accordion summary::-webkit-details-marker {
@@ -5348,6 +5556,36 @@ dialog.drawer > form > header {
5348
5556
  padding-inline-start: 2rem;
5349
5557
  }
5350
5558
 
5559
+ /* Flush — the shell is dropped and only the shared item separators remain.
5560
+ Zero inline padding puts the group edge to edge inside the surface that
5561
+ already frames it, so no override is repeated per embedding. */
5562
+ .accordion.flush {
5563
+ border: 0;
5564
+ border-radius: 0;
5565
+ background: transparent;
5566
+ }
5567
+
5568
+ .accordion.flush summary,
5569
+ .accordion.flush details > summary + * {
5570
+ padding-inline: 0;
5571
+ }
5572
+
5573
+ /* Separated — each item owns a full card. The item's own border beats the
5574
+ shared details + details separator on specificity alone, so nothing needs
5575
+ undoing for the second item onwards. */
5576
+ .accordion.separated {
5577
+ display: grid;
5578
+ gap: var(--space-20);
5579
+ border: 0;
5580
+ background: transparent;
5581
+ }
5582
+
5583
+ .accordion.separated > details {
5584
+ border: var(--border-width) solid var(--border);
5585
+ border-radius: var(--accordion-radius);
5586
+ background: var(--surface-raised);
5587
+ }
5588
+
5351
5589
  @media (forced-colors: active) {
5352
5590
  .accordion {
5353
5591
  --accordion-marker-color: CanvasText;
@@ -5389,7 +5627,6 @@ dialog.drawer > form > header {
5389
5627
  border-inline-end-color: var(--spinner-value);
5390
5628
  border-radius: var(--radius-full);
5391
5629
  opacity: 0.85;
5392
- animation: actual-spin var(--duration-spin) linear infinite;
5393
5630
  vertical-align: text-bottom;
5394
5631
  }
5395
5632
 
@@ -5410,16 +5647,17 @@ dialog.drawer > form > header {
5410
5647
  --spinner-width: 3px;
5411
5648
  }
5412
5649
 
5650
+ @media (prefers-reduced-motion: no-preference) {
5651
+ .spinner {
5652
+ animation: actual-spin var(--duration-spin) linear infinite;
5653
+ }
5654
+ }
5655
+
5413
5656
  @keyframes actual-spin {
5414
5657
  to {
5415
5658
  transform: rotate(360deg);
5416
5659
  }
5417
5660
  }
5418
- @media print {
5419
- .spinner {
5420
- display: none !important;
5421
- }
5422
- }
5423
5661
 
5424
5662
  /*
5425
5663
  * Skeleton placeholders for loading states.
@@ -5750,11 +5988,6 @@ dialog.drawer > form > header {
5750
5988
  translate var(--duration-slow) var(--ease-enter),
5751
5989
  background-color var(--duration) ease;
5752
5990
  }
5753
- @media print {
5754
- .status-bar {
5755
- display: none !important;
5756
- }
5757
- }
5758
5991
 
5759
5992
 
5760
5993
  /* Display */
@@ -5806,7 +6039,7 @@ dialog.drawer > form > header {
5806
6039
  color: var(--avatar-fg);
5807
6040
  font: inherit;
5808
6041
  font-size: var(--avatar-font-size);
5809
- font-weight: var(--font-weight-strong);
6042
+ font-weight: var(--font-weight-semibold);
5810
6043
  line-height: 1;
5811
6044
  text-decoration: none;
5812
6045
  vertical-align: middle;
@@ -5920,6 +6153,18 @@ a.avatar[aria-disabled="true"] {
5920
6153
  transform: translate(var(--avatar-status-translate-x), 50%);
5921
6154
  }
5922
6155
 
6156
+ /* Safari before 16.4 has no :dir(); the ancestor-attribute path is the
6157
+ Degraded-tier fallback and the :dir() rules are dropped on their own there.
6158
+ :dir() follows inherited directionality, so the explicit ltr rule
6159
+ neutralizes an outer RTL fallback for a nested direction change. */
6160
+ [dir="rtl"] .avatar > .badge:empty {
6161
+ --avatar-status-translate-x: -50%;
6162
+ }
6163
+
6164
+ .avatar:dir(ltr) > .badge:empty {
6165
+ --avatar-status-translate-x: 50%;
6166
+ }
6167
+
5923
6168
  .avatar:dir(rtl) > .badge:empty {
5924
6169
  --avatar-status-translate-x: -50%;
5925
6170
  }
@@ -6008,10 +6253,31 @@ a.avatar[aria-disabled="true"] {
6008
6253
  inset-block-end: 0;
6009
6254
  }
6010
6255
 
6256
+ /* Safari before 16.4 has no :dir(); the ancestor-attribute path is the
6257
+ Degraded-tier fallback and the :dir() rules are dropped on their own there.
6258
+ :dir() follows inherited directionality, so the explicit ltr rules
6259
+ neutralize an outer RTL fallback for a nested direction change. The rules
6260
+ stay grouped by specificity so the fallback never follows the :dir() rules. */
6261
+ [dir="rtl"] .indicator-item {
6262
+ --indicator-translate-x: -50%;
6263
+ }
6264
+
6265
+ .indicator-item:dir(ltr) {
6266
+ --indicator-translate-x: 50%;
6267
+ }
6268
+
6011
6269
  .indicator-item:dir(rtl) {
6012
6270
  --indicator-translate-x: -50%;
6013
6271
  }
6014
6272
 
6273
+ [dir="rtl"] .indicator-item.start {
6274
+ --indicator-translate-x: 50%;
6275
+ }
6276
+
6277
+ .indicator-item.start:dir(ltr) {
6278
+ --indicator-translate-x: -50%;
6279
+ }
6280
+
6015
6281
  .indicator-item.start:dir(rtl) {
6016
6282
  --indicator-translate-x: 50%;
6017
6283
  }
@@ -6046,6 +6312,16 @@ a.avatar[aria-disabled="true"] {
6046
6312
  }
6047
6313
  /* @sync key-recipe */
6048
6314
 
6315
+ /* .key has a compact typographic default of its own, so its local size roles
6316
+ step around that default instead of inheriting the generic mapping. */
6317
+ .key.sm {
6318
+ font-size: var(--font-size-xs);
6319
+ }
6320
+
6321
+ .key.lg {
6322
+ font-size: var(--font-size-md);
6323
+ }
6324
+
6049
6325
  /*
6050
6326
  * Message rows composed from an optional avatar, metadata, and bubble.
6051
6327
  *
@@ -6110,6 +6386,7 @@ a.avatar[aria-disabled="true"] {
6110
6386
  :where(.chat-bubble) {
6111
6387
  --intent: initial;
6112
6388
  --intent-fg: initial;
6389
+ --intent-soft-fg: initial;
6113
6390
  --ui-bg: initial;
6114
6391
  --ui-fg: initial;
6115
6392
  --ui-border: initial;
@@ -6275,12 +6552,6 @@ a.avatar[aria-disabled="true"] {
6275
6552
  transform: none;
6276
6553
  }
6277
6554
 
6278
- @media print {
6279
- .fab {
6280
- display: none;
6281
- }
6282
- }
6283
-
6284
6555
  /*
6285
6556
  * Rating — integer star rating drawn on native radio inputs.
6286
6557
  *
@@ -6393,23 +6664,16 @@ a.avatar[aria-disabled="true"] {
6393
6664
  *
6394
6665
  * `.steps` is what a step *is* — the counter, the marker, `.step-label`,
6395
6666
  * `.step-complete`, `aria-current="step"`. It carries no orientation, so it is
6396
- * never written alone. `.steps-horizontal` and `.steps-vertical` are what a
6397
- * sequence *looks like*, and they are peers: neither is the default the other
6398
- * has to exclude, so no rule says `:not(...)` and no rule undoes another's
6399
- * layout.
6667
+ * never written alone. The two orientations are peers: no rule says
6668
+ * `:not(...)` and no rule undoes another's layout.
6400
6669
  *
6401
- * Orientation and labels are independent dimensions. `.step-label` is optional
6402
- * in either orientation; when a sequence carries names, every step carries
6403
- * one:
6670
+ * Orientation and labels are independent. `.step-label` is optional in either;
6671
+ * when a sequence carries names, every step carries one:
6404
6672
  *
6405
6673
  * empty steps .step-label on every step
6406
6674
  * horizontal compact progression named sequence, stacked or inline
6407
6675
  * vertical compact rail named sequence, labels beside markers
6408
6676
  *
6409
- * An empty step is a marker and nothing else, so it is compact by structure —
6410
- * `> li:empty` in both orientations, no variant class, and nothing authored
6411
- * only to be hidden again. The page around it owns the step name.
6412
- *
6413
6677
  * A label may be an `a[href]`, which is the whole interactive surface: the
6414
6678
  * marker is never navigable, so navigating a flow means naming its
6415
6679
  * destinations. Anything else focusable in a step is out of contract.
@@ -6480,7 +6744,7 @@ a.avatar[aria-disabled="true"] {
6480
6744
  background: var(--step-marker-bg);
6481
6745
  color: var(--step-marker-fg);
6482
6746
  font-size: var(--font-size-sm);
6483
- font-weight: var(--font-weight-strong);
6747
+ font-weight: var(--font-weight-semibold);
6484
6748
  line-height: 1;
6485
6749
  }
6486
6750
 
@@ -6540,11 +6804,9 @@ a.avatar[aria-disabled="true"] {
6540
6804
  border-width: calc(var(--border-width) * 2);
6541
6805
  }
6542
6806
 
6543
- /* Weight marks "where I am now". Complete needs none: its filled disc already
6544
- reads as strongly. */
6545
- .steps > [aria-current="step"] .step-label {
6546
- font-weight: var(--font-weight-strong);
6547
- }
6807
+ /* No label weight change for current: the accented marker with its doubled
6808
+ border already carries the state, and selection never changes text metrics.
6809
+ Complete needs none either: its filled disc already reads as strongly. */
6548
6810
 
6549
6811
  .steps > .step-complete:not([aria-current="step"])::before {
6550
6812
  content: var(--step-complete-mark, counter(actual-step));
@@ -6602,8 +6864,8 @@ a.avatar[aria-disabled="true"] {
6602
6864
  `:empty` says exactly what the markup says, the way `.badge:empty` does, and
6603
6865
  it reads the item's own content: bare text in an `<li>` keeps a reading width
6604
6866
  like its wrapped peers, where `> li:has(.step-label)` would give it a marker
6605
- floor and skew the row. It also predates `:has()` by a decade, so the compact
6606
- form reaches the Degraded tier and only the inline bonus needs `:has()`. */
6867
+ floor and skew the row. `:empty` also keeps the compact form reachable on the
6868
+ Degraded tier, where only the inline bonus needs `:has()`. */
6607
6869
  .steps-horizontal > li:empty {
6608
6870
  flex: 1 1 0;
6609
6871
  justify-items: start;
@@ -6634,48 +6896,43 @@ a.avatar[aria-disabled="true"] {
6634
6896
  }
6635
6897
 
6636
6898
  /*
6637
- * The one horizontal container query. Past 60rem a labelled row has the room
6638
- * to put marker and label inline and still leave the connector a real region
6639
- * between groups, rather than a stub. Calibrated on five steps — the widest
6640
- * supported row, and therefore the binding case — and it counts nothing, so it
6641
- * serves every count. Below it, stacked; a marker row is already the shape
6642
- * this query produces and stays out of it.
6643
- *
6644
- * Horizontal is a 2-5 step component. Outside that domain the rule simply
6645
- * applies as written, because a row that is out of contract is not owed a
6646
- * curated fallback.
6899
+ * The one horizontal container query: past 60rem a labelled row can put marker
6900
+ * and label inline and still leave the connector a real region between groups.
6901
+ * It counts nothing, so it serves every count; below it, stacked. A marker row
6902
+ * is already the shape this query produces and stays out of it.
6903
+ *
6904
+ * Horizontal is a 2-5 step component. Outside that domain the rule applies as
6905
+ * written — a row out of contract is not owed a curated fallback.
6647
6906
  */
6648
- @supports (container-type: inline-size) {
6649
- @container actual-container (inline-size >= 60rem) {
6650
- .steps-horizontal:has(.step-label) > li {
6651
- display: flex;
6652
- flex: 1 1 auto;
6653
- align-items: center;
6654
- gap: var(--step-inline-gap);
6655
- min-inline-size: 0;
6656
- text-align: start;
6657
- white-space: nowrap;
6658
- }
6907
+ @container actual-container (inline-size >= 60rem) {
6908
+ .steps-horizontal:has(.step-label) > li {
6909
+ display: flex;
6910
+ flex: 1 1 auto;
6911
+ align-items: center;
6912
+ gap: var(--step-inline-gap);
6913
+ min-inline-size: 0;
6914
+ text-align: start;
6915
+ white-space: nowrap;
6916
+ }
6659
6917
 
6660
- .steps-horizontal:has(.step-label) > li::after {
6661
- position: static;
6662
- flex: 1 1 2rem;
6663
- inline-size: auto;
6664
- block-size: var(--step-size);
6665
- margin-inline-end: var(--step-inline-gap);
6666
- background: var(
6667
- --step-inline-connector,
6668
- var(
6669
- --step-connector,
6670
- linear-gradient(var(--step-line), var(--step-line)) center /
6671
- 100% var(--step-line-size) no-repeat
6672
- )
6673
- );
6674
- }
6918
+ .steps-horizontal:has(.step-label) > li::after {
6919
+ position: static;
6920
+ flex: 1 1 2rem;
6921
+ inline-size: auto;
6922
+ block-size: var(--step-size);
6923
+ margin-inline-end: var(--step-inline-gap);
6924
+ background: var(
6925
+ --step-inline-connector,
6926
+ var(
6927
+ --step-connector,
6928
+ linear-gradient(var(--step-line), var(--step-line)) center /
6929
+ 100% var(--step-line-size) no-repeat
6930
+ )
6931
+ );
6932
+ }
6675
6933
 
6676
- .steps-horizontal:has(.step-label) > li:last-child {
6677
- flex: 0 0 auto;
6678
- }
6934
+ .steps-horizontal:has(.step-label) > li:last-child {
6935
+ flex: 0 0 auto;
6679
6936
  }
6680
6937
  }
6681
6938
 
@@ -6751,8 +7008,12 @@ a.avatar[aria-disabled="true"] {
6751
7008
  * Public hooks:
6752
7009
  * --app-nav-size
6753
7010
  * --app-nav-gap
7011
+ * --app-nav-icon-size
6754
7012
  */
6755
7013
  :where(.app-nav) {
7014
+ --intent: initial;
7015
+ --intent-fg: initial;
7016
+ --intent-soft-fg: initial;
6756
7017
  --ui-bg: initial;
6757
7018
  --ui-fg: initial;
6758
7019
  --ui-border: initial;
@@ -6761,6 +7022,7 @@ a.avatar[aria-disabled="true"] {
6761
7022
  .app-nav {
6762
7023
  --app-nav-size: 4rem;
6763
7024
  --app-nav-gap: var(--space-10);
7025
+ --app-nav-icon-size: 1.5rem;
6764
7026
 
6765
7027
  position: sticky;
6766
7028
  inset-block-end: 0;
@@ -6797,9 +7059,9 @@ a.avatar[aria-disabled="true"] {
6797
7059
  }
6798
7060
 
6799
7061
  .app-nav > a > :where(svg, img, [aria-hidden="true"]) {
6800
- inline-size: 1.5rem;
6801
- block-size: 1.5rem;
6802
- font-size: 1.5rem;
7062
+ inline-size: var(--app-nav-icon-size);
7063
+ block-size: var(--app-nav-icon-size);
7064
+ font-size: var(--app-nav-icon-size);
6803
7065
  line-height: 1;
6804
7066
  }
6805
7067
 
@@ -6808,15 +7070,20 @@ a.avatar[aria-disabled="true"] {
6808
7070
  color: var(--text);
6809
7071
  }
6810
7072
 
6811
- .app-nav > a:where([aria-current]:not([aria-current="false"])) {
7073
+ /* Current tile is already a full selected surface. Selection never changes
7074
+ text metrics: the shared medium weight is kept, no permanent indicator is
7075
+ added here. */
7076
+ .app-nav > a[aria-current="page"] {
6812
7077
  background: var(--state-selected);
6813
7078
  color: var(--state-selected-fg);
6814
- font-weight: var(--font-weight-bold);
6815
7079
  }
6816
7080
 
6817
- @media print {
6818
- .app-nav {
6819
- display: none;
7081
+ @media (forced-colors: active) {
7082
+ /* --state-selected flattens to a UA color that no longer distinguishes the
7083
+ current tile from the rest of the bar, so paint the system Highlight pair. */
7084
+ .app-nav > a[aria-current="page"] {
7085
+ background: Highlight;
7086
+ color: HighlightText;
6820
7087
  }
6821
7088
  }
6822
7089
 
@@ -6831,8 +7098,15 @@ a.avatar[aria-disabled="true"] {
6831
7098
  * --gap (local value for the shared gap token)
6832
7099
  */
6833
7100
  /* Intent boundary — inherited intents do not tint this component.
6834
- Local intent classes still win because :where() has zero specificity. */
7101
+ Local intent classes still win because :where() has zero specificity.
7102
+ The intent tokens are reset alongside --ui-*: the shared variants
7103
+ (.soft/.solid/.outline/.surface) read --intent / --intent-fg / --intent-soft-fg
7104
+ when they paint this surface, so an ancestor intent would otherwise leak
7105
+ through a variant that has no local intent class. */
6835
7106
  :where(.navbar) {
7107
+ --intent: initial;
7108
+ --intent-fg: initial;
7109
+ --intent-soft-fg: initial;
6836
7110
  --ui-bg: initial;
6837
7111
  --ui-fg: initial;
6838
7112
  --ui-border: initial;
@@ -6888,10 +7162,15 @@ a.avatar[aria-disabled="true"] {
6888
7162
  text-decoration: none;
6889
7163
  }
6890
7164
 
6891
- .nav-link:where([aria-current]:not([aria-current="false"])) {
7165
+ /* nav-link serves two contracts: plain page navigation (aria-current="page")
7166
+ and scrollspy, which toggles aria-current="location" on the active link.
7167
+ Presence is the contract: the attribute is set on the current link and
7168
+ removed everywhere else, never serialized as aria-current="false".
7169
+ Selection never changes text metrics: the shared medium weight is kept and
7170
+ the current link is carried by the selected accent. */
7171
+ .nav-link[aria-current] {
6892
7172
  background: var(--surface-subtle);
6893
- color: var(--text);
6894
- font-weight: var(--font-weight-bold);
7173
+ color: var(--state-selected);
6895
7174
  }
6896
7175
 
6897
7176
  .nav-link:hover {
@@ -6899,11 +7178,18 @@ a.avatar[aria-disabled="true"] {
6899
7178
  color: var(--text);
6900
7179
  }
6901
7180
 
7181
+ /* A current link keeps its accent on hover: without this, the hover rule above
7182
+ would drop it back to --text. The surface hover still applies. */
7183
+ .nav-link[aria-current]:hover {
7184
+ color: var(--state-selected);
7185
+ }
7186
+
6902
7187
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links.
6903
7188
  Links fill each row so the whole line is an interactive target; the
6904
7189
  horizontal .navbar-nav links keep their intrinsic size. */
6905
7190
  .nav-list {
6906
7191
  display: grid;
7192
+ grid-template-columns: minmax(0, 1fr);
6907
7193
  --gap: var(--space-20);
6908
7194
  gap: var(--gap);
6909
7195
  margin: 0;
@@ -6916,9 +7202,23 @@ a.avatar[aria-disabled="true"] {
6916
7202
  }
6917
7203
 
6918
7204
  .nav-list .nav-link {
7205
+ position: relative;
6919
7206
  inline-size: 100%;
6920
7207
  }
6921
7208
 
7209
+ /* Vertical current indicator: inset inline-start trait in currentColor, so it
7210
+ follows the forced palette without a forced-colors block and never touches
7211
+ the row separators. Navbar horizontal links keep color + surface only. The
7212
+ positioning context lives on every row link, so becoming current never moves
7213
+ an app-positioned decoration to a new containing block. */
7214
+ .nav-list .nav-link[aria-current]::before {
7215
+ content: "";
7216
+ position: absolute;
7217
+ inset-block: var(--space-20);
7218
+ inset-inline-start: 0;
7219
+ border-inline-start: 2px solid currentColor;
7220
+ }
7221
+
6922
7222
  /*
6923
7223
  * Breadcrumb — current-location trail from the site root.
6924
7224
  *
@@ -6954,8 +7254,7 @@ a.avatar[aria-disabled="true"] {
6954
7254
  text-decoration: none;
6955
7255
  }
6956
7256
 
6957
- .breadcrumb a:not([aria-current]):hover,
6958
- .breadcrumb a[aria-current="false"]:hover {
7257
+ .breadcrumb a:not([aria-current="page"]):hover {
6959
7258
  color: var(--text);
6960
7259
  text-decoration: underline;
6961
7260
  }
@@ -6964,7 +7263,7 @@ a.avatar[aria-disabled="true"] {
6964
7263
  border-radius: var(--radius-sm);
6965
7264
  }
6966
7265
 
6967
- .breadcrumb :where(li, a, span):where([aria-current]:not([aria-current="false"])) {
7266
+ .breadcrumb :where(li, a, span)[aria-current="page"] {
6968
7267
  color: var(--text);
6969
7268
  font-weight: var(--font-weight-medium);
6970
7269
  }
@@ -6988,6 +7287,7 @@ a.avatar[aria-disabled="true"] {
6988
7287
  margin: 0;
6989
7288
  padding: 0;
6990
7289
  list-style: none;
7290
+ font-size: var(--control-font-size);
6991
7291
 
6992
7292
  /* Arrows, page numbers, and truncation ellipses share one geometric
6993
7293
  system: equal digit widths keep the row from re-flowing as numbers grow
@@ -7019,6 +7319,11 @@ a.avatar[aria-disabled="true"] {
7019
7319
  * and arrow-key navigation. CSS owns the tab strip appearance and
7020
7320
  * active indicator.
7021
7321
  *
7322
+ * aria-orientation is matched inside :where() so it keeps describing which
7323
+ * arrow keys move between tabs without also carrying cascade weight: an
7324
+ * author class can then adjust a vertical strip without restating the
7325
+ * attribute to reach its specificity.
7326
+ *
7022
7327
  * Public hooks:
7023
7328
  * --tab-gap
7024
7329
  */
@@ -7039,7 +7344,7 @@ a.avatar[aria-disabled="true"] {
7039
7344
  list-style: none;
7040
7345
  }
7041
7346
 
7042
- .tabs[aria-orientation="vertical"] {
7347
+ .tabs:where([aria-orientation="vertical"]) {
7043
7348
  display: grid;
7044
7349
  align-content: start;
7045
7350
  justify-items: stretch;
@@ -7060,12 +7365,15 @@ a.avatar[aria-disabled="true"] {
7060
7365
  background: transparent;
7061
7366
  color: var(--text-muted);
7062
7367
  font: inherit;
7063
- font-weight: var(--font-weight-strong);
7368
+ font-size: var(--control-font-size);
7369
+ /* Selection must not change text metrics: a heavier active label would shift
7370
+ the strip. The active state is carried by color and the indicator line. */
7371
+ font-weight: var(--font-weight-semibold);
7064
7372
  text-decoration: none;
7065
7373
  cursor: pointer;
7066
7374
  }
7067
7375
 
7068
- .tabs[aria-orientation="vertical"] .tab {
7376
+ .tabs:where([aria-orientation="vertical"]) .tab {
7069
7377
  justify-content: flex-start;
7070
7378
  padding-inline-end: var(--space-40);
7071
7379
  }
@@ -7079,6 +7387,8 @@ a.avatar[aria-disabled="true"] {
7079
7387
  color: var(--text);
7080
7388
  }
7081
7389
 
7390
+ /* The ring is square on the edge the tab is attached to, so it reads as part
7391
+ of the strip rather than a floating chip. */
7082
7392
  .tab:focus-visible {
7083
7393
  outline: var(--focus-outline);
7084
7394
  outline-color: var(--focus);
@@ -7086,18 +7396,27 @@ a.avatar[aria-disabled="true"] {
7086
7396
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
7087
7397
  }
7088
7398
 
7089
- .tab[aria-selected="true"] {
7399
+ /* A vertical tab is attached along its inline end, not its block end, so the
7400
+ horizontal geometry above would round the wrong pair of corners. Logical
7401
+ corners keep it correct in RTL. */
7402
+ .tabs:where([aria-orientation="vertical"]) .tab:focus-visible {
7403
+ border-start-start-radius: var(--radius-sm);
7404
+ border-start-end-radius: 0;
7405
+ border-end-start-radius: var(--radius-sm);
7406
+ border-end-end-radius: 0;
7407
+ }
7408
+
7409
+ .tab[aria-selected="true"],
7410
+ .tab[aria-current="page"] {
7090
7411
  color: var(--intent, var(--state-selected));
7091
- /* Second, layout-free selected cue — the underline alone is color-only in
7092
- forced colors. */
7093
- font-weight: var(--font-weight-bold);
7094
7412
  }
7095
7413
 
7096
7414
  /* The selected indicator lives on a positioned pseudo so that only the active
7097
7415
  tab owns an underline: a transparent border on every tab would surface in
7098
7416
  forced colors and erase the distinction. currentColor follows the palette the
7099
7417
  UA forces, so no forced-colors block is needed. */
7100
- .tab[aria-selected="true"]::after {
7418
+ .tab[aria-selected="true"]::after,
7419
+ .tab[aria-current="page"]::after {
7101
7420
  content: "";
7102
7421
  position: absolute;
7103
7422
  inset-inline: 0;
@@ -7105,7 +7424,8 @@ a.avatar[aria-disabled="true"] {
7105
7424
  border-block-end: 2px solid currentColor;
7106
7425
  }
7107
7426
 
7108
- .tabs[aria-orientation="vertical"] .tab[aria-selected="true"]::after {
7427
+ .tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
7428
+ .tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
7109
7429
  inset-block: 0;
7110
7430
  inset-inline: auto 0;
7111
7431
  border-block-end: 0;
@@ -7162,7 +7482,7 @@ a.avatar[aria-disabled="true"] {
7162
7482
  .data-list > dt {
7163
7483
  min-inline-size: 0;
7164
7484
  margin-block-start: 0;
7165
- font-weight: var(--font-weight-strong);
7485
+ font-weight: var(--font-weight-semibold);
7166
7486
  }
7167
7487
 
7168
7488
  .data-list > dd {
@@ -7190,6 +7510,7 @@ a.avatar[aria-disabled="true"] {
7190
7510
  --list-divider: var(--border-width) solid var(--border);
7191
7511
 
7192
7512
  display: grid;
7513
+ grid-template-columns: minmax(0, 1fr);
7193
7514
  gap: 0;
7194
7515
  margin: 0;
7195
7516
  padding: 0;
@@ -7201,10 +7522,17 @@ a.avatar[aria-disabled="true"] {
7201
7522
  margin: 0;
7202
7523
  }
7203
7524
 
7525
+ /*
7526
+ * Flex, not a three-track grid: the leading and trailing regions are optional,
7527
+ * and a fixed `auto minmax(0, 1fr) auto` template gives an absent region a
7528
+ * track anyway — the content of a row with no leading region lands in the
7529
+ * leading track and the trailing control inherits the flexible one, which is
7530
+ * the opposite of the intent. Flex drops both the track and its gap when a
7531
+ * region is not there, so all four shapes work off one rule.
7532
+ */
7204
7533
  .list > :where(.list-item),
7205
7534
  .list > li > .list-item {
7206
- display: grid;
7207
- grid-template-columns: auto minmax(0, 1fr) auto;
7535
+ display: flex;
7208
7536
  /* Regions align to the first line, so leading, title, and trailing controls
7209
7537
  share one horizontal line while supporting text wraps beneath. */
7210
7538
  align-items: start;
@@ -7220,6 +7548,7 @@ a.avatar[aria-disabled="true"] {
7220
7548
  }
7221
7549
 
7222
7550
  a.list-item {
7551
+ position: relative;
7223
7552
  color: inherit;
7224
7553
  text-decoration: none;
7225
7554
  }
@@ -7228,7 +7557,31 @@ a.list-item:hover {
7228
7557
  background: var(--surface-subtle);
7229
7558
  }
7230
7559
 
7560
+ /* Current row in a navigational list (master/detail). Generic by design: the
7561
+ visual treatment only cares that the row is current, so presence is the
7562
+ contract — the attribute is set on the current row and removed everywhere
7563
+ else, never serialized as aria-current="false". The value (page, location,
7564
+ …) stays free for assistive technology.
7565
+ Calm by design: the row keeps its normal text metrics and color on a subtle
7566
+ surface, and the selected accent lives on an inset inline-start trait only.
7567
+ The trait is a real border, so it survives forced colors without a repaint
7568
+ block. The positioning context lives on every row link, so becoming current
7569
+ never moves an app-positioned decoration to a new containing block. */
7570
+ a.list-item[aria-current] {
7571
+ background: var(--surface-subtle);
7572
+ }
7573
+
7574
+ a.list-item[aria-current]::before {
7575
+ content: "";
7576
+ position: absolute;
7577
+ inset-block: var(--list-item-pad-block);
7578
+ inset-inline-start: 0;
7579
+ border-inline-start: 2px solid var(--state-selected);
7580
+ }
7581
+
7582
+ /* The content region is the one that flexes, whichever position it holds. */
7231
7583
  .list-item-content {
7584
+ flex: 1 1 0;
7232
7585
  min-inline-size: 0;
7233
7586
  }
7234
7587
 
@@ -7236,16 +7589,19 @@ a.list-item:hover {
7236
7589
  .list-item-text {
7237
7590
  display: block;
7238
7591
  margin: 0;
7592
+ overflow-wrap: anywhere;
7239
7593
  }
7240
7594
 
7241
7595
  .list-item-text {
7242
7596
  color: var(--text-muted);
7243
7597
  font-size: var(--font-size-sm);
7244
- overflow-wrap: anywhere;
7245
7598
  }
7246
7599
 
7600
+ /* The auto margin keeps the trailing region at the row's end even in a row
7601
+ with no content region to absorb the free space. */
7247
7602
  .list-item-trailing {
7248
- justify-self: end;
7603
+ flex: none;
7604
+ margin-inline-start: auto;
7249
7605
  min-inline-size: 0;
7250
7606
  }
7251
7607
 
@@ -7301,13 +7657,13 @@ a.list-item:hover {
7301
7657
  vertical-align: baseline;
7302
7658
  }
7303
7659
 
7304
- .table thead th {
7305
- vertical-align: bottom;
7306
- }
7307
-
7308
7660
  .table th {
7309
7661
  color: var(--text-muted);
7310
- font-weight: var(--font-weight-strong);
7662
+ font-weight: var(--font-weight-semibold);
7663
+ }
7664
+
7665
+ .table thead th {
7666
+ vertical-align: bottom;
7311
7667
  }
7312
7668
 
7313
7669
  .table thead {
@@ -7875,7 +8231,8 @@ a.list-item:hover {
7875
8231
  text-decoration: none;
7876
8232
  }
7877
8233
 
7878
- .link-muted:hover {
8234
+ .link-muted:hover,
8235
+ .link-muted:focus-visible {
7879
8236
  color: var(--text);
7880
8237
  }
7881
8238
 
@@ -7916,7 +8273,8 @@ a.list-item:hover {
7916
8273
  * They are escape hatches for layout nudges, not a full atomic scale.
7917
8274
  *
7918
8275
  * Core principle:
7919
- * - .sm / .lg provide local rhythm through --gap and --density-space.
8276
+ * - .compact / .spacious provide inherited rhythm through --gap and
8277
+ * --density-space.
7920
8278
  * - utilities consume the current context instead of defining their own -sm/-lg scale.
7921
8279
  * - optional utilities extend core utilities, they do not re-express them.
7922
8280
  * - follow verbose .{property}-{value} naming.
@@ -7997,18 +8355,6 @@ a.list-item:hover {
7997
8355
  flex-wrap: nowrap;
7998
8356
  }
7999
8357
 
8000
- .gap-sm {
8001
- gap: var(--space-20);
8002
- }
8003
-
8004
- .gap-md {
8005
- gap: var(--space-40);
8006
- }
8007
-
8008
- .gap-lg {
8009
- gap: var(--space-50);
8010
- }
8011
-
8012
8358
  .justify-content-start {
8013
8359
  --cluster-justify: flex-start;
8014
8360
  justify-content: flex-start;
@@ -8066,6 +8412,10 @@ a.list-item:hover {
8066
8412
  margin-block-end: auto;
8067
8413
  }
8068
8414
 
8415
+ /* Consume the inherited --gap rhythm on an arbitrary layout container.
8416
+ Setting --gap alone only defines the custom property; it has no visual
8417
+ effect unless the element already uses gap: var(--gap) (as .stack,
8418
+ .cluster, .grid, etc. do). */
8069
8419
  .gap-context {
8070
8420
  gap: var(--gap);
8071
8421
  }
@@ -8117,15 +8467,15 @@ a.list-item:hover {
8117
8467
  }
8118
8468
 
8119
8469
  .font-weight-normal {
8120
- font-weight: var(--font-weight);
8470
+ font-weight: var(--font-weight-normal);
8121
8471
  }
8122
8472
 
8123
8473
  .font-weight-medium {
8124
8474
  font-weight: var(--font-weight-medium);
8125
8475
  }
8126
8476
 
8127
- .font-weight-strong {
8128
- font-weight: var(--font-weight-strong);
8477
+ .font-weight-semibold {
8478
+ font-weight: var(--font-weight-semibold);
8129
8479
  }
8130
8480
 
8131
8481
  .font-weight-bold {
@@ -8166,18 +8516,28 @@ a.list-item:hover {
8166
8516
  border-inline-end: var(--border-width) solid var(--border);
8167
8517
  }
8168
8518
 
8169
- /* Surface */
8519
+ /* Surface. These are semantic surfaces, not arbitrary paint: each owns its
8520
+ matching ink and rebinds --heading to that ink, so it stays readable when it
8521
+ recreates a light surface inside a context that installed a dark one
8522
+ (.inverted). Intent ink is not inherited through a surface utility; use
8523
+ .intent-color for that. */
8170
8524
  .background-surface {
8525
+ --heading: currentColor;
8526
+
8171
8527
  background: var(--surface);
8172
8528
  color: var(--text);
8173
8529
  }
8174
8530
 
8175
8531
  .background-raised {
8532
+ --heading: currentColor;
8533
+
8176
8534
  background: var(--surface-raised);
8177
8535
  color: var(--text);
8178
8536
  }
8179
8537
 
8180
8538
  .background-subtle {
8539
+ --heading: currentColor;
8540
+
8181
8541
  background: var(--surface-subtle);
8182
8542
  color: var(--text);
8183
8543
  }