actual-css 0.1.0 → 0.3.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 (215) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +25 -8
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1492 -931
  5. package/dist/actual.js +1 -3
  6. package/dist/actual.min.css +1 -1
  7. package/dist/optional.css +1048 -0
  8. package/dist/optional.min.css +1 -0
  9. package/docs/design-notes/browser-support.md +95 -6
  10. package/docs/design-notes/cascade-layer.md +1 -1
  11. package/docs/design-notes/enhancement-contract.md +101 -0
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +3 -1
  14. package/docs/design-notes/platform-alignment.md +492 -0
  15. package/docs/design-notes/references.md +8 -0
  16. package/docs/design-notes/widget-primitives.md +119 -0
  17. package/docs/navigation.json +135 -0
  18. package/docs/pages/components/accordion.md +80 -0
  19. package/docs/pages/components/alert.md +203 -0
  20. package/docs/pages/components/aura.md +35 -0
  21. package/docs/pages/components/avatar.md +95 -0
  22. package/docs/pages/components/badge.md +102 -0
  23. package/docs/pages/components/breadcrumb.md +19 -0
  24. package/docs/pages/components/button.md +205 -0
  25. package/docs/pages/components/card.md +183 -0
  26. package/docs/pages/components/chat.md +50 -0
  27. package/docs/pages/components/dialog.md +367 -0
  28. package/docs/pages/components/drawer.md +113 -0
  29. package/docs/pages/components/fab.md +118 -0
  30. package/docs/pages/components/key.md +33 -0
  31. package/docs/pages/components/meter.md +23 -0
  32. package/docs/pages/components/navbar.md +92 -0
  33. package/docs/pages/components/pagination.md +55 -0
  34. package/docs/pages/components/progress.md +29 -0
  35. package/docs/pages/components/skeleton.md +38 -0
  36. package/docs/pages/components/spinner.md +93 -0
  37. package/docs/pages/components/table.md +102 -0
  38. package/docs/pages/enhancements/flyout.md +391 -0
  39. package/docs/pages/enhancements/overview.md +36 -0
  40. package/docs/pages/enhancements/scrollspy.md +71 -0
  41. package/docs/pages/enhancements/status-bar.md +72 -0
  42. package/docs/pages/enhancements/tabs.md +80 -0
  43. package/docs/pages/enhancements/tooltip.md +130 -0
  44. package/docs/pages/examples/overview.md +41 -0
  45. package/docs/pages/forms/choice-cards.md +102 -0
  46. package/docs/pages/forms/controls.md +32 -0
  47. package/docs/pages/forms/detached-actions.md +59 -0
  48. package/docs/pages/forms/floating-field.md +88 -0
  49. package/docs/pages/forms/form-actions.md +56 -0
  50. package/docs/pages/forms/input-policy.md +91 -0
  51. package/docs/pages/forms/joined-controls.md +37 -0
  52. package/docs/pages/forms/labels.md +42 -0
  53. package/docs/pages/forms/otp.md +50 -0
  54. package/docs/pages/forms/overview.md +188 -0
  55. package/docs/pages/forms/password.md +40 -0
  56. package/docs/pages/forms/range.md +17 -0
  57. package/docs/pages/forms/states-and-sizes.md +321 -0
  58. package/docs/pages/forms/sticky-actions.md +187 -0
  59. package/docs/pages/forms/switch.md +31 -0
  60. package/docs/pages/forms/validation.md +241 -0
  61. package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
  62. package/docs/pages/foundations/theming.md +92 -0
  63. package/docs/pages/foundations/tokens.md +421 -0
  64. package/docs/{typography.md → pages/foundations/typography.md} +27 -9
  65. package/docs/pages/guides/bootstrap.md +438 -0
  66. package/docs/pages/guides/introduction.md +157 -0
  67. package/docs/pages/guides/optional-components.md +33 -0
  68. package/docs/pages/guides/progressive-enhancement.md +686 -0
  69. package/docs/pages/guides/tailwind.md +455 -0
  70. package/docs/pages/layout/app-shell.md +25 -0
  71. package/docs/pages/layout/center.md +29 -0
  72. package/docs/pages/layout/cluster.md +57 -0
  73. package/docs/pages/layout/frame.md +30 -0
  74. package/docs/pages/layout/grid.md +142 -0
  75. package/docs/pages/layout/list.md +41 -0
  76. package/docs/pages/layout/media.md +21 -0
  77. package/docs/pages/layout/overview.md +37 -0
  78. package/docs/pages/layout/scroll-snap.md +54 -0
  79. package/docs/pages/layout/sidebar-layout.md +27 -0
  80. package/docs/pages/layout/stack.md +18 -0
  81. package/docs/pages/layout/switcher.md +36 -0
  82. package/docs/pages/layout/topbar.md +23 -0
  83. package/docs/pages/patterns/actions.md +52 -0
  84. package/docs/pages/patterns/code-block.md +36 -0
  85. package/docs/pages/patterns/exclusive-accordion.md +16 -0
  86. package/docs/pages/patterns/hierarchical-nav.md +26 -0
  87. package/docs/pages/patterns/icon-input.md +25 -0
  88. package/docs/pages/patterns/intent-nesting.md +3 -0
  89. package/docs/pages/patterns/navigation-list.md +235 -0
  90. package/docs/pages/patterns/overview.md +58 -0
  91. package/docs/pages/patterns/structured-lists.md +47 -0
  92. package/docs/pages/utilities/accessibility.md +34 -0
  93. package/docs/pages/utilities/flex.md +105 -0
  94. package/docs/pages/utilities/links.md +18 -0
  95. package/docs/pages/utilities/list.md +31 -0
  96. package/docs/pages/utilities/overflow.md +43 -0
  97. package/docs/pages/utilities/overview.md +18 -0
  98. package/docs/pages/utilities/shape.md +25 -0
  99. package/docs/pages/utilities/sizing-wrapping.md +115 -0
  100. package/docs/pages/utilities/spacing.md +79 -0
  101. package/docs/pages/utilities/text.md +43 -0
  102. package/llms.txt +10 -4
  103. package/package.json +30 -16
  104. package/src/css/actual.css +12 -1
  105. package/src/css/base.css +3 -9
  106. package/src/css/components/accordion.css +18 -30
  107. package/src/css/components/actions.css +4 -1
  108. package/src/css/components/alert.css +162 -49
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +82 -39
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +7 -0
  113. package/src/css/components/button.css +82 -38
  114. package/src/css/components/card.css +17 -37
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -110
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -3
  119. package/src/css/components/key.css +2 -0
  120. package/src/css/components/menu.css +125 -0
  121. package/src/css/components/meter.css +0 -23
  122. package/src/css/components/modal.css +162 -7
  123. package/src/css/components/navbar.css +17 -18
  124. package/src/css/components/overline.css +0 -7
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/scrollspy.css +2 -2
  128. package/src/css/components/skeleton.css +7 -0
  129. package/src/css/components/spinner.css +10 -11
  130. package/src/css/components/status-bar.css +18 -28
  131. package/src/css/components/surface.css +2 -8
  132. package/src/css/components/tab.css +20 -48
  133. package/src/css/components/table.css +7 -5
  134. package/src/css/components/tooltip.css +14 -7
  135. package/src/css/focus.css +16 -3
  136. package/src/css/forms/choice-card.css +13 -15
  137. package/src/css/forms/choice.css +21 -28
  138. package/src/css/forms/control.css +16 -26
  139. package/src/css/forms/custom-select.css +9 -11
  140. package/src/css/forms/form-actions.css +14 -3
  141. package/src/css/forms/form.css +7 -4
  142. package/src/css/forms/index.css +5 -4
  143. package/src/css/forms/input-icon.css +30 -12
  144. package/src/css/forms/native.css +48 -33
  145. package/src/css/forms/range.css +103 -0
  146. package/src/css/forms/select.css +8 -8
  147. package/src/css/forms/switch.css +14 -13
  148. package/src/css/forms/validation.css +32 -5
  149. package/src/css/grid.css +19 -18
  150. package/src/css/intents.css +2 -2
  151. package/src/css/layout.css +29 -1
  152. package/src/css/optional/aura.css +93 -0
  153. package/src/css/optional/chat.css +105 -0
  154. package/src/css/optional/fab.css +104 -0
  155. package/src/css/optional/floating-field.css +87 -0
  156. package/src/css/optional/index.css +5 -0
  157. package/src/css/optional/layout-extra.css +7 -4
  158. package/src/css/optional/otp.css +115 -0
  159. package/src/css/optional/scroll-snap.css +6 -0
  160. package/src/css/optional/scroller.css +19 -7
  161. package/src/css/optional/typography-fluid.css +5 -0
  162. package/src/css/optional/utilities-extra.css +17 -2
  163. package/src/css/prose.css +18 -8
  164. package/src/css/reset.css +0 -10
  165. package/src/css/theme.css +29 -13
  166. package/src/css/themes/cyberpunk.css +1 -1
  167. package/src/css/themes/dim.css +3 -3
  168. package/src/css/themes/edge.css +11 -37
  169. package/src/css/themes/index.css +3 -3
  170. package/src/css/themes/{dark.css → indigo.css} +5 -4
  171. package/src/css/themes/neon.css +5 -5
  172. package/src/css/themes/square.css +0 -3
  173. package/src/css/tokens.css +81 -21
  174. package/src/css/utilities.css +8 -10
  175. package/src/css/variants.css +37 -65
  176. package/src/js/command.js +1 -3
  177. package/src/js/context-menu.js +72 -53
  178. package/src/js/dialog.js +68 -79
  179. package/src/js/dismiss.js +15 -0
  180. package/src/js/enhance.js +76 -19
  181. package/src/js/enhancement-loader.js +301 -0
  182. package/src/js/events.js +3 -0
  183. package/src/js/filter.js +3 -3
  184. package/src/js/floating.js +101 -131
  185. package/src/js/flyout.js +106 -96
  186. package/src/js/focus-group.js +116 -0
  187. package/src/js/focus.js +6 -1
  188. package/src/js/index.js +4 -2
  189. package/src/js/input.js +1 -1
  190. package/src/js/keys.js +27 -0
  191. package/src/js/mask.js +7 -6
  192. package/src/js/menu.js +89 -35
  193. package/src/js/parse-config.js +47 -0
  194. package/src/js/password.js +9 -1
  195. package/src/js/scrollspy.js +133 -62
  196. package/src/js/selectors.js +14 -31
  197. package/src/js/status.js +16 -14
  198. package/src/js/surface.js +219 -125
  199. package/src/js/tab.js +46 -66
  200. package/src/js/tooltip.js +53 -31
  201. package/src/js/validation.js +45 -51
  202. package/dist/actual.js.map +0 -31
  203. package/docs/README.md +0 -26
  204. package/docs/components.md +0 -860
  205. package/docs/design-notes/previous-work.md +0 -5
  206. package/docs/forms.md +0 -1265
  207. package/docs/javascript.md +0 -426
  208. package/docs/layout.md +0 -480
  209. package/docs/patterns.md +0 -415
  210. package/docs/reserved.md +0 -7
  211. package/docs/tokens.md +0 -455
  212. package/docs/ui.md +0 -937
  213. package/docs/utilities.md +0 -384
  214. package/src/css/dialog-fallback.css +0 -56
  215. package/src/js/dialog-fallback.js +0 -163
package/dist/actual.css CHANGED
@@ -24,16 +24,6 @@ select {
24
24
  display: none !important;
25
25
  }
26
26
 
27
- /* Legacy dialog fallback only. Modern dialogs keep the browser's native closed
28
- state so display/overlay transitions are not flattened by the reset.
29
- The presentation for the dialog-fallback.js shim lives in the separate
30
- dialog-fallback.css, so it can be dropped from custom bundles. */
31
- @supports not (selector(dialog:modal)) {
32
- dialog:not([open]) {
33
- display: none;
34
- }
35
- }
36
-
37
27
  :where(img, svg, video) {
38
28
  max-inline-size: 100%;
39
29
  block-size: auto;
@@ -55,64 +45,24 @@ select {
55
45
  }
56
46
  }
57
47
 
