actual-css 0.2.0 → 0.3.1

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 (214) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +24 -9
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1532 -1114
  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 +94 -14
  10. package/docs/design-notes/cascade-layer.md +7 -6
  11. package/docs/design-notes/enhancement-contract.md +11 -14
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +2 -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 +37 -1
  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 +210 -0
  25. package/docs/pages/components/card.md +195 -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 +43 -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 +99 -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 +59 -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 +28 -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 +55 -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 +32 -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 +4 -4
  103. package/package.json +21 -12
  104. package/src/css/actual.css +16 -2
  105. package/src/css/base.css +3 -5
  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 +139 -102
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +69 -42
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +6 -0
  113. package/src/css/components/button.css +84 -34
  114. package/src/css/components/card.css +26 -38
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -109
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -2
  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 +30 -23
  124. package/src/css/components/overline.css +8 -9
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/skeleton.css +7 -0
  128. package/src/css/components/spinner.css +6 -7
  129. package/src/css/components/status-bar.css +15 -24
  130. package/src/css/components/surface.css +2 -8
  131. package/src/css/components/tab.css +19 -48
  132. package/src/css/components/table.css +7 -5
  133. package/src/css/components/tooltip.css +14 -7
  134. package/src/css/focus.css +14 -38
  135. package/src/css/forms/choice-card.css +13 -14
  136. package/src/css/forms/choice.css +21 -27
  137. package/src/css/forms/control.css +18 -17
  138. package/src/css/forms/custom-select.css +9 -11
  139. package/src/css/forms/form-actions.css +14 -3
  140. package/src/css/forms/form.css +7 -4
  141. package/src/css/forms/index.css +4 -4
  142. package/src/css/forms/input-icon.css +30 -12
  143. package/src/css/forms/native.css +50 -11
  144. package/src/css/forms/range.css +17 -9
  145. package/src/css/forms/select.css +8 -8
  146. package/src/css/forms/switch.css +14 -12
  147. package/src/css/forms/validation.css +32 -5
  148. package/src/css/grid.css +32 -18
  149. package/src/css/intents.css +2 -2
  150. package/src/css/layout.css +45 -2
  151. package/src/css/optional/aura.css +93 -0
  152. package/src/css/optional/chat.css +105 -0
  153. package/src/css/optional/fab.css +104 -0
  154. package/src/css/optional/floating-field.css +87 -0
  155. package/src/css/optional/index.css +5 -0
  156. package/src/css/optional/layout-extra.css +7 -4
  157. package/src/css/optional/otp.css +115 -0
  158. package/src/css/optional/scroll-snap.css +6 -0
  159. package/src/css/optional/scroller.css +13 -7
  160. package/src/css/optional/typography-fluid.css +5 -0
  161. package/src/css/optional/utilities-extra.css +17 -2
  162. package/src/css/prose.css +18 -8
  163. package/src/css/reset.css +0 -10
  164. package/src/css/theme.css +21 -5
  165. package/src/css/themes/cyberpunk.css +1 -1
  166. package/src/css/themes/dim.css +3 -3
  167. package/src/css/themes/edge.css +8 -10
  168. package/src/css/themes/index.css +8 -4
  169. package/src/css/themes/{dark.css → indigo.css} +5 -4
  170. package/src/css/themes/neon.css +5 -5
  171. package/src/css/themes/square.css +0 -3
  172. package/src/css/tokens.css +74 -20
  173. package/src/css/utilities.css +8 -10
  174. package/src/css/variants.css +51 -66
  175. package/src/js/command.js +1 -3
  176. package/src/js/context-menu.js +67 -35
  177. package/src/js/dialog.js +68 -79
  178. package/src/js/dismiss.js +15 -0
  179. package/src/js/enhance.js +54 -23
  180. package/src/js/enhancement-loader.js +301 -0
  181. package/src/js/events.js +3 -0
  182. package/src/js/filter.js +3 -3
  183. package/src/js/floating.js +101 -131
  184. package/src/js/flyout.js +104 -87
  185. package/src/js/focus-group.js +116 -0
  186. package/src/js/focus.js +6 -1
  187. package/src/js/index.js +4 -2
  188. package/src/js/input.js +1 -1
  189. package/src/js/keys.js +27 -0
  190. package/src/js/mask.js +7 -6
  191. package/src/js/menu.js +87 -34
  192. package/src/js/parse-config.js +47 -0
  193. package/src/js/password.js +9 -1
  194. package/src/js/scrollspy.js +12 -4
  195. package/src/js/selectors.js +0 -7
  196. package/src/js/status.js +3 -1
  197. package/src/js/surface.js +187 -136
  198. package/src/js/tab.js +36 -53
  199. package/src/js/tooltip.js +70 -35
  200. package/src/js/validation.js +34 -8
  201. package/dist/actual.js.map +0 -31
  202. package/docs/README.md +0 -28
  203. package/docs/components.md +0 -860
  204. package/docs/design-notes/previous-work.md +0 -5
  205. package/docs/forms.md +0 -1265
  206. package/docs/javascript.md +0 -496
  207. package/docs/layout.md +0 -480
  208. package/docs/patterns.md +0 -504
  209. package/docs/reserved.md +0 -7
  210. package/docs/tokens.md +0 -455
  211. package/docs/ui.md +0 -929
  212. package/docs/utilities.md +0 -384
  213. package/src/css/dialog-fallback.css +0 -56
  214. 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,22 @@ 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-xs: 0 1px 2px rgb(0 0 0 / 0.04);
147
99
  --shadow: 0 1px 3px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
148
100
  --shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);
149
101
 
@@ -167,7 +119,6 @@ select {
167
119
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
168
120
  dot with the parent surface color. */
169
121
  --indicator-offset: 14.65%;
170
- --indicator-ring: var(--surface);
171
122
 
172
123
  /* Shared backdrop — consumed by modal, drawer, and mobile flyout sheets. */
173
124
  --backdrop-color: rgb(0 0 0);
@@ -178,7 +129,8 @@ select {
178
129
  --bar-height: 0.65rem;
179
130
 
180
131
  /* Controls — inputs and buttons share --control-size so heights always match.
181
- .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. */
182
134
  --control-size-sm: 2rem;
183
135
  --control-size-md: 2.375rem;
184
136
  --control-size-lg: 2.75rem;
@@ -186,19 +138,38 @@ select {
186
138
  --control-pad-x: 1em;
187
139
  --control-font-size: var(--font-size-md);
188
140
  --disabled-opacity: 0.65;
141
+ }
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
+ }
189
161
 
162
+ :root {
190
163
  /* Variant tuning — themes may adjust generated soft surfaces without
191
164
  replacing the component rules. */
192
165
  --soft-bg-mix: 88%;
193
166
  --soft-border-mix: 65%;
194
167
  --soft-hover-alpha: 12%;
195
168
 
196
- /* Shared icons */
197
- --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");
198
- --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");
199
- /* Native select uses background-image, which cannot inherit currentColor.
200
- The stroke matches the default theme's text-subtle (hue 258 neutral). */
201
- --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");
202
173
 
203
174
  /* Viewport size tokens — fallback vh/vw with dvh/vi enhancement.
204
175
  Consumed by full-viewport components (app-shell, drawer, modal, flyout).
@@ -207,13 +178,24 @@ select {
207
178
  --viewport-block: 100vh;
208
179
  --viewport-inline: 100vw;
209
180
 
210
- /* 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. */
211
185
  --z-sticky: 10;
212
186
  --z-menu: 20;
213
187
  --z-tooltip: 50;
214
188
  --z-status: 60;
215
189
  }
216
190
 
191
+ @media (forced-colors: active) {
192
+ :root {
193
+ --state-selected: Highlight;
194
+ --state-selected-fg: HighlightText;
195
+ --state-disabled: GrayText;
196
+ }
197
+ }
198
+
217
199
  @supports (block-size: 100dvh) {
218
200
  :root {
219
201
  --viewport-block: 100dvh;
@@ -227,12 +209,17 @@ select {
227
209
  }
228
210
 
229
211
  @supports (color: color-mix(in oklch, red, white)) {
230
- :root {
231
- --shadow-xs: 0 1px 2px color-mix(in oklch, var(--shadow-color) 4%, 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. */
232
216
  --shadow:
233
217
  0 1px 3px color-mix(in oklch, var(--shadow-color) 5%, transparent),
234
218
  0 4px 12px color-mix(in oklch, var(--shadow-color) 4%, transparent);
235
219
  --shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
220
+ }
221
+
222
+ :root {
236
223
  --backdrop-fill: color-mix(
237
224
  in oklch,
238
225
  var(--backdrop-color) calc(var(--backdrop-opacity) * 100%),
@@ -241,6 +228,17 @@ select {
241
228
  }
242
229
  }
243
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
+ */
244
242
  :root {
245
243
  color-scheme: light dark;
246
244
  }
@@ -308,7 +306,6 @@ select {
308
306
  /* Pre-color-mix fallback only — superseded below by a derivation from
309
307
  --focus so overriding --neutral/--focus never leaves a stale ring. */
310
308
  --focus-ring: hsl(258 7% 43% / 0.3);
311
- --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
312
309
  --hover-overlay: hsl(260 20% 10% / 0.04);
313
310
 
314
311
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -319,13 +316,19 @@ select {
319
316
  /* Shadow color — consumed by --shadow / --shadow-popout in tokens.css */
320
317
  --shadow-color: hsl(260 25% 10%);
321
318
 
322
- /* 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] {
323
328
  --heading: var(--text);
324
329
  --selection-bg: var(--primary);
325
330
  --selection-fg: var(--primary-fg);
326
-
327
- /* hgroup internal gap */
328
- --hgroup-gap: 0.25em;
331
+ --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
329
332
  }
330
333
 
331
334
  /* Tone subsystem — fixed-backdrop foreground for components placed over media
@@ -447,10 +450,8 @@ a {
447
450
  text-underline-offset: 0.18em;
448
451
  }
449
452
 
450
- @media (hover: hover) {
451
- a:hover {
452
- text-decoration-thickness: 0.12em;
453
- }
453
+ a:hover {
454
+ text-decoration-thickness: 0.12em;
454
455
  }
455
456
 
456
457
  /* Global heading chrome: color (--heading or inherit), a tight line-height,
@@ -476,7 +477,7 @@ hgroup > * {
476
477
  hr {
477
478
  border: 0;
478
479
  border-block-start: var(--border-width) solid var(--border);
479
- margin-block: var(--space-6);
480
+ margin-block: var(--space-60);
480
481
  }
481
482
 
482
483
  code,
@@ -499,8 +500,8 @@ small {
499
500
  * Intent classes do not style elements directly.
500
501
  * They only expose --intent and --intent-fg.
501
502
  * Components and variants decide how to consume them. Each component that
502
- * consumes --intent or --ui-* must open a local intent boundary in a :where()
503
- * 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
504
505
  * do not leak into nested components. Local intent classes (.primary, .danger,
505
506
  * etc.) still apply because they have higher specificity than :where().
506
507
  */
@@ -529,6 +530,242 @@ small {
529
530
  --intent-fg: var(--neutral-fg);
530
531
  }
531
532
 
533
+ /* Prose before layout: layout primitives (.stack, .cluster) reset child
534
+ margins and must win over .prose's flow margins at equal specificity —
535
+ source order is the tie-breaker, so prose loads first. */
536
+ /*
537
+ * Prose is the opt-in rich text scope for authored content.
538
+ *
539
+ * Global element styles stay mild so app UI is not shaped accidentally. Page
540
+ * layout, forms, cards, navigation, and app tables remain outside this scope.
541
+ *
542
+ * Public hooks:
543
+ * --prose-size
544
+ * --prose-line-height
545
+ * --prose-measure
546
+ * --prose-flow
547
+ * --prose-heading-line-height
548
+ * --prose-h1
549
+ * --prose-h2
550
+ * --prose-h3
551
+ * --prose-h4
552
+ */
553
+
554
+ .prose {
555
+ /* Prose scale */
556
+ --prose-size: 1rem;
557
+ --prose-line-height: 1.7;
558
+ --prose-measure: 70ch;
559
+ --prose-flow: 1em;
560
+ --prose-heading-line-height: 1.2;
561
+ --prose-h1: 2rem;
562
+ --prose-h2: 1.5rem;
563
+ --prose-h3: 1.25rem;
564
+ --prose-h4: 1.125rem;
565
+
566
+ max-inline-size: var(--prose-measure);
567
+ color: var(--text);
568
+ font-size: var(--prose-size);
569
+ line-height: var(--prose-line-height);
570
+ overflow-wrap: break-word;
571
+ }
572
+
573
+ .prose :where(p, ul, ol, dl, blockquote, pre, table, figure) {
574
+ margin-block: var(--prose-flow) 0;
575
+ }
576
+
577
+ .prose :where(h1, h2, h3, h4, h5, h6) {
578
+ margin-block: 1.5em 0;
579
+ color: var(--text);
580
+ font-weight: var(--font-weight-strong);
581
+ line-height: var(--prose-heading-line-height);
582
+ }
583
+
584
+ .prose :where(p, li, blockquote) {
585
+ text-wrap: pretty;
586
+ }
587
+
588
+ .prose > :first-child {
589
+ margin-block-start: 0;
590
+ }
591
+
592
+ .prose h1 {
593
+ font-size: var(--prose-h1);
594
+ }
595
+
596
+ .prose h2 {
597
+ font-size: var(--prose-h2);
598
+ }
599
+
600
+ .prose h3 {
601
+ font-size: var(--prose-h3);
602
+ }
603
+
604
+ .prose h4 {
605
+ font-size: var(--prose-h4);
606
+ }
607
+
608
+ .prose a {
609
+ color: var(--primary);
610
+ text-decoration-line: underline;
611
+ text-decoration-thickness: 0.08em;
612
+ text-underline-offset: 0.18em;
613
+ }
614
+
615
+ .prose a:hover {
616
+ text-decoration-thickness: 0.12em;
617
+ }
618
+
619
+ @supports (color: color-mix(in oklch, red, white)) {
620
+ .prose a:hover {
621
+ color: color-mix(in oklch, var(--primary), var(--text) 15%);
622
+ }
623
+ }
624
+
625
+ .prose :where(ul, ol) {
626
+ padding-inline-start: 1.5em;
627
+ }
628
+
629
+ .prose li + li {
630
+ margin-block-start: 0.35em;
631
+ }
632
+
633
+ .prose code {
634
+ padding: 0.15em 0.3em;
635
+ border-radius: var(--radius-sm);
636
+ background: var(--surface-subtle);
637
+ color: var(--text);
638
+ font-size: 0.875em;
639
+ }
640
+
641
+ /* Visual twin of .key (components/key.css) — keep the two recipes in sync. */
642
+ .prose kbd {
643
+ display: inline-flex;
644
+ align-items: center;
645
+ justify-content: center;
646
+ min-inline-size: 1.75em;
647
+ min-block-size: 1.6em;
648
+ padding-inline: 0.45em;
649
+ border: var(--border-width) solid var(--border);
650
+ border-block-end-width: calc(var(--border-width) * 2);
651
+ border-radius: var(--radius-sm);
652
+ background: var(--surface-raised);
653
+ color: var(--text);
654
+ font-family: var(--font-mono);
655
+ font-size: 0.875em;
656
+ font-weight: var(--font-weight-medium);
657
+ line-height: 1;
658
+ white-space: nowrap;
659
+ vertical-align: 0.08em;
660
+ }
661
+
662
+ .prose pre {
663
+ overflow-x: auto;
664
+ padding: 1em;
665
+ border: var(--border-width) solid var(--border);
666
+ border-radius: var(--radius);
667
+ background: var(--surface-subtle);
668
+ }
669
+
670
+ .prose pre code {
671
+ padding: 0;
672
+ background: transparent;
673
+ }
674
+
675
+ .prose blockquote {
676
+ padding-inline-start: 1em;
677
+ border-inline-start: var(--border-width) solid var(--border);
678
+ color: var(--text-muted);
679
+ }
680
+
681
+ .prose figure {
682
+ margin-inline: 0;
683
+ }
684
+
685
+ .prose figcaption {
686
+ margin-block-start: 0.5em;
687
+ color: var(--text-muted);
688
+ font-size: 0.875em;
689
+ }
690
+
691
+ .prose table {
692
+ inline-size: 100%;
693
+ border-collapse: collapse;
694
+ }
695
+
696
+ .prose th,
697
+ .prose td {
698
+ padding: 0.5em;
699
+ border-block-end: var(--border-width) solid var(--border);
700
+ text-align: start;
701
+ }
702
+
703
+ /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
704
+ Non-fluid and always available — use .text-lead from the optional fluid
705
+ module for marketing/display surfaces. */
706
+ .lead {
707
+ font-size: 1.125em;
708
+ line-height: var(--line-height-relaxed);
709
+ }
710
+
711
+ /* Readable content measure. Composes with .center for centering or with
712
+ margin-inline: auto for a one-off centered block. */
713
+ .measure {
714
+ max-inline-size: var(--prose-measure, 70ch);
715
+ }
716
+
717
+ .prose.sm {
718
+ --prose-size: 0.9375rem;
719
+ --prose-h1: 1.75rem;
720
+ --prose-h2: 1.375rem;
721
+ --prose-h3: 1.125rem;
722
+ --prose-h4: 1rem;
723
+ }
724
+
725
+ .prose.lg {
726
+ --prose-size: 1.125rem;
727
+ --prose-measure: 72ch;
728
+ --prose-h1: 2.25rem;
729
+ --prose-h2: 1.625rem;
730
+ --prose-h3: 1.375rem;
731
+ --prose-h4: 1.25rem;
732
+ }
733
+
734
+ /*
735
+ * Core layout primitives: stack, cluster, center, frame, app-shell, media,
736
+ * sidebar-layout. Each primitive owns a single, readable behavior; they are
737
+ * meant to compose in markup.
738
+ *
739
+ * Public hooks:
740
+ * --cluster-align
741
+ * --cluster-justify
742
+ * --cluster-wrap
743
+ * --center-size
744
+ * --center-pad
745
+ * --frame-ratio
746
+ * --sidebar-layout-size
747
+ *
748
+ * Child contracts:
749
+ * .stack owns vertical rhythm: direct-child block-axis margins are reset,
750
+ * spacing comes from --gap, inline margins stay free.
751
+ * .cluster owns inline rhythm: direct-child margins are reset, spacing comes
752
+ * from --gap.
753
+ * .frame sizes direct media children (img/video/iframe/canvas/svg) to fill the
754
+ * frame.
755
+ */
756
+
757
+ /* Responsive recipes:
758
+ - avatar/icon + flexible content -> .media
759
+ - inline items that wrap when space runs out -> .cluster (keep wrapping)
760
+ - content + aside -> .sidebar-layout inside .container-query
761
+ - row that becomes a column when constrained -> .switcher (optional module)
762
+
763
+ Anti-patterns:
764
+ - viewport breakpoints for ordinary composition
765
+ - fixed grid templates (--grid-columns) where .grid/.sidebar-layout express
766
+ the layout
767
+ - nowrap on page-level compositions; prefer wrapping and container-aware
768
+ primitives */
532
769
  .container-query {
533
770
  container-type: inline-size;
534
771
  }
@@ -539,18 +776,24 @@ small {
539
776
  gap: var(--gap);
540
777
  }
541
778
 
779
+ /* .stack owns the block axis only: block margins reset, inline margins stay
780
+ free (self-centering children via margin-inline: auto keep working). */
542
781
  .stack > * {
543
- margin: 0;
782
+ margin-block: 0;
544
783
  }
545
784
 
546
785
  .cluster {
547
786
  display: flex;
548
- flex-wrap: wrap;
787
+ flex-wrap: var(--cluster-wrap, wrap);
549
788
  align-items: var(--cluster-align, center);
550
789
  justify-content: var(--cluster-justify, flex-start);
551
790
  gap: var(--gap);
552
791
  }
553
792
 
793
+ .cluster > * {
794
+ margin: 0;
795
+ }
796
+
554
797
  .center {
555
798
  box-sizing: content-box;
556
799
  max-inline-size: var(--center-size, 72rem);
@@ -594,6 +837,8 @@ small {
594
837
  grid-template-columns: minmax(0, 1fr);
595
838
  }
596
839
 
840
+ /* Container queries are the preferred path; without them the @supports not
841
+ block below restores the same columns at the same viewport breakpoint. */
597
842
  @container (width >= 64rem) {
598
843
  .container-query .sidebar-layout {
599
844
  grid-template-columns: minmax(0, 1fr) var(--sidebar-layout-size, 18rem);
@@ -610,29 +855,40 @@ small {
610
855
  }
611
856
  }
612
857
 
858
+ /*
859
+ * Responsive equal-width grids, with fixed-column presets below.
860
+ *
861
+ * Public hooks:
862
+ * --grid-min
863
+ * --grid-columns
864
+ */
865
+
866
+ /* --grid-columns replaces the intrinsic auto-fit template: once set, the
867
+ grid no longer collapses on its own — the author owns narrow-container
868
+ collapse. Prefer .sidebar-layout inside .container-query for content +
869
+ aside layouts; reserve --grid-columns for genuinely fixed or asymmetric
870
+ templates. */
871
+
872
+ /* Recipe:
873
+ - N equal cards that collapse when narrow -> .grid (auto-fit, tune
874
+ --grid-min) or .grid-2/3/4/6 inside .container-query
875
+ - content + aside -> .sidebar-layout inside .container-query
876
+ - fixed or asymmetric columns -> --grid-columns with an explicit collapse
877
+ rule of your own */
613
878
  .grid {
614
879
  display: grid;
615
880
  gap: var(--gap);
616
- grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--grid-min, 16rem)), 1fr));
617
- }
618
-
619
- /* Query-container wrapper that opts .grid-2/3/4/6 into responsive
620
- * collapse. Wrap a .grid-2/3/4/6 inside .container-query to make the
621
- * column count collapse on narrow wrappers. Without this wrapper,
622
- * .grid-2/3/4/6 stay at their full column count. */
623
- /* Fixed-column grids at their full count by default.
624
- * .grid-2: 2 cols
625
- * .grid-3: 3 cols
626
- * .grid-4: 4 cols
627
- * .grid-6: 6 cols
628
- * When placed inside .container-query, the column count collapses
629
- * on narrow wrappers. The intermediate 2-col step applies to
630
- * .grid-3/.grid-4; .grid-6 starts at 2 columns because it is meant
631
- * for compact items that do not need a full single-column stack.
632
- * .grid-2: 1 col -> 2 cols
633
- * .grid-3: 1 col -> 2 cols -> 3 cols
634
- * .grid-4: 1 col -> 2 cols -> 4 cols
635
- * .grid-6: 2 cols -> 3 cols -> 6 cols */
881
+ grid-template-columns: var(
882
+ --grid-columns,
883
+ repeat(auto-fit, minmax(min(100%, var(--grid-min, 16rem)), 1fr))
884
+ );
885
+ }
886
+
887
+ /* Fixed-column grids at their full count by default: .grid-2/3/4/6 stay at
888
+ 2/3/4/6 columns unless wrapped in .container-query, which collapses the
889
+ column count on narrow wrappers (a 2-col intermediate step for
890
+ .grid-3/.grid-4; .grid-6 starts at 2 columns because it is meant for
891
+ compact items that do not need a full single-column stack). */
636
892
  .grid-2,
637
893
  .grid-3,
638
894
  .grid-4,
@@ -654,6 +910,9 @@ small {
654
910
  grid-template-columns: repeat(6, minmax(0, 1fr));
655
911
  }
656
912
 
913
+ /* Container queries are the preferred responsive path; browsers without them
914
+ ignore these rules and get the equivalent @media rules from the
915
+ @supports not (container-type) block below — same columns, same breakpoints. */
657
916
  @container (width < 28rem) {
658
917
  .container-query .grid-2,
659
918
  .container-query .grid-3,
@@ -710,15 +969,24 @@ small {
710
969
  * Cross-cutting variant token mapping.
711
970
  *
712
971
  * Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
713
- * shared variants. Component-specific defaults (.btn solid-by-default, .badge
714
- * solid-by-default, .alert soft-by-default) live with their components:
972
+ * shared variants. .inverted is a surface modifier, not a variant: it paints
973
+ * the element directly (background/color/border-color) so it works on any bare
974
+ * block, and remaps the shared --ui-* tokens so participating components
975
+ * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
976
+ * before components, so a component with its own explicit surface (e.g.
977
+ * .accordion) naturally keeps that surface — .inverted never forces a
978
+ * non-participant.
979
+ * Component-specific defaults (.btn solid-by-default, .badge
980
+ * soft-by-default, .alert soft-by-default) live with their components:
715
981
  * components/button.css — .btn default solid + .btn.ghost + .btn.link
716
- * components/badge.css — .badge default solid
717
- * components/alert.css — .alert default + .alert.solid/.alert.outline
982
+ * components/badge.css — .badge default soft behind explicit --ui-*
983
+ * components/alert.css — .alert default soft behind explicit --ui-*
718
984
  *
719
- * .sm/.lg set shared size tokens. Controls consume --control-*; non-control
720
- * components may consume --variant-* when the variant should affect their
721
- * local density. Compact components may consume --variant-compact-*.
985
+ * .sm/.lg set shared density tokens: spacing rhythm and geometry only.
986
+ * Density never changes typography — controls keep their font-size and
987
+ * content components keep theirs. Controls consume --control-size; non-control
988
+ * components may consume --density-* when the density context should affect
989
+ * their local rhythm. Compact components may consume --density-compact-size.
722
990
  * Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
723
991
  * applying .sm/.lg to card, dialog, accordion, navbar, table is
724
992
  * a no-op.
@@ -740,31 +1008,43 @@ small {
740
1008
 
741
1009
  .outline {
742
1010
  --ui-bg: transparent;
743
- --ui-fg: var(--intent, var(--text));
744
- --ui-border: var(--intent, var(--border));
745
- }
1011
+ --ui-fg: var(--intent, currentColor);
1012
+ --ui-border: var(--intent, currentColor);
1013
+ }
1014
+
1015
+ /* Inverse surface treatment. Paints the element directly (so it works on any
1016
+ bare block) and remaps the shared --ui-* tokens so opt-in components
1017
+ (.card, .navbar) invert through their own rules. Because this loads before
1018
+ component base surfaces, components with their own explicit surface
1019
+ (e.g. .accordion) keep it — participation is opt-in, never forced. --heading
1020
+ is set so global headings (var(--heading, inherit)) do not inherit an outside
1021
+ heading color onto the dark surface; surface-owning descendants derive
1022
+ their contextual aliases from their own local surface. */
1023
+ .inverted {
1024
+ --ui-bg: var(--surface-solid);
1025
+ --ui-fg: var(--surface);
1026
+ --ui-border: transparent;
1027
+ --ui-hover-bg: var(--surface-solid);
746
1028
 
747
- .sm {
748
- --gap: var(--space-2);
749
- --variant-space: var(--space-2);
1029
+ --heading: var(--surface);
1030
+ --busy-overlay-bg: var(--surface-solid);
1031
+
1032
+ background: var(--ui-bg);
1033
+ color: var(--ui-fg);
1034
+ border-color: var(--ui-border);
1035
+ }
1036
+
1037
+ .sm {
1038
+ --gap: var(--space-20);
1039
+ --density-space: var(--space-20);
750
1040
  --control-size: var(--control-size-sm);
751
- --control-font-size: var(--font-size-sm);
752
- --variant-font-size: var(--font-size-sm);
753
- --variant-icon-size: 1rem;
754
- --variant-pad-block: var(--space-3);
755
- --variant-compact-size: 1.25rem;
756
- --variant-compact-font-size: 0.75rem;
1041
+ --density-compact-size: 1.25rem;
757
1042
  }
758
1043
  .lg {
759
- --gap: var(--space-5);
760
- --variant-space: var(--space-5);
1044
+ --gap: var(--space-50);
1045
+ --density-space: var(--space-50);
761
1046
  --control-size: var(--control-size-lg);
762
- --control-font-size: var(--font-size-lg);
763
- --variant-font-size: var(--font-size-lg);
764
- --variant-icon-size: 1.5rem;
765
- --variant-pad-block: var(--space-5);
766
- --variant-compact-size: 1.875rem;
767
- --variant-compact-font-size: var(--font-size-sm);
1047
+ --density-compact-size: 1.875rem;
768
1048
  }
769
1049
 
770
1050
  /* Modern color enhancement. Baseline variant values above remain usable
@@ -780,238 +1060,14 @@ small {
780
1060
  );
781
1061
  /* @sync soft-recipe */
782
1062
  }
783
- }
784
-
785
- /* Interactive hover recipe shared by .btn and .badge. Declared outside :hover
786
- on purpose: --ui-hover-bg is only consumed inside their @media (hover: hover)
787
- rules, so these values are inert until hovered. :is() carries real
788
- specificity so the components' :where() intent boundaries
789
- (--ui-hover-bg: initial) cannot override the recipe. The solid/soft overlay
790
- darkening lives in the consuming components. */
791
- :is(.btn, a.badge, button.badge):is(.outline, .ghost) {
792
- --ui-hover-bg: var(--surface-subtle);
793
- }
794
-
795
- @supports (color: color-mix(in oklch, red, white)) {
796
- /* Solid has no literal class requirement: bare .btn/.badge default to solid,
797
- so this must match anything that isn't one of the other variants rather
798
- than an explicit .solid class. Stacks with the box-shadow overlay the
799
- consuming components apply on top for extra depth. */
800
- :is(.btn, a.badge, button.badge):not(:where(.soft, .outline, .ghost, .link)) {
801
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
802
- }
803
-
804
- /* Same recipe as the resting .soft bg with the surface share reduced by
805
- --soft-hover-alpha: stays opaque, so it reads on any backdrop instead of
806
- letting the container show through on hover. */
807
- :is(.btn, a.badge, button.badge):is(.soft) {
808
- --ui-hover-bg: color-mix(
809
- in oklch,
810
- var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
811
- var(--intent, var(--neutral))
812
- );
813
- }
814
-
815
- /* Mixed against the opaque --surface-subtle rather than transparent: a
816
- transparent-based tint reads clean on light surfaces but goes muddy and
817
- low-contrast on dark ones, since the same low alpha lands very
818
- differently over a light vs. a near-black backdrop. */
819
- :is(.btn, a.badge, button.badge):is(.outline) {
820
- --ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
821
- }
822
1063
 
823
- :is(.btn, a.badge, button.badge):is(.ghost) {
824
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--text)) 8%, transparent);
1064
+ .inverted {
1065
+ --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
825
1066
  }
826
1067
  }
827
1068
 
828
- /*
829
- * Prose is the opt-in rich text scope for authored content.
830
- *
831
- * Global element styles stay mild so app UI is not shaped accidentally. Page
832
- * layout, forms, cards, navigation, and app tables remain outside this scope.
833
- */
834
-
835
- .prose {
836
- /* Prose scale */
837
- --prose-size: 1rem;
838
- --prose-line-height: 1.7;
839
- --prose-measure: 70ch;
840
- --prose-flow: 1em;
841
- --prose-heading-line-height: 1.2;
842
- --prose-h1: 2rem;
843
- --prose-h2: 1.5rem;
844
- --prose-h3: 1.25rem;
845
- --prose-h4: 1.125rem;
846
-
847
- max-inline-size: var(--prose-measure);
848
- color: var(--text);
849
- font-size: var(--prose-size);
850
- line-height: var(--prose-line-height);
851
- overflow-wrap: break-word;
852
- }
853
-
854
- .prose :where(p, ul, ol, dl, blockquote, pre, table, figure) {
855
- margin-block: var(--prose-flow) 0;
856
- }
857
-
858
- .prose :where(h1, h2, h3, h4, h5, h6) {
859
- margin-block: 1.5em 0;
860
- color: var(--text);
861
- font-weight: var(--font-weight-strong);
862
- line-height: var(--prose-heading-line-height);
863
- }
864
-
865
- .prose :where(p, li, blockquote) {
866
- text-wrap: pretty;
867
- }
868
-
869
- .prose > :first-child {
870
- margin-block-start: 0;
871
- }
872
-
873
- .prose h1 {
874
- font-size: var(--prose-h1);
875
- }
876
-
877
- .prose h2 {
878
- font-size: var(--prose-h2);
879
- }
880
-
881
- .prose h3 {
882
- font-size: var(--prose-h3);
883
- }
884
-
885
- .prose h4 {
886
- font-size: var(--prose-h4);
887
- }
888
-
889
- .prose a {
890
- color: var(--primary);
891
- text-decoration-line: underline;
892
- text-decoration-thickness: 0.08em;
893
- text-underline-offset: 0.18em;
894
- }
895
-
896
- @media (hover: hover) {
897
- .prose a:hover {
898
- text-decoration-thickness: 0.12em;
899
- }
900
-
901
- @supports (color: color-mix(in oklch, red, white)) {
902
- .prose a:hover {
903
- color: color-mix(in oklch, var(--primary), var(--text) 15%);
904
- }
905
- }
906
- }
907
-
908
- .prose :where(ul, ol) {
909
- padding-inline-start: 1.5em;
910
- }
911
-
912
- .prose li + li {
913
- margin-block-start: 0.35em;
914
- }
915
-
916
- .prose code {
917
- padding: 0.15em 0.3em;
918
- border-radius: var(--radius-sm);
919
- background: var(--surface-subtle);
920
- color: var(--text);
921
- font-size: 0.875em;
922
- }
923
-
924
- .prose kbd {
925
- display: inline-flex;
926
- align-items: center;
927
- justify-content: center;
928
- min-inline-size: 1.75em;
929
- min-block-size: 1.6em;
930
- padding-inline: 0.45em;
931
- border: var(--border-width) solid var(--border);
932
- border-block-end-width: calc(var(--border-width) * 2);
933
- border-radius: var(--radius-sm);
934
- background: var(--surface-raised);
935
- color: var(--text);
936
- font-family: var(--font-mono);
937
- font-size: 0.875em;
938
- font-weight: var(--font-weight-medium);
939
- line-height: 1;
940
- white-space: nowrap;
941
- vertical-align: 0.08em;
942
- }
943
-
944
- .prose pre {
945
- overflow-x: auto;
946
- padding: 1em;
947
- border: var(--border-width) solid var(--border);
948
- border-radius: var(--radius);
949
- background: var(--surface-subtle);
950
- }
951
-
952
- .prose pre code {
953
- padding: 0;
954
- background: transparent;
955
- }
956
-
957
- .prose blockquote {
958
- padding-inline-start: 1em;
959
- border-inline-start: var(--border-width) solid var(--border);
960
- color: var(--text-muted);
961
- }
962
-
963
- .prose figure {
964
- margin-inline: 0;
965
- }
966
-
967
- .prose figcaption {
968
- margin-block-start: 0.5em;
969
- color: var(--text-muted);
970
- font-size: 0.875em;
971
- }
972
-
973
- .prose table {
974
- inline-size: 100%;
975
- border-collapse: collapse;
976
- }
977
-
978
- .prose th,
979
- .prose td {
980
- padding: 0.5em;
981
- border-block-end: var(--border-width) solid var(--border);
982
- text-align: start;
983
- }
984
-
985
- /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
986
- Non-fluid and always available — use .text-lead from the optional fluid
987
- module for marketing/display surfaces. */
988
- .lead {
989
- font-size: 1.125em;
990
- line-height: var(--line-height-relaxed);
991
- }
992
-
993
- /* Readable content measure. Composes with .center for centering or with
994
- margin-inline: auto for a one-off centered block. */
995
- .measure {
996
- max-inline-size: var(--prose-measure, 70ch);
997
- }
998
-
999
- .prose.sm {
1000
- --prose-size: 0.9375rem;
1001
- --prose-h1: 1.75rem;
1002
- --prose-h2: 1.375rem;
1003
- --prose-h3: 1.125rem;
1004
- --prose-h4: 1rem;
1005
- }
1006
-
1007
- .prose.lg {
1008
- --prose-size: 1.125rem;
1009
- --prose-measure: 72ch;
1010
- --prose-h1: 2.25rem;
1011
- --prose-h2: 1.625rem;
1012
- --prose-h3: 1.375rem;
1013
- --prose-h4: 1.25rem;
1014
- }
1069
+ /* Interactive hover recipes live in the components that consume them:
1070
+ components/button.css and components/badge.css. */
1015
1071
 
1016
1072
  /* Controls */
1017
1073
  /*
@@ -1025,19 +1081,40 @@ small {
1025
1081
  * Disabled state uses the native disabled attribute for buttons and inputs.
1026
1082
  * Anchors have no native disabled state, so a.btn uses aria-disabled="true"
1027
1083
  * for disabled styling and activation guards.
1084
+ *
1085
+ * Public hooks:
1086
+ * --btn-font-size
1087
+ * --btn-min-size
1088
+ * --btn-pad-x
1089
+ * --btn-radius
1090
+ * --btn-focus-color
1091
+ * --btn-gap
1092
+ * --btn-hover-overlay
1093
+ * --btn-default-bg
1094
+ * --btn-default-fg
1095
+ * --btn-default-border
1096
+ * --btn-border-color
1097
+ *
1098
+ * Internal:
1099
+ * --btn-bg
1100
+ * --btn-fg
1101
+ * --btn-border
1102
+ * --btn-default-hover-bg
1103
+ * --btn-focus-ring-color
1104
+ * --btn-border-block-end-color
1105
+ * --btn-border-block-end-width
1106
+ * --spinner-width
1028
1107
  */
1029
1108
 
1030
1109
  /* Intent boundary — inherited intents do not tint this component.
1031
1110
  Local intent classes still win because :where() has zero specificity. */
1032
1111
  :where(.btn) {
1033
- /* @sync intent-boundary */
1034
1112
  --intent: initial;
1035
1113
  --intent-fg: initial;
1036
1114
  --ui-bg: initial;
1037
1115
  --ui-fg: initial;
1038
1116
  --ui-border: initial;
1039
1117
  --ui-hover-bg: initial;
1040
- /* @sync intent-boundary */
1041
1118
  }
1042
1119
 
1043
1120
  .btn {
@@ -1047,19 +1124,20 @@ small {
1047
1124
  --btn-bg: var(--ui-bg, var(--btn-default-bg, var(--intent, var(--neutral))));
1048
1125
  --btn-fg: var(--ui-fg, var(--btn-default-fg, var(--intent-fg, var(--neutral-fg))));
1049
1126
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
1127
+ --btn-default-hover-bg: var(--btn-bg);
1050
1128
  --btn-radius: var(--radius);
1051
1129
  --btn-focus-color: var(--intent, var(--focus));
1052
- --btn-hover-bg: var(--ui-hover-bg, var(--btn-bg));
1053
1130
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
1054
1131
 
1132
+ /* Positioning context for indicators, overlays, and grouped states. */
1055
1133
  position: relative;
1056
1134
  display: inline-flex;
1057
1135
  align-items: center;
1058
1136
  justify-content: center;
1059
- gap: 0.375em;
1137
+ gap: var(--btn-gap, 0.375em);
1060
1138
  min-block-size: var(--btn-min-size);
1061
- padding-block: var(--space-2);
1062
- padding-block: max(var(--space-1), calc((var(--btn-min-size) - 1lh) / 2));
1139
+ padding-block: var(--space-20);
1140
+ padding-block: max(var(--space-10), calc((var(--btn-min-size) - 1lh) / 2));
1063
1141
  padding-inline: var(--btn-pad-x);
1064
1142
  border: var(--border-width) solid var(--btn-border-color, var(--btn-border));
1065
1143
  border-block-end-color: var(
@@ -1084,6 +1162,11 @@ small {
1084
1162
  transform var(--duration);
1085
1163
  }
1086
1164
 
1165
+ .btn.icon-only {
1166
+ inline-size: var(--btn-min-size);
1167
+ padding-inline: 0;
1168
+ }
1169
+
1087
1170
  @supports (color: color-mix(in oklch, red, white)) {
1088
1171
  .btn {
1089
1172
  --btn-focus-ring-color: color-mix(in oklch, var(--btn-focus-color) 28%, transparent);
@@ -1094,7 +1177,7 @@ small {
1094
1177
  fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
1095
1178
  .btn.ghost {
1096
1179
  --ui-bg: transparent;
1097
- --ui-fg: var(--intent, var(--text));
1180
+ --ui-fg: var(--intent, currentColor);
1098
1181
  --ui-border: transparent;
1099
1182
  }
1100
1183
 
@@ -1108,13 +1191,37 @@ small {
1108
1191
  --btn-hover-overlay: transparent;
1109
1192
  }
1110
1193
 
1111
- /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is shared with
1112
- .badge in variants.css; the overlay darkening for solid/soft is .btn-specific. */
1113
- @media (hover: hover) {
1114
- .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
1115
- background: var(--btn-hover-bg);
1116
- box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
1194
+ /* Shared hover recipe — sets --ui-hover-bg consumed by the hover rule below.
1195
+ Declared outside :hover: the variables resolve only when the hover rule
1196
+ reads them, so the values are inert until interaction. */
1197
+ .btn:is(.outline, .ghost) {
1198
+ --ui-hover-bg: transparent;
1199
+ }
1200
+
1201
+ @supports (color: color-mix(in oklch, red, white)) {
1202
+ .btn {
1203
+ --btn-default-hover-bg: color-mix(in oklch, var(--btn-bg) 88%, black);
1117
1204
  }
1205
+
1206
+ .btn.soft {
1207
+ --ui-hover-bg: color-mix(
1208
+ in oklch,
1209
+ var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
1210
+ var(--intent, var(--neutral))
1211
+ );
1212
+ }
1213
+
1214
+ .btn.outline,
1215
+ .btn.ghost {
1216
+ --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
1217
+ }
1218
+ }
1219
+
1220
+ /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
1221
+ the overlay darkening for solid/soft is .btn-specific. */
1222
+ .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
1223
+ background: var(--ui-hover-bg, var(--btn-default-hover-bg));
1224
+ box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
1118
1225
  }
1119
1226
 
1120
1227
  .btn:not(:where(:disabled, [aria-disabled="true"])):active {
@@ -1123,7 +1230,7 @@ small {
1123
1230
 
1124
1231
  .btn.link {
1125
1232
  --ui-bg: transparent;
1126
- --ui-fg: var(--intent, var(--text));
1233
+ --ui-fg: var(--intent, currentColor);
1127
1234
  --ui-border: transparent;
1128
1235
  --btn-radius: var(--radius-sm);
1129
1236
  --btn-border-block-end-color: transparent;
@@ -1131,6 +1238,8 @@ small {
1131
1238
 
1132
1239
  min-block-size: auto;
1133
1240
  padding-inline: 0;
1241
+ inline-size: fit-content;
1242
+ max-inline-size: 100%;
1134
1243
  border-color: transparent;
1135
1244
  text-decoration: underline;
1136
1245
  text-underline-offset: 0.18em;
@@ -1139,16 +1248,13 @@ small {
1139
1248
  /* .btn.circle needs no local rule: the .circle utility (utilities.css) loads
1140
1249
  after components and overrides border-radius at equal specificity. */
1141
1250
 
1142
- @media (hover: hover) {
1143
- .btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
1144
- background: transparent;
1145
- color: var(--intent, var(--text));
1146
- text-decoration-thickness: 0.12em;
1147
- }
1251
+ .btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
1252
+ background: transparent;
1253
+ text-decoration-thickness: 0.12em;
1148
1254
  }
1149
1255
 
1150
1256
  .btn:focus-visible {
1151
- outline: none;
1257
+ outline: 2px solid transparent;
1152
1258
  box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
1153
1259
  }
1154
1260
 
@@ -1182,13 +1288,15 @@ a.btn[aria-disabled="true"] {
1182
1288
  --spinner-width: 0.15em;
1183
1289
  }
1184
1290
 
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));
1291
+ /* Toggle and current state.
1292
+ State must outrank the shared .outline/.ghost hover recipes: a pressed or
1293
+ current button keeps its intent background and foreground while hovered.
1294
+ aria-current="page" is the pagination current-page marker; aria-pressed
1295
+ covers toggle buttons. */
1296
+ .btn:is([aria-pressed="true"], [aria-current="page"]):not(.link) {
1297
+ --btn-bg: var(--intent, var(--state-selected));
1298
+ --btn-fg: var(--intent-fg, var(--state-selected-fg));
1299
+ --btn-border: var(--intent, var(--state-selected));
1192
1300
  --ui-hover-bg: var(--btn-bg);
1193
1301
  }
1194
1302
 
@@ -1206,18 +1314,16 @@ a.btn[aria-disabled="true"] {
1206
1314
  color: LinkText;
1207
1315
  }
1208
1316
 
1209
- .btn:is([aria-pressed="true"], [aria-current]:not([aria-current="false"])):not(.link) {
1317
+ .btn:is([aria-pressed="true"], [aria-current="page"]):not(.link) {
1210
1318
  border-color: Highlight;
1211
1319
  background: Highlight;
1212
1320
  color: HighlightText;
1213
1321
  forced-color-adjust: none;
1214
1322
  }
1215
1323
 
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
- }
1324
+ .btn:hover:not(:where(:disabled, [aria-disabled="true"], .link)) {
1325
+ outline: var(--border-width) solid ButtonText;
1326
+ outline-offset: 2px;
1221
1327
  }
1222
1328
 
1223
1329
  .btn:disabled,
@@ -1235,34 +1341,54 @@ a.btn[aria-disabled="true"] {
1235
1341
  * Soft by default. .solid for counters and notification badges. The dot
1236
1342
  * (.badge:empty) stays solid regardless — a soft dot is pointless.
1237
1343
  * Shared .outline variant stays available.
1344
+ *
1345
+ * Public hooks:
1346
+ * --badge-size
1347
+ * --badge-pad-x
1348
+ * --badge-font-size
1349
+ * --badge-dot-size
1350
+ * --badge-radius
1351
+ *
1352
+ * Internal:
1353
+ * --badge-default-bg
1354
+ * --badge-default-fg
1355
+ * --badge-default-border
1356
+ * --badge-default-hover-bg
1357
+ * --badge-bg
1358
+ * --badge-fg
1359
+ * --badge-border
1238
1360
  */
1239
1361
  /* Intent boundary — inherited intents do not tint this component.
1240
1362
  Local intent classes still win because :where() has zero specificity. */
1241
1363
  :where(.badge) {
1242
- /* @sync intent-boundary */
1243
1364
  --intent: initial;
1244
1365
  --intent-fg: initial;
1245
1366
  --ui-bg: initial;
1246
1367
  --ui-fg: initial;
1247
1368
  --ui-border: initial;
1248
1369
  --ui-hover-bg: initial;
1249
- /* @sync intent-boundary */
1250
1370
  }
1251
1371
 
1252
1372
  .badge {
1253
- --badge-size: var(--variant-compact-size, 1.5rem);
1373
+ --badge-size: var(--density-compact-size);
1254
1374
  --badge-pad-x: 0.55em;
1255
- --badge-font-size: var(--variant-compact-font-size, 0.8125rem);
1375
+ --badge-font-size: 0.8125rem;
1256
1376
  --badge-dot-size: 0.5rem;
1257
- --badge-bg: var(--ui-bg, transparent);
1258
- --badge-fg: var(--ui-fg, var(--intent, var(--text)));
1259
- --badge-border: var(--ui-border, var(--intent, var(--border)));
1260
- --badge-radius: 999px;
1377
+ --badge-default-bg: var(--surface-subtle);
1378
+ --badge-default-fg: var(--intent, var(--text));
1379
+ --badge-default-border: var(--border);
1380
+ --badge-default-hover-bg: var(--badge-bg);
1381
+ --badge-bg: var(--ui-bg, var(--badge-default-bg));
1382
+ --badge-fg: var(--ui-fg, var(--badge-default-fg));
1383
+ --badge-border: var(--ui-border, var(--badge-default-border));
1384
+ --badge-radius: var(--radius-full);
1261
1385
 
1262
1386
  display: inline-flex;
1263
1387
  align-items: center;
1264
1388
  justify-content: center;
1265
1389
  gap: 0.35em;
1390
+ inline-size: fit-content;
1391
+ max-inline-size: 100%;
1266
1392
  min-block-size: var(--badge-size);
1267
1393
  padding-inline: var(--badge-pad-x);
1268
1394
  border: var(--border-width) solid var(--badge-border);
@@ -1281,18 +1407,17 @@ a.btn[aria-disabled="true"] {
1281
1407
  box-shadow var(--duration);
1282
1408
  }
1283
1409
 
1284
- /* Soft by default — duplicated here with @sync markers for check-sync.js
1285
- to guard, the same way alert.css guards its own soft recipe. */
1410
+ /* Soft by default. Local defaults remain behind --ui-* in the resolution
1411
+ chain, so explicit shared treatments compose without selector exclusions. */
1286
1412
  @supports (color: color-mix(in oklch, red, white)) {
1287
- .badge:where(:not(.solid, .outline)) {
1413
+ .badge {
1288
1414
  /* @sync soft-recipe */
1289
- --badge-bg: color-mix(
1415
+ --badge-default-bg: color-mix(
1290
1416
  in oklch,
1291
1417
  var(--surface) var(--soft-bg-mix),
1292
1418
  var(--intent, var(--neutral))
1293
1419
  );
1294
- --badge-fg: var(--intent, var(--text));
1295
- --badge-border: color-mix(
1420
+ --badge-default-border: color-mix(
1296
1421
  in oklch,
1297
1422
  var(--surface) var(--soft-border-mix),
1298
1423
  var(--intent, var(--neutral))
@@ -1331,27 +1456,44 @@ a.btn[aria-disabled="true"] {
1331
1456
  cursor: pointer;
1332
1457
  }
1333
1458
 
1334
- @media (hover: hover) {
1335
- .badge > button:not(:disabled):hover {
1336
- background: var(--hover-overlay);
1337
- }
1459
+ .badge > button:not(:disabled):hover {
1460
+ background: var(--hover-overlay);
1338
1461
  }
1339
1462
 
1340
- /* .solid/.soft/.outline are shared via variants.css, including the
1341
- .soft/.outline hover recipe (--ui-hover-bg). */
1342
- @media (hover: hover) {
1343
- :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
1344
- background: var(--ui-hover-bg, var(--badge-bg));
1463
+ /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
1464
+ Declared outside :hover like button.css for the same reason. */
1465
+ :is(a.badge, button.badge).outline {
1466
+ --ui-hover-bg: transparent;
1467
+ }
1468
+
1469
+ @supports (color: color-mix(in oklch, red, white)) {
1470
+ :is(a.badge, button.badge):is(.solid, :empty) {
1471
+ --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
1345
1472
  }
1346
1473
 
1347
- :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
1348
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
1474
+ :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
1475
+ --badge-default-hover-bg: color-mix(
1476
+ in oklch,
1477
+ var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
1478
+ var(--intent, var(--neutral))
1479
+ );
1480
+ }
1481
+
1482
+ :is(a.badge, button.badge).outline {
1483
+ --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
1349
1484
  }
1350
1485
  }
1351
1486
 
1487
+ :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
1488
+ background: var(--ui-hover-bg, var(--badge-default-hover-bg));
1489
+ }
1490
+
1491
+ :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"], .outline)):hover {
1492
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
1493
+ }
1494
+
1352
1495
  button.badge {
1353
1496
  cursor: pointer;
1354
- font: inherit;
1355
1497
  }
1356
1498
 
1357
1499
  button.badge:disabled,
@@ -1365,27 +1507,18 @@ a.badge[aria-disabled="true"] {
1365
1507
  }
1366
1508
 
1367
1509
  @media (forced-colors: active) {
1368
- .badge {
1369
- border-color: ButtonText;
1370
- background: Canvas;
1371
- color: CanvasText;
1372
- box-shadow: none;
1373
- }
1374
-
1375
1510
  .badge:empty {
1376
1511
  background: ButtonText;
1377
1512
  }
1378
1513
 
1379
- @media (hover: hover) {
1380
- :where(a.badge, button.badge):hover:not(:where(:disabled, [aria-disabled="true"])) {
1381
- outline: var(--border-width) solid ButtonText;
1382
- outline-offset: 2px;
1383
- }
1514
+ :where(a.badge, button.badge):hover:not(:where(:disabled, [aria-disabled="true"])) {
1515
+ outline: var(--border-width) solid ButtonText;
1516
+ outline-offset: 2px;
1517
+ }
1384
1518
 
1385
- .badge > button:not(:disabled):hover {
1386
- outline: var(--border-width) solid currentColor;
1387
- outline-offset: 1px;
1388
- }
1519
+ .badge > button:not(:disabled):hover {
1520
+ outline: var(--border-width) solid currentColor;
1521
+ outline-offset: 1px;
1389
1522
  }
1390
1523
 
1391
1524
  button.badge:disabled,
@@ -1405,42 +1538,54 @@ a.badge[aria-disabled="true"] {
1405
1538
  * --alert-border-inline-start-color/-width are unset extension points, mirroring
1406
1539
  * --btn-border-block-end-* (button.css), for themes that give alerts a
1407
1540
  * callout-style colored flag on the leading edge instead of a uniform border.
1541
+ *
1542
+ * Public hooks:
1543
+ * --alert-pad-block
1544
+ * --alert-pad-inline
1545
+ * --alert-font-size
1546
+ * --alert-radius
1547
+ * --alert-icon-size
1548
+ * --alert-dismiss-size
1549
+ * --alert-border-inline-start-color
1550
+ * --alert-border-inline-start-width
1551
+ *
1552
+ * Internal:
1553
+ * --alert-default-bg
1554
+ * --alert-default-border
1555
+ * --alert-bg
1556
+ * --alert-fg
1557
+ * --alert-border
1558
+ * --alert-dismiss-icon-size
1408
1559
  */
1409
1560
  /* Intent boundary — inherited intents do not tint this component.
1410
1561
  Local intent classes still win because :where() has zero specificity. */
1411
1562
  :where(.alert) {
1412
- /* @sync intent-boundary */
1413
1563
  --intent: initial;
1414
1564
  --intent-fg: initial;
1415
1565
  --ui-bg: initial;
1416
1566
  --ui-fg: initial;
1417
1567
  --ui-border: initial;
1418
- --ui-hover-bg: initial;
1419
- /* @sync intent-boundary */
1420
1568
  }
1421
1569
 
1422
1570
  .alert {
1423
- --alert-pad-block: var(--variant-pad-block, var(--space-3));
1424
- --alert-pad-inline: var(--space-4);
1425
- --alert-font-size: var(--variant-font-size, var(--font-size-md));
1426
- --alert-icon-size: var(--variant-icon-size, 1.25rem);
1427
- --alert-bg: var(--surface-subtle);
1428
- --alert-border: var(--border);
1429
- --ui-bg: var(--alert-bg);
1430
- --ui-border: var(--alert-border);
1431
- --ui-fg: var(--intent, var(--text));
1571
+ --alert-pad-block: var(--space-30);
1572
+ --alert-pad-inline: var(--space-40);
1573
+ --alert-font-size: var(--font-size-md);
1574
+ --alert-icon-size: 1.25rem;
1575
+ --alert-default-bg: var(--surface-subtle);
1576
+ --alert-default-border: var(--border);
1577
+ --alert-bg: var(--ui-bg, var(--alert-default-bg));
1578
+ --alert-fg: var(--ui-fg, var(--intent, var(--text)));
1579
+ --alert-border: var(--ui-border, var(--alert-default-border));
1432
1580
 
1433
1581
  padding-block: var(--alert-pad-block);
1434
1582
  padding-inline: var(--alert-pad-inline);
1435
- background: var(--ui-bg, transparent);
1436
- color: var(--ui-fg, var(--text));
1437
- border: var(--border-width) solid var(--ui-border, var(--border));
1438
- border-inline-start-color: var(
1439
- --alert-border-inline-start-color,
1440
- var(--ui-border, var(--border))
1441
- );
1583
+ background: var(--alert-bg);
1584
+ color: var(--alert-fg);
1585
+ border: var(--border-width) solid var(--alert-border);
1586
+ border-inline-start-color: var(--alert-border-inline-start-color, var(--alert-border));
1442
1587
  border-inline-start-width: var(--alert-border-inline-start-width, var(--border-width));
1443
- border-radius: var(--radius-lg);
1588
+ border-radius: var(--alert-radius, var(--radius-lg));
1444
1589
  font-size: var(--alert-font-size);
1445
1590
  }
1446
1591
 
@@ -1456,24 +1601,17 @@ a.badge[aria-disabled="true"] {
1456
1601
  margin-block-end: 0;
1457
1602
  }
1458
1603
 
1459
- /* Alerts are soft by default. Use .solid or .outline for stronger emphasis. */
1460
- .alert.solid {
1461
- --ui-bg: var(--intent, var(--neutral));
1462
- --ui-fg: var(--intent-fg, var(--neutral-fg));
1463
- --ui-border: transparent;
1464
- }
1465
-
1466
- .alert.outline {
1467
- --ui-bg: transparent;
1468
- --ui-fg: var(--intent, var(--text));
1469
- --ui-border: var(--intent, var(--border));
1470
- }
1471
-
1604
+ /* Alerts are soft by default. The recipe writes local defaults rather than
1605
+ --ui-* so an explicit shared treatment always wins without exclusions. */
1472
1606
  @supports (color: color-mix(in oklch, red, white)) {
1473
- .alert:where(:not(.solid, .outline, .callout)) {
1607
+ .alert {
1474
1608
  /* @sync soft-recipe */
1475
- --ui-bg: color-mix(in oklch, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
1476
- --ui-border: color-mix(
1609
+ --alert-default-bg: color-mix(
1610
+ in oklch,
1611
+ var(--surface) var(--soft-bg-mix),
1612
+ var(--intent, var(--neutral))
1613
+ );
1614
+ --alert-default-border: color-mix(
1477
1615
  in oklch,
1478
1616
  var(--surface) var(--soft-border-mix),
1479
1617
  var(--intent, var(--neutral))
@@ -1482,8 +1620,8 @@ a.badge[aria-disabled="true"] {
1482
1620
  }
1483
1621
  }
1484
1622
 
1485
- /* Decorative leading icon */
1486
- .alert > :first-child[aria-hidden="true"] {
1623
+ /* Decorative leading icon — use .alert-icon on the icon element. */
1624
+ .alert > .alert-icon {
1487
1625
  display: inline-block;
1488
1626
  flex: none;
1489
1627
  inline-size: var(--alert-icon-size);
@@ -1493,33 +1631,84 @@ a.badge[aria-disabled="true"] {
1493
1631
  vertical-align: -0.125em;
1494
1632
  }
1495
1633
 
1634
+ /* Dismiss — compact close button for inline alerts. Transparent at rest with
1635
+ a reduced opacity glyph so it stays secondary inside an already-colored
1636
+ alert; hover reinforces it via the shared overlay. Uses the shared
1637
+ --icon-close mask, so no icon font or text glyph is required in the markup.
1638
+ Standard alerts place it as a direct trailing child; admonitions place it
1639
+ inside .alert-title (see below). */
1640
+ .alert-dismiss {
1641
+ --alert-dismiss-size: 1.75rem;
1642
+ --alert-dismiss-icon-size: 0.875rem;
1643
+
1644
+ display: inline-grid;
1645
+ place-items: center;
1646
+ inline-size: var(--alert-dismiss-size);
1647
+ block-size: var(--alert-dismiss-size);
1648
+ flex: none;
1649
+ padding: 0;
1650
+ border: 0;
1651
+ border-radius: var(--radius);
1652
+ background: transparent;
1653
+ color: currentColor;
1654
+ opacity: 0.65;
1655
+ cursor: pointer;
1656
+ }
1657
+
1658
+ .alert-dismiss::before {
1659
+ content: "";
1660
+ inline-size: var(--alert-dismiss-icon-size);
1661
+ block-size: var(--alert-dismiss-icon-size);
1662
+ background: currentColor;
1663
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
1664
+ mask: var(--icon-close) center / contain no-repeat;
1665
+ }
1666
+
1667
+ @media (hover: hover) {
1668
+ .alert-dismiss:hover {
1669
+ background: var(--hover-overlay);
1670
+ opacity: 1;
1671
+ }
1672
+ }
1673
+
1674
+ /* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
1675
+ both. Icon and dismiss are pinned to the outer columns; anything else (the
1676
+ message content) flows into the remaining text column. */
1496
1677
  @supports selector(.alert:has(> *)) {
1497
- .alert:has(> :first-child[aria-hidden="true"]) {
1678
+ .alert:has(> .alert-icon, > .alert-dismiss) {
1498
1679
  display: grid;
1499
- grid-template-columns: auto minmax(0, 1fr);
1680
+ grid-template-columns: minmax(0, 1fr);
1500
1681
  align-items: var(--items-align, start);
1501
- column-gap: var(--space-3);
1682
+ column-gap: var(--space-30);
1502
1683
  }
1503
1684
 
1504
- .alert:has(> :first-child[aria-hidden="true"]) > :first-child {
1685
+ .alert:has(> .alert-icon) {
1686
+ grid-template-columns: auto minmax(0, 1fr);
1687
+ }
1688
+
1689
+ .alert:has(> .alert-icon) > .alert-icon {
1505
1690
  grid-column: 1;
1506
1691
  grid-row: 1 / -1;
1507
1692
  display: inline-grid;
1508
1693
  place-items: center;
1509
- inline-size: var(--alert-icon-size);
1510
- block-size: var(--alert-icon-size);
1511
1694
  margin-block-start: 0.125em;
1512
- font-size: var(--alert-icon-size);
1513
- line-height: 1;
1514
1695
  }
1515
1696
 
1516
- .alert:has(> :first-child[aria-hidden="true"]) > :first-child:is(svg, i) {
1517
- inline-size: var(--alert-icon-size);
1518
- block-size: var(--alert-icon-size);
1697
+ .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
1698
+ grid-column: 2;
1519
1699
  }
1520
1700
 
1521
- .alert:has(> :first-child[aria-hidden="true"]) > :not(:first-child) {
1522
- grid-column: 2;
1701
+ .alert:has(> .alert-dismiss) {
1702
+ grid-template-columns: minmax(0, 1fr) auto;
1703
+ }
1704
+
1705
+ .alert:has(> .alert-icon):has(> .alert-dismiss) {
1706
+ grid-template-columns: auto minmax(0, 1fr) auto;
1707
+ }
1708
+
1709
+ .alert:has(> .alert-dismiss) > .alert-dismiss {
1710
+ grid-column: -2 / -1;
1711
+ grid-row: 1;
1523
1712
  }
1524
1713
  }
1525
1714
 
@@ -1534,100 +1723,84 @@ a.badge[aria-disabled="true"] {
1534
1723
  Canvas backplate (unreadable) — so .solid intentionally falls back to the
1535
1724
  default look. */
1536
1725
 
1537
- /* Callout — neutral surface with an inset accent band on the leading edge.
1538
- The ::before pseudo is absolutely positioned and inset to respect the
1539
- outer border-radius, so it never needs its own radius. */
1726
+ /* Callout — neutral surface with a thick accent border on the leading edge.
1727
+ The thickness follows the shared --alert-border-inline-start-width hook. */
1540
1728
  .alert.callout {
1541
1729
  --alert-bg: var(--surface-subtle);
1542
- --ui-bg: var(--alert-bg);
1543
- --ui-fg: var(--text);
1544
- --ui-border: var(--border);
1545
- position: relative;
1546
- padding-inline-start: calc(var(--alert-pad-inline) + var(--space-3));
1547
- }
1548
-
1549
- .alert.callout::before {
1550
- content: "";
1551
- position: absolute;
1552
- inset-block: var(--space-2);
1553
- inset-inline-start: var(--space-2);
1554
- inline-size: 0.25rem;
1555
- border-radius: var(--radius-full);
1556
- background: var(--intent, var(--neutral));
1730
+ --alert-fg: var(--text);
1731
+ border: 0;
1732
+ border-inline-start: var(--alert-border-inline-start-width, 4px) solid
1733
+ var(--alert-border-inline-start-color, var(--intent, var(--neutral)));
1557
1734
  }
1558
1735
 
1559
- /* Admonition — structured title/body anatomy with a tinted title band.
1560
- The root is layout-neutralized: no padding, no gap, no icon grid. */
1736
+ /* Admonition — structured static box with a tinted title bar and a body
1737
+ that rests on the page surface. Not collapsible — use .accordion inside
1738
+ the body if the content needs to be expandable. */
1561
1739
  .alert.admonition {
1562
1740
  padding: 0;
1563
- gap: 0;
1564
1741
  display: block;
1565
1742
  overflow: clip;
1743
+ border-color: var(--intent, var(--border));
1566
1744
  }
1567
1745
 
1568
- .alert-title {
1746
+ .alert.admonition > .alert-title {
1747
+ display: flex;
1748
+ align-items: center;
1749
+ gap: var(--space-20);
1569
1750
  padding-block: var(--alert-pad-block);
1570
1751
  padding-inline: var(--alert-pad-inline);
1752
+ border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
1753
+ border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
1754
+ }
1571
1755
 
1572
- @supports (color: color-mix(in oklch, red, white)) {
1756
+ /* Dismiss inside an admonition title bar rides the inline end. */
1757
+ .alert.admonition > .alert-title > .alert-dismiss {
1758
+ margin-inline-start: auto;
1759
+ }
1760
+
1761
+ @supports (color: color-mix(in oklch, red, white)) {
1762
+ .alert.admonition > .alert-title {
1573
1763
  background: color-mix(
1574
1764
  in oklch,
1575
1765
  var(--surface) var(--soft-bg-mix),
1576
1766
  var(--intent, var(--neutral))
1577
1767
  );
1578
1768
  }
1769
+ }
1579
1770
 
1580
- @supports not (color: color-mix(in oklch, red, white)) {
1771
+ @supports not (color: color-mix(in oklch, red, white)) {
1772
+ .alert.admonition > .alert-title {
1581
1773
  background: var(--intent, var(--neutral));
1774
+ color: var(--intent-fg, var(--neutral-fg));
1582
1775
  }
1583
-
1584
- border-start-start-radius: calc(var(--radius-lg) - var(--border-width));
1585
- border-start-end-radius: calc(var(--radius-lg) - var(--border-width));
1586
- }
1587
-
1588
- .alert-title:where(summary) {
1589
- cursor: pointer;
1590
- user-select: none;
1591
- list-style: none;
1592
- }
1593
-
1594
- .alert-title:where(summary)::-webkit-details-marker {
1595
- display: none;
1596
- }
1597
-
1598
- .alert-title[aria-hidden] {
1599
- display: inline-flex;
1600
- gap: var(--space-2);
1601
- align-items: center;
1602
1776
  }
1603
1777
 
1604
- .alert-body {
1778
+ .alert.admonition > .alert-body {
1605
1779
  padding-block: var(--alert-pad-block);
1606
1780
  padding-inline: var(--alert-pad-inline);
1781
+ background: var(--surface);
1607
1782
  color: var(--text);
1608
1783
  }
1609
1784
 
1610
- .alert-body > :first-child {
1785
+ .alert.admonition > .alert-body > :first-child {
1611
1786
  margin-block-start: 0;
1612
1787
  }
1613
1788
 
1614
- .alert-body > :last-child {
1789
+ .alert.admonition > .alert-body > :last-child {
1615
1790
  margin-block-end: 0;
1616
1791
  }
1617
1792
 
1618
- details.alert.admonition[open] .alert-title {
1619
- border-end-start-radius: 0;
1620
- border-end-end-radius: 0;
1621
- }
1622
-
1623
1793
  /*
1624
1794
  * Actions — a horizontal action-button cluster.
1625
1795
  *
1626
1796
  * Composes with .btn (or any inline element) and feeds layout via the shared
1627
1797
  * --gap token.
1798
+ *
1799
+ * Internal:
1800
+ * --gap (local value for the shared gap token)
1628
1801
  */
1629
1802
  .actions {
1630
- --gap: var(--space-2);
1803
+ --gap: var(--space-20);
1631
1804
 
1632
1805
  display: flex;
1633
1806
  flex-wrap: wrap;
@@ -1650,29 +1823,45 @@ details.alert.admonition[open] .alert-title {
1650
1823
  * Surface, border, radius, padding, density, and elevation stay local here.
1651
1824
  * Direct header/footer children are structural slots. Page layout remains the
1652
1825
  * job of markup and layout primitives outside the card.
1826
+ *
1827
+ * Public hooks:
1828
+ * --card-pad
1829
+ * --card-radius
1830
+ * --card-max-inline-size
1831
+ *
1832
+ * Child contract:
1833
+ * Direct .bleed children escape --card-pad (negative inset, wider media);
1834
+ * edge children inherit the card corners. .bleed must be a direct child.
1835
+ *
1836
+ * Internal:
1837
+ * --card-bg
1838
+ * --card-fg
1839
+ * --heading
1840
+ * --busy-overlay-bg
1653
1841
  */
1654
1842
  /* Intent boundary — inherited intents do not tint this component.
1655
1843
  Local intent classes still win because :where() has zero specificity. */
1656
1844
  :where(.card) {
1657
- /* @sync intent-boundary */
1658
- --intent: initial;
1659
- --intent-fg: initial;
1660
1845
  --ui-bg: initial;
1661
1846
  --ui-fg: initial;
1662
1847
  --ui-border: initial;
1663
- --ui-hover-bg: initial;
1664
- /* @sync intent-boundary */
1665
1848
  }
1666
1849
 
1667
1850
  .card {
1668
- --card-pad: var(--space-4);
1851
+ --card-pad: var(--space-40);
1669
1852
  --card-radius: var(--radius-lg);
1670
1853
  --card-max-inline-size: none;
1854
+ --card-bg: var(--ui-bg, var(--surface-raised));
1855
+ --card-fg: var(--ui-fg, var(--text));
1856
+
1857
+ /* Keep contextual descendants tied to the surface this card actually owns. */
1858
+ --heading: var(--card-fg);
1859
+ --busy-overlay-bg: var(--card-bg);
1671
1860
 
1672
1861
  max-inline-size: var(--card-max-inline-size);
1673
1862
  padding: var(--card-pad);
1674
- background: var(--ui-bg, var(--surface));
1675
- color: var(--ui-fg, var(--text));
1863
+ background: var(--card-bg);
1864
+ color: var(--card-fg);
1676
1865
  border: var(--border-width) solid var(--ui-border, var(--border));
1677
1866
  border-radius: var(--card-radius);
1678
1867
  }
@@ -1686,7 +1875,7 @@ details.alert.admonition[open] .alert-title {
1686
1875
  flex-wrap: wrap;
1687
1876
  align-items: var(--items-align, center);
1688
1877
  justify-content: space-between;
1689
- gap: var(--space-3);
1878
+ gap: var(--space-30);
1690
1879
  min-inline-size: 0;
1691
1880
  }
1692
1881
 
@@ -1713,16 +1902,8 @@ details.alert.admonition[open] .alert-title {
1713
1902
  .card.subtle {
1714
1903
  background: var(--surface-subtle);
1715
1904
  }
1716
- .card.inverted {
1717
- background: var(--surface-solid);
1718
- color: var(--surface);
1719
- border-color: transparent;
1720
- --heading: var(--surface);
1721
- --busy-overlay-bg: var(--surface-solid);
1722
- }
1723
-
1724
1905
  .card.compact {
1725
- --card-pad: var(--space-2);
1906
+ --card-pad: var(--space-20);
1726
1907
  }
1727
1908
 
1728
1909
  /* Full-bleed children */
@@ -1765,26 +1946,6 @@ details.alert.admonition[open] .alert-title {
1765
1946
  padding: var(--card-pad);
1766
1947
  }
1767
1948
 
1768
- @media (forced-colors: active) {
1769
- .card {
1770
- border-color: ButtonText;
1771
- background: Canvas;
1772
- color: CanvasText;
1773
- }
1774
-
1775
- .card.raised {
1776
- box-shadow: none;
1777
- }
1778
-
1779
- .card.inverted {
1780
- --heading: CanvasText;
1781
-
1782
- border-color: ButtonText;
1783
- background: Canvas;
1784
- color: CanvasText;
1785
- }
1786
- }
1787
-
1788
1949
  /*
1789
1950
  * Surface — shared modal scrim and backdrop for flyout and drawer.
1790
1951
  *
@@ -1806,7 +1967,7 @@ details.alert.admonition[open] .alert-title {
1806
1967
  z-index: calc(var(--z-menu) - 1);
1807
1968
  background: var(--backdrop-fill);
1808
1969
  opacity: 1;
1809
- transition: opacity var(--duration) var(--ease, ease);
1970
+ transition: opacity var(--duration) var(--ease);
1810
1971
  }
1811
1972
 
1812
1973
  .surface-backdrop[hidden] {
@@ -1823,28 +1984,40 @@ details.alert.admonition[open] .alert-title {
1823
1984
  @supports (transition-behavior: allow-discrete) {
1824
1985
  .surface-backdrop {
1825
1986
  transition:
1826
- opacity var(--duration) var(--ease, ease),
1987
+ opacity var(--duration) var(--ease),
1827
1988
  display var(--duration) allow-discrete;
1828
1989
  }
1829
1990
  }
1830
1991
 
1831
- @media (forced-colors: active) {
1832
- .surface-backdrop {
1833
- background: Canvas;
1834
- }
1835
- }
1836
-
1837
1992
  /*
1838
1993
  * Modal shell styling for native dialog elements.
1839
1994
  *
1840
1995
  * Spacing, radius, transitions, and focus treatment come from shared tokens.
1841
1996
  * Opening behavior and intent-specific decisions stay outside this component.
1997
+ *
1998
+ * Public hooks:
1999
+ * --modal-size
2000
+ * --dialog-viewport-gap
2001
+ * --dialog-icon-size
2002
+ * --dialog-icon-glyph-size
2003
+ *
2004
+ * Internal:
2005
+ * --dialog-close-icon-size
2006
+ * --dialog-viewport-gap-2x
1842
2007
  */
1843
2008
  dialog.modal {
1844
2009
  --modal-size: 32rem;
2010
+ --dialog-viewport-gap: var(--space-40);
2011
+ /* calc() multiplication is not in the supported browser range, so the
2012
+ doubled gap is spelled out with an additive calc. */
2013
+ --dialog-viewport-gap-2x: calc(var(--dialog-viewport-gap) + var(--dialog-viewport-gap));
2014
+
2015
+ /* Keep the surface anchored to the viewport in the top layer. This also
2016
+ provides the containing block for the absolutely positioned close button. */
2017
+ position: fixed;
1845
2018
 
1846
- max-inline-size: min(100% - 2rem, var(--modal-size));
1847
- max-block-size: calc(var(--viewport-block) - 2rem);
2019
+ max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size));
2020
+ max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
1848
2021
  border: var(--border-width) solid var(--border);
1849
2022
  border-radius: var(--radius-lg);
1850
2023
  background: var(--surface-raised);
@@ -1868,11 +2041,18 @@ dialog.modal {
1868
2041
  dialog.modal > form,
1869
2042
  dialog.modal > .stack {
1870
2043
  display: grid;
1871
- gap: var(--space-4);
1872
- max-block-size: calc(var(--viewport-block) - 2rem);
2044
+ gap: var(--space-40);
2045
+ max-block-size: calc(var(--viewport-block) - var(--dialog-viewport-gap-2x));
1873
2046
  overflow: auto;
1874
2047
  overscroll-behavior: contain;
1875
- padding: var(--space-4);
2048
+ padding: var(--space-40);
2049
+ }
2050
+
2051
+ /* Keep the dialog off the viewport edges on small screens. */
2052
+ @media (max-width: 30rem) {
2053
+ dialog.modal {
2054
+ --dialog-viewport-gap: var(--space-30);
2055
+ }
1876
2056
  }
1877
2057
 
1878
2058
  dialog.modal.scrollable > form,
@@ -1898,6 +2078,13 @@ dialog.modal footer > :last-child {
1898
2078
  margin-block-end: 0;
1899
2079
  }
1900
2080
 
2081
+ /* The close button is anchored to the dialog at top inline-end, out of the
2082
+ content flow; headers reserve inline room so titles never run under it. */
2083
+ dialog.modal > form > header,
2084
+ dialog.modal > .stack > header {
2085
+ padding-inline-end: calc(var(--control-size) + var(--space-30));
2086
+ }
2087
+
1901
2088
  dialog.modal footer {
1902
2089
  display: flex;
1903
2090
  flex-wrap: wrap;
@@ -1905,6 +2092,65 @@ dialog.modal footer {
1905
2092
  gap: var(--gap);
1906
2093
  }
1907
2094
 
2095
+ /* Confirmation layout — a focused message followed by a visually distinct
2096
+ action band. The media primitive owns icon/content alignment; this variant
2097
+ only removes the standard form inset so both regions can own their padding. */
2098
+ dialog.modal.dialog-confirmation > form {
2099
+ gap: 0;
2100
+ padding: 0;
2101
+ }
2102
+
2103
+ dialog.modal.dialog-confirmation > form > .media {
2104
+ gap: var(--space-40);
2105
+ padding: var(--space-50);
2106
+ }
2107
+
2108
+ dialog.modal.dialog-confirmation > form > footer {
2109
+ padding: var(--space-40) var(--space-50);
2110
+ border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
2111
+ border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
2112
+ background: var(--surface-subtle);
2113
+ }
2114
+
2115
+ /* Intent boundary — inherited intents do not tint the icon well. A local
2116
+ intent plus .soft opts into the shared soft-variant recipe. */
2117
+ :where(.dialog-icon) {
2118
+ --intent: initial;
2119
+ --intent-fg: initial;
2120
+ --ui-bg: initial;
2121
+ --ui-fg: initial;
2122
+ }
2123
+
2124
+ .dialog-icon {
2125
+ --dialog-icon-size: 3rem;
2126
+ --dialog-icon-glyph-size: 1.5rem;
2127
+
2128
+ display: inline-grid;
2129
+ place-items: center;
2130
+ inline-size: var(--dialog-icon-size);
2131
+ block-size: var(--dialog-icon-size);
2132
+ flex: none;
2133
+ border-radius: var(--radius-full);
2134
+ background: var(--ui-bg, var(--surface-subtle));
2135
+ color: var(--ui-fg, var(--intent, var(--text-muted)));
2136
+ }
2137
+
2138
+ .dialog-icon > * {
2139
+ display: inline-grid;
2140
+ place-items: center;
2141
+ inline-size: var(--dialog-icon-glyph-size);
2142
+ block-size: var(--dialog-icon-glyph-size);
2143
+ font-size: var(--dialog-icon-glyph-size);
2144
+ line-height: 1;
2145
+ }
2146
+
2147
+ @media (max-width: 30rem) {
2148
+ dialog.modal.dialog-confirmation > form > .media,
2149
+ dialog.modal.dialog-confirmation > form > footer {
2150
+ padding: var(--space-40);
2151
+ }
2152
+ }
2153
+
1908
2154
  dialog.modal[open] {
1909
2155
  opacity: 1;
1910
2156
  transform: none;
@@ -1960,7 +2206,7 @@ html.has-modal-open.had-scrollbar {
1960
2206
 
1961
2207
  @media (prefers-reduced-motion: no-preference) {
1962
2208
  dialog.modal.is-static {
1963
- animation: actual-modal-static 180ms var(--ease);
2209
+ animation: actual-dialog-static var(--duration-slow) var(--ease);
1964
2210
  }
1965
2211
 
1966
2212
  ::view-transition-group(actual-dialog) {
@@ -1969,7 +2215,7 @@ html.has-modal-open.had-scrollbar {
1969
2215
  }
1970
2216
  }
1971
2217
 
1972
- @keyframes actual-modal-static {
2218
+ @keyframes actual-dialog-static {
1973
2219
  0%,
1974
2220
  100% {
1975
2221
  transform: none;
@@ -1995,6 +2241,70 @@ html.has-modal-open.had-scrollbar {
1995
2241
  dialog.modal::backdrop {
1996
2242
  background: Canvas;
1997
2243
  }
2244
+
2245
+ .dialog-icon {
2246
+ border: var(--border-width) solid CanvasText;
2247
+ }
2248
+ }
2249
+
2250
+ /* Close controls. A close button is an action attached to a surface, so it
2251
+ never participates in the content flow. The modal and the drawer share the
2252
+ same simple placement — absolute at the panel's top inline-end, inside the
2253
+ surface — so both close buttons look and behave identically. The glyph comes
2254
+ from --icon-close, masked to currentColor, so no icon font or text glyph is
2255
+ required in the markup. */
2256
+ .dialog-close,
2257
+ .drawer-close {
2258
+ --dialog-close-icon-size: 1rem;
2259
+
2260
+ display: inline-grid;
2261
+ place-items: center;
2262
+ padding: 0;
2263
+ border: 0;
2264
+ border-radius: var(--radius);
2265
+ color: var(--text-muted);
2266
+ cursor: pointer;
2267
+
2268
+ /* Out of flow, anchored to the positioned dialog surface, not the header or
2269
+ any scroll container inside it. */
2270
+ position: absolute;
2271
+ z-index: 1;
2272
+ inset-block-start: var(--space-20);
2273
+ inset-inline-end: var(--space-20);
2274
+
2275
+ inline-size: var(--control-size);
2276
+ block-size: var(--control-size);
2277
+
2278
+ background: var(--surface-subtle);
2279
+ }
2280
+
2281
+ .dialog-close::before,
2282
+ .drawer-close::before {
2283
+ content: "";
2284
+ inline-size: var(--dialog-close-icon-size);
2285
+ block-size: var(--dialog-close-icon-size);
2286
+ background: currentColor;
2287
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
2288
+ mask: var(--icon-close) center / contain no-repeat;
2289
+ }
2290
+
2291
+ @media (hover: hover) {
2292
+ .dialog-close:hover,
2293
+ .drawer-close:hover {
2294
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
2295
+ color: var(--text);
2296
+ }
2297
+ }
2298
+
2299
+ .dialog-close:active,
2300
+ .drawer-close:active {
2301
+ box-shadow: inset 0 0 0 999px var(--hover-overlay);
2302
+ }
2303
+
2304
+ .dialog-close:focus-visible,
2305
+ .drawer-close:focus-visible {
2306
+ outline: 2px solid transparent;
2307
+ box-shadow: var(--focus-ring-shadow);
1998
2308
  }
1999
2309
 
2000
2310
  /*
@@ -2005,16 +2315,30 @@ html.has-modal-open.had-scrollbar {
2005
2315
  * discrete display/overlay transitions. The drawer is a layout shell only —
2006
2316
  * navigation content inside uses .nav-link and .accordion, which carry their
2007
2317
  * own link/summary styling.
2318
+ *
2319
+ * Public hooks:
2320
+ * --drawer-size
2321
+ * --drawer-pad
2322
+ *
2323
+ * Internal:
2324
+ * --drawer-closed-translate (side/RTL positioning state)
2008
2325
  */
2009
2326
  dialog.drawer {
2010
2327
  --drawer-size: 20rem;
2011
- --drawer-pad: var(--space-4);
2328
+ --drawer-pad: var(--space-40);
2012
2329
  --drawer-closed-translate: -100%;
2013
2330
 
2331
+ /* Keep the surface anchored to the viewport in the top layer. This also
2332
+ provides the containing block for the absolutely positioned close button.
2333
+ A percentage height resolves against the fixed containing viewport, while
2334
+ dynamic viewport units can be stale in embedded previews. */
2335
+ position: fixed;
2336
+ inset-block-start: 0;
2337
+
2014
2338
  inline-size: min(calc(var(--viewport-inline) * 0.9), var(--drawer-size));
2015
- block-size: var(--viewport-block);
2339
+ block-size: 100%;
2016
2340
  max-inline-size: none;
2017
- max-block-size: var(--viewport-block);
2341
+ max-block-size: 100%;
2018
2342
  margin: 0;
2019
2343
  padding: var(--drawer-pad);
2020
2344
  border: 0;
@@ -2033,7 +2357,7 @@ dialog.drawer {
2033
2357
  dialog.drawer[open] {
2034
2358
  display: grid;
2035
2359
  grid-template-rows: auto minmax(0, 1fr) auto;
2036
- gap: var(--space-4);
2360
+ gap: var(--space-40);
2037
2361
  opacity: 1;
2038
2362
  transform: translateX(0);
2039
2363
  }
@@ -2104,20 +2428,23 @@ dialog.drawer nav {
2104
2428
  }
2105
2429
 
2106
2430
  @media (forced-colors: active) {
2107
- dialog.drawer {
2108
- border-inline-end-color: ButtonText;
2431
+ dialog.drawer::backdrop {
2109
2432
  background: Canvas;
2110
- color: CanvasText;
2111
- box-shadow: none;
2112
2433
  }
2434
+ }
2113
2435
 
2114
- dialog.drawer[data-side="end"] {
2115
- border-inline-start-color: ButtonText;
2436
+ @media (prefers-reduced-motion: no-preference) {
2437
+ dialog.drawer.is-static {
2438
+ animation: actual-dialog-static var(--duration-slow) var(--ease);
2116
2439
  }
2440
+ }
2117
2441
 
2118
- dialog.drawer::backdrop {
2119
- background: Canvas;
2120
- }
2442
+ /* The close button shares the modal's interior placement (modal.css): absolute
2443
+ at the panel's top inline-end, out of the content flow. The header reserves
2444
+ inline room so the title never runs under it. */
2445
+ dialog.drawer > header,
2446
+ dialog.drawer > form > header {
2447
+ padding-inline-end: calc(var(--control-size) + var(--space-30));
2121
2448
  }
2122
2449
 
2123
2450
 
@@ -2133,7 +2460,16 @@ dialog.drawer nav {
2133
2460
  * .is-sheet marks "auto" resolved to bottom-sheet presentation on coarse
2134
2461
  * pointers. Authors opt into mobile behavior with
2135
2462
  * [data-flyout-mobile="sheet|auto|none|anchored"].
2136
- * --flyout-trigger-width is set by surface.js while an open flyout is positioned.
2463
+ * --surface-anchor-width is set by surface.js while an open flyout is positioned.
2464
+ *
2465
+ * Public hooks:
2466
+ * --flyout-inline-size
2467
+ * --flyout-max-inline-size
2468
+ *
2469
+ * Internal:
2470
+ * --menu-item-size (derived from --control-size for menu rows)
2471
+ * --available-height (measured by floating.js)
2472
+ * --surface-anchor-width (measured by surface.js)
2137
2473
  */
2138
2474
 
2139
2475
  .flyout-trigger {
@@ -2156,28 +2492,32 @@ dialog.drawer nav {
2156
2492
  }
2157
2493
 
2158
2494
  .flyout {
2495
+ --menu-item-size: var(--control-size);
2159
2496
  z-index: var(--z-menu);
2160
2497
  /* No-JS fallback: below the trigger inside the relative .flyout-trigger.
2161
2498
  surface.js switches an open flyout to position: fixed with measured
2162
2499
  coordinates; this only covers a flyout revealed without the runtime. */
2163
2500
  position: absolute;
2164
- inset-block-start: calc(100% + var(--space-1));
2501
+ inset-block-start: calc(100% + var(--space-10));
2165
2502
  inset-inline-start: 0;
2166
2503
  display: grid;
2167
- gap: var(--space-1);
2504
+ gap: var(--space-10);
2168
2505
  inline-size: var(--flyout-inline-size, max-content);
2169
2506
  min-inline-size: min(
2170
- var(--flyout-trigger-width, 0px),
2171
- min(var(--flyout-max-inline-size, 20rem), calc(var(--viewport-inline) - var(--space-6)))
2507
+ var(--surface-anchor-width, 0px),
2508
+ min(var(--flyout-max-inline-size, 20rem), calc(var(--viewport-inline) - var(--space-60)))
2172
2509
  );
2173
2510
  max-inline-size: min(
2174
2511
  var(--flyout-max-inline-size, 20rem),
2175
- calc(var(--viewport-inline) - var(--space-6))
2512
+ calc(var(--viewport-inline) - var(--space-60))
2513
+ );
2514
+ max-block-size: min(
2515
+ 24rem,
2516
+ var(--available-height, calc(var(--viewport-block) - var(--space-60)))
2176
2517
  );
2177
- max-block-size: min(24rem, var(--available-height, calc(var(--viewport-block) - var(--space-6))));
2178
2518
  overflow: auto;
2179
2519
  margin: 0;
2180
- padding: var(--space-1);
2520
+ padding: var(--space-10);
2181
2521
  border: var(--border-width) solid var(--border);
2182
2522
  border-radius: var(--radius);
2183
2523
  background: var(--surface-raised);
@@ -2186,9 +2526,9 @@ dialog.drawer nav {
2186
2526
  opacity: 1;
2187
2527
  scale: 1;
2188
2528
  transition:
2189
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2190
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2191
- translate var(--duration-fast, var(--duration)) var(--ease, ease);
2529
+ opacity var(--duration-fast) var(--ease),
2530
+ scale var(--duration-fast) var(--ease),
2531
+ translate var(--duration-fast) var(--ease);
2192
2532
  }
2193
2533
 
2194
2534
  .flyout.is-open:not(.is-sheet) {
@@ -2205,6 +2545,7 @@ dialog.drawer nav {
2205
2545
  /* Bottom-sheet presentation. surface.js toggles .is-sheet when the flyout is
2206
2546
  opened on coarse pointer / narrow viewports. */
2207
2547
  .flyout.is-sheet {
2548
+ --menu-item-size: var(--control-size-lg);
2208
2549
  position: fixed;
2209
2550
  inset-inline: 0;
2210
2551
  inset-block-start: auto;
@@ -2215,8 +2556,8 @@ dialog.drawer nav {
2215
2556
  max-block-size: min(calc(var(--viewport-block) * 0.75), 32rem);
2216
2557
  overflow: auto;
2217
2558
  grid-template-columns: 1fr;
2218
- padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3)
2219
- max(var(--space-3), env(safe-area-inset-bottom));
2559
+ padding: max(var(--space-30), env(safe-area-inset-top)) var(--space-30)
2560
+ max(var(--space-30), env(safe-area-inset-bottom));
2220
2561
  border-end-start-radius: 0;
2221
2562
  border-end-end-radius: 0;
2222
2563
  }
@@ -2229,23 +2570,23 @@ dialog.drawer nav {
2229
2570
 
2230
2571
  .flyout.is-sheet[hidden] {
2231
2572
  scale: 1;
2232
- translate: 0 var(--space-3);
2573
+ translate: 0 var(--space-30);
2233
2574
  }
2234
2575
 
2235
2576
  @starting-style {
2236
2577
  .flyout.is-sheet.is-open {
2237
2578
  scale: 1;
2238
- translate: 0 var(--space-3);
2579
+ translate: 0 var(--space-30);
2239
2580
  }
2240
2581
  }
2241
2582
 
2242
2583
  @supports (transition-behavior: allow-discrete) {
2243
2584
  .flyout {
2244
2585
  transition:
2245
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2246
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2247
- translate var(--duration-fast, var(--duration)) var(--ease, ease),
2248
- display var(--duration-fast, var(--duration)) allow-discrete;
2586
+ opacity var(--duration-fast) var(--ease),
2587
+ scale var(--duration-fast) var(--ease),
2588
+ translate var(--duration-fast) var(--ease),
2589
+ display var(--duration-fast) allow-discrete;
2249
2590
  }
2250
2591
  }
2251
2592
 
@@ -2255,7 +2596,7 @@ dialog.drawer nav {
2255
2596
 
2256
2597
  .flyout > :where(section, footer) {
2257
2598
  min-inline-size: 0;
2258
- padding: var(--space-2);
2599
+ padding: var(--space-20);
2259
2600
  }
2260
2601
 
2261
2602
  .flyout > section > :where(h1, h2, h3, h4, h5, h6) {
@@ -2267,8 +2608,8 @@ dialog.drawer nav {
2267
2608
 
2268
2609
  .flyout > section > ul {
2269
2610
  display: grid;
2270
- gap: var(--space-1);
2271
- margin: var(--space-1) 0 0;
2611
+ gap: var(--space-10);
2612
+ margin: var(--space-10) 0 0;
2272
2613
  padding: 0;
2273
2614
  list-style: none;
2274
2615
  }
@@ -2277,91 +2618,133 @@ dialog.drawer nav {
2277
2618
  margin: 0;
2278
2619
  }
2279
2620
 
2280
- /* Item contract: a button, link, or menuitem placed directly in the flyout
2281
- or directly in any of its <li> (bare list, or grouped section > ul > li).
2282
- menu.js mirrors this contract in MENU_ITEM_SELECTOR — keep them in sync.
2283
- :where(li) keeps specificity at a single class (0,1,0) so consumer
2284
- overrides behave the same as a plain .flyout > rule. */
2285
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]) {
2621
+ /* Vertical dividers — .menu-separator (menu.css) is the canonical way to
2622
+ separate menu items. Flyout panels that are not menus use auto grid gap
2623
+ declared on .flyout itself. */
2624
+
2625
+ /*
2626
+ * Menu — action list primitive consumed by flyout and context-menu.
2627
+ *
2628
+ * Strict anatomy: .menu > li > .menu-item. Separators and section labels
2629
+ * sit directly under .menu as siblings of <li>.
2630
+ *
2631
+ * Public hooks:
2632
+ * --menu-item-icon-size
2633
+ */
2634
+
2635
+ .menu {
2636
+ display: grid;
2637
+ gap: var(--space-10);
2638
+ margin: 0;
2639
+ padding: var(--space-10);
2640
+ list-style: none;
2641
+ }
2642
+
2643
+ .menu > li {
2644
+ margin: 0;
2645
+ }
2646
+
2647
+ .menu-item {
2648
+ --menu-item-icon-size: 1.25em;
2649
+
2286
2650
  display: flex;
2287
2651
  align-items: center;
2288
- justify-content: flex-start;
2289
- gap: var(--space-2);
2652
+ gap: var(--space-20);
2290
2653
  inline-size: 100%;
2291
- min-block-size: var(--control-size);
2292
- padding-inline: var(--space-2);
2654
+ min-block-size: var(--menu-item-size, var(--control-size));
2655
+ padding-inline: var(--space-20);
2293
2656
  border: 0;
2294
2657
  border-radius: var(--radius-sm);
2295
- background: none;
2658
+ background: transparent;
2296
2659
  color: var(--text);
2297
2660
  font: inherit;
2298
- font-weight: var(--font-weight);
2299
- text-decoration: none;
2300
2661
  text-align: start;
2662
+ text-decoration: none;
2301
2663
  white-space: nowrap;
2302
2664
  cursor: pointer;
2303
2665
  }
2304
2666
 
2305
- :is(.flyout.is-sheet, .flyout.is-sheet :where(li)) > :where(button, a, [role="menuitem"]) {
2306
- min-block-size: var(--control-size-lg);
2667
+ .menu-item-icon {
2668
+ display: grid;
2669
+ flex: 0 0 var(--menu-item-icon-size);
2670
+ inline-size: var(--menu-item-icon-size);
2671
+ place-items: center;
2672
+ }
2673
+
2674
+ .menu-item-text {
2675
+ flex: 1;
2676
+ min-inline-size: 0;
2677
+ overflow: hidden;
2678
+ text-overflow: ellipsis;
2679
+ }
2680
+
2681
+ .menu-item-end {
2682
+ display: inline-flex;
2683
+ align-items: center;
2684
+ gap: var(--space-10);
2685
+ margin-inline-start: auto;
2686
+ color: var(--text-muted);
2687
+ }
2688
+
2689
+ /* Checkable menu items are one command with one accessible state. Their
2690
+ indicator is generated so authors do not add a second interactive input. */
2691
+ .menu-item:is([role="menuitemcheckbox"], [role="menuitemradio"])::before {
2692
+ display: grid;
2693
+ flex: 0 0 var(--menu-item-icon-size);
2694
+ inline-size: var(--menu-item-icon-size);
2695
+ place-items: center;
2696
+ }
2697
+
2698
+ .menu-item[role="menuitemcheckbox"]::before {
2699
+ content: "";
2700
+ }
2701
+
2702
+ .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before {
2703
+ content: "✓";
2704
+ }
2705
+
2706
+ .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before {
2707
+ content: "−";
2708
+ }
2709
+
2710
+ .menu-item[role="menuitemradio"]::before {
2711
+ content: "○";
2712
+ }
2713
+
2714
+ .menu-item[role="menuitemradio"][aria-checked="true"]::before {
2715
+ content: "●";
2307
2716
  }
2308
2717
 
2309
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]):focus-visible {
2718
+ .menu-item:focus-visible {
2310
2719
  background: var(--surface-subtle);
2311
- outline: var(--focus-outline);
2312
- outline-offset: var(--focus-outline-offset);
2313
2720
  }
2314
2721
 
2315
- :is(.flyout, .flyout :where(li))
2316
- > :where(button, a, [role="menuitem"]):is(:disabled, [aria-disabled="true"]) {
2317
- cursor: not-allowed;
2318
- opacity: var(--disabled-opacity);
2722
+ .menu-item:hover:not(:where(:disabled, [aria-disabled="true"])) {
2723
+ background: var(--surface-subtle);
2319
2724
  }
2320
2725
 
2321
- :is(.flyout, .flyout :where(li)) > :where(a, [role="menuitem"])[aria-disabled="true"] {
2322
- pointer-events: none;
2726
+ .menu-item:is(:disabled, [aria-disabled="true"]) {
2727
+ cursor: not-allowed;
2728
+ opacity: var(--disabled-opacity);
2323
2729
  }
2324
2730
 
2325
- @media (hover: hover) {
2326
- :is(.flyout, .flyout :where(li))
2327
- > :where(button, a, [role="menuitem"]):hover:not(
2328
- :where(:focus-visible, :disabled, [aria-disabled="true"])
2329
- ) {
2330
- background: var(--surface-subtle);
2331
- outline: none;
2332
- }
2731
+ .menu-label {
2732
+ padding: var(--space-20);
2733
+ color: var(--text-muted);
2734
+ font-size: var(--font-size-xs);
2735
+ font-weight: var(--font-weight-strong);
2333
2736
  }
2334
2737
 
2335
- .flyout > hr,
2336
- .flyout > li > hr {
2337
- inline-size: 100%;
2738
+ .menu-separator {
2338
2739
  margin: 0;
2339
- margin-block: var(--space-1);
2340
2740
  border: 0;
2341
2741
  border-block-start: var(--border-width) solid var(--border);
2342
2742
  }
2343
2743
 
2344
2744
  @media (forced-colors: active) {
2345
- .flyout {
2346
- border-color: ButtonText;
2347
- background: Canvas;
2348
- color: CanvasText;
2349
- box-shadow: none;
2350
- }
2351
-
2352
- :is(.flyout, .flyout :where(li)) > :where(button, a, [role="menuitem"]):focus-visible {
2353
- background: Canvas;
2354
- color: CanvasText;
2355
- }
2356
-
2357
- @media (hover: hover) {
2358
- :is(.flyout, .flyout :where(li))
2359
- > :where(button, a, [role="menuitem"]):hover:not(:where(:disabled, [aria-disabled="true"])) {
2360
- outline: var(--border-width) solid ButtonText;
2361
- outline-offset: -1px;
2362
- background: Canvas;
2363
- color: CanvasText;
2364
- }
2745
+ .menu-item:focus-visible {
2746
+ outline: var(--border-width) solid ButtonText;
2747
+ outline-offset: -1px;
2365
2748
  }
2366
2749
  }
2367
2750
 
@@ -2371,6 +2754,15 @@ dialog.drawer nav {
2371
2754
  * Position, placement, and arrow position are managed by JavaScript
2372
2755
  * via position: fixed, left, top, data-placement, and --arrow-x.
2373
2756
  * Visibility is managed with hidden; CSS owns entry and exit animation.
2757
+ *
2758
+ * Public hooks:
2759
+ * --tooltip-bg
2760
+ * --tooltip-fg
2761
+ * --tooltip-arrow-size
2762
+ *
2763
+ * Internal:
2764
+ * --arrow-x (arrow position, written by floating.js)
2765
+ * --arrow-y (arrow position, written by floating.js)
2374
2766
  */
2375
2767
  .tooltip {
2376
2768
  --tooltip-bg: var(--surface-solid);
@@ -2392,8 +2784,8 @@ dialog.drawer nav {
2392
2784
  opacity: 1;
2393
2785
  scale: 1;
2394
2786
  transition:
2395
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2396
- scale var(--duration-fast, var(--duration)) var(--ease, ease);
2787
+ opacity var(--duration-fast) var(--ease),
2788
+ scale var(--duration-fast) var(--ease);
2397
2789
  }
2398
2790
 
2399
2791
  .tooltip[hidden] {
@@ -2412,9 +2804,9 @@ dialog.drawer nav {
2412
2804
  @supports (transition-behavior: allow-discrete) {
2413
2805
  .tooltip {
2414
2806
  transition:
2415
- opacity var(--duration-fast, var(--duration)) var(--ease, ease),
2416
- scale var(--duration-fast, var(--duration)) var(--ease, ease),
2417
- display var(--duration-fast, var(--duration)) allow-discrete;
2807
+ opacity var(--duration-fast) var(--ease),
2808
+ scale var(--duration-fast) var(--ease),
2809
+ display var(--duration-fast) allow-discrete;
2418
2810
  }
2419
2811
  }
2420
2812
 
@@ -2464,8 +2856,6 @@ dialog.drawer nav {
2464
2856
  .tooltip {
2465
2857
  --tooltip-bg: Canvas;
2466
2858
  --tooltip-fg: CanvasText;
2467
-
2468
- border: var(--border-width) solid ButtonText;
2469
2859
  }
2470
2860
 
2471
2861
  .tooltip::before {
@@ -2481,9 +2871,14 @@ dialog.drawer nav {
2481
2871
  * Keeps the clickable summary affordance aligned and usable without JavaScript
2482
2872
  * or demo styles. Decorative palettes and custom markers belong outside the
2483
2873
  * base component.
2874
+ *
2875
+ * Public hooks:
2876
+ * --accordion-radius
2877
+ * --accordion-marker-color
2484
2878
  */
2485
2879
  .accordion {
2486
2880
  --accordion-radius: var(--radius);
2881
+ --accordion-marker-color: var(--text-muted);
2487
2882
 
2488
2883
  border: var(--border-width) solid var(--border);
2489
2884
  border-radius: var(--accordion-radius);
@@ -2497,14 +2892,18 @@ dialog.drawer nav {
2497
2892
  .accordion summary {
2498
2893
  display: flex;
2499
2894
  align-items: center;
2500
- gap: var(--space-3);
2895
+ gap: var(--space-30);
2501
2896
  cursor: pointer;
2502
2897
  list-style: none;
2503
- padding: var(--space-4);
2898
+ padding: var(--space-40);
2504
2899
  color: var(--text);
2505
2900
  font-weight: var(--font-weight-bold);
2506
2901
  }
2507
2902
 
2903
+ .accordion summary:hover {
2904
+ color: var(--primary);
2905
+ }
2906
+
2508
2907
  .accordion summary::-webkit-details-marker {
2509
2908
  display: none;
2510
2909
  }
@@ -2515,15 +2914,9 @@ dialog.drawer nav {
2515
2914
  margin-inline-start: auto;
2516
2915
  inline-size: 1rem;
2517
2916
  block-size: 1rem;
2518
- background-color: var(--text-muted);
2519
- -webkit-mask-image: var(--icon-chevron-mask);
2520
- mask-image: var(--icon-chevron-mask);
2521
- -webkit-mask-repeat: no-repeat;
2522
- mask-repeat: no-repeat;
2523
- -webkit-mask-size: contain;
2524
- mask-size: contain;
2525
- -webkit-mask-position: center;
2526
- mask-position: center;
2917
+ background-color: var(--accordion-marker-color);
2918
+ -webkit-mask: var(--icon-chevron) center / contain no-repeat;
2919
+ mask: var(--icon-chevron) center / contain no-repeat;
2527
2920
  transition: transform var(--duration);
2528
2921
  }
2529
2922
 
@@ -2532,14 +2925,14 @@ dialog.drawer nav {
2532
2925
  }
2533
2926
 
2534
2927
  .accordion details > summary + * {
2535
- padding-block: 0 var(--space-4);
2536
- padding-inline: var(--space-4);
2928
+ padding-block: 0 var(--space-40);
2929
+ padding-inline: var(--space-40);
2537
2930
  color: var(--text-muted);
2538
2931
  margin-block: 0;
2539
2932
  }
2540
2933
 
2541
2934
  .accordion details[open] > summary + * {
2542
- padding-block-start: var(--space-1);
2935
+ padding-block-start: var(--space-10);
2543
2936
  }
2544
2937
 
2545
2938
  .accordion details > summary + * > :first-child {
@@ -2554,22 +2947,7 @@ dialog.drawer nav {
2554
2947
 
2555
2948
  @media (forced-colors: active) {
2556
2949
  .accordion {
2557
- border-color: ButtonText;
2558
- background: Canvas;
2559
- color: CanvasText;
2560
- }
2561
-
2562
- .accordion > details + details {
2563
- border-block-start-color: ButtonText;
2564
- }
2565
-
2566
- .accordion summary {
2567
- color: CanvasText;
2568
- }
2569
-
2570
- .accordion summary::after {
2571
- background-color: ButtonText;
2572
- forced-color-adjust: none;
2950
+ --accordion-marker-color: CanvasText;
2573
2951
  }
2574
2952
  }
2575
2953
 
@@ -2580,18 +2958,17 @@ dialog.drawer nav {
2580
2958
  *
2581
2959
  * Uses currentColor so it adapts to buttons, alerts, badges, and text. The
2582
2960
  * [aria-busy] overlay composition lives in components/busy.css.
2961
+ *
2962
+ * Public hooks:
2963
+ * --spinner-size
2964
+ * --spinner-width
2965
+ * --spinner-track
2966
+ * --spinner-value
2583
2967
  */
2584
2968
  /* Intent boundary — inherited intents do not tint this component.
2585
2969
  Local intent classes still win because :where() has zero specificity. */
2586
2970
  :where(.spinner) {
2587
- /* @sync intent-boundary */
2588
2971
  --intent: initial;
2589
- --intent-fg: initial;
2590
- --ui-bg: initial;
2591
- --ui-fg: initial;
2592
- --ui-border: initial;
2593
- --ui-hover-bg: initial;
2594
- /* @sync intent-boundary */
2595
2972
  }
2596
2973
 
2597
2974
  .spinner {
@@ -2641,6 +3018,13 @@ dialog.drawer nav {
2641
3018
  *
2642
3019
  * Shows approximate loading structure without implying real content hierarchy.
2643
3020
  * Geometry, animation, and color stay theme-aware.
3021
+ *
3022
+ * Public hooks:
3023
+ * --skeleton-size
3024
+ * --skeleton-width
3025
+ * --skeleton-radius
3026
+ * --skeleton-track
3027
+ * --skeleton-highlight
2644
3028
  */
2645
3029
  .skeleton {
2646
3030
  --skeleton-size: 1rem;
@@ -2710,6 +3094,12 @@ dialog.drawer nav {
2710
3094
  * A non-button element with aria-busy="true" and a direct last-child .spinner
2711
3095
  * shows that spinner as a centered overlay over a faded surface. Buttons keep
2712
3096
  * their spinners inline (e.g. .btn[aria-busy] > .spinner stays inline-flex).
3097
+ *
3098
+ * Public hooks:
3099
+ * --busy-overlay-bg
3100
+ *
3101
+ * Internal:
3102
+ * --spinner-size (overlay copy of the shared spinner hook)
2713
3103
  */
2714
3104
  [aria-busy] > .spinner:last-child {
2715
3105
  display: none;
@@ -2755,26 +3145,21 @@ dialog.drawer nav {
2755
3145
  *
2756
3146
  * Consumes the shared --intent/--primary for the value bar and --bar-height
2757
3147
  * for thickness. Indeterminate state animates a shimmer using --duration-shimmer.
3148
+ *
3149
+ * Public hooks:
3150
+ * --progress-track
3151
+ * --progress-value
2758
3152
  */
2759
3153
  /* Intent boundary — inherited intents do not tint this component.
2760
3154
  Local intent classes still win because :where() has zero specificity. */
2761
3155
  :where(.progress) {
2762
- /* @sync intent-boundary */
2763
3156
  --intent: initial;
2764
- --intent-fg: initial;
2765
- --ui-bg: initial;
2766
- --ui-fg: initial;
2767
- --ui-border: initial;
2768
- --ui-hover-bg: initial;
2769
- /* @sync intent-boundary */
2770
3157
  }
2771
3158
 
2772
3159
  .progress {
2773
3160
  --progress-track: var(--surface-subtle);
2774
3161
  --progress-value: var(--intent, var(--primary));
2775
- }
2776
3162
 
2777
- .progress {
2778
3163
  appearance: none;
2779
3164
  -webkit-appearance: none;
2780
3165
  display: block;
@@ -2853,27 +3238,14 @@ dialog.drawer nav {
2853
3238
  }
2854
3239
 
2855
3240
  @media (forced-colors: active) {
2856
- .progress {
2857
- border: var(--border-width) solid ButtonText;
2858
- background: Canvas;
2859
- }
2860
-
2861
- .progress::-webkit-progress-bar {
2862
- background: Canvas;
2863
- }
2864
-
2865
- .progress::-webkit-progress-value {
3241
+ .progress::-webkit-progress-value,
3242
+ .progress:indeterminate::-webkit-progress-value {
2866
3243
  background: Highlight;
2867
- forced-color-adjust: none;
2868
3244
  }
2869
3245
 
2870
- .progress::-moz-progress-bar {
3246
+ .progress::-moz-progress-bar,
3247
+ .progress:indeterminate::-moz-progress-bar {
2871
3248
  background: Highlight;
2872
- forced-color-adjust: none;
2873
- }
2874
-
2875
- .progress:indeterminate {
2876
- background: Canvas;
2877
3249
  }
2878
3250
  }
2879
3251
 
@@ -2892,19 +3264,17 @@ dialog.drawer nav {
2892
3264
  * CSS API: .status-bar
2893
3265
  * JS target contract: [data-status][role="status"]
2894
3266
  * Normally combined, but they describe separate concerns.
3267
+ *
3268
+ * Internal:
3269
+ * --status-bg
3270
+ * --status-fg
2895
3271
  */
2896
3272
 
2897
3273
  /* Intent boundary — inherited intents do not tint this component.
2898
3274
  Local intent classes still win because :where() has zero specificity. */
2899
3275
  :where(.status-bar) {
2900
- /* @sync intent-boundary */
2901
3276
  --intent: initial;
2902
3277
  --intent-fg: initial;
2903
- --ui-bg: initial;
2904
- --ui-fg: initial;
2905
- --ui-border: initial;
2906
- --ui-hover-bg: initial;
2907
- /* @sync intent-boundary */
2908
3278
  }
2909
3279
 
2910
3280
  .status-bar {
@@ -2912,22 +3282,22 @@ dialog.drawer nav {
2912
3282
  --status-fg: var(--intent-fg, var(--surface));
2913
3283
 
2914
3284
  position: fixed;
2915
- inset-block-end: var(--space-4);
2916
- inset-inline: var(--space-4);
2917
- z-index: var(--z-status, 60);
3285
+ inset-block-end: var(--space-40);
3286
+ inset-inline: var(--space-40);
3287
+ z-index: var(--z-status);
2918
3288
 
2919
3289
  display: inline-flex;
2920
3290
  align-items: center;
2921
3291
  justify-content: center;
2922
- gap: var(--space-2);
3292
+ gap: var(--space-20);
2923
3293
 
2924
3294
  inline-size: fit-content;
2925
- max-inline-size: min(calc(100vw - var(--space-6)), 32rem);
3295
+ max-inline-size: min(calc(var(--viewport-inline) - var(--space-60)), 32rem);
2926
3296
  margin-inline: auto;
2927
3297
  overflow-wrap: anywhere;
2928
3298
 
2929
- padding-block: var(--space-2);
2930
- padding-inline: var(--space-3);
3299
+ padding-block: var(--space-20);
3300
+ padding-inline: var(--space-30);
2931
3301
 
2932
3302
  border-radius: var(--radius-full);
2933
3303
  background: var(--status-bg);
@@ -2940,25 +3310,18 @@ dialog.drawer nav {
2940
3310
  pointer-events: none;
2941
3311
 
2942
3312
  transition:
2943
- opacity var(--duration-slow) var(--ease, ease),
2944
- scale var(--duration-slow) var(--ease, ease),
2945
- translate var(--duration-slow) var(--ease, ease);
3313
+ opacity var(--duration-slow) var(--ease),
3314
+ scale var(--duration-slow) var(--ease),
3315
+ translate var(--duration-slow) var(--ease);
2946
3316
  }
2947
3317
 
2948
3318
  .status-bar:empty {
2949
3319
  opacity: 0;
2950
3320
  scale: 0.96;
2951
- translate: 0 var(--space-2);
3321
+ translate: 0 var(--space-20);
2952
3322
  pointer-events: none;
2953
3323
  }
2954
3324
 
2955
- @media (forced-colors: active) {
2956
- .status-bar {
2957
- border: var(--border-width) solid ButtonText;
2958
- box-shadow: none;
2959
- }
2960
- }
2961
-
2962
3325
 
2963
3326
  /* Display */
2964
3327
  /*
@@ -2967,18 +3330,26 @@ dialog.drawer nav {
2967
3330
  * The visual box stays the same across semantic elements. Grouping and status
2968
3331
  * dots (an empty .badge child) compose around the avatar without changing its
2969
3332
  * meaning.
3333
+ *
3334
+ * Public hooks:
3335
+ * --avatar-size
3336
+ * --avatar-radius
3337
+ * --avatar-font-size
3338
+ * --avatar-stack-size
3339
+ * --avatar-stack-font-size
3340
+ * --avatar-stack-overlap
3341
+ * --avatar-stack-ring
3342
+ *
3343
+ * Internal:
3344
+ * --avatar-bg
3345
+ * --avatar-fg
3346
+ * --avatar-status-translate-x
2970
3347
  */
2971
3348
  /* Intent boundary — inherited intents do not tint this component.
2972
3349
  Local intent classes still win because :where() has zero specificity. */
2973
3350
  :where(.avatar) {
2974
- /* @sync intent-boundary */
2975
3351
  --intent: initial;
2976
3352
  --intent-fg: initial;
2977
- --ui-bg: initial;
2978
- --ui-fg: initial;
2979
- --ui-border: initial;
2980
- --ui-hover-bg: initial;
2981
- /* @sync intent-boundary */
2982
3353
  }
2983
3354
 
2984
3355
  .avatar {
@@ -3070,10 +3441,8 @@ button.avatar {
3070
3441
  transition: opacity var(--duration);
3071
3442
  }
3072
3443
 
3073
- @media (hover: hover) {
3074
- :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):hover::after {
3075
- opacity: 1;
3076
- }
3444
+ :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):hover::after {
3445
+ opacity: 1;
3077
3446
  }
3078
3447
 
3079
3448
  :where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):active {
@@ -3166,6 +3535,8 @@ a.avatar[aria-disabled="true"] {
3166
3535
  *
3167
3536
  * Use .key outside prose. Plain kbd inside .prose gets the same visual
3168
3537
  * treatment from prose.css so authored content stays convenient.
3538
+ *
3539
+ * Visual twin of .prose kbd (prose.css) — keep the two recipes in sync.
3169
3540
  */
3170
3541
  .key {
3171
3542
  display: inline-flex;
@@ -3193,22 +3564,17 @@ a.avatar[aria-disabled="true"] {
3193
3564
  * Base form is unstyled chrome that takes its surrounding color. Add .pill for
3194
3565
  * the bordered rounded shape, and combine with an intent class (.primary,
3195
3566
  * .success, etc.) to tint the text, border, and background.
3567
+ *
3568
+ * Public hooks:
3569
+ * --overline-radius
3196
3570
  */
3197
3571
  /* Intent boundary — inherited intents do not tint this component.
3198
3572
  Local intent classes still win because :where() has zero specificity. */
3199
3573
  :where(.overline) {
3200
- /* @sync intent-boundary */
3201
3574
  --intent: initial;
3202
- --intent-fg: initial;
3203
- --ui-bg: initial;
3204
- --ui-fg: initial;
3205
- --ui-border: initial;
3206
- --ui-hover-bg: initial;
3207
- /* @sync intent-boundary */
3208
3575
  }
3209
3576
 
3210
3577
  .overline {
3211
- align-self: flex-start;
3212
3578
  color: var(--text-muted);
3213
3579
  font-size: var(--font-size-sm);
3214
3580
  font-weight: var(--font-weight-medium);
@@ -3217,14 +3583,18 @@ a.avatar[aria-disabled="true"] {
3217
3583
  }
3218
3584
 
3219
3585
  .overline.pill {
3586
+ --overline-radius: var(--radius-full);
3587
+
3220
3588
  display: inline-flex;
3221
3589
  align-items: center;
3222
3590
  gap: 0.5em;
3223
3591
  padding-inline: 0.6em;
3224
3592
  padding-block: 0.25em;
3593
+ inline-size: fit-content;
3594
+ max-inline-size: 100%;
3225
3595
  color: var(--intent, var(--text-muted));
3226
3596
  border: var(--border-width) solid var(--border);
3227
- border-radius: var(--radius-full);
3597
+ border-radius: var(--overline-radius);
3228
3598
  background: var(--surface);
3229
3599
  }
3230
3600
 
@@ -3245,26 +3615,37 @@ a.avatar[aria-disabled="true"] {
3245
3615
  * .navbar is a horizontal bar; .navbar-brand is the brand link; .navbar-nav is
3246
3616
  * the inline link list using .nav-link items. .nav-list is the vertical nav
3247
3617
  * list shared with .drawer, fed by the shared --gap token.
3618
+ *
3619
+ * Internal:
3620
+ * --gap (local value for the shared gap token)
3248
3621
  */
3622
+ /* Intent boundary — inherited intents do not tint this component.
3623
+ Local intent classes still win because :where() has zero specificity. */
3624
+ :where(.navbar) {
3625
+ --ui-bg: initial;
3626
+ --ui-fg: initial;
3627
+ --ui-border: initial;
3628
+ }
3629
+
3249
3630
  .navbar {
3250
3631
  display: flex;
3251
3632
  flex-wrap: wrap;
3252
3633
  align-items: center;
3253
3634
  justify-content: space-between;
3254
3635
  gap: var(--gap);
3255
- padding-block: var(--space-3);
3256
- padding-inline: var(--space-4);
3257
- border: var(--border-width) solid var(--border);
3636
+ padding-block: var(--space-30);
3637
+ padding-inline: var(--space-40);
3638
+ border: var(--border-width) solid var(--ui-border, var(--border));
3258
3639
  border-radius: var(--radius-lg);
3259
- background: var(--surface-raised);
3260
- color: var(--text);
3640
+ background: var(--ui-bg, var(--surface-raised));
3641
+ color: var(--ui-fg, var(--text));
3261
3642
  }
3262
3643
 
3263
3644
  .navbar-brand {
3264
3645
  display: inline-flex;
3265
3646
  align-items: center;
3266
- gap: var(--space-2);
3267
- color: var(--text);
3647
+ gap: var(--space-20);
3648
+ color: var(--ui-fg, var(--text));
3268
3649
  font-weight: var(--font-weight-bold);
3269
3650
  text-decoration: none;
3270
3651
  }
@@ -3277,7 +3658,7 @@ a.avatar[aria-disabled="true"] {
3277
3658
  display: flex;
3278
3659
  flex-wrap: wrap;
3279
3660
  align-items: center;
3280
- gap: var(--space-1);
3661
+ gap: var(--space-10);
3281
3662
  margin: 0;
3282
3663
  padding: 0;
3283
3664
  list-style: none;
@@ -3287,9 +3668,9 @@ a.avatar[aria-disabled="true"] {
3287
3668
  display: inline-flex;
3288
3669
  align-items: center;
3289
3670
  min-block-size: var(--control-size-sm);
3290
- padding-inline: var(--space-2);
3671
+ padding-inline: var(--space-20);
3291
3672
  border-radius: var(--radius);
3292
- color: var(--text-muted);
3673
+ color: var(--ui-fg, var(--text-muted));
3293
3674
  font-weight: var(--font-weight-medium);
3294
3675
  text-decoration: none;
3295
3676
  }
@@ -3299,21 +3680,19 @@ a.avatar[aria-disabled="true"] {
3299
3680
  color: var(--text);
3300
3681
  }
3301
3682
 
3302
- @media (hover: hover) {
3303
- .nav-link:hover {
3304
- background: var(--surface-subtle);
3305
- color: var(--text);
3306
- }
3683
+ .nav-link:hover {
3684
+ background: var(--surface-subtle);
3685
+ color: var(--text);
3307
3686
  }
3308
3687
 
3309
3688
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links. */
3310
3689
  .nav-list {
3311
3690
  display: grid;
3691
+ --gap: var(--space-20);
3312
3692
  gap: var(--gap);
3313
3693
  margin: 0;
3314
3694
  padding: 0;
3315
3695
  list-style: none;
3316
- --gap: var(--space-2);
3317
3696
  }
3318
3697
 
3319
3698
  .nav-list > li {
@@ -3328,13 +3707,11 @@ a.avatar[aria-disabled="true"] {
3328
3707
  forced-color-adjust: none;
3329
3708
  }
3330
3709
 
3331
- @media (hover: hover) {
3332
- .nav-link:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3333
- outline: var(--border-width) solid ButtonText;
3334
- outline-offset: 2px;
3335
- background: Canvas;
3336
- color: CanvasText;
3337
- }
3710
+ .nav-link:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3711
+ outline: var(--border-width) solid ButtonText;
3712
+ outline-offset: 2px;
3713
+ background: Canvas;
3714
+ color: CanvasText;
3338
3715
  }
3339
3716
  }
3340
3717
 
@@ -3348,7 +3725,7 @@ a.avatar[aria-disabled="true"] {
3348
3725
  display: flex;
3349
3726
  flex-wrap: wrap;
3350
3727
  align-items: center;
3351
- gap: var(--space-1);
3728
+ gap: var(--space-10);
3352
3729
  margin: 0;
3353
3730
  padding: 0;
3354
3731
  color: var(--text-subtle);
@@ -3359,7 +3736,7 @@ a.avatar[aria-disabled="true"] {
3359
3736
  .breadcrumb li {
3360
3737
  display: inline-flex;
3361
3738
  align-items: center;
3362
- gap: var(--space-1);
3739
+ gap: var(--space-10);
3363
3740
  }
3364
3741
 
3365
3742
  .breadcrumb li + li::before {
@@ -3372,25 +3749,17 @@ a.avatar[aria-disabled="true"] {
3372
3749
  text-decoration: none;
3373
3750
  }
3374
3751
 
3375
- @media (hover: hover) {
3376
- .breadcrumb
3377
- li:not(:where([aria-current]:not([aria-current="false"])))
3378
- > a:hover:not(:where([aria-current]:not([aria-current="false"]))) {
3379
- color: var(--text);
3380
- text-decoration: underline;
3381
- }
3752
+ .breadcrumb a:not([aria-current]):hover,
3753
+ .breadcrumb a[aria-current="false"]:hover {
3754
+ color: var(--text);
3755
+ text-decoration: underline;
3382
3756
  }
3383
3757
 
3384
3758
  .breadcrumb a:focus-visible {
3385
3759
  border-radius: var(--radius-sm);
3386
3760
  }
3387
3761
 
3388
- .breadcrumb a:where([aria-current]:not([aria-current="false"])) {
3389
- color: var(--text);
3390
- font-weight: var(--font-weight-medium);
3391
- }
3392
-
3393
- .breadcrumb li:where([aria-current]:not([aria-current="false"])) > :where(a, span) {
3762
+ .breadcrumb :where(a, span):where([aria-current]:not([aria-current="false"])) {
3394
3763
  color: var(--text);
3395
3764
  font-weight: var(--font-weight-medium);
3396
3765
  }
@@ -3409,7 +3778,7 @@ a.avatar[aria-disabled="true"] {
3409
3778
  display: flex;
3410
3779
  flex-wrap: wrap;
3411
3780
  align-items: center;
3412
- gap: var(--space-1);
3781
+ gap: var(--space-10);
3413
3782
  margin: 0;
3414
3783
  padding: 0;
3415
3784
  list-style: none;
@@ -3432,24 +3801,20 @@ a.avatar[aria-disabled="true"] {
3432
3801
  * JavaScript owns roving tabindex, aria-selected, panel visibility,
3433
3802
  * and arrow-key navigation. CSS owns the tab strip appearance and
3434
3803
  * active indicator.
3804
+ *
3805
+ * Public hooks:
3806
+ * --tab-gap
3435
3807
  */
3436
3808
  /* Intent boundary — inherited intents do not tint this component.
3437
3809
  Local intent classes still win because :where() has zero specificity. */
3438
3810
  :where(.tab) {
3439
- /* @sync intent-boundary */
3440
3811
  --intent: initial;
3441
- --intent-fg: initial;
3442
- --ui-bg: initial;
3443
- --ui-fg: initial;
3444
- --ui-border: initial;
3445
- --ui-hover-bg: initial;
3446
- /* @sync intent-boundary */
3447
3812
  }
3448
3813
 
3449
3814
  .tabs {
3450
3815
  display: flex;
3451
3816
  flex-wrap: wrap;
3452
- gap: var(--space-1);
3817
+ gap: var(--space-10);
3453
3818
  margin: 0;
3454
3819
  padding: 0;
3455
3820
  border-block-end: var(--border-width) solid var(--border);
@@ -3467,8 +3832,9 @@ a.avatar[aria-disabled="true"] {
3467
3832
  .tab {
3468
3833
  display: inline-flex;
3469
3834
  align-items: center;
3835
+ gap: var(--tab-gap, 0.375em);
3470
3836
  min-block-size: var(--control-size);
3471
- padding-inline: var(--space-3);
3837
+ padding-inline: var(--space-30);
3472
3838
  border: 0;
3473
3839
  border-block-end: 2px solid transparent;
3474
3840
  border-radius: 0;
@@ -3484,7 +3850,7 @@ a.avatar[aria-disabled="true"] {
3484
3850
  justify-content: flex-start;
3485
3851
  border-block-end: 0;
3486
3852
  border-inline-end: 2px solid transparent;
3487
- padding-inline-end: var(--space-4);
3853
+ padding-inline-end: var(--space-40);
3488
3854
  }
3489
3855
 
3490
3856
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -3492,10 +3858,8 @@ a.avatar[aria-disabled="true"] {
3492
3858
  opacity: var(--disabled-opacity);
3493
3859
  }
3494
3860
 
3495
- @media (hover: hover) {
3496
- .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3497
- color: var(--text);
3498
- }
3861
+ .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3862
+ color: var(--text);
3499
3863
  }
3500
3864
 
3501
3865
  .tab:focus-visible {
@@ -3505,52 +3869,28 @@ a.avatar[aria-disabled="true"] {
3505
3869
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
3506
3870
  }
3507
3871
 
3508
- .tab[aria-selected="true"],
3509
- .tab[aria-current]:not([aria-current="false"]),
3510
- .tab[aria-pressed="true"] {
3511
- border-block-end-color: var(--intent, var(--primary));
3512
- color: var(--intent, var(--primary));
3872
+ .tab[aria-selected="true"] {
3873
+ border-block-end-color: var(--intent, var(--state-selected));
3874
+ color: var(--intent, var(--state-selected));
3513
3875
  }
3514
3876
 
3515
- .tabs[aria-orientation="vertical"] .tab[aria-selected="true"],
3516
- .tabs[aria-orientation="vertical"] .tab[aria-current]:not([aria-current="false"]),
3517
- .tabs[aria-orientation="vertical"] .tab[aria-pressed="true"] {
3877
+ .tabs[aria-orientation="vertical"] .tab[aria-selected="true"] {
3518
3878
  border-block-end-color: transparent;
3519
- border-inline-end-color: var(--intent, var(--primary));
3879
+ border-inline-end-color: var(--intent, var(--state-selected));
3520
3880
  }
3521
3881
 
3522
3882
  @media (forced-colors: active) {
3523
- .tabs {
3524
- border-block-end-color: ButtonText;
3525
- }
3526
-
3527
- .tabs[aria-orientation="vertical"] {
3528
- border-block-end-color: transparent;
3529
- border-inline-end-color: ButtonText;
3530
- }
3531
-
3532
- .tab {
3533
- color: ButtonText;
3534
- }
3535
-
3536
- @media (hover: hover) {
3537
- .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3538
- color: ButtonText;
3539
- text-decoration: underline;
3540
- text-underline-offset: 0.18em;
3541
- }
3883
+ .tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
3884
+ text-decoration: underline;
3885
+ text-underline-offset: 0.18em;
3542
3886
  }
3543
3887
 
3544
- .tab[aria-selected="true"],
3545
- .tab[aria-current]:not([aria-current="false"]),
3546
- .tab[aria-pressed="true"] {
3888
+ .tab[aria-selected="true"] {
3547
3889
  border-block-end-color: Highlight;
3548
3890
  color: Highlight;
3549
3891
  }
3550
3892
 
3551
- .tabs[aria-orientation="vertical"] .tab[aria-selected="true"],
3552
- .tabs[aria-orientation="vertical"] .tab[aria-current]:not([aria-current="false"]),
3553
- .tabs[aria-orientation="vertical"] .tab[aria-pressed="true"] {
3893
+ .tabs[aria-orientation="vertical"] .tab[aria-selected="true"] {
3554
3894
  border-block-end-color: transparent;
3555
3895
  border-inline-end-color: Highlight;
3556
3896
  }
@@ -3591,6 +3931,10 @@ a.avatar[aria-disabled="true"] {
3591
3931
  *
3592
3932
  * .table-wrap provides the scroll container and outer chrome; .table owns row
3593
3933
  * and cell rules. Tokens are sourced from the shared scale.
3934
+ *
3935
+ * Public hooks:
3936
+ * --table-min
3937
+ * --table-cell-pad
3594
3938
  */
3595
3939
  .table-wrap {
3596
3940
  inline-size: 100%;
@@ -3604,7 +3948,7 @@ a.avatar[aria-disabled="true"] {
3604
3948
 
3605
3949
  .table {
3606
3950
  --table-min: 100%;
3607
- --table-cell-pad: var(--space-3);
3951
+ --table-cell-pad: var(--space-30);
3608
3952
 
3609
3953
  inline-size: max(var(--table-min), 100%);
3610
3954
  border-collapse: collapse;
@@ -3640,10 +3984,8 @@ a.avatar[aria-disabled="true"] {
3640
3984
  background: var(--surface-subtle);
3641
3985
  }
3642
3986
 
3643
- @media (hover: hover) {
3644
- .table tbody tr:hover {
3645
- background-color: var(--hover-overlay);
3646
- }
3987
+ .table tbody tr:hover {
3988
+ background-color: var(--hover-overlay);
3647
3989
  }
3648
3990
 
3649
3991
  .table > tr:last-child > *,
@@ -3657,18 +3999,6 @@ a.avatar[aria-disabled="true"] {
3657
3999
  * Maps the three standard meter zones (optimum / suboptimum / even-less-good)
3658
4000
  * to the shared success / warning / danger intent colors.
3659
4001
  */
3660
- /* Intent boundary — inherited intents do not tint this component.
3661
- Local intent classes still win because :where() has zero specificity. */
3662
- :where(.meter) {
3663
- /* @sync intent-boundary */
3664
- --intent: initial;
3665
- --intent-fg: initial;
3666
- --ui-bg: initial;
3667
- --ui-fg: initial;
3668
- --ui-border: initial;
3669
- --ui-hover-bg: initial;
3670
- /* @sync intent-boundary */
3671
- }
3672
4002
 
3673
4003
  .meter {
3674
4004
  appearance: none;
@@ -3714,29 +4044,18 @@ a.avatar[aria-disabled="true"] {
3714
4044
  }
3715
4045
 
3716
4046
  @media (forced-colors: active) {
3717
- .meter {
3718
- border: var(--border-width) solid ButtonText;
3719
- background: Canvas;
3720
- }
3721
-
3722
- .meter::-webkit-meter-bar {
3723
- border: 0;
3724
- background: Canvas;
3725
- }
3726
-
3727
4047
  .meter::-webkit-meter-optimum-value,
3728
4048
  .meter::-webkit-meter-suboptimum-value,
3729
4049
  .meter::-webkit-meter-even-less-good-value,
3730
4050
  .meter::-moz-meter-bar {
3731
4051
  background: Highlight;
3732
- forced-color-adjust: none;
3733
4052
  }
3734
4053
  }
3735
4054
 
3736
- /* Forms — import order matters: switch.css must follow choice.css so its
3737
- :not([role="switch"]) exclusion in choice.css still applies.
3738
- validation.css is part of the default forms bundle, but custom builds may
3739
- omit it when validation styling is app-owned. */
4055
+ /* Forms — import order matters: switch.css follows choice.css so its rules
4056
+ land after the shared choice recipe. validation.css is part of the default
4057
+ forms bundle, but custom builds may omit it when validation styling is
4058
+ app-owned. */
3740
4059
  /*
3741
4060
  * Form layout and supporting text.
3742
4061
  *
@@ -3745,23 +4064,26 @@ a.avatar[aria-disabled="true"] {
3745
4064
  *
3746
4065
  * Public API: .field, .field-label, .field-help, .field-error, .field-group,
3747
4066
  * .required-mark.
4067
+ *
4068
+ * Public hooks:
4069
+ * --fieldset-legend-padding-inline
3748
4070
  */
3749
4071
  .field {
3750
4072
  display: grid;
3751
- gap: var(--space-1);
4073
+ gap: var(--space-10);
3752
4074
  color: var(--text);
3753
4075
  }
3754
4076
 
3755
4077
  .field-group {
3756
4078
  min-inline-size: 0;
3757
4079
  margin: 0;
3758
- padding: var(--space-3);
4080
+ padding: var(--space-30);
3759
4081
  border: var(--border-width) solid var(--border);
3760
4082
  border-radius: var(--radius);
3761
4083
  }
3762
4084
 
3763
4085
  .field-group + .field-group {
3764
- margin-block-start: var(--space-4);
4086
+ margin-block-start: var(--space-40);
3765
4087
  }
3766
4088
 
3767
4089
  .field-label,
@@ -3772,7 +4094,7 @@ a.avatar[aria-disabled="true"] {
3772
4094
 
3773
4095
  .field-group > legend {
3774
4096
  padding-block: 0;
3775
- padding-inline: var(--fieldset-legend-padding-inline, var(--space-1));
4097
+ padding-inline: var(--fieldset-legend-padding-inline, var(--space-10));
3776
4098
  font-size: var(--font-size-xs);
3777
4099
  }
3778
4100
 
@@ -3811,6 +4133,14 @@ a.avatar[aria-disabled="true"] {
3811
4133
  * --control-border-block-end-color/-width are unset extension points, mirroring
3812
4134
  * --btn-border-block-end-* (button.css), for themes that give text controls an
3813
4135
  * underline-style lower edge instead of a uniform border.
4136
+ *
4137
+ * Public hooks:
4138
+ * --control-border
4139
+ * --control-bg
4140
+ * --control-fg
4141
+ * --control-cursor
4142
+ * --control-border-block-end-color
4143
+ * --control-border-block-end-width
3814
4144
  */
3815
4145
  .input,
3816
4146
  .textarea,
@@ -3818,14 +4148,12 @@ a.avatar[aria-disabled="true"] {
3818
4148
  --control-border: var(--border);
3819
4149
  --control-bg: var(--surface);
3820
4150
  --control-fg: var(--text);
3821
- --control-ring: none;
3822
4151
  --control-cursor: auto;
3823
- --control-opacity: 1;
3824
4152
 
3825
4153
  appearance: none;
3826
4154
  inline-size: 100%;
3827
4155
  min-block-size: var(--control-size);
3828
- padding-inline: var(--space-3);
4156
+ padding-inline: var(--space-30);
3829
4157
  border: var(--border-width) solid var(--form-invalid-border, var(--control-border));
3830
4158
  border-block-end-color: var(
3831
4159
  --form-invalid-border,
@@ -3836,9 +4164,7 @@ a.avatar[aria-disabled="true"] {
3836
4164
  background: var(--control-bg);
3837
4165
  color: var(--control-fg);
3838
4166
  font-size: var(--control-font-size);
3839
- box-shadow: var(--control-ring);
3840
4167
  cursor: var(--control-cursor);
3841
- opacity: var(--control-opacity);
3842
4168
  outline: none;
3843
4169
  transition:
3844
4170
  border-color var(--duration),
@@ -3849,26 +4175,24 @@ a.avatar[aria-disabled="true"] {
3849
4175
 
3850
4176
  .textarea {
3851
4177
  min-block-size: 6rem;
3852
- padding-block: var(--space-2);
4178
+ padding-block: var(--space-20);
3853
4179
  resize: vertical;
3854
4180
  }
3855
4181
 
3856
- @media (hover: hover) {
3857
- .input:hover:not(:disabled),
3858
- .textarea:hover:not(:disabled) {
3859
- --control-border: var(--text-subtle);
3860
- }
4182
+ .input:hover:not(:disabled),
4183
+ .textarea:hover:not(:disabled) {
4184
+ --control-border: var(--text-subtle);
4185
+ }
3861
4186
 
3862
- .select:hover:not(:disabled) {
3863
- --control-bg: var(--surface-subtle);
3864
- }
4187
+ .select:hover:not(:disabled) {
4188
+ --control-bg: var(--surface-subtle);
3865
4189
  }
3866
4190
 
3867
4191
  .input:focus-visible,
3868
4192
  .textarea:focus-visible,
3869
4193
  .select:focus-visible {
3870
4194
  --control-border: var(--focus);
3871
- outline: none;
4195
+ outline: 2px solid transparent;
3872
4196
  box-shadow: var(--focus-ring-shadow);
3873
4197
  }
3874
4198
 
@@ -3882,10 +4206,9 @@ a.avatar[aria-disabled="true"] {
3882
4206
  .textarea:disabled,
3883
4207
  .select:disabled {
3884
4208
  --control-bg: var(--surface-subtle);
3885
- --control-fg: var(--text-subtle);
4209
+ --control-fg: var(--state-disabled);
3886
4210
  --control-border: var(--border);
3887
4211
  --control-cursor: not-allowed;
3888
- --control-opacity: 1;
3889
4212
  }
3890
4213
 
3891
4214
  .input::placeholder,
@@ -3894,40 +4217,58 @@ a.avatar[aria-disabled="true"] {
3894
4217
  }
3895
4218
 
3896
4219
  /*
3897
- * Input Icon — leading decorative icon inside a relative wrapper.
4220
+ * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
3898
4221
  *
3899
- * Icon-library agnostic: selects the wrapper's first child rather than a
3900
- * specific tag, so it works with an <i> webfont icon, an inline <svg>, or a
3901
- * <span>. Sizes with --variant-icon-size (variants.css) so .sm/.lg pick up
3902
- * the same icon scale used elsewhere (alert.css); the input's start padding
3903
- * grows to match so text never runs under the icon.
4222
+ * Icon-library agnostic: detects position by DOM order. First child that is
4223
+ * not .input = leading icon; icon after .input = trailing. The icon keeps a
4224
+ * fixed size; density contexts shrink the control geometry around it, not
4225
+ * the pictogram.
4226
+ *
4227
+ * Public hooks:
4228
+ * --input-icon-size
3904
4229
  */
3905
4230
  .input-icon {
3906
- --input-icon-size: var(--variant-icon-size, 1.25rem);
4231
+ --input-icon-size: 1.25rem;
3907
4232
 
3908
4233
  display: block;
3909
4234
  position: relative;
3910
4235
  }
3911
4236
 
3912
- .input-icon > :first-child {
4237
+ /* Icon — shared by leading and trailing */
4238
+ .input-icon > :where(:not(.input)) {
3913
4239
  position: absolute;
3914
4240
  inset-block-start: 50%;
3915
- inset-inline-start: var(--space-3);
3916
4241
  transform: translateY(-50%);
3917
4242
  font-size: var(--input-icon-size);
3918
4243
  color: var(--text-muted);
3919
4244
  pointer-events: none;
3920
4245
  }
3921
4246
 
4247
+ /* Leading: icon is the first child */
4248
+ .input-icon > :first-child:where(:not(.input)) {
4249
+ inset-inline-start: var(--space-30);
4250
+ }
4251
+
4252
+ /* Trailing: input is the first child, icon follows */
4253
+ .input-icon > .input:first-child ~ :where(:not(.input)) {
4254
+ inset-inline-end: var(--space-30);
4255
+ }
4256
+
3922
4257
  /* font-size only sizes text-metric icons (webfont <i>, <span>). An inline
3923
4258
  <svg> with just a viewBox ignores it, so give it explicit dimensions. */
3924
- .input-icon > svg:first-child {
4259
+ .input-icon > svg:where(:not(.input)) {
3925
4260
  inline-size: var(--input-icon-size);
3926
4261
  block-size: var(--input-icon-size);
3927
4262
  }
3928
4263
 
3929
- .input-icon > .input {
3930
- padding-inline-start: calc(var(--space-3) * 2 + var(--input-icon-size));
4264
+ /* Leading: input gets start padding to clear the icon */
4265
+ .input-icon > :first-child:where(:not(.input)) ~ .input {
4266
+ padding-inline-start: calc(var(--space-30) * 2 + var(--input-icon-size));
4267
+ }
4268
+
4269
+ /* Trailing: input is first child and has a sibling → end padding */
4270
+ .input-icon > .input:first-child:where(:not(:last-child)) {
4271
+ padding-inline-end: calc(var(--space-30) * 2 + var(--input-icon-size));
3931
4272
  }
3932
4273
 
3933
4274
  /*
@@ -3940,23 +4281,23 @@ a.avatar[aria-disabled="true"] {
3940
4281
  *
3941
4282
  * Switches (input[role="switch"]) are excluded so switch.css owns their
3942
4283
  * geometry and states. switch.css is imported after this file.
4284
+ *
4285
+ * Public hooks:
4286
+ * --choice-control-offset
4287
+ * --choice-border
4288
+ * --choice-bg
4289
+ * --choice-mark
3943
4290
  */
3944
4291
  /* Intent boundary — inherited intents do not tint this component.
3945
4292
  Local intent classes still win because :where() has zero specificity. */
3946
4293
  :where(.choice, .check, .radio) {
3947
- /* @sync intent-boundary */
3948
4294
  --intent: initial;
3949
4295
  --intent-fg: initial;
3950
- --ui-bg: initial;
3951
- --ui-fg: initial;
3952
- --ui-border: initial;
3953
- --ui-hover-bg: initial;
3954
- /* @sync intent-boundary */
3955
4296
  }
3956
4297
 
3957
- /* A classed .choice is the component surface. Let its nested input consume a
4298
+ /* A classed .choice is the component surface. Let its direct input consume a
3958
4299
  local intent without reopening inherited page-level intents. */
3959
- :where(.choice) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
4300
+ :where(.choice) > :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
3960
4301
  --intent: inherit;
3961
4302
  --intent-fg: inherit;
3962
4303
  }
@@ -3966,7 +4307,7 @@ a.avatar[aria-disabled="true"] {
3966
4307
 
3967
4308
  display: inline-flex;
3968
4309
  align-items: flex-start;
3969
- gap: var(--space-2);
4310
+ gap: var(--space-20);
3970
4311
  color: var(--text);
3971
4312
  font-size: var(--control-font-size);
3972
4313
  cursor: pointer;
@@ -3989,8 +4330,11 @@ a.avatar[aria-disabled="true"] {
3989
4330
  display: block;
3990
4331
  }
3991
4332
 
3992
- .choice:has(:disabled) {
3993
- color: var(--text-subtle);
4333
+ /* Progressive enhancement: dim the whole row when one of its controls is
4334
+ disabled. Without :has() the native disabled control keeps its own state
4335
+ styling — the row-level dim is lost but the field stays clearly disabled. */
4336
+ .choice:has(> :disabled) {
4337
+ color: var(--state-disabled);
3994
4338
  cursor: not-allowed;
3995
4339
  }
3996
4340
 
@@ -4007,8 +4351,7 @@ a.avatar[aria-disabled="true"] {
4007
4351
  place-content: center;
4008
4352
  inline-size: 1.125em;
4009
4353
  block-size: 1.125em;
4010
- margin-inline: 0;
4011
- margin-block-end: 0;
4354
+ margin: 0;
4012
4355
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-border));
4013
4356
  background: var(--choice-bg);
4014
4357
  color: var(--choice-mark);
@@ -4049,7 +4392,7 @@ a.avatar[aria-disabled="true"] {
4049
4392
  background: currentColor;
4050
4393
  }
4051
4394
 
4052
- .check:checked,
4395
+ .check:is(:checked, :indeterminate),
4053
4396
  .radio:checked {
4054
4397
  --choice-border: var(--intent, var(--primary));
4055
4398
  --choice-bg: var(--intent, var(--primary));
@@ -4060,12 +4403,6 @@ a.avatar[aria-disabled="true"] {
4060
4403
  transform: translateY(-0.06em) rotate(-45deg) scale(1);
4061
4404
  }
4062
4405
 
4063
- .check:indeterminate {
4064
- --choice-border: var(--intent, var(--primary));
4065
- --choice-bg: var(--intent, var(--primary));
4066
- --choice-mark: var(--intent-fg, var(--primary-fg));
4067
- }
4068
-
4069
4406
  .check:indeterminate::before {
4070
4407
  border-inline-start: 0;
4071
4408
  inline-size: 0.6em;
@@ -4078,17 +4415,15 @@ a.avatar[aria-disabled="true"] {
4078
4415
  transform: scale(1);
4079
4416
  }
4080
4417
 
4081
- @media (hover: hover) {
4082
- .check:hover:not(:checked, :indeterminate, :disabled),
4083
- .radio:hover:not(:checked, :indeterminate, :disabled) {
4084
- --choice-border: var(--text-subtle);
4085
- }
4418
+ .check:hover:not(:checked, :indeterminate, :disabled),
4419
+ .radio:hover:not(:checked, :indeterminate, :disabled) {
4420
+ --choice-border: var(--text-subtle);
4086
4421
  }
4087
4422
 
4088
4423
  .check:focus-visible,
4089
4424
  .radio:focus-visible {
4090
4425
  --choice-border: var(--focus);
4091
- outline: none;
4426
+ outline: 2px solid transparent;
4092
4427
  box-shadow: var(--focus-ring-shadow);
4093
4428
  }
4094
4429
 
@@ -4096,7 +4431,7 @@ a.avatar[aria-disabled="true"] {
4096
4431
  .radio:disabled {
4097
4432
  --choice-bg: var(--surface-subtle);
4098
4433
  --choice-border: var(--border);
4099
- --choice-mark: var(--text-subtle);
4434
+ --choice-mark: var(--state-disabled);
4100
4435
  cursor: not-allowed;
4101
4436
  opacity: 1;
4102
4437
  }
@@ -4138,20 +4473,21 @@ a.avatar[aria-disabled="true"] {
4138
4473
  * The badge overhangs the top-right corner by 0.375em; ancestors with
4139
4474
  * overflow: hidden will clip it. Pair with grid or flex utilities for group
4140
4475
  * layout.
4476
+ *
4477
+ * Public hooks:
4478
+ * --choice-card-pad
4479
+ * --choice-card-radius
4480
+ * --choice-card-border
4481
+ * --choice-card-bg
4482
+ * --choice-card-check-size
4141
4483
  */
4142
4484
  :where(.choice-card) {
4143
- /* @sync intent-boundary */
4144
4485
  --intent: initial;
4145
4486
  --intent-fg: initial;
4146
- --ui-bg: initial;
4147
- --ui-fg: initial;
4148
- --ui-border: initial;
4149
- --ui-hover-bg: initial;
4150
- /* @sync intent-boundary */
4151
4487
  }
4152
4488
 
4153
4489
  .choice-card {
4154
- --choice-card-pad: var(--variant-space, var(--space-4));
4490
+ --choice-card-pad: var(--density-space, var(--space-40));
4155
4491
  --choice-card-radius: var(--radius);
4156
4492
  --choice-card-border: var(--border);
4157
4493
  --choice-card-bg: var(--surface);
@@ -4191,7 +4527,7 @@ a.avatar[aria-disabled="true"] {
4191
4527
 
4192
4528
  /* Focus ring on the label follows the hidden input's focus. */
4193
4529
  .choice-card:has(:focus-visible) {
4194
- outline: none;
4530
+ outline: 2px solid transparent;
4195
4531
  box-shadow: var(--focus-ring-shadow);
4196
4532
  }
4197
4533
 
@@ -4200,10 +4536,8 @@ a.avatar[aria-disabled="true"] {
4200
4536
  cursor: not-allowed;
4201
4537
  }
4202
4538
 
4203
- @media (hover: hover) {
4204
- .choice-card:hover:not(:has(:disabled)) {
4205
- --choice-card-border: var(--text-subtle);
4206
- }
4539
+ .choice-card:hover:not(:has(:disabled)) {
4540
+ --choice-card-border: var(--text-subtle);
4207
4541
  }
4208
4542
 
4209
4543
  /* Checked border — shared by radio and checkbox */
@@ -4250,8 +4584,8 @@ a.avatar[aria-disabled="true"] {
4250
4584
  /* Check mark */
4251
4585
  .choice-card::after {
4252
4586
  background: var(--intent-fg, var(--primary-fg));
4253
- -webkit-mask: var(--icon-check-mask) center / 60% no-repeat;
4254
- mask: var(--icon-check-mask) center / 60% no-repeat;
4587
+ -webkit-mask: var(--icon-check) center / 60% no-repeat;
4588
+ mask: var(--icon-check) center / 60% no-repeat;
4255
4589
  z-index: 2;
4256
4590
  }
4257
4591
 
@@ -4294,19 +4628,19 @@ a.avatar[aria-disabled="true"] {
4294
4628
  * custom-select.css can be opted out independently. Add .native to a specific
4295
4629
  * select to opt out of the enhancement.
4296
4630
  */
4297
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]) {
4631
+ .select:not([multiple], [size]:not([size="1"])) {
4298
4632
  appearance: none;
4299
- padding-inline-end: calc(var(--space-3) + var(--space-6));
4300
- background-image: var(--icon-chevron-image);
4301
- background-position: center right var(--space-3);
4633
+ padding-inline-end: calc(var(--space-30) + var(--space-60));
4634
+ background-image: var(--icon-chevron);
4635
+ background-position: center right var(--space-30);
4302
4636
  background-size: 1rem auto;
4303
4637
  background-repeat: no-repeat;
4304
4638
  }
4305
4639
 
4306
4640
  /* The chevron sits on the inline-end side; flip it in RTL so it follows
4307
4641
  the padding-inline-end space reserved for it. */
4308
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]):dir(rtl) {
4309
- background-position: center left var(--space-3);
4642
+ .select:not([multiple], [size]:not([size="1"])):dir(rtl) {
4643
+ background-position: center left var(--space-30);
4310
4644
  }
4311
4645
 
4312
4646
  /* Both forced-colors and prefers-contrast hand the arrow back to the
@@ -4314,10 +4648,10 @@ a.avatar[aria-disabled="true"] {
4314
4648
  simpler than maintaining contrast variants, and native rendering already
4315
4649
  matches whatever contrast mode the user picked. */
4316
4650
  @media (forced-colors: active), (prefers-contrast: more) {
4317
- .select:is(:not([multiple], [size]), :not([multiple])[size="1"]) {
4651
+ .select:not([multiple], [size]:not([size="1"])) {
4318
4652
  appearance: auto;
4319
4653
  background-image: none;
4320
- padding-inline-end: var(--space-3);
4654
+ padding-inline-end: var(--space-30);
4321
4655
  }
4322
4656
  }
4323
4657
 
@@ -4330,6 +4664,10 @@ a.avatar[aria-disabled="true"] {
4330
4664
  *
4331
4665
  * No layout or interaction contract may depend on appearance: base-select.
4332
4666
  * Arbitrary menu and popover behavior belongs elsewhere.
4667
+ *
4668
+ * Internal:
4669
+ * --control-border
4670
+ * --control-bg
4333
4671
  */
4334
4672
  @media (forced-colors: none) and (prefers-contrast: no-preference) and (hover: hover) and (
4335
4673
  pointer: fine
@@ -4342,7 +4680,7 @@ a.avatar[aria-disabled="true"] {
4342
4680
  &::picker(select) {
4343
4681
  appearance: base-select;
4344
4682
  max-height: min(24rem, calc(var(--viewport-block) * 0.7));
4345
- margin-block: var(--space-2);
4683
+ margin-block: var(--space-20);
4346
4684
  padding: 0;
4347
4685
  border: var(--border-width) solid var(--border);
4348
4686
  border-radius: var(--radius);
@@ -4364,8 +4702,8 @@ a.avatar[aria-disabled="true"] {
4364
4702
 
4365
4703
  & option {
4366
4704
  position: relative;
4367
- padding-block: var(--space-2);
4368
- padding-inline: var(--space-3);
4705
+ padding-block: var(--space-20);
4706
+ padding-inline: var(--space-30);
4369
4707
  white-space: normal;
4370
4708
  transition-property: color, background-color;
4371
4709
  transition-duration: var(--duration);
@@ -4393,14 +4731,8 @@ a.avatar[aria-disabled="true"] {
4393
4731
  inline-size: 1rem;
4394
4732
  block-size: 1rem;
4395
4733
  background-color: currentColor;
4396
- -webkit-mask-image: var(--icon-check-mask);
4397
- mask-image: var(--icon-check-mask);
4398
- -webkit-mask-repeat: no-repeat;
4399
- mask-repeat: no-repeat;
4400
- -webkit-mask-size: contain;
4401
- mask-size: contain;
4402
- -webkit-mask-position: center;
4403
- mask-position: center;
4734
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
4735
+ mask: var(--icon-check) center / contain no-repeat;
4404
4736
  }
4405
4737
  }
4406
4738
 
@@ -4416,9 +4748,9 @@ a.avatar[aria-disabled="true"] {
4416
4748
  }
4417
4749
 
4418
4750
  /*
4419
- * Native-only control polish: file input that mostly defeats custom styling,
4420
- * so the framework stays out of the way and just keeps it readable and
4421
- * theme-aware.
4751
+ * Native-only control polish: the file input and color picker mostly defeat
4752
+ * custom styling, so the framework stays out of the way and just keeps them
4753
+ * readable and theme-aware.
4422
4754
  *
4423
4755
  * Range has its own custom-styled file (range.css) so the focus ring can
4424
4756
  * target the thumb pseudo-element rather than the rectangular CSS box.
@@ -4428,9 +4760,9 @@ a.avatar[aria-disabled="true"] {
4428
4760
  }
4429
4761
 
4430
4762
  .file::file-selector-button {
4431
- margin-inline-end: var(--space-3);
4432
- padding-block: var(--space-1);
4433
- padding-inline: var(--space-2);
4763
+ margin-inline-end: var(--space-30);
4764
+ padding-block: var(--space-10);
4765
+ padding-inline: var(--space-20);
4434
4766
  border: var(--border-width) solid var(--border);
4435
4767
  border-radius: var(--radius-sm);
4436
4768
  background: var(--surface-subtle);
@@ -4449,17 +4781,56 @@ a.avatar[aria-disabled="true"] {
4449
4781
  cursor: not-allowed;
4450
4782
  }
4451
4783
 
4452
- @media (hover: hover) {
4453
- .file:not(:disabled)::file-selector-button:hover {
4454
- background: var(--border);
4455
- }
4784
+ .file:not(:disabled)::file-selector-button:hover {
4785
+ background: var(--border);
4456
4786
  }
4457
4787
 
4458
4788
  .file:focus-visible {
4459
- outline: none;
4789
+ outline: 2px solid transparent;
4790
+ box-shadow: var(--focus-ring-shadow);
4791
+ }
4792
+
4793
+ /* Native color picker — keep the browser's dialog and selected-color UI. */
4794
+ .color {
4795
+ inline-size: var(--control-size);
4796
+ block-size: var(--control-size);
4797
+ padding: var(--space-10);
4798
+ border: var(--border-width) solid var(--border);
4799
+ border-radius: var(--radius);
4800
+ background: var(--surface);
4801
+ cursor: pointer;
4802
+ }
4803
+
4804
+ .color::-webkit-color-swatch-wrapper {
4805
+ padding: 0;
4806
+ }
4807
+
4808
+ .color::-webkit-color-swatch {
4809
+ border: 0;
4810
+ }
4811
+
4812
+ .color::-moz-color-swatch {
4813
+ border: 0;
4814
+ }
4815
+
4816
+ .color:disabled {
4817
+ cursor: not-allowed;
4818
+ opacity: var(--disabled-opacity);
4819
+ }
4820
+
4821
+ .color:focus-visible {
4822
+ outline: 2px solid transparent;
4460
4823
  box-shadow: var(--focus-ring-shadow);
4461
4824
  }
4462
4825
 
4826
+ @media (forced-colors: active) {
4827
+ .color {
4828
+ border-color: ButtonText;
4829
+ background: ButtonFace;
4830
+ forced-color-adjust: none;
4831
+ }
4832
+ }
4833
+
4463
4834
  /*
4464
4835
  * Range slider — custom-styled so the focus ring always targets the
4465
4836
  * user-manipulated surface (the thumb) rather than the CSS box.
@@ -4467,19 +4838,18 @@ a.avatar[aria-disabled="true"] {
4467
4838
  * appearance: none gives us full control over track and thumb; without it,
4468
4839
  * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
4469
4840
  * are not reliably applied across browsers.
4841
+ *
4842
+ * Public hooks:
4843
+ * --range-thumb-size
4844
+ * --range-track-height
4845
+ * --range-thumb-bg
4846
+ * --range-track-bg
4470
4847
  */
4471
4848
 
4472
4849
  /* Intent boundary — inherited intents do not tint this component.
4473
4850
  Local intent classes still win because :where() has zero specificity. */
4474
4851
  :where(.range) {
4475
- /* @sync intent-boundary */
4476
4852
  --intent: initial;
4477
- --intent-fg: initial;
4478
- --ui-bg: initial;
4479
- --ui-fg: initial;
4480
- --ui-border: initial;
4481
- --ui-hover-bg: initial;
4482
- /* @sync intent-boundary */
4483
4853
  }
4484
4854
 
4485
4855
  .range {
@@ -4530,7 +4900,7 @@ a.avatar[aria-disabled="true"] {
4530
4900
  }
4531
4901
 
4532
4902
  .range:focus-visible {
4533
- outline: none;
4903
+ outline: 2px solid transparent;
4534
4904
  }
4535
4905
 
4536
4906
  /*
@@ -4551,28 +4921,41 @@ a.avatar[aria-disabled="true"] {
4551
4921
  }
4552
4922
 
4553
4923
  .range:disabled {
4554
- --range-thumb-bg: var(--text-subtle);
4924
+ --range-thumb-bg: var(--state-disabled);
4555
4925
  cursor: not-allowed;
4556
4926
  opacity: var(--disabled-opacity);
4557
4927
  }
4558
4928
 
4929
+ /* Forced colors: map the custom thumb/track to system colors. The focus
4930
+ indicator stays visible through the transparent outline on :focus-visible. */
4931
+ @media (forced-colors: active) {
4932
+ .range {
4933
+ --range-thumb-bg: ButtonText;
4934
+ --range-track-bg: Canvas;
4935
+ }
4936
+ }
4937
+
4559
4938
  /*
4560
4939
  * Switch controls for checkbox inputs with switch semantics.
4561
4940
  *
4562
4941
  * The switch remains an input control, not a decorative toggle. Checked, hover,
4563
4942
  * focus-visible, and disabled states stay clear.
4943
+ *
4944
+ * Public hooks:
4945
+ * --switch-bg
4946
+ * --switch-border
4947
+ * --switch-knob
4948
+ * --switch-width
4949
+ * --switch-block-size
4950
+ * --switch-knob-margin
4951
+ * --switch-knob-size
4952
+ * --switch-travel
4564
4953
  */
4565
4954
  /* Intent boundary — inherited intents do not tint this component.
4566
4955
  Local intent classes still win because :where() has zero specificity. */
4567
4956
  :where(.switch) {
4568
- /* @sync intent-boundary */
4569
4957
  --intent: initial;
4570
4958
  --intent-fg: initial;
4571
- --ui-bg: initial;
4572
- --ui-fg: initial;
4573
- --ui-border: initial;
4574
- --ui-hover-bg: initial;
4575
- /* @sync intent-boundary */
4576
4959
  }
4577
4960
 
4578
4961
  .switch {
@@ -4620,10 +5003,8 @@ a.avatar[aria-disabled="true"] {
4620
5003
  box-shadow var(--duration);
4621
5004
  }
4622
5005
 
4623
- @media (hover: hover) {
4624
- .switch:hover:not(:disabled) {
4625
- --switch-border: var(--intent, var(--primary));
4626
- }
5006
+ .switch:hover:not(:disabled) {
5007
+ --switch-border: var(--intent, var(--primary));
4627
5008
  }
4628
5009
 
4629
5010
  .switch:checked {
@@ -4644,14 +5025,14 @@ a.avatar[aria-disabled="true"] {
4644
5025
 
4645
5026
  .switch:focus-visible {
4646
5027
  --switch-border: var(--focus);
4647
- outline: none;
5028
+ outline: 2px solid transparent;
4648
5029
  box-shadow: var(--focus-ring-shadow);
4649
5030
  }
4650
5031
 
4651
5032
  .switch:disabled {
4652
5033
  --switch-bg: var(--surface-subtle);
4653
5034
  --switch-border: var(--border);
4654
- --switch-knob: var(--text-subtle);
5035
+ --switch-knob: var(--state-disabled);
4655
5036
  opacity: 1;
4656
5037
  cursor: not-allowed;
4657
5038
  }
@@ -4688,6 +5069,17 @@ a.avatar[aria-disabled="true"] {
4688
5069
  *
4689
5070
  * Imported by default through forms/index.css. Custom builds may omit this
4690
5071
  * file when an app wants to own validation styling completely.
5072
+ *
5073
+ * State coverage (which states each component ships) is audited in
5074
+ * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
5075
+ * not kept as a source comment that can drift from the implementation.
5076
+ *
5077
+ * Public hooks:
5078
+ * --form-invalid-border
5079
+ *
5080
+ * Internal:
5081
+ * --choice-bg
5082
+ * --switch-bg
4691
5083
  */
4692
5084
 
4693
5085
  .field.danger {
@@ -4710,17 +5102,33 @@ a.avatar[aria-disabled="true"] {
4710
5102
  --form-invalid-border: var(--danger);
4711
5103
  }
4712
5104
 
4713
- /* Override checked/indeterminate background so both border and fill signal
4714
- danger instead of showing a red border around a primary-colored control. */
4715
- .check[aria-invalid="true"]:is(:checked, :indeterminate),
4716
- .radio[aria-invalid="true"]:checked {
5105
+ /* Override checked/indeterminate fill so both border and fill signal danger
5106
+ instead of a red border around a primary-colored control. All three
5107
+ invalid routes must converge: aria-invalid, was-validated :invalid, and
5108
+ :user-invalid. */
5109
+ .check:is(:checked, :indeterminate)[aria-invalid="true"],
5110
+ .needs-validation.was-validated .check:is(:checked, :indeterminate):invalid,
5111
+ .radio:checked[aria-invalid="true"],
5112
+ .needs-validation.was-validated .radio:checked:invalid {
4717
5113
  --choice-bg: var(--danger);
4718
5114
  }
4719
5115
 
4720
- .switch[aria-invalid="true"]:checked {
5116
+ .switch:checked[aria-invalid="true"],
5117
+ .needs-validation.was-validated .switch:checked:invalid {
4721
5118
  --switch-bg: var(--danger);
4722
5119
  }
4723
5120
 
5121
+ @supports selector(:user-invalid) {
5122
+ .needs-validation .check:is(:checked, :indeterminate):user-invalid,
5123
+ .needs-validation .radio:checked:user-invalid {
5124
+ --choice-bg: var(--danger);
5125
+ }
5126
+
5127
+ .needs-validation .switch:checked:user-invalid {
5128
+ --switch-bg: var(--danger);
5129
+ }
5130
+ }
5131
+
4724
5132
  /* Choice cards hide their native input, so invalid state must surface on the
4725
5133
  card itself. Sets the same --form-invalid-border hook the card's border
4726
5134
  consumes. */
@@ -4753,13 +5161,24 @@ a.avatar[aria-disabled="true"] {
4753
5161
  * A flex cluster of buttons/actions that closes a form. The sticky variant
4754
5162
  * docks to the viewport bottom with a translucent blur where supported; at
4755
5163
  * rest it stays visually neutral so it does not double as a separator.
5164
+ *
5165
+ * Public hooks:
5166
+ * --form-actions-align
5167
+ * --form-actions-justify
5168
+ * --form-actions-margin-block-start
5169
+ *
5170
+ * Internal:
5171
+ * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
5172
+ * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
5173
+ * --form-actions-sticky-padding (owned by .form-actions.sticky)
4756
5174
  */
4757
5175
  .form-actions {
4758
5176
  display: flex;
4759
5177
  flex-wrap: wrap;
4760
5178
  gap: var(--gap);
4761
- align-items: center;
4762
- margin-block-start: var(--form-actions-margin-block-start, var(--space-5));
5179
+ align-items: var(--form-actions-align, center);
5180
+ justify-content: var(--form-actions-justify, flex-start);
5181
+ margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
4763
5182
  }
4764
5183
 
4765
5184
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -4773,7 +5192,7 @@ a.avatar[aria-disabled="true"] {
4773
5192
  isolation: isolate;
4774
5193
 
4775
5194
  margin-inline: calc(var(--form-actions-sticky-inline-offset, 0px) * -1);
4776
- padding: var(--form-actions-sticky-padding, var(--space-3));
5195
+ padding: var(--form-actions-sticky-padding, var(--space-30));
4777
5196
 
4778
5197
  border-block-start: var(--border-width) solid var(--border);
4779
5198
  background: var(--surface);
@@ -4823,7 +5242,7 @@ a.avatar[aria-disabled="true"] {
4823
5242
  min-inline-size: 0;
4824
5243
  }
4825
5244
 
4826
- .nowrap {
5245
+ .text-nowrap {
4827
5246
  white-space: nowrap;
4828
5247
  }
4829
5248
 
@@ -4918,15 +5337,13 @@ a.avatar[aria-disabled="true"] {
4918
5337
  text-decoration: none;
4919
5338
  }
4920
5339
 
4921
- @media (hover: hover) {
4922
- .link-muted:hover {
4923
- color: var(--text);
4924
- }
5340
+ .link-muted:hover {
5341
+ color: var(--text);
4925
5342
  }
4926
5343
 
4927
5344
  /* Spacing step helpers — semantic scale (sm / default / lg).
4928
5345
  Names map to logical properties for writing-direction safety. The default
4929
- step uses --space-4, which matches the framework's built-in padding on
5346
+ step uses --space-40, which matches the framework's built-in padding on
4930
5347
  cards, dialogs, and form-actions. */
4931
5348
 
4932
5349
  /* Gap */
@@ -4936,22 +5353,22 @@ a.avatar[aria-disabled="true"] {
4936
5353
 
4937
5354
  /* Padding — block (vertical) */
4938
5355
  .py {
4939
- padding-block: var(--variant-space);
5356
+ padding-block: var(--density-space);
4940
5357
  }
4941
5358
 
4942
5359
  /* Padding — inline (horizontal) */
4943
5360
  .px {
4944
- padding-inline: var(--variant-space);
5361
+ padding-inline: var(--density-space);
4945
5362
  }
4946
5363
 
4947
5364
  /* Margin — block-start */
4948
5365
  .mbs {
4949
- margin-block-start: var(--variant-space);
5366
+ margin-block-start: var(--density-space);
4950
5367
  }
4951
5368
 
4952
5369
  /* Margin — block-end */
4953
5370
  .mbe {
4954
- margin-block-end: var(--variant-space);
5371
+ margin-block-end: var(--density-space);
4955
5372
  }
4956
5373
 
4957
5374
  /* Joined controls are a component, but must load after the controls it groups. */
@@ -4964,6 +5381,18 @@ a.avatar[aria-disabled="true"] {
4964
5381
  * Cascade note: .join writes border-radius longhands on its direct children.
4965
5382
  * Keep this file loaded after control/component styles so those corners win at
4966
5383
  * equal specificity.
5384
+ *
5385
+ * Public hooks:
5386
+ * --join-radius
5387
+ *
5388
+ * Internal:
5389
+ * --join-ss
5390
+ * --join-se
5391
+ * --join-es
5392
+ * --join-ee
5393
+ * --control-border
5394
+ * --control-bg
5395
+ * --control-fg
4967
5396
  */
4968
5397
  .join {
4969
5398
  --join-radius: var(--radius);
@@ -4991,7 +5420,7 @@ a.avatar[aria-disabled="true"] {
4991
5420
  display: inline-flex;
4992
5421
  justify-content: center;
4993
5422
  min-block-size: var(--control-size);
4994
- padding-inline: var(--space-3);
5423
+ padding-inline: var(--space-30);
4995
5424
  border: var(--border-width) solid var(--control-border);
4996
5425
  border-radius: var(--radius);
4997
5426
  background: var(--control-bg);
@@ -5052,7 +5481,7 @@ a.avatar[aria-disabled="true"] {
5052
5481
  focus, and unsupported :has() browsers retain the control's normal ring. */
5053
5482
  .join:has(> :is(.input, .textarea, .select):focus-visible) {
5054
5483
  border-radius: var(--join-radius);
5055
- outline: none;
5484
+ outline: 2px solid transparent;
5056
5485
  box-shadow: var(--focus-ring-shadow);
5057
5486
  }
5058
5487
 
@@ -5061,9 +5490,14 @@ a.avatar[aria-disabled="true"] {
5061
5490
  box-shadow: none;
5062
5491
  }
5063
5492
 
5064
- /* Focus baseline — loaded after components so its legacy fallback also covers
5065
- controls that reset their native outline. Modern browsers hide it for focus
5066
- that is not keyboard-relevant; components layer local state on top. */
5493
+ /* Focus baseline — a visible outline is the guaranteed focus affordance. The
5494
+ :focus outline covers legacy browsers; modern browsers hide it for
5495
+ pointer/mouse focus and interactive components layer their ring on top.
5496
+
5497
+ Invariant: an interactive component must never rely on box-shadow alone
5498
+ for its keyboard focus indicator. Components that remove the native
5499
+ outline on :focus-visible replace it with a transparent outline — invisible
5500
+ normally, forced to the system focus color under forced-colors. */
5067
5501
  :focus {
5068
5502
  outline: var(--focus-outline);
5069
5503
  outline-offset: var(--focus-outline-offset);
@@ -5073,42 +5507,13 @@ a.avatar[aria-disabled="true"] {
5073
5507
  :focus:not(:focus-visible) {
5074
5508
  outline: none;
5075
5509
  }
5076
- }
5077
-
5078
- /*
5079
- * Forced-colors focus — centralised so individual components do not need
5080
- * their own fallback. The double :focus keeps specificity high enough
5081
- * (0,0,2,0) to beat a single :focus-visible; against a class selector like
5082
- * .input:focus-visible it ties, but this file loads last so it wins on
5083
- * source order.
5084
- *
5085
- * .choice-card and .join receive focus indirectly (:has on a wrapper that
5086
- * never matches :focus), so they are listed explicitly.
5087
- *
5088
- * .range is excluded from the rectangular fallback: its focus is drawn on
5089
- * the thumb pseudo-element, which is the manipulated surface.
5090
- *
5091
- * box-shadow: none is redundant when forced-color-adjust is auto, but safe
5092
- * when a component uses forced-color-adjust: none (e.g. checked switches /
5093
- * checkboxes).
5094
- */
5095
- @media (forced-colors: active) {
5096
- :focus:focus-visible:not(.range),
5097
- .choice-card:has(:focus-visible),
5098
- .join:has(> :is(.input, .textarea, .select):focus-visible) {
5099
- outline: var(--focus-outline);
5100
- outline-offset: var(--focus-outline-offset);
5101
- box-shadow: none;
5102
- }
5103
-
5104
- .range:focus-visible::-webkit-slider-thumb {
5105
- outline: var(--focus-outline);
5106
- outline-offset: var(--focus-outline-offset);
5107
- }
5108
5510
 
5109
- .range:focus-visible::-moz-range-thumb {
5110
- outline: var(--focus-outline);
5111
- outline-offset: var(--focus-outline-offset);
5511
+ /* Pointer focus must still signal in high-contrast mode, where the shared
5512
+ box-shadow rings are disabled (--focus-ring-shadow: none). */
5513
+ @media (forced-colors: active) {
5514
+ :focus:not(:focus-visible) {
5515
+ outline: var(--focus-outline);
5516
+ }
5112
5517
  }
5113
5518
  }
5114
5519
 
@@ -5159,3 +5564,16 @@ a.avatar[aria-disabled="true"] {
5159
5564
  break-after: avoid;
5160
5565
  }
5161
5566
  }
5567
+
5568
+
5569
+ /*
5570
+ * NOTE: The 'optional/' directory contains layout and utility extensions
5571
+ * (such as optional grids, split, visual effects, and extra utility classes
5572
+ * like .gap-sm, .flex-nowrap, .shadow-sm, etc.). These are intentionally not
5573
+ * bundled here. Two entry points ship them:
5574
+ *
5575
+ * actual-css/css/optional/index optional layer only
5576
+ * actual-css/css/actual.full core + optional
5577
+ *
5578
+ * Import one of these paths in your project when you need them.
5579
+ */