58
- /*
59
- * Legacy dialog fallback — presentation for the dialog-fallback.js shim
60
- * (browsers without native <dialog>: Safari <= 15.3, Firefox <= 97).
61
- *
62
- * Isolated in its own file so a custom bundle can opt out by dropping this
63
- * import; the JS side is disabled with data-dialog-fallback="false" on the
64
- * root element or on a dialog. Must load before component styles so ties
65
- * such as dialog.modal's padding resolve in the component's favor.
66
- *
67
- * The whole file is inert on modern browsers: rules key on the
68
- * .dialog-fallback marker written by the shim, so browsers that support
69
- * <dialog> but not :modal are never affected either.
70
- */
71
- @supports not (selector(dialog:modal)) {
72
- /* Browsers without native <dialog> ship no UA dialog styles at all;
73
- approximate them. :where() keeps specificity at dialog[open] level so
74
- component styles such as dialog.modal still override the emulation. */
75
- dialog[open]:where(.dialog-fallback) {
76
- position: absolute;
77
- inset-inline: 0;
78
- width: -moz-fit-content;
79
- width: fit-content;
80
- max-inline-size: calc(100% - 2rem);
81
- margin: auto;
82
- padding: var(--space-4, 1em);
83
- border: var(--border-width, 1px) solid var(--border, currentColor);
84
- background: var(--surface-raised, #fff);
85
- color: var(--text, inherit);
86
- }
87
-
88
- /* Open modally: fixed near the top (no fit-content in the block axis on
89
- these browsers, so no vertical centering) and above menus, below
90
- tooltips. Legacy-only layer, hence no z-index token. The spread shadow is
91
- the visible backdrop: the dialog is its own stacking context, where a
92
- negative z-index child paints above the element's background, so the
93
- dimming cannot live on the ::before. */
94
- dialog[open].is-fallback-modal {
95
- position: fixed;
96
- inset-block: 8vh auto;
97
- max-block-size: 84vh;
98
- overflow: auto;
99
- z-index: 40;
100
- box-shadow: 0 0 0 100vmax var(--backdrop-fill, rgb(0 0 0 / 35%));
101
- }
102
-
103
- /* Transparent stand-in for ::backdrop, hit-testing only. Clicks on it hit
104
- the dialog element outside its own box, which dialog.js already treats as
105
- a backdrop click, and it blocks pointer interaction with the page behind
106
- the modal. */
107
- dialog[open].is-fallback-modal::before {
108
- content: "";
109
- position: fixed;
110
- inset: 0;
111
- z-index: -1;
112
- }
113
- }
114
-
115
48
  :root {
49
+ /*
50
+ * Global framework tokens.
51
+ *
52
+ * Public: properties in the named groups below are public API (typography,
53
+ * spacing, shape, motion, focus, controls, viewport, z-index, icons) unless
54
+ * a group is explicitly marked Internal or Derived.
55
+ *
56
+ * Derived: "Theme-derived aliases" and "State tokens" reference intent and
57
+ * surface tokens and follow the theme; do not hard-code values into them.
58
+ * Internal: indicator offsets, --bar-height, --disabled-opacity and the
59
+ * like are consumed by components, not authored.
60
+ *
61
+ * Component-local hooks and internal state relays live with their
62
+ * components. See docs/pages/foundations/tokens.md for the public/internal
63
+ * token contract.
64
+ */
65
+
116
66
  /* Typography */
117
67
  --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
118
68
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -130,20 +80,23 @@ select {
130
80
  --font-size-md: 1rem;
131
81
  --font-size-lg: 1.125rem;
132
82
 
133
- /* Spacing */
83
+ /* Spacing — official steps are named in tens so the scale stays open for
84
+ application themes to intercalate intermediate values (--space-15,
85
+ --space-45…) without colliding with future framework steps. */
134
86
  --gap: 0.75rem;
135
- --variant-space: var(--space-4);
136
- --space-1: 0.25rem;
137
- --space-2: 0.5rem;
138
- --space-3: 0.75rem;
139
- --space-4: 1rem;
140
- --space-5: 1.5rem;
141
- --space-6: 2rem;
87
+ --density-space: var(--space-40);
88
+ --density-compact-size: 1.5rem;
89
+ --space-10: 0.25rem;
90
+ --space-20: 0.5rem;
91
+ --space-30: 0.75rem;
92
+ --space-40: 1rem;
93
+ --space-50: 1.5rem;
94
+ --space-60: 2rem;
142
95
 
143
96
  /* Shape */
144
97
  --border-width: 1px;
145
98
  --radius-full: 9999px;
146
- --shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.08);
99
+ --shadow: 0 1px 3px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
147
100
  --shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);
148
101
 
149
102
  /* Motion — see reset.css for the prefers-reduced-motion hard reset.
@@ -157,14 +110,15 @@ select {
157
110
  --ease: ease;
158
111
 
159
112
  /* Focus ring — shared by every interactive component's :focus-visible */
160
- --focus-outline: calc(var(--border-width) * 2) solid var(--focus);
113
+ --focus-outline-color: currentColor;
114
+ --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
161
115
  --focus-outline-offset: 2px;
116
+ --focus-ring-width: 3px;
162
117
 
163
118
  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
164
119
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
165
120
  dot with the parent surface color. */
166
121
  --indicator-offset: 14.65%;
167
- --indicator-ring: var(--surface);
168
122
 
169
123
  /* Shared backdrop — consumed by modal, drawer, and mobile flyout sheets. */
170
124
  --backdrop-color: rgb(0 0 0);
@@ -175,7 +129,8 @@ select {
175
129
  --bar-height: 0.65rem;
176
130
 
177
131
  /* Controls — inputs and buttons share --control-size so heights always match.
178
- .sm/.lg (variants.css) switch --control-size and --control-font-size. */
132
+ .sm/.lg (variants.css) switch --control-size; --control-font-size stays
133
+ at its baseline because density does not change typography. */
179
134
  --control-size-sm: 2rem;
180
135
  --control-size-md: 2.375rem;
181
136
  --control-size-lg: 2.75rem;
@@ -183,19 +138,38 @@ select {
183
138
  --control-pad-x: 1em;
184
139
  --control-font-size: var(--font-size-md);
185
140
  --disabled-opacity: 0.65;
141
+ }
186
142
 
143
+ /* Theme-derived aliases. These reference intent/surface tokens, so they must
144
+ be re-declared on every theme boundary: a custom property resolves its
145
+ var() references at computed-value time on the element that declares it,
146
+ so an alias declared only on :root is inherited as an already-resolved
147
+ value and would not follow a [data-theme] island's overridden tokens.
148
+ Themes that want a distinct alias override it explicitly afterwards. */
149
+ :root,
150
+ [data-theme] {
151
+ /* State tokens — semantic colors for selected/pressed/current and disabled.
152
+ Components consume these instead of hard-wiring --primary/--text-subtle
153
+ so forced-colors can override them in one place. */
154
+ --state-selected: var(--primary);
155
+ --state-selected-fg: var(--primary-fg);
156
+ --state-disabled: var(--text-subtle);
157
+
158
+ /* Status dot ring — follows the island's surface. */
159
+ --indicator-ring: var(--surface);
160
+ }
161
+
162
+ :root {
187
163
  /* Variant tuning — themes may adjust generated soft surfaces without
188
164
  replacing the component rules. */
189
165
  --soft-bg-mix: 88%;
190
166
  --soft-border-mix: 65%;
191
167
  --soft-hover-alpha: 12%;
192
168
 
193
- /* Shared icons */
194
- --icon-chevron-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
195
- --icon-check-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
196
- /* Native select uses background-image, which cannot inherit currentColor.
197
- The stroke matches the default theme's text-subtle (hue 258 neutral). */
198
- --icon-chevron-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238a8594' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
169
+ /* Shared icon assets. Override these URLs when a strict CSP disallows data:. */
170
+ --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238a8594' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
171
+ --icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
172
+ --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3C/svg%3E");
199
173
 
200
174
  /* Viewport size tokens — fallback vh/vw with dvh/vi enhancement.
201
175
  Consumed by full-viewport components (app-shell, drawer, modal, flyout).
@@ -204,13 +178,24 @@ select {
204
178
  --viewport-block: 100vh;
205
179
  --viewport-inline: 100vw;
206
180
 
207
- /* Layering */
181
+ /* Z-index scale — used ONLY by non-dialog overlays (see the layering
182
+ note below for the full model). Actual CSS reserves multiples of 10 for
183
+ framework layers; applications may use intermediate values for local
184
+ layering. */
208
185
  --z-sticky: 10;
209
186
  --z-menu: 20;
210
187
  --z-tooltip: 50;
211
188
  --z-status: 60;
212
189
  }
213
190
 
191
+ @media (forced-colors: active) {
192
+ :root {
193
+ --state-selected: Highlight;
194
+ --state-selected-fg: HighlightText;
195
+ --state-disabled: GrayText;
196
+ }
197
+ }
198
+
214
199
  @supports (block-size: 100dvh) {
215
200
  :root {
216
201
  --viewport-block: 100dvh;
@@ -224,9 +209,17 @@ select {
224
209
  }
225
210
 
226
211
  @supports (color: color-mix(in oklch, red, white)) {
227
- :root {
228
- --shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--shadow-color) 8%, transparent);
212
+ :root,
213
+ [data-theme] {
214
+ /* Shadows derive from --shadow-color (theme.css) — re-declared on every
215
+ theme boundary so islands follow their own shadow-color. */
216
+ --shadow:
217
+ 0 1px 3px color-mix(in oklch, var(--shadow-color) 5%, transparent),
218
+ 0 4px 12px color-mix(in oklch, var(--shadow-color) 4%, transparent);
229
219
  --shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
220
+ }
221
+
222
+ :root {
230
223
  --backdrop-fill: color-mix(
231
224
  in oklch,
232
225
  var(--backdrop-color) calc(var(--backdrop-opacity) * 100%),
@@ -235,6 +228,17 @@ select {
235
228
  }
236
229
  }
237
230
 
231
+ /*
232
+ * Theme tokens — the palette, surfaces, text, borders, radius and shadow
233
+ * color. Named themes (themes/*.css) override this file; authors can set the
234
+ * same tokens on :root or on [data-theme="…"] islands.
235
+ *
236
+ * Public: Intent colors, Surfaces, Text, Lines and focus, Radius scale,
237
+ * Shadow color, Focus ring derivation.
238
+ * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
239
+ * other tokens; hard-coded overrides on a custom theme will break the intent.
240
+ * Internal: --hgroup-gap.
241
+ */
238
242
  :root {
239
243
  color-scheme: light dark;
240
244
  }
@@ -298,11 +302,10 @@ select {
298
302
 
299
303
  /* Lines and focus */
300
304
  --border: hsl(260 15% 88%);
301
- --focus: var(--primary);
305
+ --focus: var(--neutral);
302
306
  /* Pre-color-mix fallback only — superseded below by a derivation from
303
- --focus so overriding --primary/--focus never leaves a stale ring. */
304
- --focus-ring: hsl(268 32% 33% / 0.22);
305
- --focus-ring-shadow: 0 0 0 3px var(--focus-ring);
307
+ --focus so overriding --neutral/--focus never leaves a stale ring. */
308
+ --focus-ring: hsl(258 7% 43% / 0.3);
306
309
  --hover-overlay: hsl(260 20% 10% / 0.04);
307
310
 
308
311
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -313,13 +316,19 @@ select {
313
316
  /* Shadow color — consumed by --shadow / --shadow-popout in tokens.css */
314
317
  --shadow-color: hsl(260 25% 10%);
315
318
 
316
- /* Heading and selection — derived from theme color tokens */
319
+ /* hgroup internal gap */
320
+ --hgroup-gap: 0.25em;
321
+ }
322
+
323
+ /* Theme-derived aliases — re-declared on every theme boundary (see tokens.css
324
+ for the custom-property resolution rationale): islands recompute these from
325
+ their own tokens instead of inheriting :root's resolved values. */
326
+ :root,
327
+ [data-theme] {
317
328
  --heading: var(--text);
318
329
  --selection-bg: var(--primary);
319
330
  --selection-fg: var(--primary-fg);
320
-
321
- /* hgroup internal gap */
322
- --hgroup-gap: 0.25em;
331
+ --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
323
332
  }
324
333
 
325
334
  /* Tone subsystem — fixed-backdrop foreground for components placed over media
@@ -343,7 +352,7 @@ select {
343
352
  :root,
344
353
  [data-theme="light"],
345
354
  [data-theme="dark"] {
346
- --focus-ring: color-mix(in oklch, var(--focus) 22%, transparent);
355
+ --focus-ring: color-mix(in oklch, var(--focus) 30%, transparent);
347
356
  }
348
357
  }
349
358
 
@@ -383,7 +392,7 @@ select {
383
392
  --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));
384
393
 
385
394
  --border: light-dark(hsl(260 15% 88%), hsl(262 12% 24%));
386
- --focus-ring: light-dark(hsl(268 32% 33% / 0.22), hsl(268 42% 76% / 0.28));
395
+ --focus-ring: light-dark(hsl(258 7% 43% / 0.3), hsl(258 10% 72% / 0.28));
387
396
  --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
388
397
  --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
389
398
  }
@@ -393,7 +402,7 @@ select {
393
402
  :root,
394
403
  [data-theme="light"] {
395
404
  --focus-ring: light-dark(
396
- color-mix(in oklch, var(--focus) 22%, transparent),
405
+ color-mix(in oklch, var(--focus) 30%, transparent),
397
406
  color-mix(in oklch, var(--focus) 28%, transparent)
398
407
  );
399
408
  }
@@ -403,7 +412,7 @@ select {
403
412
  :root,
404
413
  [data-theme="light"],
405
414
  [data-theme="dark"] {
406
- --focus-outline: calc(var(--border-width) * 2) solid Highlight;
415
+ --focus-outline-color: Highlight;
407
416
  --focus-ring-shadow: none;
408
417
 
409
418
  --backdrop-fill: Canvas;
@@ -416,7 +425,7 @@ select {
416
425
  [data-theme="dark"] {
417
426
  --border-width: 2px;
418
427
  --focus-outline-offset: 3px;
419
- --focus-ring-shadow: 0 0 0 4px var(--focus-ring);
428
+ --focus-ring-width: 4px;
420
429
  --text-muted: var(--text);
421
430
  --text-subtle: var(--text);
422
431
  }
@@ -441,14 +450,8 @@ a {
441
450
  text-underline-offset: 0.18em;
442
451
  }
443
452
 
444
- a:focus-visible {
445
- box-shadow: var(--focus-ring-shadow);
446
- }
447
-
448
- @media (hover: hover) {
449
- a:hover {
450
- text-decoration-thickness: 0.12em;
451
- }
453
+ a:hover {
454
+ text-decoration-thickness: 0.12em;
452
455
  }
453
456
 
454
457
  /* Global heading chrome: color (--heading or inherit), a tight line-height,
@@ -474,7 +477,7 @@ hgroup > * {
474
477
  hr {
475
478
  border: 0;
476
479
  border-block-start: var(--border-width) solid var(--border);
477
- margin-block: var(--space-6);
480
+ margin-block: var(--space-60);
478
481
  }
479
482
 
480
483
  code,
@@ -497,8 +500,8 @@ small {
497
500
  * Intent classes do not style elements directly.
498
501
  * They only expose --intent and --intent-fg.
499
502
  * Components and variants decide how to consume them. Each component that
500
- * consumes --intent or --ui-* must open a local intent boundary in a :where()
501
- * block that resets --intent / --intent-fg / --ui-* to initial. Parent intents
503
+ * consumes --intent or --ui-* opens a local intent boundary in a :where()
504
+ * block that resets exactly the tokens it consumes to initial. Parent intents
502
505
  * do not leak into nested components. Local intent classes (.primary, .danger,
503
506
  * etc.) still apply because they have higher specificity than :where().
504
507
  */
@@ -527,6 +530,28 @@ small {
527
530
  --intent-fg: var(--neutral-fg);
528
531
  }
529
532
 
533
+ /*
534
+ * Core layout primitives: stack, cluster, center, frame, app-shell, media,
535
+ * sidebar-layout. Each primitive owns a single, readable behavior; they are
536
+ * meant to compose in markup.
537
+ *
538
+ * Public hooks:
539
+ * --cluster-align
540
+ * --cluster-justify
541
+ * --cluster-wrap
542
+ * --center-size
543
+ * --center-pad
544
+ * --frame-ratio
545
+ * --sidebar-layout-size
546
+ *
547
+ * Child contracts:
548
+ * .stack owns vertical rhythm: direct-child margins are reset, spacing comes
549
+ * from --gap.
550
+ * .cluster owns inline rhythm: direct-child margins are reset, spacing comes
551
+ * from --gap.
552
+ * .frame sizes direct media children (img/video/iframe/canvas/svg) to fill the
553
+ * frame.
554
+ */
530
555
  .container-query {
531
556
  container-type: inline-size;
532
557
  }
@@ -543,12 +568,16 @@ small {
543
568
 
544
569
  .cluster {
545
570
  display: flex;
546
- flex-wrap: wrap;
571
+ flex-wrap: var(--cluster-wrap, wrap);
547
572
  align-items: var(--cluster-align, center);
548
573
  justify-content: var(--cluster-justify, flex-start);
549
574
  gap: var(--gap);
550
575
  }
551
576
 
577
+ .cluster > * {
578
+ margin: 0;
579
+ }
580
+
552
581
  .center {
553
582
  box-sizing: content-box;
554
583
  max-inline-size: var(--center-size, 72rem);
@@ -592,6 +621,8 @@ small {
592
621
  grid-template-columns: minmax(0, 1fr);
593
622
  }
594
623
 
624
+ /* Container queries are the preferred path; without them the @supports not
625
+ block below restores the same columns at the same viewport breakpoint. */
595
626
  @container (width >= 64rem) {
596
627
  .container-query .sidebar-layout {
597
628
  grid-template-columns: minmax(0, 1fr) var(--sidebar-layout-size, 18rem);
@@ -608,29 +639,27 @@ small {
608
639
  }
609
640
  }
610
641
 
642
+ /*
643
+ * Responsive equal-width grids, with fixed-column presets below.
644
+ *
645
+ * Public hooks:
646
+ * --grid-min
647
+ * --grid-columns
648
+ */
611
649
  .grid {
612
650
  display: grid;
613
651
  gap: var(--gap);
614
- grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--grid-min, 16rem)), 1fr));
615
- }
616
-
617
- /* Query-container wrapper that opts .grid-2/3/4/6 into responsive
618
- * collapse. Wrap a .grid-2/3/4/6 inside .container-query to make the
619
- * column count collapse on narrow wrappers. Without this wrapper,
620
- * .grid-2/3/4/6 stay at their full column count. */
621
- /* Fixed-column grids at their full count by default.
622
- * .grid-2: 2 cols
623
- * .grid-3: 3 cols
624
- * .grid-4: 4 cols
625
- * .grid-6: 6 cols
626
- * When placed inside .container-query, the column count collapses
627
- * on narrow wrappers. The intermediate 2-col step applies to
628
- * .grid-3/.grid-4; .grid-6 starts at 2 columns because it is meant
629
- * for compact items that do not need a full single-column stack.
630
- * .grid-2: 1 col -> 2 cols
631
- * .grid-3: 1 col -> 2 cols -> 3 cols
632
- * .grid-4: 1 col -> 2 cols -> 4 cols
633
- * .grid-6: 2 cols -> 3 cols -> 6 cols */
652
+ grid-template-columns: var(
653
+ --grid-columns,
654
+ repeat(auto-fit, minmax(min(100%, var(--grid-min, 16rem)), 1fr))
655
+ );
656
+ }
657
+
658
+ /* Fixed-column grids at their full count by default: .grid-2/3/4/6 stay at
659
+ 2/3/4/6 columns unless wrapped in .container-query, which collapses the
660
+ column count on narrow wrappers (a 2-col intermediate step for
661
+ .grid-3/.grid-4; .grid-6 starts at 2 columns because it is meant for
662
+ compact items that do not need a full single-column stack). */
634
663
  .grid-2,
635
664
  .grid-3,
636
665
  .grid-4,
@@ -652,6 +681,9 @@ small {
652
681
  grid-template-columns: repeat(6, minmax(0, 1fr));
653
682
  }
654
683
 
684
+ /* Container queries are the preferred responsive path; browsers without them
685
+ ignore these rules and get the equivalent @media rules from the
686
+ @supports not (container-type) block below — same columns, same breakpoints. */
655
687
  @container (width < 28rem) {
656
688
  .container-query .grid-2,
657
689
  .container-query .grid-3,
@@ -708,15 +740,20 @@ small {
708
740
  * Cross-cutting variant token mapping.
709
741
  *
710
742
  * Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
711
- * shared variants. Component-specific defaults (.btn solid-by-default, .badge
712
- * solid-by-default, .alert soft-by-default) live with their components:
743
+ * shared variants. .inverted is a surface modifier, not a variant: it also
744
+ * paints the element directly (background/color/border-color) so it works on
745
+ * any block, competing with component surface treatments (.card.subtle).
746
+ * Component-specific defaults (.btn solid-by-default, .badge
747
+ * soft-by-default, .alert soft-by-default) live with their components:
713
748
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
714
- * components/badge.css — .badge default solid
715
- * components/alert.css — .alert default + .alert.solid/.alert.outline
749
+ * components/badge.css — .badge default soft
750
+ * components/alert.css — .alert default soft (consumes --ui-* directly)
716
751
  *
717
- * .sm/.lg set shared size tokens. Controls consume --control-*; non-control
718
- * components may consume --variant-* when the variant should affect their
719
- * local density. Compact components may consume --variant-compact-*.
752
+ * .sm/.lg set shared density tokens: spacing rhythm and geometry only.
753
+ * Density never changes typography — controls keep their font-size and
754
+ * content components keep theirs. Controls consume --control-size; non-control
755
+ * components may consume --density-* when the density context should affect
756
+ * their local rhythm. Compact components may consume --density-compact-size.
720
757
  * Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
721
758
  * applying .sm/.lg to card, dialog, accordion, navbar, table is
722
759
  * a no-op.
@@ -742,27 +779,34 @@ small {
742
779
  --ui-border: var(--intent, var(--border));
743
780
  }
744
781
 
782
+ /* Inverse surface treatment. Components (e.g. .card) compose through --ui-*;
783
+ the direct background/color/border-color also make .inverted useful on a
784
+ generic element. --heading is set so global headings (var(--heading, inherit))
785
+ do not inherit an outside heading color onto the dark surface. */
786
+ .inverted {
787
+ --ui-bg: var(--surface-solid);
788
+ --ui-fg: var(--surface);
789
+ --ui-border: transparent;
790
+
791
+ --heading: var(--surface);
792
+ --busy-overlay-bg: var(--surface-solid);
793
+
794
+ background: var(--ui-bg);
795
+ color: var(--ui-fg);
796
+ border-color: var(--ui-border);
797
+ }
798
+
745
799
  .sm {
746
- --gap: var(--space-2);
747
- --variant-space: var(--space-2);
800
+ --gap: var(--space-20);
801
+ --density-space: var(--space-20);
748
802
  --control-size: var(--control-size-sm);
749
- --control-font-size: var(--font-size-sm);
750
- --variant-font-size: var(--font-size-sm);
751
- --variant-icon-size: 1rem;
752
- --variant-pad-block: var(--space-3);
753
- --variant-compact-size: 1.25rem;
754
- --variant-compact-font-size: 0.75rem;
803
+ --density-compact-size: 1.25rem;
755
804
  }
756
805
  .lg {
757
- --gap: var(--space-5);
758
- --variant-space: var(--space-5);
806
+ --gap: var(--space-50);
807
+ --density-space: var(--space-50);
759
808
  --control-size: var(--control-size-lg);
760
- --control-font-size: var(--font-size-lg);
761
- --variant-font-size: var(--font-size-lg);
762
- --variant-icon-size: 1.5rem;
763
- --variant-pad-block: var(--space-5);
764
- --variant-compact-size: 1.875rem;
765
- --variant-compact-font-size: var(--font-size-sm);
809
+ --density-compact-size: 1.875rem;
766
810
  }
767
811
 
768
812
  /* Modern color enhancement. Baseline variant values above remain usable
@@ -780,54 +824,25 @@ small {
780
824
  }
781
825
  }
782
826
 
783
- /* Interactive hover recipe shared by .btn and .badge. Declared outside :hover
784
- on purpose: --ui-hover-bg is only consumed inside their @media (hover: hover)
785
- rules, so these values are inert until hovered. :is() carries real
786
- specificity so the components' :where() intent boundaries
787
- (--ui-hover-bg: initial) cannot override the recipe. The solid/soft overlay
788
- darkening lives in the consuming components. */
789
- :is(.btn, a.badge, button.badge):is(.outline, .ghost) {
790
- --ui-hover-bg: var(--surface-subtle);
791
- }
792
-
793
- @supports (color: color-mix(in oklch, red, white)) {
794
- /* Solid has no literal class requirement: bare .btn/.badge default to solid,
795
- so this must match anything that isn't one of the other variants rather
796
- than an explicit .solid class. Stacks with the box-shadow overlay the
797
- consuming components apply on top for extra depth. */
798
- :is(.btn, a.badge, button.badge):not(:where(.soft, .outline, .ghost, .link)) {
799
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
800
- }
801
-
802
- /* Same recipe as the resting .soft bg with the surface share reduced by
803
- --soft-hover-alpha: stays opaque, so it reads on any backdrop instead of
804
- letting the container show through on hover. */
805
- :is(.btn, a.badge, button.badge):is(.soft) {
806
- --ui-hover-bg: color-mix(
807
- in oklch,
808
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
809
- var(--intent, var(--neutral))
810
- );
811
- }
812
-
813
- /* Mixed against the opaque --surface-subtle rather than transparent: a
814
- transparent-based tint reads clean on light surfaces but goes muddy and
815
- low-contrast on dark ones, since the same low alpha lands very
816
- differently over a light vs. a near-black backdrop. */
817
- :is(.btn, a.badge, button.badge):is(.outline) {
818
- --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
819
- }
820
-
821
- :is(.btn, a.badge, button.badge):is(.ghost) {
822
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--text)) 8%, transparent);
823
- }
824
- }
827
+ /* Interactive hover recipes live in the components that consume them:
828
+ components/button.css and components/badge.css. */
825
829
 
826
830
  /*
827
831
  * Prose is the opt-in rich text scope for authored content.
828
832
  *
829
833
  * Global element styles stay mild so app UI is not shaped accidentally. Page
830
834
  * layout, forms, cards, navigation, and app tables remain outside this scope.
835
+ *
836
+ * Public hooks:
837
+ * --prose-size
838
+ * --prose-line-height
839
+ * --prose-measure
840
+ * --prose-flow
841
+ * --prose-heading-line-height
842
+ * --prose-h1
843
+ * --prose-h2
844
+ * --prose-h3
845
+ * --prose-h4
831
846
  */
832
847
 
833
848
  .prose {
@@ -891,15 +906,13 @@ small {
891
906
  text-underline-offset: 0.18em;
892
907
  }
893
908
 
894
- @media (hover: hover) {
895
- .prose a:hover {
896
- text-decoration-thickness: 0.12em;
897
- }
909
+ .prose a:hover {
910
+ text-decoration-thickness: 0.12em;
911
+ }
898
912
 
899
- @supports (color: color-mix(in oklch, red, white)) {
900
- .prose a:hover {
901
- color: color-mix(in oklch, var(--primary), var(--text) 15%);
902
- }
913
+ @supports (color: color-mix(in oklch, red, white)) {
914
+ .prose a:hover {
915
+ color: color-mix(in oklch, var(--primary), var(--text) 15%);
903
916
  }
904
917
  }
905
918
 
@@ -919,6 +932,7 @@ small {
919
932
  font-size: 0.875em;
920
933
  }
921
934
 
935
+ /* Visual twin of .key (components/key.css) — keep the two recipes in sync. */
922
936
  .prose kbd {
923
937
  display: inline-flex;
924
938
  align-items: center;
@@ -1023,19 +1037,35 @@ small {
1023
1037
  * Disabled state uses the native disabled attribute for buttons and inputs.
1024
1038
  * Anchors have no native disabled state, so a.btn uses aria-disabled="true"
1025
1039
  * for disabled styling and activation guards.
1040
+ *
1041
+ * Public hooks:
1042
+ * --btn-font-size
1043
+ * --btn-min-size
1044
+ * --btn-pad-x
1045
+ * --btn-radius
1046
+ * --btn-focus-color
1047
+ * --btn-gap
1048
+ * --btn-hover-overlay
1049
+ *
1050
+ * Internal:
1051
+ * --btn-bg
1052
+ * --btn-fg
1053
+ * --btn-border
1054
+ * --btn-focus-ring-color
1055
+ * --btn-border-block-end-color
1056
+ * --btn-border-block-end-width
1057
+ * --spinner-width
1026
1058
  */
1027
1059
 
1028
1060
  /* Intent boundary — inherited intents do not tint this component.
1029
1061
  Local intent classes still win because :where() has zero specificity. */
1030
1062
  :where(.btn) {
1031
- /* @sync intent-boundary */
1032
1063
  --intent: initial;
1033
1064
  --intent-fg: initial;
1034
1065
  --ui-bg: initial;
1035
1066
  --ui-fg: initial;
1036
1067
  --ui-border: initial;
1037
1068
  --ui-hover-bg: initial;
1038
- /* @sync intent-boundary */
1039
1069
  }
1040
1070
 
1041
1071
  .btn {
@@ -1047,18 +1077,17 @@ small {
1047
1077
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
1048
1078
  --btn-radius: var(--radius);
1049
1079
  --btn-focus-color: var(--intent, var(--focus));
1050
- --btn-focus-outline-width: calc(var(--border-width) * 2);
1051
- --btn-hover-bg: var(--ui-hover-bg, var(--btn-bg));
1052
1080
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
1053
1081
 
1082
+ /* Positioning context for indicators, overlays, and grouped states. */
1054
1083
  position: relative;
1055
1084
  display: inline-flex;
1056
1085
  align-items: center;
1057
1086
  justify-content: center;
1058
- gap: 0.5em;
1087
+ gap: var(--btn-gap, 0.375em);
1059
1088
  min-block-size: var(--btn-min-size);
1060
- padding-block: var(--space-2);
1061
- padding-block: max(var(--space-1), calc((var(--btn-min-size) - 1lh) / 2));
1089
+ padding-block: var(--space-20);
1090
+ padding-block: max(var(--space-10), calc((var(--btn-min-size) - 1lh) / 2));
1062
1091
  padding-inline: var(--btn-pad-x);
1063
1092
  border: var(--border-width) solid var(--btn-border-color, var(--btn-border));
1064
1093
  border-block-end-color: var(
@@ -1071,7 +1100,7 @@ small {
1071
1100
  color: var(--btn-fg);
1072
1101
  font: inherit;
1073
1102
  font-size: var(--btn-font-size);
1074
- font-weight: var(--font-weight-strong);
1103
+ font-weight: var(--font-weight-medium);
1075
1104
  line-height: 1;
1076
1105
  text-decoration: none;
1077
1106
  cursor: pointer;
@@ -1083,6 +1112,11 @@ small {
1083
1112
  transform var(--duration);
1084
1113
  }
1085
1114
 
1115
+ .btn.icon-only {
1116
+ inline-size: var(--btn-min-size);
1117
+ padding-inline: 0;
1118
+ }
1119
+
1086
1120
  @supports (color: color-mix(in oklch, red, white)) {
1087
1121
  .btn {
1088
1122
  --btn-focus-ring-color: color-mix(in oklch, var(--btn-focus-color) 28%, transparent);
@@ -1107,15 +1141,42 @@ small {
1107
1141
  --btn-hover-overlay: transparent;
1108
1142
  }
1109
1143
 
1110
- /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is shared with
1111
- .badge in variants.css; the overlay darkening for solid/soft is .btn-specific. */
1112
- @media (hover: hover) {
1113
- .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
1114
- background: var(--btn-hover-bg);
1115
- box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
1144
+ /* Shared hover recipe — sets --ui-hover-bg consumed by the hover rule below.
1145
+ Declared outside :hover: the variables resolve only when the hover rule
1146
+ reads them, so the values are inert until interaction. */
1147
+ .btn:is(.outline, .ghost) {
1148
+ --ui-hover-bg: var(--surface-subtle);
1149
+ }
1150
+
1151
+ @supports (color: color-mix(in oklch, red, white)) {
1152
+ .btn:not(:where(.soft, .outline, .ghost, .link)) {
1153
+ --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
1154
+ }
1155
+
1156
+ .btn.soft {
1157
+ --ui-hover-bg: color-mix(
1158
+ in oklch,
1159
+ var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
1160
+ var(--intent, var(--neutral))
1161
+ );
1162
+ }
1163
+
1164
+ .btn.outline {
1165
+ --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
1166
+ }
1167
+
1168
+ .btn.ghost {
1169
+ --ui-hover-bg: color-mix(in oklch, var(--intent, var(--text)) 8%, transparent);
1116
1170
  }
1117
1171
  }
1118
1172
 
1173
+ /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
1174
+ the overlay darkening for solid/soft is .btn-specific. */
1175
+ .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
1176
+ background: var(--ui-hover-bg, var(--btn-bg));
1177
+ box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
1178
+ }
1179
+
1119
1180
  .btn:not(:where(:disabled, [aria-disabled="true"])):active {
1120
1181
  transform: translateY(1px);
1121
1182
  }
@@ -1138,23 +1199,21 @@ small {
1138
1199
  /* .btn.circle needs no local rule: the .circle utility (utilities.css) loads
1139
1200
  after components and overrides border-radius at equal specificity. */
1140
1201
 
1141
- @media (hover: hover) {
1142
- .btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
1143
- background: transparent;
1144
- color: var(--intent, var(--text));
1145
- text-decoration-thickness: 0.12em;
1146
- }
1202
+ .btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
1203
+ background: transparent;
1204
+ color: var(--intent, var(--text));
1205
+ text-decoration-thickness: 0.12em;
1147
1206
  }
1148
1207
 
1149
1208
  .btn:focus-visible {
1150
- outline: var(--btn-focus-outline-width) solid var(--btn-focus-color);
1151
- outline-offset: var(--focus-outline-offset);
1152
- box-shadow: 0 0 0 3px var(--btn-focus-ring-color, var(--focus-ring));
1209
+ outline: 2px solid transparent;
1210
+ box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
1153
1211
  }
1154
1212
 
1155
1213
  .btn.link:focus-visible {
1156
- outline-color: currentColor;
1214
+ outline: var(--focus-outline);
1157
1215
  outline-offset: 0.2em;
1216
+ box-shadow: none;
1158
1217
  }
1159
1218
 
1160
1219
  .btn:disabled {
@@ -1178,17 +1237,18 @@ a.btn[aria-disabled="true"] {
1178
1237
  }
1179
1238
 
1180
1239
  .btn[aria-busy="true"] > .spinner {
1181
- --spinner-size: 1em;
1182
1240
  --spinner-width: 0.15em;
1183
1241
  }
1184
1242
 
1185
- /* Toggle */
1186
- /* State must outrank the shared .outline/.ghost hover recipes: a pressed
1187
- button keeps its intent background and foreground while hovered. */
1188
- .btn:is([aria-pressed="true"], [aria-current]:not([aria-current="false"])):not(.link) {
1189
- --btn-bg: var(--intent, var(--primary));
1190
- --btn-fg: var(--intent-fg, var(--primary-fg));
1191
- --btn-border: var(--intent, var(--primary));
1243
+ /* Toggle and current state.
1244
+ State must outrank the shared .outline/.ghost hover recipes: a pressed or
1245
+ current button keeps its intent background and foreground while hovered.
1246
+ aria-current="page" is the pagination current-page marker; aria-pressed
1247
+ covers toggle buttons. */
1248
+ .btn:is([aria-pressed="true"], [aria-current="page"]):not(.link) {
1249
+ --btn-bg: var(--intent, var(--state-selected));
1250
+ --btn-fg: var(--intent-fg, var(--state-selected-fg));
1251
+ --btn-border: var(--intent, var(--state-selected));
1192
1252
  --ui-hover-bg: var(--btn-bg);
1193
1253
  }
1194
1254
 
@@ -1206,18 +1266,16 @@ a.btn[aria-disabled="true"] {
1206
1266
  color: LinkText;
1207
1267
  }
1208
1268
 
1209
- .btn:is([aria-pressed="true"], [aria-current]:not([aria-current="false"])):not(.link) {
1269
+ .btn:is([aria-pressed="true"], [aria-current="page"]):not(.link) {
1210
1270
  border-color: Highlight;
1211
1271
  background: Highlight;
1212
1272
  color: HighlightText;
1213
1273
  forced-color-adjust: none;
1214
1274
  }
1215
1275
 
1216
- @media (hover: hover) {
1217
- .btn:hover:not(:where(:disabled, [aria-disabled="true"], .link)) {
1218
- outline: var(--border-width) solid ButtonText;
1219
- outline-offset: 2px;
1220
- }
1276
+ .btn:hover:not(:where(:disabled, [aria-disabled="true"], .link)) {
1277
+ outline: var(--border-width) solid ButtonText;
1278
+ outline-offset: 2px;
1221
1279
  }
1222
1280
 
1223
1281
  .btn:disabled,
@@ -1232,36 +1290,49 @@ a.btn[aria-disabled="true"] {
1232
1290
  /*
1233
1291
  * Badges for compact labels, counts, and state hints.
1234
1292
  *
1235
- * Solid by default. Shared .soft/.outline variants stay available for quieter
1236
- * tags without adding a badge-specific color API.
1293
+ * Soft by default. .solid for counters and notification badges. The dot
1294
+ * (.badge:empty) stays solid regardless — a soft dot is pointless.
1295
+ * Shared .outline variant stays available.
1296
+ *
1297
+ * Public hooks:
1298
+ * --badge-size
1299
+ * --badge-pad-x
1300
+ * --badge-font-size
1301
+ * --badge-dot-size
1302
+ * --badge-radius
1303
+ *
1304
+ * Internal:
1305
+ * --badge-bg
1306
+ * --badge-fg
1307
+ * --badge-border
1237
1308
  */
1238
1309
  /* Intent boundary — inherited intents do not tint this component.
1239
1310
  Local intent classes still win because :where() has zero specificity. */
1240
1311
  :where(.badge) {
1241
- /* @sync intent-boundary */
1242
1312
  --intent: initial;
1243
1313
  --intent-fg: initial;
1244
1314
  --ui-bg: initial;
1245
1315
  --ui-fg: initial;
1246
1316
  --ui-border: initial;
1247
1317
  --ui-hover-bg: initial;
1248
- /* @sync intent-boundary */
1249
1318
  }
1250
1319
 
1251
1320
  .badge {
1252
- --badge-size: var(--variant-compact-size, 1.5rem);
1321
+ --badge-size: var(--density-compact-size);
1253
1322
  --badge-pad-x: 0.55em;
1254
- --badge-font-size: var(--variant-compact-font-size, 0.8125rem);
1323
+ --badge-font-size: 0.8125rem;
1255
1324
  --badge-dot-size: 0.5rem;
1256
- --badge-bg: var(--ui-bg, var(--intent, var(--neutral)));
1257
- --badge-fg: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
1258
- --badge-border: var(--ui-border, transparent);
1259
- --badge-radius: 999px;
1325
+ --badge-bg: var(--ui-bg, var(--surface-subtle));
1326
+ --badge-fg: var(--ui-fg, var(--intent, var(--text)));
1327
+ --badge-border: var(--ui-border, var(--border));
1328
+ --badge-radius: var(--radius-full);
1260
1329
 
1261
1330
  display: inline-flex;
1262
1331
  align-items: center;
1263
1332
  justify-content: center;
1264
1333
  gap: 0.35em;
1334
+ inline-size: fit-content;
1335
+ max-inline-size: 100%;
1265
1336
  min-block-size: var(--badge-size);
1266
1337
  padding-inline: var(--badge-pad-x);
1267
1338
  border: var(--border-width) solid var(--badge-border);
@@ -1280,8 +1351,31 @@ a.btn[aria-disabled="true"] {
1280
1351
  box-shadow var(--duration);
1281
1352
  }
1282
1353
 
1354
+ /* Soft by default — duplicated here with @sync markers for check-sync.js
1355
+ to guard, the same way alert.css guards its own soft recipe. */
1356
+ @supports (color: color-mix(in oklch, red, white)) {
1357
+ .badge:where(:not(.solid, .outline)) {
1358
+ /* @sync soft-recipe */
1359
+ --badge-bg: color-mix(
1360
+ in oklch,
1361
+ var(--surface) var(--soft-bg-mix),
1362
+ var(--intent, var(--neutral))
1363
+ );
1364
+ --badge-fg: var(--intent, var(--text));
1365
+ --badge-border: color-mix(
1366
+ in oklch,
1367
+ var(--surface) var(--soft-border-mix),
1368
+ var(--intent, var(--neutral))
1369
+ );
1370
+ /* @sync soft-recipe */
1371
+ }
1372
+ }
1373
+
1283
1374
  .badge:empty {
1284
1375
  --badge-size: var(--badge-dot-size);
1376
+ --badge-bg: var(--intent, var(--neutral));
1377
+ --badge-fg: var(--intent-fg, var(--neutral-fg));
1378
+ --badge-border: transparent;
1285
1379
  padding: 0;
1286
1380
  border: 0;
1287
1381
  inline-size: var(--badge-size);
@@ -1307,28 +1401,44 @@ a.btn[aria-disabled="true"] {
1307
1401
  cursor: pointer;
1308
1402
  }
1309
1403
 
1310
- @media (hover: hover) {
1311
- .badge > button:not(:disabled):hover {
1312
- background: var(--hover-overlay);
1313
- }
1404
+ .badge > button:not(:disabled):hover {
1405
+ background: var(--hover-overlay);
1314
1406
  }
1315
1407
 
1316
- /* .solid/.soft/.outline are shared via variants.css, including the
1317
- .soft/.outline hover recipe (--ui-hover-bg). The --badge-* fallbacks make
1318
- .badge solid by default. */
1319
- @media (hover: hover) {
1320
- :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
1321
- background: var(--ui-hover-bg, var(--badge-bg));
1408
+ /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
1409
+ Declared outside :hover like button.css for the same reason. */
1410
+ :is(a.badge, button.badge).outline {
1411
+ --ui-hover-bg: var(--surface-subtle);
1412
+ }
1413
+
1414
+ @supports (color: color-mix(in oklch, red, white)) {
1415
+ :is(a.badge, button.badge):is(.solid, :empty) {
1416
+ --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
1322
1417
  }
1323
1418
 
1324
- :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
1325
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
1419
+ :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
1420
+ --ui-hover-bg: color-mix(
1421
+ in oklch,
1422
+ var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
1423
+ var(--intent, var(--neutral))
1424
+ );
1326
1425
  }
1426
+
1427
+ :is(a.badge, button.badge).outline {
1428
+ --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
1429
+ }
1430
+ }
1431
+
1432
+ :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
1433
+ background: var(--ui-hover-bg, var(--badge-bg));
1434
+ }
1435
+
1436
+ :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
1437
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
1327
1438
  }
1328
1439
 
1329
1440
  button.badge {
1330
1441
  cursor: pointer;
1331
- font: inherit;
1332
1442
  }
1333
1443
 
1334
1444
  button.badge:disabled,
@@ -1342,27 +1452,18 @@ a.badge[aria-disabled="true"] {
1342
1452
  }
1343
1453
 
1344
1454
  @media (forced-colors: active) {
1345
- .badge {
1346
- border-color: ButtonText;
1347
- background: Canvas;
1348
- color: CanvasText;
1349
- box-shadow: none;
1350
- }
1351
-
1352
1455
  .badge:empty {
1353
1456
  background: ButtonText;
1354
1457
  }
1355
1458
 
1356
- @media (hover: hover) {
1357
- :where(a.badge, button.badge):hover:not(:where(:disabled, [aria-disabled="true"])) {
1358
- outline: var(--border-width) solid ButtonText;
1359
- outline-offset: 2px;
1360
- }
1459
+ :where(a.badge, button.badge):hover:not(:where(:disabled, [aria-disabled="true"])) {
1460
+ outline: var(--border-width) solid ButtonText;
1461
+ outline-offset: 2px;
1462
+ }
1361
1463
 
1362
- .badge > button:not(:disabled):hover {
1363
- outline: var(--border-width) solid currentColor;
1364
- outline-offset: 1px;
1365
- }
1464
+ .badge > button:not(:disabled):hover {
1465
+ outline: var(--border-width) solid currentColor;
1466
+ outline-offset: 1px;
1366
1467
  }
1367
1468
 
1368
1469
  button.badge:disabled,
@@ -1382,42 +1483,48 @@ a.badge[aria-disabled="true"] {
1382
1483
  * --alert-border-inline-start-color/-width are unset extension points, mirroring
1383
1484
  * --btn-border-block-end-* (button.css), for themes that give alerts a
1384
1485
  * callout-style colored flag on the leading edge instead of a uniform border.
1486
+ *
1487
+ * Public hooks:
1488
+ * --alert-pad-block
1489
+ * --alert-pad-inline
1490
+ * --alert-font-size
1491
+ * --alert-radius
1492
+ * --alert-icon-size
1493
+ * --alert-dismiss-size
1494
+ *
1495
+ * Internal:
1496
+ * --alert-bg
1497
+ * --alert-fg
1498
+ * --alert-border
1499
+ * --alert-dismiss-icon-size
1385
1500
  */
1386
1501
  /* Intent boundary — inherited intents do not tint this component.
1387
1502
  Local intent classes still win because :where() has zero specificity. */
1388
1503
  :where(.alert) {
1389
- /* @sync intent-boundary */
1390
1504
  --intent: initial;
1391
1505
  --intent-fg: initial;
1392
1506
  --ui-bg: initial;
1393
1507
  --ui-fg: initial;
1394
1508
  --ui-border: initial;
1395
- --ui-hover-bg: initial;
1396
- /* @sync intent-boundary */
1397
1509
  }
1398
1510
 
1399
1511
  .alert {
1400
- --alert-pad-block: var(--variant-pad-block, var(--space-4));
1401
- --alert-pad-inline: var(--space-4);
1402
- --alert-font-size: var(--variant-font-size, var(--font-size-md));
1403
- --alert-icon-size: var(--variant-icon-size, 1.25rem);
1404
- --alert-bg: var(--surface-subtle);
1405
- --alert-border: var(--border);
1406
- --ui-bg: var(--alert-bg);
1407
- --ui-border: var(--alert-border);
1408
- --ui-fg: var(--intent, var(--text));
1512
+ --alert-pad-block: var(--space-30);
1513
+ --alert-pad-inline: var(--space-40);
1514
+ --alert-font-size: var(--font-size-md);
1515
+ --alert-icon-size: 1.25rem;
1516
+ --alert-bg: var(--ui-bg, var(--surface-subtle));
1517
+ --alert-fg: var(--ui-fg, var(--intent, var(--text)));
1518
+ --alert-border: var(--ui-border, var(--border));
1409
1519
 
1410
1520
  padding-block: var(--alert-pad-block);
1411
1521
  padding-inline: var(--alert-pad-inline);
1412
- background: var(--ui-bg, transparent);
1413
- color: var(--ui-fg, var(--text));
1414
- border: var(--border-width) solid var(--ui-border, var(--border));
1415
- border-inline-start-color: var(
1416
- --alert-border-inline-start-color,
1417
- var(--ui-border, var(--border))
1418
- );
1522
+ background: var(--alert-bg);
1523
+ color: var(--alert-fg);
1524
+ border: var(--border-width) solid var(--alert-border);
1525
+ border-inline-start-color: var(--alert-border-inline-start-color, var(--alert-border));
1419
1526
  border-inline-start-width: var(--alert-border-inline-start-width, var(--border-width));
1420
- border-radius: var(--radius-lg);
1527
+ border-radius: var(--alert-radius, var(--radius-lg));
1421
1528
  font-size: var(--alert-font-size);
1422
1529
  }
1423
1530
 
@@ -1433,21 +1540,10 @@ a.badge[aria-disabled="true"] {
1433
1540
  margin-block-end: 0;
1434
1541
  }
1435
1542
 
1436
- /* Alerts are soft by default. Use .solid or .outline for stronger emphasis. */
1437
- .alert.solid {
1438
- --ui-bg: var(--intent, var(--neutral));
1439
- --ui-fg: var(--intent-fg, var(--neutral-fg));
1440
- --ui-border: transparent;
1441
- }
1442
-
1443
- .alert.outline {
1444
- --ui-bg: transparent;
1445
- --ui-fg: var(--intent, var(--text));
1446
- --ui-border: var(--intent, var(--border));
1447
- }
1448
-
1543
+ /* Alerts are soft by default. .solid and .outline need no local rules — the
1544
+ shared variant plumbing in variants.css sets --ui-* that alert consumes. */
1449
1545
  @supports (color: color-mix(in oklch, red, white)) {
1450
- .alert:where(:not(.solid, .outline)) {
1546
+ .alert:where(:not(.solid, .outline, .callout)) {
1451
1547
  /* @sync soft-recipe */
1452
1548
  --ui-bg: color-mix(in oklch, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
1453
1549
  --ui-border: color-mix(
@@ -1459,8 +1555,8 @@ a.badge[aria-disabled="true"] {
1459
1555
  }
1460
1556
  }
1461
1557
 
1462
- /* Decorative leading icon */
1463
- .alert > :first-child[aria-hidden="true"] {
1558
+ /* Decorative leading icon — use .alert-icon on the icon element. */
1559
+ .alert > .alert-icon {
1464
1560
  display: inline-block;
1465
1561
  flex: none;
1466
1562
  inline-size: var(--alert-icon-size);
@@ -1470,33 +1566,84 @@ a.badge[aria-disabled="true"] {
1470
1566
  vertical-align: -0.125em;
1471
1567
  }
1472
1568
 
1569
+ /* Dismiss — compact close button for inline alerts. Transparent at rest with
1570
+ a reduced opacity glyph so it stays secondary inside an already-colored
1571
+ alert; hover reinforces it via the shared overlay. Uses the shared
1572
+ --icon-close mask, so no icon font or text glyph is required in the markup.
1573
+ Standard alerts place it as a direct trailing child; admonitions place it
1574
+ inside .alert-title (see below). */
1575
+ .alert-dismiss {
1576
+ --alert-dismiss-size: 1.75rem;
1577
+ --alert-dismiss-icon-size: 0.875rem;
1578
+
1579
+ display: inline-grid;
1580
+ place-items: center;
1581
+ inline-size: var(--alert-dismiss-size);
1582
+ block-size: var(--alert-dismiss-size);
1583
+ flex: none;
1584
+ padding: 0;
1585
+ border: 0;
1586
+ border-radius: var(--radius);
1587
+ background: transparent;
1588
+ color: currentColor;
1589
+ opacity: 0.65;
1590
+ cursor: pointer;
1591
+ }
1592
+
1593
+ .alert-dismiss::before {
1594
+ content: "";
1595
+ inline-size: var(--alert-dismiss-icon-size);
1596
+ block-size: var(--alert-dismiss-icon-size);
1597
+ background: currentColor;
1598
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
1599
+ mask: var(--icon-close) center / contain no-repeat;
1600
+ }
1601
+
1602
+ @media (hover: hover) {
1603
+ .alert-dismiss:hover {
1604
+ background: var(--hover-overlay);
1605
+ opacity: 1;
1606
+ }
1607
+ }
1608
+
1609
+ /* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
1610
+ both. Icon and dismiss are pinned to the outer columns; anything else (the
1611
+ message content) flows into the remaining text column. */
1473
1612
  @supports selector(.alert:has(> *)) {
1474
- .alert:has(> :first-child[aria-hidden="true"]) {
1613
+ .alert:has(> .alert-icon, > .alert-dismiss) {
1475
1614
  display: grid;
1476
- grid-template-columns: auto minmax(0, 1fr);
1615
+ grid-template-columns: minmax(0, 1fr);
1477
1616
  align-items: var(--items-align, start);
1478
- column-gap: var(--space-3);
1617
+ column-gap: var(--space-30);
1618
+ }
1619
+
1620
+ .alert:has(> .alert-icon) {
1621
+ grid-template-columns: auto minmax(0, 1fr);
1479
1622
  }
1480
1623
 
1481
- .alert:has(> :first-child[aria-hidden="true"]) > :first-child {
1624
+ .alert:has(> .alert-icon) > .alert-icon {
1482
1625
  grid-column: 1;
1483
1626
  grid-row: 1 / -1;
1484
1627
  display: inline-grid;
1485
1628
  place-items: center;
1486
- inline-size: var(--alert-icon-size);
1487
- block-size: var(--alert-icon-size);
1488
1629
  margin-block-start: 0.125em;
1489
- font-size: var(--alert-icon-size);
1490
- line-height: 1;
1491
1630
  }
1492
1631
 
1493
- .alert:has(> :first-child[aria-hidden="true"]) > :first-child:is(svg, i) {
1494
- inline-size: var(--alert-icon-size);
1495
- block-size: var(--alert-icon-size);
1632
+ .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
1633
+ grid-column: 2;
1496
1634
  }
1497
1635
 
1498
- .alert:has(> :first-child[aria-hidden="true"]) > :not(:first-child) {
1499
- grid-column: 2;
1636
+ .alert:has(> .alert-dismiss) {
1637
+ grid-template-columns: minmax(0, 1fr) auto;
1638
+ }
1639
+
1640
+ .alert:has(> .alert-icon):has(> .alert-dismiss) {
1641
+ grid-template-columns: auto minmax(0, 1fr) auto;
1642
+ }
1643
+
1644
+ .alert:has(> .alert-dismiss) > .alert-dismiss {
1645
+ grid-column: -2 / -1;
1646
+ grid-row: 1;
1500
1647
  }
1501
1648
  }
1502
1649
 
@@ -1511,14 +1658,84 @@ a.badge[aria-disabled="true"] {
1511
1658
  Canvas backplate (unreadable) — so .solid intentionally falls back to the
1512
1659
  default look. */
1513
1660
 
1661
+ /* Callout — neutral surface with a thick accent border on the leading edge.
1662
+ The thickness follows the shared --alert-border-inline-start-width hook. */
1663
+ .alert.callout {
1664
+ --alert-bg: var(--surface-subtle);
1665
+ --alert-fg: var(--text);
1666
+ border: 0;
1667
+ border-inline-start: var(--alert-border-inline-start-width, 4px) solid
1668
+ var(--alert-border-inline-start-color, var(--intent, var(--neutral)));
1669
+ }
1670
+
1671
+ /* Admonition — structured static box with a tinted title bar and a body
1672
+ that rests on the page surface. Not collapsible — use .accordion inside
1673
+ the body if the content needs to be expandable. */
1674
+ .alert.admonition {
1675
+ padding: 0;
1676
+ display: block;
1677
+ overflow: clip;
1678
+ border-color: var(--intent, var(--border));
1679
+ }
1680
+
1681
+ .alert.admonition > .alert-title {
1682
+ display: flex;
1683
+ align-items: center;
1684
+ gap: var(--space-20);
1685
+ padding-block: var(--alert-pad-block);
1686
+ padding-inline: var(--alert-pad-inline);
1687
+ border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
1688
+ border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
1689
+ }
1690
+
1691
+ /* Dismiss inside an admonition title bar rides the inline end. */
1692
+ .alert.admonition > .alert-title > .alert-dismiss {
1693
+ margin-inline-start: auto;
1694
+ }
1695
+
1696
+ @supports (color: color-mix(in oklch, red, white)) {
1697
+ .alert.admonition > .alert-title {
1698
+ background: color-mix(
1699
+ in oklch,
1700
+ var(--surface) var(--soft-bg-mix),
1701
+ var(--intent, var(--neutral))
1702
+ );
1703
+ }
1704
+ }
1705
+
1706
+ @supports not (color: color-mix(in oklch, red, white)) {
1707
+ .alert.admonition > .alert-title {
1708
+ background: var(--intent, var(--neutral));
1709
+ color: var(--intent-fg, var(--neutral-fg));
1710
+ }
1711
+ }
1712
+
1713
+ .alert.admonition > .alert-body {
1714
+ padding-block: var(--alert-pad-block);
1715
+ padding-inline: var(--alert-pad-inline);
1716
+ background: var(--surface);
1717
+ color: var(--text);
1718
+ }
1719
+
1720
+ .alert.admonition > .alert-body > :first-child {
1721
+ margin-block-start: 0;
1722
+ }
1723
+
1724
+ .alert.admonition > .alert-body > :last-child {
1725
+ margin-block-end: 0;
1726
+ }
1727
+
1514
1728
  /*
1515
1729
  * Actions — a horizontal action-button cluster.
1516
1730
  *
1517
1731
  * Composes with .btn (or any inline element) and feeds layout via the shared
1518
1732
  * --gap token.
1733
+ *
1734
+ * Internal:
1735
+ * --gap (local value for the shared gap token)
1519
1736
  */
1520
1737
  .actions {
1521
- --gap: var(--space-2);
1738
+ --gap: var(--space-20);
1522
1739
 
1523
1740
  display: flex;
1524
1741
  flex-wrap: wrap;
@@ -1541,28 +1758,36 @@ a.badge[aria-disabled="true"] {
1541
1758
  * Surface, border, radius, padding, density, and elevation stay local here.
1542
1759
  * Direct header/footer children are structural slots. Page layout remains the
1543
1760
  * job of markup and layout primitives outside the card.
1761
+ *
1762
+ * Public hooks:
1763
+ * --card-pad
1764
+ * --card-radius
1765
+ * --card-max-inline-size
1766
+ *
1767
+ * Child contract:
1768
+ * Direct .bleed children escape --card-pad (negative inset, wider media);
1769
+ * edge children inherit the card corners. .bleed must be a direct child.
1770
+ *
1771
+ * Internal:
1772
+ * --heading
1773
+ * --busy-overlay-bg
1544
1774
  */
1545
1775
  /* Intent boundary — inherited intents do not tint this component.
1546
1776
  Local intent classes still win because :where() has zero specificity. */
1547
1777
  :where(.card) {
1548
- /* @sync intent-boundary */
1549
- --intent: initial;
1550
- --intent-fg: initial;
1551
1778
  --ui-bg: initial;
1552
1779
  --ui-fg: initial;
1553
1780
  --ui-border: initial;
1554
- --ui-hover-bg: initial;
1555
- /* @sync intent-boundary */
1556
1781
  }
1557
1782
 
1558
1783
  .card {
1559
- --card-pad: var(--space-4);
1784
+ --card-pad: var(--space-40);
1560
1785
  --card-radius: var(--radius-lg);
1561
1786
  --card-max-inline-size: none;
1562
1787
 
1563
1788
  max-inline-size: var(--card-max-inline-size);
1564
1789
  padding: var(--card-pad);
1565
- background: var(--ui-bg, var(--surface));
1790
+ background: var(--ui-bg, var(--surface-raised));
1566
1791
  color: var(--ui-fg, var(--text));
1567
1792
  border: var(--border-width) solid var(--ui-border, var(--border));
1568
1793
  border-radius: var(--card-radius);
@@ -1577,7 +1802,7 @@ a.badge[aria-disabled="true"] {
1577
1802
  flex-wrap: wrap;
1578
1803
  align-items: var(--items-align, center);
1579
1804
  justify-content: space-between;
1580
- gap: var(--space-3);
1805
+ gap: var(--space-30);
1581
1806
  min-inline-size: 0;
1582
1807
  }
1583
1808
 
@@ -1604,16 +1829,8 @@ a.badge[aria-disabled="true"] {
1604
1829
  .card.subtle {
1605
1830
  background: var(--surface-subtle);
1606
1831
  }
1607
- .card.inverted {
1608
- background: var(--surface-solid);
1609
- color: var(--surface);
1610
- border-color: transparent;
1611
- --heading: var(--surface);
1612
- --busy-overlay-bg: var(--surface-solid);
1613
- }
1614
-
1615
1832
  .card.compact {
1616
- --card-pad: var(--space-2);
1833
+ --card-pad: var(--space-20);
1617
1834
  }
1618
1835
 
1619
1836
  /* Full-bleed children */
@@ -1656,26 +1873,6 @@ a.badge[aria-disabled="true"] {
1656
1873
  padding: var(--card-pad);
1657
1874
  }
1658
1875
 
1659
- @media (forced-colors: active) {
1660
- .card {
1661
- border-color: ButtonText;
1662
- background: Canvas;
1663
- color: CanvasText;
1664
- }
1665
-
1666
- .card.raised {
1667
- box-shadow: none;
1668
- }
1669
-
1670
- .card.inverted {
1671
- --heading: CanvasText;
1672
-
1673
- border-color: ButtonText;
1674
- background: Canvas;
1675
- color: CanvasText;
1676
- }
1677
- }
1678
-
1679
1876
  /*
1680
1877
  * Surface — shared modal scrim and backdrop for flyout and drawer.
1681
1878
  *
@@ -1697,7 +1894,7 @@ a.badge[aria-disabled="true"] {
1697
1894
  z-index: calc(var(--z-menu) - 1);
1698
1895
  background: var(--backdrop-fill);
1699
1896
  opacity: 1;
1700
- transition: opacity var(--duration) var(--ease, ease);
1897
+ transition: opacity var(--duration) var(--ease);
1701
1898
  }
1702
1899
 
1703
1900
  .surface-backdrop[hidden] {
@@ -1714,28 +1911,40 @@ a.badge[aria-disabled="true"] {
1714
1911
  @supports (transition-behavior: allow-discrete) {
1715
1912
  .surface-backdrop {
1716
1913
  transition:
1717
- opacity var(--duration) var(--ease, ease),
1914
+ opacity var(--duration) var(--ease),
1718
1915
  display var(--duration) allow-discrete;
1719
1916
  }
1720
1917
  }
1721
1918
 
1722
- @media (forced-colors: active) {
1723
- .surface-backdrop {
1724
- background: Canvas;
1725
- }
1726
- }
1727
-
1728
1919
  /*
1729
1920
  * Modal shell styling for native dialog elements.
1730
1921
  *
1731
1922
  * Spacing, radius, transitions, and focus treatment come from shared tokens.
1732
1923
  * Opening behavior and intent-specific decisions stay outside this component.
1924
+ *
1925
+ * Public hooks:
1926
+ * --modal-size
1927
+ * --dialog-viewport-gap
1928
+ * --dialog-icon-size
1929
+ * --dialog-icon-glyph-size
1930
+ *
1931
+ * Internal:
1932
+ * --dialog-close-icon-size
1933
+ * --dialog-viewport-gap-2x
1733
1934
  */
1734
1935
  dialog.modal {
1735
1936
  --modal-size: 32rem;
1937
+ --dialog-viewport-gap: var(--space-40);
1938
+ /* calc() multiplication is not in the supported browser range, so the
1939
+ doubled gap is spelled out with an additive calc. */
1940
+ --dialog-viewport-gap-2x: calc(var(--dialog-viewport-gap) + var(--dialog-viewport-gap));
1941
+
1942
+ /* Keep the surface anchored to the viewport in the top layer. This also
1943
+ provides the containing block for the absolutely positioned close button. */
1944
+ position: fixed;
1736
1945
 
1737
- max-inline-size: min(100% - 2rem, var(--modal-size));
1738
- max-block-size: calc(var(--viewport-block) - 2rem);
1946
+ max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size));
1947
+ max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
1739
1948
  border: var(--border-width) solid var(--border);
1740
1949
  border-radius: var(--radius-lg);
1741
1950
  background: var(--surface-raised);
@@ -1759,11 +1968,18 @@ dialog.modal {
1759
1968
  dialog.modal > form,
1760
1969
  dialog.modal > .stack {
1761
1970
  display: grid;
1762
- gap: var(--space-4);
1763
- max-block-size: calc(var(--viewport-block) - 2rem);
1971
+ gap: var(--space-40);
1972
+ max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
1764
1973
  overflow: auto;
1765
1974
  overscroll-behavior: contain;
1766
- padding: var(--space-4);
1975
+ padding: var(--space-40);
1976
+ }
1977
+
1978
+ /* Keep the dialog off the viewport edges on small screens. */
1979
+ @media (max-width: 30rem) {
1980
+ dialog.modal {
1981
+ --dialog-viewport-gap: var(--space-30);
1982
+ }
1767
1983
  }
1768
1984
 
1769
1985
  dialog.modal.scrollable > form,
@@ -1779,21 +1995,87 @@ dialog.modal.scrollable > .stack > :not(header, footer) {
1779
1995
  overscroll-behavior: contain;
1780
1996
  }
1781
1997
 
1782
- dialog.modal header > :first-child,
1783
- dialog.modal footer > :first-child {
1784
- margin-block-start: 0;
1998
+ dialog.modal header > :first-child,
1999
+ dialog.modal footer > :first-child {
2000
+ margin-block-start: 0;
2001
+ }
2002
+
2003
+ dialog.modal header > :last-child,
2004
+ dialog.modal footer > :last-child {
2005
+ margin-block-end: 0;
2006
+ }
2007
+
2008
+ /* The close button is anchored to the dialog at top inline-end, out of the
2009
+ content flow; headers reserve inline room so titles never run under it. */
2010
+ dialog.modal > form > header,
2011
+ dialog.modal > .stack > header {
2012
+ padding-inline-end: calc(var(--control-size) + var(--space-30));
2013
+ }
2014
+
2015
+ dialog.modal footer {
2016
+ display: flex;
2017
+ flex-wrap: wrap;
2018
+ justify-content: flex-end;
2019
+ gap: var(--gap);
2020
+ }
2021
+
2022
+ /* Confirmation layout — a focused message followed by a visually distinct
2023
+ action band. The media primitive owns icon/content alignment; this variant
2024
+ only removes the standard form inset so both regions can own their padding. */
2025
+ dialog.modal.dialog-confirmation > form {
2026
+ gap: 0;
2027
+ padding: 0;
2028
+ }
2029
+
2030
+ dialog.modal.dialog-confirmation > form > .media {
2031
+ gap: var(--space-40);
2032
+ padding: var(--space-50);
2033
+ }
2034
+
2035
+ dialog.modal.dialog-confirmation > form > footer {
2036
+ padding: var(--space-40) var(--space-50);
2037
+ border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
2038
+ border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
2039
+ background: var(--surface-subtle);
2040
+ }
2041
+
2042
+ /* Intent boundary — inherited intents do not tint the icon well. A local
2043
+ intent plus .soft opts into the shared soft-variant recipe. */
2044
+ :where(.dialog-icon) {
2045
+ --intent: initial;
2046
+ --intent-fg: initial;
2047
+ --ui-bg: initial;
2048
+ --ui-fg: initial;
2049
+ }
2050
+
2051
+ .dialog-icon {
2052
+ --dialog-icon-size: 3rem;
2053
+ --dialog-icon-glyph-size: 1.5rem;
2054
+
2055
+ display: inline-grid;
2056
+ place-items: center;
2057
+ inline-size: var(--dialog-icon-size);
2058
+ block-size: var(--dialog-icon-size);
2059
+ flex: none;
2060
+ border-radius: var(--radius-full);
2061
+ background: var(--ui-bg, var(--surface-subtle));
2062
+ color: var(--ui-fg, var(--intent, var(--text-muted)));
1785
2063
  }
1786
2064
 
1787
- dialog.modal header > :last-child,
1788
- dialog.modal footer > :last-child {
1789
- margin-block-end: 0;
2065
+ .dialog-icon > * {
2066
+ display: inline-grid;
2067
+ place-items: center;
2068
+ inline-size: var(--dialog-icon-glyph-size);
2069
+ block-size: var(--dialog-icon-glyph-size);
2070
+ font-size: var(--dialog-icon-glyph-size);
2071
+ line-height: 1;
1790
2072
  }
1791
2073
 
1792
- dialog.modal footer {
1793
- display: flex;
1794
- flex-wrap: wrap;
1795
- justify-content: flex-end;
1796
- gap: var(--gap);
2074
+ @media (max-width: 30rem) {
2075
+ dialog.modal.dialog-confirmation > form > .media,
2076
+ dialog.modal.dialog-confirmation > form > footer {
2077
+ padding: var(--space-40);
2078
+ }
1797
2079
  }
1798
2080
 
1799
2081
  dialog.modal[open] {
@@ -1851,7 +2133,7 @@ html.has-modal-open.had-scrollbar {
1851
2133
 
1852
2134
  @media (prefers-reduced-motion: no-preference) {
1853
2135
  dialog.modal.is-static {
1854
- animation: actual-modal-static 180ms var(--ease);
2136
+ animation: actual-dialog-static var(--duration-slow) var(--ease);
1855
2137
  }
1856
2138
 
1857
2139
  ::view-transition-group(actual-dialog) {
@@ -1860,7 +2142,7 @@ html.has-modal-open.had-scrollbar {
1860
2142
  }
1861
2143
  }
1862
2144
 
1863
- @keyframes actual-modal-static {
2145
+ @keyframes actual-dialog-static {
1864
2146
  0%,
1865
2147
  100% {
1866
2148
  transform: none;
@@ -1886,6 +2168,70 @@ html.has-modal-open.had-scrollbar {
1886
2168
  dialog.modal::backdrop {
1887
2169
  background: Canvas;
1888
2170
  }
2171
+
2172
+ .dialog-icon {
2173
+ border: var(--border-width) solid CanvasText;
2174
+ }
2175
+ }
2176
+
2177
+ /* Close controls. A close button is an action attached to a surface, so it
2178
+ never participates in the content flow. The modal and the drawer share the
2179
+ same simple placement — absolute at the panel's top inline-end, inside the
2180
+ surface — so both close buttons look and behave identically. The glyph comes
2181
+ from --icon-close, masked to currentColor, so no icon font or text glyph is
2182
+ required in the markup. */
2183
+ .dialog-close,
2184
+ .drawer-close {
2185
+ --dialog-close-icon-size: 1rem;
2186
+
2187
+ display: inline-grid;
2188
+ place-items: center;
2189
+ padding: 0;
2190
+ border: 0;
2191
+ border-radius: var(--radius);
2192
+ color: var(--text-muted);
2193
+ cursor: pointer;
2194
+
2195
+ /* Out of flow, anchored to the positioned dialog surface, not the header or
2196
+ any scroll container inside it. */
2197
+ position: absolute;
2198
+ z-index: 1;
2199
+ inset-block-start: var(--space-20);
2200
+ inset-inline-end: var(--space-20);
2201
+
2202
+ inline-size: var(--control-size);
2203
+ block-size: var(--control-size);
2204
+
2205
+ background: var(--surface-subtle);
2206
+ }
2207
+
2208
+ .dialog-close::before,
2209
+ .drawer-close::before {
2210
+ content: "";
2211
+ inline-size: var(--dialog-close-icon-size);
2212
+ block-size: var(--dialog-close-icon-size);
2213
+ background: currentColor;
2214
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
2215
+ mask: var(--icon-close) center / contain no-repeat;
2216
+ }
2217
+
2218
+ @media (hover: hover) {
2219
+ .dialog-close:hover,
2220
+ .drawer-close:hover {
2221
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
2222
+ color: var(--text);
2223
+ }
2224
+ }
2225
+
2226
+ .dialog-close:active,
2227
+ .drawer-close:active {
2228
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
2229
+ }
2230
+
2231
+ .dialog-close:focus-visible,
2232
+ .drawer-close:focus-visible {
2233
+ outline: 2px solid transparent;
2234
+ box-shadow: var(--focus-ring-shadow);
1889
2235
  }
1890
2236
 
1891
2237
  /*
@@ -1896,16 +2242,30 @@ html.has-modal-open.had-scrollbar {
1896
2242
  * discrete display/overlay transitions. The drawer is a layout shell only —
1897
2243
  * navigation content inside uses .nav-link and .accordion, which carry their
1898
2244
  * own link/summary styling.
2245
+ *
2246
+ * Public hooks:
2247
+ * --drawer-size
2248
+ * --drawer-pad
2249
+ *
2250
+ * Internal:
2251
+ * --drawer-closed-translate (side/RTL positioning state)
1899
2252
  */
1900
2253
  dialog.drawer {
1901
2254
  --drawer-size: 20rem;
1902
- --drawer-pad: var(--space-4);
2255
+ --drawer-pad: var(--space-40);
1903
2256
  --drawer-closed-translate: -100%;
1904
2257
 
2258
+ /* Keep the surface anchored to the viewport in the top layer. This also
2259
+ provides the containing block for the absolutely positioned close button.
2260
+ A percentage height resolves against the fixed containing viewport, while
2261
+ dynamic viewport units can be stale in embedded previews. */
2262
+ position: fixed;
2263
+ inset-block-start: 0;
2264
+
1905
2265
  inline-size: min(calc(var(--viewport-inline) * 0.9), var(--drawer-size));
1906
- block-size: var(--viewport-block);
2266
+ block-size: 100%;
1907
2267
  max-inline-size: none;
1908
- max-block-size: var(--viewport-block);
2268
+ max-block-size: 100%;
1909
2269
  margin: 0;
1910
2270
  padding: var(--drawer-pad);
1911
2271
  border: 0;
@@ -1924,7 +2284,7 @@ dialog.drawer {
1924
2284
  dialog.drawer[open] {
1925
2285
  display: grid;
1926
2286
  grid-template-rows: auto minmax(0, 1fr) auto;
1927
- gap: var(--space-4);
2287
+ gap: var(--space-40);
1928
2288
  opacity: 1;
1929
2289
  transform: translateX(0);
1930
2290
  }
@@ -1995,20 +2355,23 @@ dialog.drawer nav {
1995
2355
  }
1996
2356
 
1997
2357
  @media (forced-colors: active) {
1998
- dialog.drawer {
1999
- border-inline-end-color: ButtonText;
2358
+ dialog.drawer::backdrop {
2000
2359
  background: Canvas;
2001
- color: CanvasText;
2002
- box-shadow: none;
2003
2360
  }
2361
+ }
2004
2362
 
2005
- dialog.drawer[data-side="end"] {
2006
- border-inline-start-color: ButtonText;
2363
+ @media (prefers-reduced-motion: no-preference) {
2364
+ dialog.drawer.is-static {
2365
+ animation: actual-dialog-static var(--duration-slow) var(--ease);
2007
2366
  }
2367
+ }
2008
2368
 
2009
- dialog.drawer::backdrop {
2010
- background: Canvas;
2011
- }
2369
+ /* The close button shares the modal's interior placement (modal.css): absolute
2370
+ at the panel's top inline-end, out of the content flow. The header reserves
2371
+ inline room so the title never runs under it. */
2372
+ dialog.drawer > header,
2373
+ dialog.drawer > form > header {
2374
+ padding-inline-end: calc(var(--control-size) + var(--space-30));
2012
2375
  }
2013
2376
 
2014
2377
 
@@ -2024,7 +2387,16 @@ dialog.drawer nav {
2024
2387
  * .is-sheet marks "auto" resolved to bottom-sheet presentation on coarse
2025
2388
  * pointers. Authors opt into mobile behavior with
2026
2389
  * [data-flyout-mobile="sheet|auto|none|anchored"].
2027
- * --flyout-trigger-width is set by surface.js while an open flyout is positioned.
2390
+ * --surface-anchor-width is set by surface.js while an open flyout is positioned.
2391
+ *
2392
+ * Public hooks:
2393
+ * --flyout-inline-size
2394
+ * --flyout-max-inline-size
2395
+ *
2396
+ * Internal:
2397
+ * --menu-item-size (derived from --control-size for menu rows)
2398
+ * --available-height (measured by floating.js)
2399
+ * --surface-anchor-width (measured by surface.js)
2028
2400
  */
2029
2401
 
2030
2402
  .flyout-trigger {
@@ -2047,28 +2419,32 @@ dialog.drawer nav {
2047
2419
  }
2048
2420
 
2049
2421
  .flyout {
2422
+ --menu-item-size: var(--control-size);
2050
2423
  z-index: var(--z-menu);
2051
2424
  /* No-JS fallback: below the trigger inside the relative .flyout-trigger.
2052
2425
  surface.js switches an open flyout to position: fixed with measured
2053
2426
  coordinates; this only covers a flyout revealed without the runtime. */
2054
2427
  position: absolute;
2055
- inset-block-start: calc(100% + var(--space-1));
2428
+ inset-block-start: calc(100% + var(--space-10));
2056
2429
  inset-inline-start: 0;
2057
2430
  display: grid;
2058
- gap: var(--space-1);
2431
+ gap: var(--space-10);
2059
2432
  inline-size: var(--flyout-inline-size, max-content);
2060
2433
  min-inline-size: min(
2061
- var(--flyout-trigger-width, 0px),
2062
- min(var(--flyout-max-inline-size, 20rem), calc(var(--viewport-inline) - var(--space-6)))
2434
+ var(--surface-anchor-width, 0px),
2435
+ min(var(--flyout-max-inline-size, 20rem), calc(var(--viewport-inline) - var(--space-60)))
2063
2436
  );
2064
2437
  max-inline-size: min(
2065
2438
  var(--flyout-max-inline-size, 20rem),
2066
- calc(var(--viewport-inline) - var(--space-6))
2439
+ calc(var(--viewport-inline) - var(--space-60))
2440
+ );
2441
+ max-block-size: min(
2442
+ 24rem,
2443
+ var(--available-height, calc(var(--viewport-block) - var(--space-60)))
2067
2444
  );
2068
- max-block-size: min(24rem, var(--available-height, calc(var(--viewport-block) - var(--space-6))));
2069
2445
  overflow: auto;
2070
2446
  margin: 0;
2071
- padding: var(--space-1);
2447
+ padding: var(--space-10);
2072
2448
  border: var(--border-width) solid var(--border);
2073
2449
  border-radius: var(--radius);
2074
2450
  background: var(--surface-raised);
@@ -2077,9 +2453,9 @@ dialog.drawer nav {
2077
2453
  opacity: 1;
2078
2454
  scale: 1;
2079
2455
  transition:
2080
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2081
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2082
- translate var(--duration-fast, var(--duration)) var(--ease, ease);
2456
+ opacity var(--duration-fast) var(--ease),
2457
+ scale var(--duration-fast) var(--ease),
2458
+ translate var(--duration-fast) var(--ease);
2083
2459
  }
2084
2460
 
2085
2461
  .flyout.is-open:not(.is-sheet) {
@@ -2096,6 +2472,7 @@ dialog.drawer nav {
2096
2472
  /* Bottom-sheet presentation. surface.js toggles .is-sheet when the flyout is
2097
2473
  opened on coarse pointer / narrow viewports. */
2098
2474
  .flyout.is-sheet {
2475
+ --menu-item-size: var(--control-size-lg);
2099
2476
  position: fixed;
2100
2477
  inset-inline: 0;
2101
2478
  inset-block-start: auto;
@@ -2106,8 +2483,8 @@ dialog.drawer nav {
2106
2483
  max-block-size: min(calc(var(--viewport-block) * 0.75), 32rem);
2107
2484
  overflow: auto;
2108
2485
  grid-template-columns: 1fr;
2109
- padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3)
2110
- max(var(--space-3), env(safe-area-inset-bottom));
2486
+ padding: max(var(--space-30), env(safe-area-inset-top)) var(--space-30)
2487
+ max(var(--space-30), env(safe-area-inset-bottom));
2111
2488
  border-end-start-radius: 0;
2112
2489
  border-end-end-radius: 0;
2113
2490
  }
@@ -2120,23 +2497,23 @@ dialog.drawer nav {
2120
2497
 
2121
2498
  .flyout.is-sheet[hidden] {
2122
2499
  scale: 1;
2123
- translate: 0 var(--space-3);
2500
+ translate: 0 var(--space-30);
2124
2501
  }
2125
2502
 
2126
2503
  @starting-style {
2127
2504
  .flyout.is-sheet.is-open {
2128
2505
  scale: 1;
2129
- translate: 0 var(--space-3);
2506
+ translate: 0 var(--space-30);
2130
2507
  }
2131
2508
  }
2132
2509
 
2133
2510
  @supports (transition-behavior: allow-discrete) {
2134
2511
  .flyout {
2135
2512
  transition:
2136
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2137
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2138
- translate var(--duration-fast, var(--duration)) var(--ease, ease),
2139
- display var(--duration-fast, var(--duration)) allow-discrete;
2513
+ opacity var(--duration-fast) var(--ease),
2514
+ scale var(--duration-fast) var(--ease),
2515
+ translate var(--duration-fast) var(--ease),
2516
+ display var(--duration-fast) allow-discrete;
2140
2517
  }
2141
2518
  }
2142
2519
 
@@ -2146,7 +2523,7 @@ dialog.drawer nav {
2146
2523
 
2147
2524
  .flyout > :where(section, footer) {
2148
2525
  min-inline-size: 0;
2149
- padding: var(--space-2);
2526
+ padding: var(--space-20);
2150
2527
  }
2151
2528
 
2152
2529
  .flyout > section > :where(h1, h2, h3, h4, h5, h6) {
@@ -2158,8 +2535,8 @@ dialog.drawer nav {
2158
2535
 
2159
2536
  .flyout > section > ul {
2160
2537
  display: grid;
2161
- gap: var(--space-1);
2162
- margin: var(--space-1) 0 0;
2538
+ gap: var(--space-10);
2539
+ margin: var(--space-10) 0 0;
2163
2540
  padding: 0;
2164
2541
  list-style: none;
2165
2542
  }
@@ -2168,92 +2545,133 @@ dialog.drawer nav {
2168
2545
  margin: 0;
2169
2546
  }
2170
2547
 
2171
- /* Item contract: a button, link, or menuitem placed directly in the flyout
2172
- or directly in any of its <li> (bare list, or grouped section > ul > li).
2173
- menu.js mirrors this contract in MENU_ITEM_SELECTOR — keep them in sync.
2174
- :where(li) keeps specificity at a single class (0,1,0) so consumer
2175
- overrides behave the same as a plain .flyout > rule. */
2176
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]) {
2548
+ /* Vertical dividers — .menu-separator (menu.css) is the canonical way to
2549
+ separate menu items. Flyout panels that are not menus use auto grid gap
2550
+ declared on .flyout itself. */
2551
+
2552
+ /*
2553
+ * Menu — action list primitive consumed by flyout and context-menu.
2554
+ *
2555
+ * Strict anatomy: .menu > li > .menu-item. Separators and section labels
2556
+ * sit directly under .menu as siblings of <li>.
2557
+ *
2558
+ * Public hooks:
2559
+ * --menu-item-icon-size
2560
+ */
2561
+
2562
+ .menu {
2563
+ display: grid;
2564
+ gap: var(--space-10);
2565
+ margin: 0;
2566
+ padding: var(--space-10);
2567
+ list-style: none;
2568
+ }
2569
+
2570
+ .menu > li {
2571
+ margin: 0;
2572
+ }
2573
+
2574
+ .menu-item {
2575
+ --menu-item-icon-size: 1.25em;
2576
+
2177
2577
  display: flex;
2178
2578
  align-items: center;
2179
- justify-content: flex-start;
2180
- gap: var(--space-2);
2579
+ gap: var(--space-20);
2181
2580
  inline-size: 100%;
2182
- min-block-size: var(--control-size);
2183
- padding-inline: var(--space-2);
2581
+ min-block-size: var(--menu-item-size, var(--control-size));
2582
+ padding-inline: var(--space-20);
2184
2583
  border: 0;
2185
2584
  border-radius: var(--radius-sm);
2186
- background: none;
2585
+ background: transparent;
2187
2586
  color: var(--text);
2188
2587
  font: inherit;
2189
- font-weight: var(--font-weight);
2190
- text-decoration: none;
2191
2588
  text-align: start;
2589
+ text-decoration: none;
2192
2590
  white-space: nowrap;
2193
2591
  cursor: pointer;
2194
2592
  }
2195
2593
 
2196
- :is(.flyout.is-sheet, .flyout.is-sheet :where(li)) > :where(button, a, [role="menuitem"]) {
2197
- min-block-size: var(--control-size-lg);
2594
+ .menu-item-icon {
2595
+ display: grid;
2596
+ flex: 0 0 var(--menu-item-icon-size);
2597
+ inline-size: var(--menu-item-icon-size);
2598
+ place-items: center;
2599
+ }
2600
+
2601
+ .menu-item-text {
2602
+ flex: 1;
2603
+ min-inline-size: 0;
2604
+ overflow: hidden;
2605
+ text-overflow: ellipsis;
2606
+ }
2607
+
2608
+ .menu-item-end {
2609
+ display: inline-flex;
2610
+ align-items: center;
2611
+ gap: var(--space-10);
2612
+ margin-inline-start: auto;
2613
+ color: var(--text-muted);
2614
+ }
2615
+
2616
+ /* Checkable menu items are one command with one accessible state. Their
2617
+ indicator is generated so authors do not add a second interactive input. */
2618
+ .menu-item:is([role="menuitemcheckbox"], [role="menuitemradio"])::before {
2619
+ display: grid;
2620
+ flex: 0 0 var(--menu-item-icon-size);
2621
+ inline-size: var(--menu-item-icon-size);
2622
+ place-items: center;
2623
+ }
2624
+
2625
+ .menu-item[role="menuitemcheckbox"]::before {
2626
+ content: "";
2198
2627
  }
2199
2628
 
2200
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]):focus-visible {
2629
+ .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before {
2630
+ content: "✓";
2631
+ }
2632
+
2633
+ .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before {
2634
+ content: "−";
2635
+ }
2636
+
2637
+ .menu-item[role="menuitemradio"]::before {
2638
+ content: "○";
2639
+ }
2640
+
2641
+ .menu-item[role="menuitemradio"][aria-checked="true"]::before {
2642
+ content: "●";
2643
+ }
2644
+
2645
+ .menu-item:focus-visible {
2201
2646
  background: var(--surface-subtle);
2202
- outline: var(--focus-outline);
2203
- outline-offset: var(--focus-outline-offset);
2204
- box-shadow: var(--focus-ring-shadow);
2205
2647
  }
2206
2648
 
2207
- :is(.flyout, .flyout :where(li))
2208
- > :where(button, a, [role="menuitem"]):is(:disabled, [aria-disabled="true"]) {
2209
- cursor: not-allowed;
2210
- opacity: var(--disabled-opacity);
2649
+ .menu-item:hover:not(:where(:disabled, [aria-disabled="true"])) {
2650
+ background: var(--surface-subtle);
2211
2651
  }
2212
2652
 
2213
- :is(.flyout, .flyout :where(li)) > :where(a, [role="menuitem"])[aria-disabled="true"] {
2214
- pointer-events: none;
2653
+ .menu-item:is(:disabled, [aria-disabled="true"]) {
2654
+ cursor: not-allowed;
2655
+ opacity: var(--disabled-opacity);
2215
2656
  }
2216
2657
 
2217
- @media (hover: hover) {
2218
- :is(.flyout, .flyout :where(li))
2219
- > :where(button, a, [role="menuitem"]):hover:not(
2220
- :where(:focus-visible, :disabled, [aria-disabled="true"])
2221
- ) {
2222
- background: var(--surface-subtle);
2223
- outline: none;
2224
- }
2658
+ .menu-label {
2659
+ padding: var(--space-20);
2660
+ color: var(--text-muted);
2661
+ font-size: var(--font-size-xs);
2662
+ font-weight: var(--font-weight-strong);
2225
2663
  }
2226
2664
 
2227
- .flyout > hr,
2228
- .flyout > li > hr {
2229
- inline-size: 100%;
2665
+ .menu-separator {
2230
2666
  margin: 0;
2231
- margin-block: var(--space-1);
2232
2667
  border: 0;
2233
2668
  border-block-start: var(--border-width) solid var(--border);
2234
2669
  }
2235
2670
 
2236
2671
  @media (forced-colors: active) {
2237
- .flyout {
2238
- border-color: ButtonText;
2239
- background: Canvas;
2240
- color: CanvasText;
2241
- box-shadow: none;
2242
- }
2243
-
2244
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]):focus-visible {
2245
- background: Canvas;
2246
- color: CanvasText;
2247
- }
2248
-
2249
- @media (hover: hover) {
2250
- :is(.flyout, .flyout :where(li))
2251
- > :where(button, a, [role="menuitem"]):hover:not(:where(:disabled, [aria-disabled="true"])) {
2252
- outline: var(--border-width) solid ButtonText;
2253
- outline-offset: -1px;
2254
- background: Canvas;
2255
- color: CanvasText;
2256
- }
2672
+ .menu-item:focus-visible {
2673
+ outline: var(--border-width) solid ButtonText;
2674
+ outline-offset: -1px;
2257
2675
  }
2258
2676
  }
2259
2677
 
@@ -2263,6 +2681,15 @@ dialog.drawer nav {
2263
2681
  * Position, placement, and arrow position are managed by JavaScript
2264
2682
  * via position: fixed, left, top, data-placement, and --arrow-x.
2265
2683
  * Visibility is managed with hidden; CSS owns entry and exit animation.
2684
+ *
2685
+ * Public hooks:
2686
+ * --tooltip-bg
2687
+ * --tooltip-fg
2688
+ * --tooltip-arrow-size
2689
+ *
2690
+ * Internal:
2691
+ * --arrow-x (arrow position, written by floating.js)
2692
+ * --arrow-y (arrow position, written by floating.js)
2266
2693
  */
2267
2694
  .tooltip {
2268
2695
  --tooltip-bg: var(--surface-solid);
@@ -2284,8 +2711,8 @@ dialog.drawer nav {
2284
2711
  opacity: 1;
2285
2712
  scale: 1;
2286
2713
  transition:
2287
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2288
- scale var(--duration-fast, var(--duration)) var(--ease, ease);
2714
+ opacity var(--duration-fast) var(--ease),
2715
+ scale var(--duration-fast) var(--ease);
2289
2716
  }
2290
2717
 
2291
2718
  .tooltip[hidden] {
@@ -2304,9 +2731,9 @@ dialog.drawer nav {
2304
2731
  @supports (transition-behavior: allow-discrete) {
2305
2732
  .tooltip {
2306
2733
  transition:
2307
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2308
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2309
- display var(--duration-fast, var(--duration)) allow-discrete;
2734
+ opacity var(--duration-fast) var(--ease),
2735
+ scale var(--duration-fast) var(--ease),
2736
+ display var(--duration-fast) allow-discrete;
2310
2737
  }
2311
2738
  }
2312
2739
 
@@ -2356,8 +2783,6 @@ dialog.drawer nav {
2356
2783
  .tooltip {
2357
2784
  --tooltip-bg: Canvas;
2358
2785
  --tooltip-fg: CanvasText;
2359
-
2360
- border: var(--border-width) solid ButtonText;
2361
2786
  }
2362
2787
 
2363
2788
  .tooltip::before {
@@ -2373,9 +2798,14 @@ dialog.drawer nav {
2373
2798
  * Keeps the clickable summary affordance aligned and usable without JavaScript
2374
2799
  * or demo styles. Decorative palettes and custom markers belong outside the
2375
2800
  * base component.
2801
+ *
2802
+ * Public hooks:
2803
+ * --accordion-radius
2804
+ * --accordion-marker-color
2376
2805
  */
2377
2806
  .accordion {
2378
2807
  --accordion-radius: var(--radius);
2808
+ --accordion-marker-color: var(--text-muted);
2379
2809
 
2380
2810
  border: var(--border-width) solid var(--border);
2381
2811
  border-radius: var(--accordion-radius);
@@ -2389,14 +2819,18 @@ dialog.drawer nav {
2389
2819
  .accordion summary {
2390
2820
  display: flex;
2391
2821
  align-items: center;
2392
- gap: var(--space-3);
2822
+ gap: var(--space-30);
2393
2823
  cursor: pointer;
2394
2824
  list-style: none;
2395
- padding: var(--space-4);
2825
+ padding: var(--space-40);
2396
2826
  color: var(--text);
2397
2827
  font-weight: var(--font-weight-bold);
2398
2828
  }
2399
2829
 
2830
+ .accordion summary:hover {
2831
+ color: var(--primary);
2832
+ }
2833
+
2400
2834
  .accordion summary::-webkit-details-marker {
2401
2835
  display: none;
2402
2836
  }
@@ -2407,15 +2841,9 @@ dialog.drawer nav {
2407
2841
  margin-inline-start: auto;
2408
2842
  inline-size: 1rem;
2409
2843
  block-size: 1rem;
2410
- background-color: var(--text-muted);
2411
- -webkit-mask-image: var(--icon-chevron-mask);
2412
- mask-image: var(--icon-chevron-mask);
2413
- -webkit-mask-repeat: no-repeat;
2414
- mask-repeat: no-repeat;
2415
- -webkit-mask-size: contain;
2416
- mask-size: contain;
2417
- -webkit-mask-position: center;
2418
- mask-position: center;
2844
+ background-color: var(--accordion-marker-color);
2845
+ -webkit-mask: var(--icon-chevron) center / contain no-repeat;
2846
+ mask: var(--icon-chevron) center / contain no-repeat;
2419
2847
  transition: transform var(--duration);
2420
2848
  }
2421
2849
 
@@ -2424,14 +2852,14 @@ dialog.drawer nav {
2424
2852
  }
2425
2853
 
2426
2854
  .accordion details > summary + * {
2427
- padding-block: 0 var(--space-4);
2428
- padding-inline: var(--space-4);
2855
+ padding-block: 0 var(--space-40);
2856
+ padding-inline: var(--space-40);
2429
2857
  color: var(--text-muted);
2430
2858
  margin-block: 0;
2431
2859
  }
2432
2860
 
2433
2861
  .accordion details[open] > summary + * {
2434
- padding-block-start: var(--space-1);
2862
+ padding-block-start: var(--space-10);
2435
2863
  }
2436
2864
 
2437
2865
  .accordion details > summary + * > :first-child {
@@ -2446,22 +2874,7 @@ dialog.drawer nav {
2446
2874
 
2447
2875
  @media (forced-colors: active) {
2448
2876
  .accordion {
2449
- border-color: ButtonText;
2450
- background: Canvas;
2451
- color: CanvasText;
2452
- }
2453
-
2454
- .accordion > details + details {
2455
- border-block-start-color: ButtonText;
2456
- }
2457
-
2458
- .accordion summary {
2459
- color: CanvasText;
2460
- }
2461
-
2462
- .accordion summary::after {
2463
- background-color: ButtonText;
2464
- forced-color-adjust: none;
2877
+ --accordion-marker-color: CanvasText;
2465
2878
  }
2466
2879
  }
2467
2880
 
@@ -2472,23 +2885,22 @@ dialog.drawer nav {
2472
2885
  *
2473
2886
  * Uses currentColor so it adapts to buttons, alerts, badges, and text. The
2474
2887
  * [aria-busy] overlay composition lives in components/busy.css.
2888
+ *
2889
+ * Public hooks:
2890
+ * --spinner-size
2891
+ * --spinner-width
2892
+ * --spinner-track
2893
+ * --spinner-value
2475
2894
  */
2476
2895
  /* Intent boundary — inherited intents do not tint this component.
2477
2896
  Local intent classes still win because :where() has zero specificity. */
2478
2897
  :where(.spinner) {
2479
- /* @sync intent-boundary */
2480
2898
  --intent: initial;
2481
- --intent-fg: initial;
2482
- --ui-bg: initial;
2483
- --ui-fg: initial;
2484
- --ui-border: initial;
2485
- --ui-hover-bg: initial;
2486
- /* @sync intent-boundary */
2487
2899
  }
2488
2900
 
2489
2901
  .spinner {
2490
- --spinner-size: 1.5rem;
2491
- --spinner-width: 2px;
2902
+ --spinner-size: 1em;
2903
+ --spinner-width: 0.125em;
2492
2904
  --spinner-track: currentColor;
2493
2905
  --spinner-value: transparent;
2494
2906
 
@@ -2514,11 +2926,11 @@ dialog.drawer nav {
2514
2926
  }
2515
2927
 
2516
2928
  .spinner.sm {
2517
- --spinner-size: 1rem;
2929
+ --spinner-size: 0.75em;
2518
2930
  }
2519
2931
 
2520
2932
  .spinner.lg {
2521
- --spinner-size: 3rem;
2933
+ --spinner-size: 2rem;
2522
2934
  --spinner-width: 3px;
2523
2935
  }
2524
2936
 
@@ -2533,6 +2945,13 @@ dialog.drawer nav {
2533
2945
  *
2534
2946
  * Shows approximate loading structure without implying real content hierarchy.
2535
2947
  * Geometry, animation, and color stay theme-aware.
2948
+ *
2949
+ * Public hooks:
2950
+ * --skeleton-size
2951
+ * --skeleton-width
2952
+ * --skeleton-radius
2953
+ * --skeleton-track
2954
+ * --skeleton-highlight
2536
2955
  */
2537
2956
  .skeleton {
2538
2957
  --skeleton-size: 1rem;
@@ -2602,6 +3021,12 @@ dialog.drawer nav {
2602
3021
  * A non-button element with aria-busy="true" and a direct last-child .spinner
2603
3022
  * shows that spinner as a centered overlay over a faded surface. Buttons keep
2604
3023
  * their spinners inline (e.g. .btn[aria-busy] > .spinner stays inline-flex).
3024
+ *
3025
+ * Public hooks:
3026
+ * --busy-overlay-bg
3027
+ *
3028
+ * Internal:
3029
+ * --spinner-size (overlay copy of the shared spinner hook)
2605
3030
  */
2606
3031
  [aria-busy] > .spinner:last-child {
2607
3032
  display: none;
@@ -2626,6 +3051,7 @@ dialog.drawer nav {
2626
3051
  }
2627
3052
 
2628
3053
  [aria-busy="true"]:not(.btn) > .spinner:last-child {
3054
+ --spinner-size: 2rem;
2629
3055
  position: absolute;
2630
3056
  inset: 0;
2631
3057
  z-index: 2;
@@ -2646,26 +3072,21 @@ dialog.drawer nav {
2646
3072
  *
2647
3073
  * Consumes the shared --intent/--primary for the value bar and --bar-height
2648
3074
  * for thickness. Indeterminate state animates a shimmer using --duration-shimmer.
3075
+ *
3076
+ * Public hooks:
3077
+ * --progress-track
3078
+ * --progress-value
2649
3079
  */
2650
3080
  /* Intent boundary — inherited intents do not tint this component.
2651
3081
  Local intent classes still win because :where() has zero specificity. */
2652
3082
  :where(.progress) {
2653
- /* @sync intent-boundary */
2654
3083
  --intent: initial;
2655
- --intent-fg: initial;
2656
- --ui-bg: initial;
2657
- --ui-fg: initial;
2658
- --ui-border: initial;
2659
- --ui-hover-bg: initial;
2660
- /* @sync intent-boundary */
2661
3084
  }
2662
3085
 
2663
3086
  .progress {
2664
3087
  --progress-track: var(--surface-subtle);
2665
3088
  --progress-value: var(--intent, var(--primary));
2666
- }
2667
3089
 
2668
- .progress {
2669
3090
  appearance: none;
2670
3091
  -webkit-appearance: none;
2671
3092
  display: block;
@@ -2744,27 +3165,14 @@ dialog.drawer nav {
2744
3165
  }
2745
3166
 
2746
3167
  @media (forced-colors: active) {
2747
- .progress {
2748
- border: var(--border-width) solid ButtonText;
2749
- background: Canvas;
2750
- }
2751
-
2752
- .progress::-webkit-progress-bar {
2753
- background: Canvas;
2754
- }
2755
-
2756
- .progress::-webkit-progress-value {
3168
+ .progress::-webkit-progress-value,
3169
+ .progress:indeterminate::-webkit-progress-value {
2757
3170
  background: Highlight;
2758
- forced-color-adjust: none;
2759
3171
  }
2760
3172
 
2761
- .progress::-moz-progress-bar {
3173
+ .progress::-moz-progress-bar,
3174
+ .progress:indeterminate::-moz-progress-bar {
2762
3175
  background: Highlight;
2763
- forced-color-adjust: none;
2764
- }
2765
-
2766
- .progress:indeterminate {
2767
- background: Canvas;
2768
3176
  }
2769
3177
  }
2770
3178
 
@@ -2780,23 +3188,20 @@ dialog.drawer nav {
2780
3188
  * the coloring via --intent / --intent-fg. status.js applies these as plain
2781
3189
  * classes on the target; it does not know their names or that they exist.
2782
3190
  *
2783
- * Public API: .status-bar + [data-status], both required — status.js reads
2784
- * the class name from js/selectors.js rather than hardcoding it, but still
2785
- * needs it to scope its document-wide lookup away from unrelated elements
2786
- * that carry their own generic `data-status`.
3191
+ * CSS API: .status-bar
3192
+ * JS target contract: [data-status][role="status"]
3193
+ * Normally combined, but they describe separate concerns.
3194
+ *
3195
+ * Internal:
3196
+ * --status-bg
3197
+ * --status-fg
2787
3198
  */
2788
3199
 
2789
3200
  /* Intent boundary — inherited intents do not tint this component.
2790
3201
  Local intent classes still win because :where() has zero specificity. */
2791
3202
  :where(.status-bar) {
2792
- /* @sync intent-boundary */
2793
3203
  --intent: initial;
2794
3204
  --intent-fg: initial;
2795
- --ui-bg: initial;
2796
- --ui-fg: initial;
2797
- --ui-border: initial;
2798
- --ui-hover-bg: initial;
2799
- /* @sync intent-boundary */
2800
3205
  }
2801
3206
 
2802
3207
  .status-bar {
@@ -2804,22 +3209,22 @@ dialog.drawer nav {
2804
3209
  --status-fg: var(--intent-fg, var(--surface));
2805
3210
 
2806
3211
  position: fixed;
2807
- inset-block-end: var(--space-4);
2808
- inset-inline: var(--space-4);
2809
- z-index: var(--z-status, 60);
3212
+ inset-block-end: var(--space-40);
3213
+ inset-inline: var(--space-40);
3214
+ z-index: var(--z-status);
2810
3215
 
2811
3216
  display: inline-flex;
2812
3217
  align-items: center;
2813
3218
  justify-content: center;
2814
- gap: var(--space-2);
3219
+ gap: var(--space-20);
2815
3220
 
2816
3221
  inline-size: fit-content;
2817
- max-inline-size: min(calc(100vw - var(--space-6)), 32rem);
3222
+ max-inline-size: min(calc(var(--viewport-inline) - var(--space-60)), 32rem);
2818
3223
  margin-inline: auto;
2819
3224
  overflow-wrap: anywhere;
2820
3225
 
2821
- padding-block: var(--space-2);
2822
- padding-inline: var(--space-3);
3226
+ padding-block: var(--space-20);
3227
+ padding-inline: var(--space-30);
2823
3228
 
2824
3229
  border-radius: var(--radius-full);
2825
3230
  background: var(--status-bg);
@@ -2832,25 +3237,18 @@ dialog.drawer nav {
2832
3237
  pointer-events: none;
2833
3238
 
2834
3239
  transition:
2835
- opacity var(--duration-slow) var(--ease, ease),
2836
- scale var(--duration-slow) var(--ease, ease),
2837
- translate var(--duration-slow) var(--ease, ease);
3240
+ opacity var(--duration-slow) var(--ease),
3241
+ scale var(--duration-slow) var(--ease),
3242
+ translate var(--duration-slow) var(--ease);
2838
3243
  }
2839
3244
 
2840
3245
  .status-bar:empty {
2841
3246
  opacity: 0;
2842
3247
  scale: 0.96;
2843
- translate: 0 var(--space-2);
3248
+ translate: 0 var(--space-20);
2844
3249
  pointer-events: none;
2845
3250
  }
2846
3251
 
2847
- @media (forced-colors: active) {
2848
- .status-bar {
2849
- border: var(--border-width) solid ButtonText;
2850
- box-shadow: none;
2851
- }
2852
- }
2853
-
2854
3252
 
2855
3253
  /* Display */
2856
3254
  /*
@@ -2859,18 +3257,26 @@ dialog.drawer nav {
2859
3257
  * The visual box stays the same across semantic elements. Grouping and status
2860
3258
  * dots (an empty .badge child) compose around the avatar without changing its
2861
3259
  * meaning.
3260
+ *
3261
+ * Public hooks:
3262
+ * --avatar-size
3263
+ * --avatar-radius
3264
+ * --avatar-font-size
3265
+ * --avatar-stack-size
3266
+ * --avatar-stack-font-size
3267
+ * --avatar-stack-overlap
3268
+ * --avatar-stack-ring
3269
+ *
3270
+ * Internal:
3271
+ * --avatar-bg
3272
+ * --avatar-fg
3273
+ * --avatar-status-translate-x
2862
3274
  */
2863
3275
  /* Intent boundary — inherited intents do not tint this component.
2864
3276
  Local intent classes still win because :where() has zero specificity. */
2865
3277
  :where(.avatar) {
2866
- /* @sync intent-boundary */
2867
3278
  --intent: initial;
2868
3279
  --intent-fg: initial;
2869
- --ui-bg: initial;
2870
- --ui-fg: initial;
2871
- --ui-border: initial;
2872
- --ui-hover-bg: initial;
2873
- /* @sync intent-boundary */
2874
3280
  }
2875
3281
 
2876
3282
  .avatar {
@@ -2962,10 +3368,8 @@ button.avatar {
2962
3368
  transition: opacity var(--duration);
2963
3369
  }
2964
3370
 
2965
- @media (hover: hover) {
2966
- :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):hover::after {
2967
- opacity: 1;
2968
- }
3371
+ :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):hover::after {
3372
+ opacity: 1;
2969
3373
  }
2970
3374
 
2971
3375
  :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):active {
@@ -3058,6 +3462,8 @@ a.avatar[aria-disabled="true"] {
3058
3462
  *
3059
3463
  * Use .key outside prose. Plain kbd inside .prose gets the same visual
3060
3464
  * treatment from prose.css so authored content stays convenient.
3465
+ *
3466
+ * Visual twin of .prose kbd (prose.css) — keep the two recipes in sync.
3061
3467
  */
3062
3468
  .key {
3063
3469
  display: inline-flex;
@@ -3089,14 +3495,7 @@ a.avatar[aria-disabled="true"] {
3089
3495
  /* Intent boundary — inherited intents do not tint this component.
3090
3496
  Local intent classes still win because :where() has zero specificity. */
3091
3497
  :where(.overline) {
3092
- /* @sync intent-boundary */
3093
3498
  --intent: initial;
3094
- --intent-fg: initial;
3095
- --ui-bg: initial;
3096
- --ui-fg: initial;
3097
- --ui-border: initial;
3098
- --ui-hover-bg: initial;
3099
- /* @sync intent-boundary */
3100
3499
  }
3101
3500
 
3102
3501
  .overline {
@@ -3137,6 +3536,9 @@ a.avatar[aria-disabled="true"] {
3137
3536
  * .navbar is a horizontal bar; .navbar-brand is the brand link; .navbar-nav is
3138
3537
  * the inline link list using .nav-link items. .nav-list is the vertical nav
3139
3538
  * list shared with .drawer, fed by the shared --gap token.
3539
+ *
3540
+ * Internal:
3541
+ * --gap (local value for the shared gap token)
3140
3542
  */
3141
3543
  .navbar {
3142
3544
  display: flex;
@@ -3144,8 +3546,8 @@ a.avatar[aria-disabled="true"] {
3144
3546
  align-items: center;
3145
3547
  justify-content: space-between;
3146
3548
  gap: var(--gap);
3147
- padding-block: var(--space-3);
3148
- padding-inline: var(--space-4);
3549
+ padding-block: var(--space-30);
3550
+ padding-inline: var(--space-40);
3149
3551
  border: var(--border-width) solid var(--border);
3150
3552
  border-radius: var(--radius-lg);
3151
3553
  background: var(--surface-raised);
@@ -3155,7 +3557,7 @@ a.avatar[aria-disabled="true"] {
3155
3557
  .navbar-brand {
3156
3558
  display: inline-flex;
3157
3559
  align-items: center;
3158
- gap: var(--space-2);
3560
+ gap: var(--space-20);
3159
3561
  color: var(--text);
3160
3562
  font-weight: var(--font-weight-bold);
3161
3563
  text-decoration: none;
@@ -3169,7 +3571,7 @@ a.avatar[aria-disabled="true"] {
3169
3571
  display: flex;
3170
3572
  flex-wrap: wrap;
3171
3573
  align-items: center;
3172
- gap: var(--space-1);
3574
+ gap: var(--space-10);
3173
3575
  margin: 0;
3174
3576
  padding: 0;
3175
3577
  list-style: none;
@@ -3179,7 +3581,7 @@ a.avatar[aria-disabled="true"] {
3179
3581
  display: inline-flex;
3180
3582
  align-items: center;
3181
3583
  min-block-size: var(--control-size-sm);
3182
- padding-inline: var(--space-2);
3584
+ padding-inline: var(--space-20);
3183
3585
  border-radius: var(--radius);
3184
3586
  color: var(--text-muted);
3185
3587
  font-weight: var(--font-weight-medium);
@@ -3191,21 +3593,19 @@ a.avatar[aria-disabled="true"] {
3191
3593
  color: var(--text);
3192
3594
  }
3193
3595
 
3194
- @media (hover: hover) {
3195
- .nav-link:hover {
3196
- background: var(--surface-subtle);
3197
- color: var(--text);
3198
- }
3596
+ .nav-link:hover {
3597
+ background: var(--surface-subtle);
3598
+ color: var(--text);
3199
3599
  }
3200
3600
 
3201
3601
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links. */
3202
3602
  .nav-list {
3203
3603
  display: grid;
3604
+ --gap: var(--space-20);
3204
3605
  gap: var(--gap);
3205
3606
  margin: 0;
3206
3607
  padding: 0;
3207
3608
  list-style: none;
3208
- --gap: var(--space-2);
3209
3609
  }
3210
3610
 
3211
3611
  .nav-list > li {
@@ -3220,13 +3620,11 @@ a.avatar[aria-disabled="true"] {
3220
3620
  forced-color-adjust: none;
3221
3621
  }
3222
3622
 
3223
- @media (hover: hover) {
3224
- .nav-link:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3225
- outline: var(--border-width) solid ButtonText;
3226
- outline-offset: 2px;
3227
- background: Canvas;
3228
- color: CanvasText;
3229
- }
3623
+ .nav-link:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3624
+ outline: var(--border-width) solid ButtonText;
3625
+ outline-offset: 2px;
3626
+ background: Canvas;
3627
+ color: CanvasText;
3230
3628
  }
3231
3629
  }
3232
3630
 
@@ -3240,7 +3638,7 @@ a.avatar[aria-disabled="true"] {
3240
3638
  display: flex;
3241
3639
  flex-wrap: wrap;
3242
3640
  align-items: center;
3243
- gap: var(--space-1);
3641
+ gap: var(--space-10);
3244
3642
  margin: 0;
3245
3643
  padding: 0;
3246
3644
  color: var(--text-subtle);
@@ -3251,7 +3649,7 @@ a.avatar[aria-disabled="true"] {
3251
3649
  .breadcrumb li {
3252
3650
  display: inline-flex;
3253
3651
  align-items: center;
3254
- gap: var(--space-1);
3652
+ gap: var(--space-10);
3255
3653
  }
3256
3654
 
3257
3655
  .breadcrumb li + li::before {
@@ -3264,25 +3662,17 @@ a.avatar[aria-disabled="true"] {
3264
3662
  text-decoration: none;
3265
3663
  }
3266
3664
 
3267
- @media (hover: hover) {
3268
- .breadcrumb
3269
- li:not(:where([aria-current]:not([aria-current="false"])))
3270
- > a:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3271
- color: var(--text);
3272
- text-decoration: underline;
3273
- }
3665
+ .breadcrumb a:not([aria-current]):hover,
3666
+ .breadcrumb a[aria-current="false"]:hover {
3667
+ color: var(--text);
3668
+ text-decoration: underline;
3274
3669
  }
3275
3670
 
3276
3671
  .breadcrumb a:focus-visible {
3277
3672
  border-radius: var(--radius-sm);
3278
3673
  }
3279
3674
 
3280
- .breadcrumb a:where([aria-current]:not([aria-current="false"])) {
3281
- color: var(--text);
3282
- font-weight: var(--font-weight-medium);
3283
- }
3284
-
3285
- .breadcrumb li:where([aria-current]:not([aria-current="false"])) > :where(a, span) {
3675
+ .breadcrumb :where(a, span):where([aria-current]:not([aria-current="false"])) {
3286
3676
  color: var(--text);
3287
3677
  font-weight: var(--font-weight-medium);
3288
3678
  }
@@ -3301,7 +3691,7 @@ a.avatar[aria-disabled="true"] {
3301
3691
  display: flex;
3302
3692
  flex-wrap: wrap;
3303
3693
  align-items: center;
3304
- gap: var(--space-1);
3694
+ gap: var(--space-10);
3305
3695
  margin: 0;
3306
3696
  padding: 0;
3307
3697
  list-style: none;
@@ -3324,24 +3714,20 @@ a.avatar[aria-disabled="true"] {
3324
3714
  * JavaScript owns roving tabindex, aria-selected, panel visibility,
3325
3715
  * and arrow-key navigation. CSS owns the tab strip appearance and
3326
3716
  * active indicator.
3717
+ *
3718
+ * Public hooks:
3719
+ * --tab-gap
3327
3720
  */
3328
3721
  /* Intent boundary — inherited intents do not tint this component.
3329
3722
  Local intent classes still win because :where() has zero specificity. */
3330
3723
  :where(.tab) {
3331
- /* @sync intent-boundary */
3332
3724
  --intent: initial;
3333
- --intent-fg: initial;
3334
- --ui-bg: initial;
3335
- --ui-fg: initial;
3336
- --ui-border: initial;
3337
- --ui-hover-bg: initial;
3338
- /* @sync intent-boundary */
3339
3725
  }
3340
3726
 
3341
3727
  .tabs {
3342
3728
  display: flex;
3343
3729
  flex-wrap: wrap;
3344
- gap: var(--space-1);
3730
+ gap: var(--space-10);
3345
3731
  margin: 0;
3346
3732
  padding: 0;
3347
3733
  border-block-end: var(--border-width) solid var(--border);
@@ -3359,8 +3745,9 @@ a.avatar[aria-disabled="true"] {
3359
3745
  .tab {
3360
3746
  display: inline-flex;
3361
3747
  align-items: center;
3748
+ gap: var(--tab-gap, 0.375em);
3362
3749
  min-block-size: var(--control-size);
3363
- padding-inline: var(--space-3);
3750
+ padding-inline: var(--space-30);
3364
3751
  border: 0;
3365
3752
  border-block-end: 2px solid transparent;
3366
3753
  border-radius: 0;
@@ -3376,7 +3763,7 @@ a.avatar[aria-disabled="true"] {
3376
3763
  justify-content: flex-start;
3377
3764
  border-block-end: 0;
3378
3765
  border-inline-end: 2px solid transparent;
3379
- padding-inline-end: var(--space-4);
3766
+ padding-inline-end: var(--space-40);
3380
3767
  }
3381
3768
 
3382
3769
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -3384,64 +3771,39 @@ a.avatar[aria-disabled="true"] {
3384
3771
  opacity: var(--disabled-opacity);
3385
3772
  }
3386
3773
 
3387
- @media (hover: hover) {
3388
- .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3389
- color: var(--text);
3390
- }
3391
- }
3392
-
3774
+ .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3775
+ color: var(--text);
3776
+ }
3777
+
3393
3778
  .tab:focus-visible {
3394
3779
  outline: var(--focus-outline);
3780
+ outline-color: var(--focus);
3395
3781
  outline-offset: -2px;
3396
3782
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
3397
3783
  }
3398
3784
 
3399
- .tab[aria-selected="true"],
3400
- .tab[aria-current]:not([aria-current="false"]),
3401
- .tab[aria-pressed="true"] {
3402
- border-block-end-color: var(--intent, var(--primary));
3403
- color: var(--intent, var(--primary));
3785
+ .tab[aria-selected="true"] {
3786
+ border-block-end-color: var(--intent, var(--state-selected));
3787
+ color: var(--intent, var(--state-selected));
3404
3788
  }
3405
3789
 
3406
- .tabs[aria-orientation="vertical"] .tab[aria-selected="true"],
3407
- .tabs[aria-orientation="vertical"] .tab[aria-current]:not([aria-current="false"]),
3408
- .tabs[aria-orientation="vertical"] .tab[aria-pressed="true"] {
3790
+ .tabs[aria-orientation="vertical"] .tab[aria-selected="true"] {
3409
3791
  border-block-end-color: transparent;
3410
- border-inline-end-color: var(--intent, var(--primary));
3792
+ border-inline-end-color: var(--intent, var(--state-selected));
3411
3793
  }
3412
3794
 
3413
3795
  @media (forced-colors: active) {
3414
- .tabs {
3415
- border-block-end-color: ButtonText;
3416
- }
3417
-
3418
- .tabs[aria-orientation="vertical"] {
3419
- border-block-end-color: transparent;
3420
- border-inline-end-color: ButtonText;
3421
- }
3422
-
3423
- .tab {
3424
- color: ButtonText;
3425
- }
3426
-
3427
- @media (hover: hover) {
3428
- .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3429
- color: ButtonText;
3430
- text-decoration: underline;
3431
- text-underline-offset: 0.18em;
3432
- }
3796
+ .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3797
+ text-decoration: underline;
3798
+ text-underline-offset: 0.18em;
3433
3799
  }
3434
3800
 
3435
- .tab[aria-selected="true"],
3436
- .tab[aria-current]:not([aria-current="false"]),
3437
- .tab[aria-pressed="true"] {
3801
+ .tab[aria-selected="true"] {
3438
3802
  border-block-end-color: Highlight;
3439
3803
  color: Highlight;
3440
3804
  }
3441
3805
 
3442
- .tabs[aria-orientation="vertical"] .tab[aria-selected="true"],
3443
- .tabs[aria-orientation="vertical"] .tab[aria-current]:not([aria-current="false"]),
3444
- .tabs[aria-orientation="vertical"] .tab[aria-pressed="true"] {
3806
+ .tabs[aria-orientation="vertical"] .tab[aria-selected="true"] {
3445
3807
  border-block-end-color: transparent;
3446
3808
  border-inline-end-color: Highlight;
3447
3809
  }
@@ -3460,8 +3822,8 @@ a.avatar[aria-disabled="true"] {
3460
3822
  * Progressive enhancement: on browsers that support :target-current,
3461
3823
  * this file enables the native target group and marks the active link.
3462
3824
  *
3463
- * Falls back gracefully: links still work when IntersectionObserver or
3464
- * :target-current is unavailable.
3825
+ * Falls back gracefully: links still work when :target-current is unavailable
3826
+ * and when the JavaScript enhancer is not loaded at all.
3465
3827
  */
3466
3828
 
3467
3829
  @supports selector(.scrollspy a:target-current) {
@@ -3482,6 +3844,10 @@ a.avatar[aria-disabled="true"] {
3482
3844
  *
3483
3845
  * .table-wrap provides the scroll container and outer chrome; .table owns row
3484
3846
  * and cell rules. Tokens are sourced from the shared scale.
3847
+ *
3848
+ * Public hooks:
3849
+ * --table-min
3850
+ * --table-cell-pad
3485
3851
  */
3486
3852
  .table-wrap {
3487
3853
  inline-size: 100%;
@@ -3495,7 +3861,7 @@ a.avatar[aria-disabled="true"] {
3495
3861
 
3496
3862
  .table {
3497
3863
  --table-min: 100%;
3498
- --table-cell-pad: var(--space-3);
3864
+ --table-cell-pad: var(--space-30);
3499
3865
 
3500
3866
  inline-size: max(var(--table-min), 100%);
3501
3867
  border-collapse: collapse;
@@ -3531,10 +3897,8 @@ a.avatar[aria-disabled="true"] {
3531
3897
  background: var(--surface-subtle);
3532
3898
  }
3533
3899
 
3534
- @media (hover: hover) {
3535
- .table tbody tr:hover {
3536
- background-color: var(--hover-overlay);
3537
- }
3900
+ .table tbody tr:hover {
3901
+ background-color: var(--hover-overlay);
3538
3902
  }
3539
3903
 
3540
3904
  .table > tr:last-child > *,
@@ -3548,18 +3912,6 @@ a.avatar[aria-disabled="true"] {
3548
3912
  * Maps the three standard meter zones (optimum / suboptimum / even-less-good)
3549
3913
  * to the shared success / warning / danger intent colors.
3550
3914
  */
3551
- /* Intent boundary — inherited intents do not tint this component.
3552
- Local intent classes still win because :where() has zero specificity. */
3553
- :where(.meter) {
3554
- /* @sync intent-boundary */
3555
- --intent: initial;
3556
- --intent-fg: initial;
3557
- --ui-bg: initial;
3558
- --ui-fg: initial;
3559
- --ui-border: initial;
3560
- --ui-hover-bg: initial;
3561
- /* @sync intent-boundary */
3562
- }
3563
3915
 
3564
3916
  .meter {
3565
3917
  appearance: none;
@@ -3605,29 +3957,18 @@ a.avatar[aria-disabled="true"] {
3605
3957
  }
3606
3958
 
3607
3959
  @media (forced-colors: active) {
3608
- .meter {
3609
- border: var(--border-width) solid ButtonText;
3610
- background: Canvas;
3611
- }
3612
-
3613
- .meter::-webkit-meter-bar {
3614
- border: 0;
3615
- background: Canvas;
3616
- }
3617
-
3618
3960
  .meter::-webkit-meter-optimum-value,
3619
3961
  .meter::-webkit-meter-suboptimum-value,
3620
3962
  .meter::-webkit-meter-even-less-good-value,
3621
3963
  .meter::-moz-meter-bar {
3622
3964
  background: Highlight;
3623
- forced-color-adjust: none;
3624
3965
  }
3625
3966
  }
3626
3967
 
3627
- /* Forms — import order matters: switch.css must follow choice.css so its
3628
- :not([role="switch"]) exclusion in choice.css still applies.
3629
- validation.css is part of the default forms bundle, but custom builds may
3630
- omit it when validation styling is app-owned. */
3968
+ /* Forms — import order matters: switch.css follows choice.css so its rules
3969
+ land after the shared choice recipe. validation.css is part of the default
3970
+ forms bundle, but custom builds may omit it when validation styling is
3971
+ app-owned. */
3631
3972
  /*
3632
3973
  * Form layout and supporting text.
3633
3974
  *
@@ -3636,23 +3977,26 @@ a.avatar[aria-disabled="true"] {
3636
3977
  *
3637
3978
  * Public API: .field, .field-label, .field-help, .field-error, .field-group,
3638
3979
  * .required-mark.
3980
+ *
3981
+ * Public hooks:
3982
+ * --fieldset-legend-padding-inline
3639
3983
  */
3640
3984
  .field {
3641
3985
  display: grid;
3642
- gap: var(--space-1);
3986
+ gap: var(--space-10);
3643
3987
  color: var(--text);
3644
3988
  }
3645
3989
 
3646
3990
  .field-group {
3647
3991
  min-inline-size: 0;
3648
3992
  margin: 0;
3649
- padding: var(--space-3);
3993
+ padding: var(--space-30);
3650
3994
  border: var(--border-width) solid var(--border);
3651
3995
  border-radius: var(--radius);
3652
3996
  }
3653
3997
 
3654
3998
  .field-group + .field-group {
3655
- margin-block-start: var(--space-4);
3999
+ margin-block-start: var(--space-40);
3656
4000
  }
3657
4001
 
3658
4002
  .field-label,
@@ -3663,7 +4007,7 @@ a.avatar[aria-disabled="true"] {
3663
4007
 
3664
4008
  .field-group > legend {
3665
4009
  padding-block: 0;
3666
- padding-inline: var(--fieldset-legend-padding-inline, var(--space-1));
4010
+ padding-inline: var(--fieldset-legend-padding-inline, var(--space-10));
3667
4011
  font-size: var(--font-size-xs);
3668
4012
  }
3669
4013
 
@@ -3702,6 +4046,12 @@ a.avatar[aria-disabled="true"] {
3702
4046
  * --control-border-block-end-color/-width are unset extension points, mirroring
3703
4047
  * --btn-border-block-end-* (button.css), for themes that give text controls an
3704
4048
  * underline-style lower edge instead of a uniform border.
4049
+ *
4050
+ * Public hooks:
4051
+ * --control-border
4052
+ * --control-bg
4053
+ * --control-fg
4054
+ * --control-cursor
3705
4055
  */
3706
4056
  .input,
3707
4057
  .textarea,
@@ -3709,14 +4059,12 @@ a.avatar[aria-disabled="true"] {
3709
4059
  --control-border: var(--border);
3710
4060
  --control-bg: var(--surface);
3711
4061
  --control-fg: var(--text);
3712
- --control-ring: none;
3713
4062
  --control-cursor: auto;
3714
- --control-opacity: 1;
3715
4063
 
3716
4064
  appearance: none;
3717
4065
  inline-size: 100%;
3718
4066
  min-block-size: var(--control-size);
3719
- padding-inline: var(--space-3);
4067
+ padding-inline: var(--space-30);
3720
4068
  border: var(--border-width) solid var(--form-invalid-border, var(--control-border));
3721
4069
  border-block-end-color: var(
3722
4070
  --form-invalid-border,
@@ -3727,9 +4075,7 @@ a.avatar[aria-disabled="true"] {
3727
4075
  background: var(--control-bg);
3728
4076
  color: var(--control-fg);
3729
4077
  font-size: var(--control-font-size);
3730
- box-shadow: var(--control-ring);
3731
4078
  cursor: var(--control-cursor);
3732
- opacity: var(--control-opacity);
3733
4079
  outline: none;
3734
4080
  transition:
3735
4081
  border-color var(--duration),
@@ -3740,27 +4086,24 @@ a.avatar[aria-disabled="true"] {
3740
4086
 
3741
4087
  .textarea {
3742
4088
  min-block-size: 6rem;
3743
- padding-block: var(--space-2);
4089
+ padding-block: var(--space-20);
3744
4090
  resize: vertical;
3745
4091
  }
3746
4092
 
3747
- @media (hover: hover) {
3748
- .input:hover:not(:disabled),
3749
- .textarea:hover:not(:disabled) {
3750
- --control-border: var(--text-subtle);
3751
- }
4093
+ .input:hover:not(:disabled),
4094
+ .textarea:hover:not(:disabled) {
4095
+ --control-border: var(--text-subtle);
4096
+ }
3752
4097
 
3753
- .select:hover:not(:disabled) {
3754
- --control-bg: var(--surface-subtle);
3755
- }
4098
+ .select:hover:not(:disabled) {
4099
+ --control-bg: var(--surface-subtle);
3756
4100
  }
3757
4101
 
3758
4102
  .input:focus-visible,
3759
4103
  .textarea:focus-visible,
3760
4104
  .select:focus-visible {
3761
4105
  --control-border: var(--focus);
3762
- outline: var(--focus-outline);
3763
- outline-offset: var(--focus-outline-offset);
4106
+ outline: 2px solid transparent;
3764
4107
  box-shadow: var(--focus-ring-shadow);
3765
4108
  }
3766
4109
 
@@ -3774,10 +4117,9 @@ a.avatar[aria-disabled="true"] {
3774
4117
  .textarea:disabled,
3775
4118
  .select:disabled {
3776
4119
  --control-bg: var(--surface-subtle);
3777
- --control-fg: var(--text-subtle);
4120
+ --control-fg: var(--state-disabled);
3778
4121
  --control-border: var(--border);
3779
4122
  --control-cursor: not-allowed;
3780
- --control-opacity: 1;
3781
4123
  }
3782
4124
 
3783
4125
  .input::placeholder,
@@ -3785,49 +4127,59 @@ a.avatar[aria-disabled="true"] {
3785
4127
  color: var(--text-muted);
3786
4128
  }
3787
4129
 
3788
- @media (forced-colors: active) {
3789
- .input:focus-visible,
3790
- .textarea:focus-visible,
3791
- .select:focus-visible {
3792
- box-shadow: none;
3793
- }
3794
- }
3795
-
3796
4130
  /*
3797
- * Input Icon — leading decorative icon inside a relative wrapper.
4131
+ * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
4132
+ *
4133
+ * Icon-library agnostic: detects position by DOM order. First child that is
4134
+ * not .input = leading icon; icon after .input = trailing. The icon keeps a
4135
+ * fixed size; density contexts shrink the control geometry around it, not
4136
+ * the pictogram.
3798
4137
  *
3799
- * Icon-library agnostic: selects the wrapper's first child rather than a
3800
- * specific tag, so it works with an <i> webfont icon, an inline <svg>, or a
3801
- * <span>. Sizes with --variant-icon-size (variants.css) so .sm/.lg pick up
3802
- * the same icon scale used elsewhere (alert.css); the input's start padding
3803
- * grows to match so text never runs under the icon.
4138
+ * Public hooks:
4139
+ * --input-icon-size
3804
4140
  */
3805
4141
  .input-icon {
3806
- --input-icon-size: var(--variant-icon-size, 1.25rem);
4142
+ --input-icon-size: 1.25rem;
3807
4143
 
3808
4144
  display: block;
3809
4145
  position: relative;
3810
4146
  }
3811
4147
 
3812
- .input-icon > :first-child {
4148
+ /* Icon — shared by leading and trailing */
4149
+ .input-icon > :where(:not(.input)) {
3813
4150
  position: absolute;
3814
4151
  inset-block-start: 50%;
3815
- inset-inline-start: var(--space-3);
3816
4152
  transform: translateY(-50%);
3817
4153
  font-size: var(--input-icon-size);
3818
4154
  color: var(--text-muted);
3819
4155
  pointer-events: none;
3820
4156
  }
3821
4157
 
4158
+ /* Leading: icon is the first child */
4159
+ .input-icon > :first-child:where(:not(.input)) {
4160
+ inset-inline-start: var(--space-30);
4161
+ }
4162
+
4163
+ /* Trailing: input is the first child, icon follows */
4164
+ .input-icon > .input:first-child ~ :where(:not(.input)) {
4165
+ inset-inline-end: var(--space-30);
4166
+ }
4167
+
3822
4168
  /* font-size only sizes text-metric icons (webfont <i>, <span>). An inline
3823
4169
  <svg> with just a viewBox ignores it, so give it explicit dimensions. */
3824
- .input-icon > svg:first-child {
4170
+ .input-icon > svg:where(:not(.input)) {
3825
4171
  inline-size: var(--input-icon-size);
3826
4172
  block-size: var(--input-icon-size);
3827
4173
  }
3828
4174
 
3829
- .input-icon > .input {
3830
- padding-inline-start: calc(var(--space-3) * 2 + var(--input-icon-size));
4175
+ /* Leading: input gets start padding to clear the icon */
4176
+ .input-icon > :first-child:where(:not(.input)) ~ .input {
4177
+ padding-inline-start: calc(var(--space-30) * 2 + var(--input-icon-size));
4178
+ }
4179
+
4180
+ /* Trailing: input is first child and has a sibling → end padding */
4181
+ .input-icon > .input:first-child:where(:not(:last-child)) {
4182
+ padding-inline-end: calc(var(--space-30) * 2 + var(--input-icon-size));
3831
4183
  }
3832
4184
 
3833
4185
  /*
@@ -3840,23 +4192,23 @@ a.avatar[aria-disabled="true"] {
3840
4192
  *
3841
4193
  * Switches (input[role="switch"]) are excluded so switch.css owns their
3842
4194
  * geometry and states. switch.css is imported after this file.
4195
+ *
4196
+ * Public hooks:
4197
+ * --choice-control-offset
4198
+ * --choice-border
4199
+ * --choice-bg
4200
+ * --choice-mark
3843
4201
  */
3844
4202
  /* Intent boundary — inherited intents do not tint this component.
3845
4203
  Local intent classes still win because :where() has zero specificity. */
3846
4204
  :where(.choice, .check, .radio) {
3847
- /* @sync intent-boundary */
3848
4205
  --intent: initial;
3849
4206
  --intent-fg: initial;
3850
- --ui-bg: initial;
3851
- --ui-fg: initial;
3852
- --ui-border: initial;
3853
- --ui-hover-bg: initial;
3854
- /* @sync intent-boundary */
3855
4207
  }
3856
4208
 
3857
- /* A classed .choice is the component surface. Let its nested input consume a
4209
+ /* A classed .choice is the component surface. Let its direct input consume a
3858
4210
  local intent without reopening inherited page-level intents. */
3859
- :where(.choice) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
4211
+ :where(.choice) > :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
3860
4212
  --intent: inherit;
3861
4213
  --intent-fg: inherit;
3862
4214
  }
@@ -3866,7 +4218,7 @@ a.avatar[aria-disabled="true"] {
3866
4218
 
3867
4219
  display: inline-flex;
3868
4220
  align-items: flex-start;
3869
- gap: var(--space-2);
4221
+ gap: var(--space-20);
3870
4222
  color: var(--text);
3871
4223
  font-size: var(--control-font-size);
3872
4224
  cursor: pointer;
@@ -3889,8 +4241,11 @@ a.avatar[aria-disabled="true"] {
3889
4241
  display: block;
3890
4242
  }
3891
4243
 
3892
- .choice:has(:disabled) {
3893
- color: var(--text-subtle);
4244
+ /* Progressive enhancement: dim the whole row when one of its controls is
4245
+ disabled. Without :has() the native disabled control keeps its own state
4246
+ styling — the row-level dim is lost but the field stays clearly disabled. */
4247
+ .choice:has(> :disabled) {
4248
+ color: var(--state-disabled);
3894
4249
  cursor: not-allowed;
3895
4250
  }
3896
4251
 
@@ -3907,8 +4262,7 @@ a.avatar[aria-disabled="true"] {
3907
4262
  place-content: center;
3908
4263
  inline-size: 1.125em;
3909
4264
  block-size: 1.125em;
3910
- margin-inline: 0;
3911
- margin-block-end: 0;
4265
+ margin: 0;
3912
4266
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-border));
3913
4267
  background: var(--choice-bg);
3914
4268
  color: var(--choice-mark);
@@ -3949,7 +4303,7 @@ a.avatar[aria-disabled="true"] {
3949
4303
  background: currentColor;
3950
4304
  }
3951
4305
 
3952
- .check:checked,
4306
+ .check:is(:checked, :indeterminate),
3953
4307
  .radio:checked {
3954
4308
  --choice-border: var(--intent, var(--primary));
3955
4309
  --choice-bg: var(--intent, var(--primary));
@@ -3960,12 +4314,6 @@ a.avatar[aria-disabled="true"] {
3960
4314
  transform: translateY(-0.06em) rotate(-45deg) scale(1);
3961
4315
  }
3962
4316
 
3963
- .check:indeterminate {
3964
- --choice-border: var(--intent, var(--primary));
3965
- --choice-bg: var(--intent, var(--primary));
3966
- --choice-mark: var(--intent-fg, var(--primary-fg));
3967
- }
3968
-
3969
4317
  .check:indeterminate::before {
3970
4318
  border-inline-start: 0;
3971
4319
  inline-size: 0.6em;
@@ -3978,18 +4326,15 @@ a.avatar[aria-disabled="true"] {
3978
4326
  transform: scale(1);
3979
4327
  }
3980
4328
 
3981
- @media (hover: hover) {
3982
- .check:hover:not(:checked, :indeterminate, :disabled),
3983
- .radio:hover:not(:checked, :indeterminate, :disabled) {
3984
- --choice-border: var(--text-subtle);
3985
- }
4329
+ .check:hover:not(:checked, :indeterminate, :disabled),
4330
+ .radio:hover:not(:checked, :indeterminate, :disabled) {
4331
+ --choice-border: var(--text-subtle);
3986
4332
  }
3987
4333
 
3988
4334
  .check:focus-visible,
3989
4335
  .radio:focus-visible {
3990
4336
  --choice-border: var(--focus);
3991
- outline: var(--focus-outline);
3992
- outline-offset: var(--focus-outline-offset);
4337
+ outline: 2px solid transparent;
3993
4338
  box-shadow: var(--focus-ring-shadow);
3994
4339
  }
3995
4340
 
@@ -3997,7 +4342,7 @@ a.avatar[aria-disabled="true"] {
3997
4342
  .radio:disabled {
3998
4343
  --choice-bg: var(--surface-subtle);
3999
4344
  --choice-border: var(--border);
4000
- --choice-mark: var(--text-subtle);
4345
+ --choice-mark: var(--state-disabled);
4001
4346
  cursor: not-allowed;
4002
4347
  opacity: 1;
4003
4348
  }
@@ -4030,23 +4375,30 @@ a.avatar[aria-disabled="true"] {
4030
4375
  /*
4031
4376
  * Choice cards — card-style radio and checkbox labels.
4032
4377
  *
4033
- * Each .choice-card is a <label> with a hidden native input. Both radio and
4034
- * checkbox show a checkmark badge on selection: round for radio, rounded
4035
- * square for checkbox. Pair with grid or flex utilities for group layout.
4378
+ * Each .choice-card is a <label> wrapping a native radio or checkbox. When
4379
+ * :has() is supported the input is visually hidden and the card mirrors its
4380
+ * state: checked border and tint, checkmark badge (round for radio, rounded
4381
+ * square for checkbox), focus ring, disabled dimming. Without :has() the
4382
+ * native input stays visible so selection state never disappears.
4383
+ *
4384
+ * The badge overhangs the top-right corner by 0.375em; ancestors with
4385
+ * overflow: hidden will clip it. Pair with grid or flex utilities for group
4386
+ * layout.
4387
+ *
4388
+ * Public hooks:
4389
+ * --choice-card-pad
4390
+ * --choice-card-radius
4391
+ * --choice-card-border
4392
+ * --choice-card-bg
4393
+ * --choice-card-check-size
4036
4394
  */
4037
4395
  :where(.choice-card) {
4038
- /* @sync intent-boundary */
4039
4396
  --intent: initial;
4040
4397
  --intent-fg: initial;
4041
- --ui-bg: initial;
4042
- --ui-fg: initial;
4043
- --ui-border: initial;
4044
- --ui-hover-bg: initial;
4045
- /* @sync intent-boundary */
4046
4398
  }
4047
4399
 
4048
4400
  .choice-card {
4049
- --choice-card-pad: var(--variant-space, var(--space-4));
4401
+ --choice-card-pad: var(--density-space, var(--space-40));
4050
4402
  --choice-card-radius: var(--radius);
4051
4403
  --choice-card-border: var(--border);
4052
4404
  --choice-card-bg: var(--surface);
@@ -4055,7 +4407,7 @@ a.avatar[aria-disabled="true"] {
4055
4407
  position: relative;
4056
4408
  display: block;
4057
4409
  padding: var(--choice-card-pad);
4058
- border: var(--border-width) solid var(--choice-card-border);
4410
+ border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
4059
4411
  border-radius: var(--choice-card-radius);
4060
4412
  background: var(--choice-card-bg);
4061
4413
  color: var(--text);
@@ -4067,23 +4419,26 @@ a.avatar[aria-disabled="true"] {
4067
4419
  }
4068
4420
 
4069
4421
  /* The native input is visually hidden but stays focusable and participates in
4070
- form submission and keyboard navigation (sr-only pattern). */
4071
- .choice-card > input {
4072
- position: absolute;
4073
- inline-size: 1px;
4074
- block-size: 1px;
4075
- padding: 0;
4076
- margin: -1px;
4077
- overflow: hidden;
4078
- clip: rect(0, 0, 0, 0);
4079
- white-space: nowrap;
4080
- border: 0;
4422
+ form submission and keyboard navigation (sr-only pattern). Guarded: without
4423
+ :has() the card cannot mirror the input's state, so the visible native
4424
+ control is the fallback affordance. */
4425
+ @supports selector(:has(*)) {
4426
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
4427
+ position: absolute;
4428
+ inline-size: 1px;
4429
+ block-size: 1px;
4430
+ padding: 0;
4431
+ margin: -1px;
4432
+ overflow: hidden;
4433
+ clip: rect(0, 0, 0, 0);
4434
+ white-space: nowrap;
4435
+ border: 0;
4436
+ }
4081
4437
  }
4082
4438
 
4083
4439
  /* Focus ring on the label follows the hidden input's focus. */
4084
4440
  .choice-card:has(:focus-visible) {
4085
- outline: var(--focus-outline);
4086
- outline-offset: var(--focus-outline-offset);
4441
+ outline: 2px solid transparent;
4087
4442
  box-shadow: var(--focus-ring-shadow);
4088
4443
  }
4089
4444
 
@@ -4092,10 +4447,8 @@ a.avatar[aria-disabled="true"] {
4092
4447
  cursor: not-allowed;
4093
4448
  }
4094
4449
 
4095
- @media (hover: hover) {
4096
- .choice-card:hover:not(:has(:disabled)) {
4097
- --choice-card-border: var(--text-subtle);
4098
- }
4450
+ .choice-card:hover:not(:has(:disabled)) {
4451
+ --choice-card-border: var(--text-subtle);
4099
4452
  }
4100
4453
 
4101
4454
  /* Checked border — shared by radio and checkbox */
@@ -4110,40 +4463,48 @@ a.avatar[aria-disabled="true"] {
4110
4463
  }
4111
4464
  }
4112
4465
 
4113
- /* Checkmark badge — circle for radio, rounded square for checkbox */
4114
- .choice-card:has(:checked)::before {
4466
+ /* Checkmark badge — always rendered at scale(0) and scaled in on check, so
4467
+ the entrance can transition (a pseudo created on :checked could not). */
4468
+ .choice-card::before,
4469
+ .choice-card::after {
4115
4470
  content: "";
4116
4471
  position: absolute;
4117
4472
  inset-inline-end: -0.375em;
4118
4473
  inset-block-start: -0.375em;
4119
4474
  inline-size: var(--choice-card-check-size);
4120
4475
  block-size: var(--choice-card-check-size);
4476
+ transform: scale(0);
4477
+ transition: transform var(--duration);
4478
+ }
4479
+
4480
+ /* Badge disc — circle for radio, rounded square for checkbox */
4481
+ .choice-card::before {
4121
4482
  background: var(--intent, var(--primary));
4122
- z-index: 1;
4123
4483
  box-shadow: 0 0 0 2px var(--surface);
4484
+ z-index: 1;
4124
4485
  }
4125
4486
 
4126
- .choice-card:has(input[type="radio"]:checked)::before {
4487
+ .choice-card:has(input[type="radio"])::before {
4127
4488
  border-radius: var(--radius-full);
4128
4489
  }
4129
4490
 
4130
- .choice-card:has(input[type="checkbox"]:checked)::before {
4491
+ .choice-card:has(input[type="checkbox"])::before {
4131
4492
  border-radius: var(--radius-sm);
4132
4493
  }
4133
4494
 
4134
- .choice-card:has(:checked)::after {
4135
- content: "";
4136
- position: absolute;
4137
- inset-inline-end: -0.375em;
4138
- inset-block-start: -0.375em;
4139
- inline-size: var(--choice-card-check-size);
4140
- block-size: var(--choice-card-check-size);
4495
+ /* Check mark */
4496
+ .choice-card::after {
4141
4497
  background: var(--intent-fg, var(--primary-fg));
4142
- mask: var(--icon-check-mask) center / 60% no-repeat;
4143
- -webkit-mask: var(--icon-check-mask) center / 60% no-repeat;
4498
+ -webkit-mask: var(--icon-check) center / 60% no-repeat;
4499
+ mask: var(--icon-check) center / 60% no-repeat;
4144
4500
  z-index: 2;
4145
4501
  }
4146
4502
 
4503
+ .choice-card:has(:checked)::before,
4504
+ .choice-card:has(:checked)::after {
4505
+ transform: scale(1);
4506
+ }
4507
+
4147
4508
  @media (forced-colors: active) {
4148
4509
  .choice-card {
4149
4510
  --choice-card-border: ButtonText;
@@ -4152,6 +4513,9 @@ a.avatar[aria-disabled="true"] {
4152
4513
 
4153
4514
  .choice-card:has(:checked) {
4154
4515
  --choice-card-border: Highlight;
4516
+ /* Pin the background: with forced-color-adjust: none the color-mix tint
4517
+ above would actually render, uncontrolled in high-contrast themes. */
4518
+ --choice-card-bg: Canvas;
4155
4519
  forced-color-adjust: none;
4156
4520
  }
4157
4521
 
@@ -4160,14 +4524,6 @@ a.avatar[aria-disabled="true"] {
4160
4524
  box-shadow: 0 0 0 2px Canvas;
4161
4525
  }
4162
4526
 
4163
- .choice-card:has(input[type="radio"]:checked)::before {
4164
- border-radius: var(--radius-full);
4165
- }
4166
-
4167
- .choice-card:has(input[type="checkbox"]:checked)::before {
4168
- border-radius: var(--radius-sm);
4169
- }
4170
-
4171
4527
  .choice-card:has(:checked)::after {
4172
4528
  background: HighlightText;
4173
4529
  }
@@ -4183,19 +4539,19 @@ a.avatar[aria-disabled="true"] {
4183
4539
  * custom-select.css can be opted out independently. Add .native to a specific
4184
4540
  * select to opt out of the enhancement.
4185
4541
  */
4186
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]) {
4542
+ .select:not([multiple], [size]:not([size="1"])) {
4187
4543
  appearance: none;
4188
- padding-inline-end: calc(var(--space-3) + var(--space-6));
4189
- background-image: var(--icon-chevron-image);
4190
- background-position: center right var(--space-3);
4544
+ padding-inline-end: calc(var(--space-30) + var(--space-60));
4545
+ background-image: var(--icon-chevron);
4546
+ background-position: center right var(--space-30);
4191
4547
  background-size: 1rem auto;
4192
4548
  background-repeat: no-repeat;
4193
4549
  }
4194
4550
 
4195
4551
  /* The chevron sits on the inline-end side; flip it in RTL so it follows
4196
4552
  the padding-inline-end space reserved for it. */
4197
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]):dir(rtl) {
4198
- background-position: center left var(--space-3);
4553
+ .select:not([multiple], [size]:not([size="1"])):dir(rtl) {
4554
+ background-position: center left var(--space-30);
4199
4555
  }
4200
4556
 
4201
4557
  /* Both forced-colors and prefers-contrast hand the arrow back to the
@@ -4203,10 +4559,10 @@ a.avatar[aria-disabled="true"] {
4203
4559
  simpler than maintaining contrast variants, and native rendering already
4204
4560
  matches whatever contrast mode the user picked. */
4205
4561
  @media (forced-colors: active), (prefers-contrast: more) {
4206
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]) {
4562
+ .select:not([multiple], [size]:not([size="1"])) {
4207
4563
  appearance: auto;
4208
4564
  background-image: none;
4209
- padding-inline-end: var(--space-3);
4565
+ padding-inline-end: var(--space-30);
4210
4566
  }
4211
4567
  }
4212
4568
 
@@ -4219,6 +4575,10 @@ a.avatar[aria-disabled="true"] {
4219
4575
  *
4220
4576
  * No layout or interaction contract may depend on appearance: base-select.
4221
4577
  * Arbitrary menu and popover behavior belongs elsewhere.
4578
+ *
4579
+ * Internal:
4580
+ * --control-border
4581
+ * --control-bg
4222
4582
  */
4223
4583
  @media (forced-colors: none) and (prefers-contrast: no-preference) and (hover: hover) and (
4224
4584
  pointer: fine
@@ -4231,7 +4591,7 @@ a.avatar[aria-disabled="true"] {
4231
4591
  &::picker(select) {
4232
4592
  appearance: base-select;
4233
4593
  max-height: min(24rem, calc(var(--viewport-block) * 0.7));
4234
- margin-block: var(--space-2);
4594
+ margin-block: var(--space-20);
4235
4595
  padding: 0;
4236
4596
  border: var(--border-width) solid var(--border);
4237
4597
  border-radius: var(--radius);
@@ -4253,8 +4613,8 @@ a.avatar[aria-disabled="true"] {
4253
4613
 
4254
4614
  & option {
4255
4615
  position: relative;
4256
- padding-block: var(--space-2);
4257
- padding-inline: var(--space-3);
4616
+ padding-block: var(--space-20);
4617
+ padding-inline: var(--space-30);
4258
4618
  white-space: normal;
4259
4619
  transition-property: color, background-color;
4260
4620
  transition-duration: var(--duration);
@@ -4282,14 +4642,8 @@ a.avatar[aria-disabled="true"] {
4282
4642
  inline-size: 1rem;
4283
4643
  block-size: 1rem;
4284
4644
  background-color: currentColor;
4285
- -webkit-mask-image: var(--icon-check-mask);
4286
- mask-image: var(--icon-check-mask);
4287
- -webkit-mask-repeat: no-repeat;
4288
- mask-repeat: no-repeat;
4289
- -webkit-mask-size: contain;
4290
- mask-size: contain;
4291
- -webkit-mask-position: center;
4292
- mask-position: center;
4645
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
4646
+ mask: var(--icon-check) center / contain no-repeat;
4293
4647
  }
4294
4648
  }
4295
4649
 
@@ -4305,31 +4659,21 @@ a.avatar[aria-disabled="true"] {
4305
4659
  }
4306
4660
 
4307
4661
  /*
4308
- * Native-only control polish: file and range inputs that defeat most custom
4309
- * styling, so the framework stays out of their way and just keeps them readable
4310
- * and theme-aware.
4662
+ * Native-only control polish: the file input and color picker mostly defeat
4663
+ * custom styling, so the framework stays out of the way and just keeps them
4664
+ * readable and theme-aware.
4665
+ *
4666
+ * Range has its own custom-styled file (range.css) so the focus ring can
4667
+ * target the thumb pseudo-element rather than the rectangular CSS box.
4311
4668
  */
4312
- /* Intent boundary — inherited intents do not tint this component.
4313
- Local intent classes still win because :where() has zero specificity. */
4314
- :where(.range) {
4315
- /* @sync intent-boundary */
4316
- --intent: initial;
4317
- --intent-fg: initial;
4318
- --ui-bg: initial;
4319
- --ui-fg: initial;
4320
- --ui-border: initial;
4321
- --ui-hover-bg: initial;
4322
- /* @sync intent-boundary */
4323
- }
4324
-
4325
4669
  .file {
4326
4670
  color: var(--text-muted);
4327
4671
  }
4328
4672
 
4329
4673
  .file::file-selector-button {
4330
- margin-inline-end: var(--space-3);
4331
- padding-block: var(--space-1);
4332
- padding-inline: var(--space-2);
4674
+ margin-inline-end: var(--space-30);
4675
+ padding-block: var(--space-10);
4676
+ padding-inline: var(--space-20);
4333
4677
  border: var(--border-width) solid var(--border);
4334
4678
  border-radius: var(--radius-sm);
4335
4679
  background: var(--surface-subtle);
@@ -4348,28 +4692,157 @@ a.avatar[aria-disabled="true"] {
4348
4692
  cursor: not-allowed;
4349
4693
  }
4350
4694
 
4351
- @media (hover: hover) {
4352
- .file:not(:disabled)::file-selector-button:hover {
4353
- background: var(--border);
4695
+ .file:not(:disabled)::file-selector-button:hover {
4696
+ background: var(--border);
4697
+ }
4698
+
4699
+ .file:focus-visible {
4700
+ outline: 2px solid transparent;
4701
+ box-shadow: var(--focus-ring-shadow);
4702
+ }
4703
+
4704
+ /* Native color picker — keep the browser's dialog and selected-color UI. */
4705
+ .color {
4706
+ inline-size: var(--control-size);
4707
+ block-size: var(--control-size);
4708
+ padding: var(--space-10);
4709
+ border: var(--border-width) solid var(--border);
4710
+ border-radius: var(--radius);
4711
+ background: var(--surface);
4712
+ cursor: pointer;
4713
+ }
4714
+
4715
+ .color::-webkit-color-swatch-wrapper {
4716
+ padding: 0;
4717
+ }
4718
+
4719
+ .color::-webkit-color-swatch {
4720
+ border: 0;
4721
+ }
4722
+
4723
+ .color::-moz-color-swatch {
4724
+ border: 0;
4725
+ }
4726
+
4727
+ .color:disabled {
4728
+ cursor: not-allowed;
4729
+ opacity: var(--disabled-opacity);
4730
+ }
4731
+
4732
+ .color:focus-visible {
4733
+ outline: 2px solid transparent;
4734
+ box-shadow: var(--focus-ring-shadow);
4735
+ }
4736
+
4737
+ @media (forced-colors: active) {
4738
+ .color {
4739
+ border-color: ButtonText;
4740
+ background: ButtonFace;
4741
+ forced-color-adjust: none;
4354
4742
  }
4355
4743
  }
4356
4744
 
4745
+ /*
4746
+ * Range slider — custom-styled so the focus ring always targets the
4747
+ * user-manipulated surface (the thumb) rather than the CSS box.
4748
+ *
4749
+ * appearance: none gives us full control over track and thumb; without it,
4750
+ * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
4751
+ * are not reliably applied across browsers.
4752
+ *
4753
+ * Public hooks:
4754
+ * --range-thumb-size
4755
+ * --range-track-height
4756
+ * --range-thumb-bg
4757
+ * --range-track-bg
4758
+ */
4759
+
4760
+ /* Intent boundary — inherited intents do not tint this component.
4761
+ Local intent classes still win because :where() has zero specificity. */
4762
+ :where(.range) {
4763
+ --intent: initial;
4764
+ }
4765
+
4357
4766
  .range {
4767
+ --range-thumb-size: 1.25rem;
4768
+ --range-track-height: 0.25rem;
4769
+ --range-thumb-bg: var(--intent, var(--primary));
4770
+ --range-track-bg: var(--border);
4771
+
4772
+ appearance: none;
4358
4773
  inline-size: 100%;
4359
- accent-color: var(--intent, var(--primary));
4774
+ block-size: var(--range-thumb-size);
4775
+ background: transparent;
4776
+ cursor: pointer;
4777
+ }
4778
+
4779
+ .range::-webkit-slider-runnable-track {
4780
+ block-size: var(--range-track-height);
4781
+ border-radius: var(--radius-full);
4782
+ background: var(--range-track-bg);
4783
+ }
4784
+
4785
+ .range::-moz-range-track {
4786
+ block-size: var(--range-track-height);
4787
+ border-radius: var(--radius-full);
4788
+ background: var(--range-track-bg);
4789
+ }
4790
+
4791
+ .range::-webkit-slider-thumb {
4792
+ appearance: none;
4793
+ inline-size: var(--range-thumb-size);
4794
+ block-size: var(--range-thumb-size);
4795
+ border-radius: var(--radius-full);
4796
+ border: 0;
4797
+ background: var(--range-thumb-bg);
4798
+ margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
4799
+ cursor: inherit;
4800
+ transition: box-shadow var(--duration);
4801
+ }
4802
+
4803
+ .range::-moz-range-thumb {
4804
+ inline-size: var(--range-thumb-size);
4805
+ block-size: var(--range-thumb-size);
4806
+ border-radius: var(--radius-full);
4807
+ border: 0;
4808
+ background: var(--range-thumb-bg);
4809
+ cursor: inherit;
4810
+ transition: box-shadow var(--duration);
4360
4811
  }
4361
4812
 
4362
- .file:focus-visible,
4363
4813
  .range:focus-visible {
4364
- outline: var(--focus-outline);
4365
- outline-offset: var(--focus-outline-offset);
4366
- box-shadow: var(--focus-ring-shadow);
4814
+ outline: 2px solid transparent;
4815
+ }
4816
+
4817
+ /*
4818
+ * The 1px surface-coloured separator keeps the ring visually distinct from
4819
+ * the small, coloured thumb — unlike a full-size input where the same gap
4820
+ * would read as an unwanted void.
4821
+ */
4822
+ .range:focus-visible::-webkit-slider-thumb {
4823
+ box-shadow:
4824
+ 0 0 0 1px var(--surface),
4825
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
4826
+ }
4827
+
4828
+ .range:focus-visible::-moz-range-thumb {
4829
+ box-shadow:
4830
+ 0 0 0 1px var(--surface),
4831
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
4832
+ }
4833
+
4834
+ .range:disabled {
4835
+ --range-thumb-bg: var(--state-disabled);
4836
+ cursor: not-allowed;
4837
+ opacity: var(--disabled-opacity);
4367
4838
  }
4368
4839
 
4840
+ /* Forced colors: map the custom thumb/track to system colors. The focus
4841
+ indicator stays visible through the transparent outline on :focus-visible. */
4369
4842
  @media (forced-colors: active) {
4370
- .file:focus-visible,
4371
- .range:focus-visible {
4372
- box-shadow: none;
4843
+ .range {
4844
+ --range-thumb-bg: ButtonText;
4845
+ --range-track-bg: Canvas;
4373
4846
  }
4374
4847
  }
4375
4848
 
@@ -4378,18 +4851,22 @@ a.avatar[aria-disabled="true"] {
4378
4851
  *
4379
4852
  * The switch remains an input control, not a decorative toggle. Checked, hover,
4380
4853
  * focus-visible, and disabled states stay clear.
4854
+ *
4855
+ * Public hooks:
4856
+ * --switch-bg
4857
+ * --switch-border
4858
+ * --switch-knob
4859
+ * --switch-width
4860
+ * --switch-block-size
4861
+ * --switch-knob-margin
4862
+ * --switch-knob-size
4863
+ * --switch-travel
4381
4864
  */
4382
4865
  /* Intent boundary — inherited intents do not tint this component.
4383
4866
  Local intent classes still win because :where() has zero specificity. */
4384
4867
  :where(.switch) {
4385
- /* @sync intent-boundary */
4386
4868
  --intent: initial;
4387
4869
  --intent-fg: initial;
4388
- --ui-bg: initial;
4389
- --ui-fg: initial;
4390
- --ui-border: initial;
4391
- --ui-hover-bg: initial;
4392
- /* @sync intent-boundary */
4393
4870
  }
4394
4871
 
4395
4872
  .switch {
@@ -4437,10 +4914,8 @@ a.avatar[aria-disabled="true"] {
4437
4914
  box-shadow var(--duration);
4438
4915
  }
4439
4916
 
4440
- @media (hover: hover) {
4441
- .switch:hover:not(:disabled) {
4442
- --switch-border: var(--intent, var(--primary));
4443
- }
4917
+ .switch:hover:not(:disabled) {
4918
+ --switch-border: var(--intent, var(--primary));
4444
4919
  }
4445
4920
 
4446
4921
  .switch:checked {
@@ -4461,15 +4936,14 @@ a.avatar[aria-disabled="true"] {
4461
4936
 
4462
4937
  .switch:focus-visible {
4463
4938
  --switch-border: var(--focus);
4464
- outline: var(--focus-outline);
4465
- outline-offset: var(--focus-outline-offset);
4939
+ outline: 2px solid transparent;
4466
4940
  box-shadow: var(--focus-ring-shadow);
4467
4941
  }
4468
4942
 
4469
4943
  .switch:disabled {
4470
4944
  --switch-bg: var(--surface-subtle);
4471
4945
  --switch-border: var(--border);
4472
- --switch-knob: var(--text-subtle);
4946
+ --switch-knob: var(--state-disabled);
4473
4947
  opacity: 1;
4474
4948
  cursor: not-allowed;
4475
4949
  }
@@ -4506,6 +4980,17 @@ a.avatar[aria-disabled="true"] {
4506
4980
  *
4507
4981
  * Imported by default through forms/index.css. Custom builds may omit this
4508
4982
  * file when an app wants to own validation styling completely.
4983
+ *
4984
+ * State coverage (which states each component ships) is audited in
4985
+ * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
4986
+ * not kept as a source comment that can drift from the implementation.
4987
+ *
4988
+ * Public hooks:
4989
+ * --form-invalid-border
4990
+ *
4991
+ * Internal:
4992
+ * --choice-bg
4993
+ * --switch-bg
4509
4994
  */
4510
4995
 
4511
4996
  .field.danger {
@@ -4528,17 +5013,43 @@ a.avatar[aria-disabled="true"] {
4528
5013
  --form-invalid-border: var(--danger);
4529
5014
  }
4530
5015
 
4531
- /* Override checked/indeterminate background so both border and fill signal
4532
- danger instead of showing a red border around a primary-colored control. */
4533
- .check[aria-invalid="true"]:is(:checked, :indeterminate),
4534
- .radio[aria-invalid="true"]:checked {
5016
+ /* Override checked/indeterminate fill so both border and fill signal danger
5017
+ instead of a red border around a primary-colored control. All three
5018
+ invalid routes must converge: aria-invalid, was-validated :invalid, and
5019
+ :user-invalid. */
5020
+ .check:is(:checked, :indeterminate)[aria-invalid="true"],
5021
+ .needs-validation.was-validated .check:is(:checked, :indeterminate):invalid,
5022
+ .radio:checked[aria-invalid="true"],
5023
+ .needs-validation.was-validated .radio:checked:invalid {
4535
5024
  --choice-bg: var(--danger);
4536
5025
  }
4537
5026
 
4538
- .switch[aria-invalid="true"]:checked {
5027
+ .switch:checked[aria-invalid="true"],
5028
+ .needs-validation.was-validated .switch:checked:invalid {
4539
5029
  --switch-bg: var(--danger);
4540
5030
  }
4541
5031
 
5032
+ @supports selector(:user-invalid) {
5033
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
5034
+ .needs-validation .radio:checked:user-invalid {
5035
+ --choice-bg: var(--danger);
5036
+ }
5037
+
5038
+ .needs-validation .switch:checked:user-invalid {
5039
+ --switch-bg: var(--danger);
5040
+ }
5041
+ }
5042
+
5043
+ /* Choice cards hide their native input, so invalid state must surface on the
5044
+ card itself. Sets the same --form-invalid-border hook the card's border
5045
+ consumes. */
5046
+ @supports selector(:has(*)) {
5047
+ .choice-card:has([aria-invalid="true"]),
5048
+ .needs-validation.was-validated .choice-card:has(:invalid) {
5049
+ --form-invalid-border: var(--danger);
5050
+ }
5051
+ }
5052
+
4542
5053
  @supports selector(:user-invalid) {
4543
5054
  .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
4544
5055
  --form-invalid-border: var(--danger);
@@ -4549,6 +5060,10 @@ a.avatar[aria-disabled="true"] {
4549
5060
  .needs-validation .field:has(:user-invalid) > .field-error {
4550
5061
  display: block;
4551
5062
  }
5063
+
5064
+ .needs-validation .choice-card:has(:user-invalid) {
5065
+ --form-invalid-border: var(--danger);
5066
+ }
4552
5067
  }
4553
5068
 
4554
5069
  /*
@@ -4557,13 +5072,24 @@ a.avatar[aria-disabled="true"] {
4557
5072
  * A flex cluster of buttons/actions that closes a form. The sticky variant
4558
5073
  * docks to the viewport bottom with a translucent blur where supported; at
4559
5074
  * rest it stays visually neutral so it does not double as a separator.
5075
+ *
5076
+ * Public hooks:
5077
+ * --form-actions-align
5078
+ * --form-actions-justify
5079
+ * --form-actions-margin-block-start
5080
+ *
5081
+ * Internal:
5082
+ * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
5083
+ * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
5084
+ * --form-actions-sticky-padding (owned by .form-actions.sticky)
4560
5085
  */
4561
5086
  .form-actions {
4562
5087
  display: flex;
4563
5088
  flex-wrap: wrap;
4564
5089
  gap: var(--gap);
4565
- align-items: center;
4566
- margin-block-start: var(--form-actions-margin-block-start, var(--space-5));
5090
+ align-items: var(--form-actions-align, center);
5091
+ justify-content: var(--form-actions-justify, flex-start);
5092
+ margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
4567
5093
  }
4568
5094
 
4569
5095
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -4577,7 +5103,7 @@ a.avatar[aria-disabled="true"] {
4577
5103
  isolation: isolate;
4578
5104
 
4579
5105
  margin-inline: calc(var(--form-actions-sticky-inline-offset, 0px) * -1);
4580
- padding: var(--form-actions-sticky-padding, var(--space-3));
5106
+ padding: var(--form-actions-sticky-padding, var(--space-30));
4581
5107
 
4582
5108
  border-block-start: var(--border-width) solid var(--border);
4583
5109
  background: var(--surface);
@@ -4627,7 +5153,7 @@ a.avatar[aria-disabled="true"] {
4627
5153
  min-inline-size: 0;
4628
5154
  }
4629
5155
 
4630
- .nowrap {
5156
+ .text-nowrap {
4631
5157
  white-space: nowrap;
4632
5158
  }
4633
5159
 
@@ -4722,15 +5248,13 @@ a.avatar[aria-disabled="true"] {
4722
5248
  text-decoration: none;
4723
5249
  }
4724
5250
 
4725
- @media (hover: hover) {
4726
- .link-muted:hover {
4727
- color: var(--text);
4728
- }
5251
+ .link-muted:hover {
5252
+ color: var(--text);
4729
5253
  }
4730
5254
 
4731
5255
  /* Spacing step helpers — semantic scale (sm / default / lg).
4732
5256
  Names map to logical properties for writing-direction safety. The default
4733
- step uses --space-4, which matches the framework's built-in padding on
5257
+ step uses --space-40, which matches the framework's built-in padding on
4734
5258
  cards, dialogs, and form-actions. */
4735
5259
 
4736
5260
  /* Gap */
@@ -4740,22 +5264,22 @@ a.avatar[aria-disabled="true"] {
4740
5264
 
4741
5265
  /* Padding — block (vertical) */
4742
5266
  .py {
4743
- padding-block: var(--variant-space);
5267
+ padding-block: var(--density-space);
4744
5268
  }
4745
5269
 
4746
5270
  /* Padding — inline (horizontal) */
4747
5271
  .px {
4748
- padding-inline: var(--variant-space);
5272
+ padding-inline: var(--density-space);
4749
5273
  }
4750
5274
 
4751
5275
  /* Margin — block-start */
4752
5276
  .mbs {
4753
- margin-block-start: var(--variant-space);
5277
+ margin-block-start: var(--density-space);
4754
5278
  }
4755
5279
 
4756
5280
  /* Margin — block-end */
4757
5281
  .mbe {
4758
- margin-block-end: var(--variant-space);
5282
+ margin-block-end: var(--density-space);
4759
5283
  }
4760
5284
 
4761
5285
  /* Joined controls are a component, but must load after the controls it groups. */
@@ -4768,6 +5292,18 @@ a.avatar[aria-disabled="true"] {
4768
5292
  * Cascade note: .join writes border-radius longhands on its direct children.
4769
5293
  * Keep this file loaded after control/component styles so those corners win at
4770
5294
  * equal specificity.
5295
+ *
5296
+ * Public hooks:
5297
+ * --join-radius
5298
+ *
5299
+ * Internal:
5300
+ * --join-ss
5301
+ * --join-se
5302
+ * --join-es
5303
+ * --join-ee
5304
+ * --control-border
5305
+ * --control-bg
5306
+ * --control-fg
4771
5307
  */
4772
5308
  .join {
4773
5309
  --join-radius: var(--radius);
@@ -4795,7 +5331,7 @@ a.avatar[aria-disabled="true"] {
4795
5331
  display: inline-flex;
4796
5332
  justify-content: center;
4797
5333
  min-block-size: var(--control-size);
4798
- padding-inline: var(--space-3);
5334
+ padding-inline: var(--space-30);
4799
5335
  border: var(--border-width) solid var(--control-border);
4800
5336
  border-radius: var(--radius);
4801
5337
  background: var(--control-bg);
@@ -4856,8 +5392,7 @@ a.avatar[aria-disabled="true"] {
4856
5392
  focus, and unsupported :has() browsers retain the control's normal ring. */
4857
5393
  .join:has(> :is(.input, .textarea, .select):focus-visible) {
4858
5394
  border-radius: var(--join-radius);
4859
- outline: var(--focus-outline);
4860
- outline-offset: var(--focus-outline-offset);
5395
+ outline: 2px solid transparent;
4861
5396
  box-shadow: var(--focus-ring-shadow);
4862
5397
  }
4863
5398
 
@@ -4866,9 +5401,14 @@ a.avatar[aria-disabled="true"] {
4866
5401
  box-shadow: none;
4867
5402
  }
4868
5403
 
4869
- /* Focus baseline — loaded after components so its legacy fallback also covers
4870
- controls that reset their native outline. Modern browsers hide it for focus
4871
- that is not keyboard-relevant; components layer local state on top. */
5404
+ /* Focus baseline — a visible outline is the guaranteed focus affordance. The
5405
+ :focus outline covers legacy browsers; modern browsers hide it for
5406
+ pointer/mouse focus and interactive components layer their ring on top.
5407
+
5408
+ Invariant: an interactive component must never rely on box-shadow alone
5409
+ for its keyboard focus indicator. Components that remove the native
5410
+ outline on :focus-visible replace it with a transparent outline — invisible
5411
+ normally, forced to the system focus color under forced-colors. */
4872
5412
  :focus {
4873
5413
  outline: var(--focus-outline);
4874
5414
  outline-offset: var(--focus-outline-offset);
@@ -4878,6 +5418,14 @@ a.avatar[aria-disabled="true"] {
4878
5418
  :focus:not(:focus-visible) {
4879
5419
  outline: none;
4880
5420
  }
5421
+
5422
+ /* Pointer focus must still signal in high-contrast mode, where the shared
5423
+ box-shadow rings are disabled (--focus-ring-shadow: none). */
5424
+ @media (forced-colors: active) {
5425
+ :focus:not(:focus-visible) {
5426
+ outline: var(--focus-outline);
5427
+ }
5428
+ }
4881
5429
  }
4882
5430
 
4883
5431
  @media print {
@@ -4927,3 +5475,16 @@ a.avatar[aria-disabled="true"] {
4927
5475
  break-after: avoid;
4928
5476
  }
4929
5477
  }
5478
+
5479
+
5480
+ /*
5481
+ * NOTE: The 'optional/' directory contains layout and utility extensions
5482
+ * (such as optional grids, split, visual effects, and extra utility classes
5483
+ * like .gap-sm, .flex-nowrap, .shadow-sm, etc.). These are intentionally not
5484
+ * bundled here. Two entry points ship them:
5485
+ *
5486
+ * actual-css/css/optional/index optional layer only
5487
+ * actual-css/css/actual.full core + optional
5488
+ *
5489
+ * Import one of these paths in your project when you need them.
5490
+ */