actual-css 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +29 -5
  3. package/components.json +15 -17
  4. package/dist/actual.full.css +403 -227
  5. package/dist/actual.full.min.css +1 -1
  6. package/docs/brand/favicon.svg +5 -0
  7. package/docs/brand/lockup.svg +12 -0
  8. package/docs/brand/logo-dark.svg +9 -0
  9. package/docs/brand/logo-light.svg +9 -0
  10. package/docs/brand/logo.svg +11 -0
  11. package/docs/brand/mark.svg +11 -0
  12. package/docs/navigation.json +4 -2
  13. package/docs/pages/components/alert.md +2 -10
  14. package/docs/pages/components/badge.md +3 -9
  15. package/docs/pages/components/bleed.md +5 -4
  16. package/docs/pages/components/card.md +31 -2
  17. package/docs/pages/components/close.md +9 -3
  18. package/docs/pages/components/dialog.md +248 -31
  19. package/docs/pages/components/drawer.md +2 -2
  20. package/docs/pages/components/meter.md +2 -0
  21. package/docs/pages/components/navbar.md +8 -9
  22. package/docs/pages/components/table.md +3 -3
  23. package/docs/pages/enhancements/flyout.md +2 -2
  24. package/docs/pages/enhancements/scrollspy.md +1 -1
  25. package/docs/pages/enhancements/status-bar.md +2 -2
  26. package/docs/pages/examples/overview.md +3 -1
  27. package/docs/pages/forms/date-picker.md +90 -0
  28. package/docs/pages/forms/detached-actions.md +37 -30
  29. package/docs/pages/forms/form-actions.md +61 -28
  30. package/docs/pages/forms/input-policy.md +1 -1
  31. package/docs/pages/forms/overview.md +6 -5
  32. package/docs/pages/forms/sticky-actions.md +85 -83
  33. package/docs/pages/forms/validation.md +5 -5
  34. package/docs/pages/foundations/philosophy.md +5 -3
  35. package/docs/pages/foundations/theming.md +15 -4
  36. package/docs/pages/foundations/tokens.md +50 -33
  37. package/docs/pages/foundations/typography.md +4 -7
  38. package/docs/pages/guides/bootstrap.md +2 -2
  39. package/docs/pages/guides/design-tools.md +36 -0
  40. package/docs/pages/guides/integrating-actual-css.md +5 -1
  41. package/docs/pages/guides/introduction.md +3 -4
  42. package/docs/pages/guides/modular-import.md +3 -0
  43. package/docs/pages/guides/progressive-enhancement.md +2 -2
  44. package/docs/pages/guides/tailwind.md +70 -0
  45. package/docs/pages/guides/upgrading.md +74 -0
  46. package/docs/pages/layout/cluster.md +28 -2
  47. package/docs/pages/layout/overview.md +1 -1
  48. package/docs/pages/layout/stack.md +21 -16
  49. package/docs/pages/layout/topbar.md +1 -1
  50. package/docs/pages/patterns/navigation-list.md +16 -46
  51. package/docs/pages/patterns/overview.md +3 -4
  52. package/docs/pages/utilities/flex.md +30 -20
  53. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  54. package/docs/pages/utilities/spacing.md +4 -3
  55. package/package.json +3 -2
  56. package/reserved-classes.json +3 -3
  57. package/skills/actual-css/references/enhancements.md +38 -0
  58. package/src/cli/actual-css.js +75 -6
  59. package/src/css/components/avatar.css +16 -9
  60. package/src/css/components/badge.css +1 -5
  61. package/src/css/components/bleed.css +5 -5
  62. package/src/css/components/button.css +17 -10
  63. package/src/css/components/card.css +18 -10
  64. package/src/css/components/chat.css +4 -5
  65. package/src/css/components/close.css +17 -6
  66. package/src/css/components/drawer.css +19 -3
  67. package/src/css/components/flyout.css +3 -0
  68. package/src/css/components/index.css +0 -1
  69. package/src/css/components/meter.css +5 -2
  70. package/src/css/components/modal.css +157 -69
  71. package/src/css/components/navbar.css +14 -25
  72. package/src/css/core/base.css +1 -1
  73. package/src/css/core/reset.css +2 -1
  74. package/src/css/core/theme.css +4 -0
  75. package/src/css/core/tokens.css +28 -10
  76. package/src/css/core/variants.css +41 -14
  77. package/src/css/forms/form-actions.css +15 -10
  78. package/src/css/forms/native.css +5 -1
  79. package/src/css/layout/cluster.css +11 -0
  80. package/src/css/layout/topbar.css +12 -6
  81. package/src/css/utilities/base.css +5 -0
  82. package/src/css/utilities/extra.css +3 -7
  83. package/src/tooling/color.js +65 -0
  84. package/src/tooling/design-components.js +286 -0
  85. package/src/tooling/design-export.js +69 -0
  86. package/src/tooling/design-tokens.js +474 -0
  87. package/src/tooling/penpot-plugin/manifest.json +7 -0
  88. package/src/tooling/penpot-plugin/plugin.js +272 -0
  89. package/src/tooling/penpot-plugin/ui.html +58 -0
  90. package/src/tooling/theme-contrast.js +209 -0
  91. package/docs/pages/patterns/actions.md +0 -52
  92. package/src/css/components/actions.css +0 -27
@@ -8,13 +8,20 @@
8
8
  *
9
9
  * Public hooks:
10
10
  * --modal-size
11
+ * --modal-pad
11
12
  * --dialog-viewport-gap
12
13
  * --dialog-icon-size
13
14
  * --dialog-icon-glyph-size
14
15
  *
15
16
  * The corner close control is .close (components/close.css).
16
17
  *
18
+ * The body footer is a structural slot with no layout of its own: its action
19
+ * row is composed in markup (`<footer class="cluster justify-content-end">`),
20
+ * as in the drawer. A default row here would fight every composed one.
21
+ *
17
22
  * Internal:
23
+ * --dialog-inner-radius
24
+ * --dialog-close-reserve (header room for a corner close; zero without a close)
18
25
  * --dialog-focus-bleed
19
26
  * --dialog-viewport-block
20
27
  * --dialog-viewport-gap-default
@@ -23,16 +30,28 @@
23
30
  :where(dialog).modal {
24
31
  /* --dialog-viewport-gap stays fallback-only so the responsive default can
25
32
  live in an internal token: a media query moves it without closing the
26
- public hook.
27
-
28
- calc() multiplication is not in the supported browser range, so the
29
- doubled gap is spelled out with an additive calc. */
33
+ public hook. `-2x` is the gap on the two viewport edges, shared by the
34
+ width and height caps. */
30
35
  --dialog-viewport-gap-default: var(--space-40);
31
36
  --dialog-viewport-gap-2x: calc(
32
37
  var(--dialog-viewport-gap, var(--dialog-viewport-gap-default)) +
33
38
  var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
34
39
  );
35
40
 
41
+ /* The panel is borderless, so its inner and outer radius are the same. Kept
42
+ as one name for the padded wrapper and the header band; a .bleed band
43
+ inherits it from the wrapper, so none of them drifts from the corner. */
44
+ --dialog-inner-radius: var(--radius-lg);
45
+
46
+ /* Header room for a corner close. Unconditional baseline: without :has()
47
+ (Degraded tier) a header keeps too much room rather than a title running
48
+ under the close. Zeroed when the dialog has no close. */
49
+ --dialog-close-reserve: calc(
50
+ var(--close-size, var(--control-size)) +
51
+ var(--space-20) +
52
+ var(--space-30)
53
+ );
54
+
36
55
  /* Keep the surface anchored to the viewport in the top layer. This also
37
56
  provides the containing block for the absolutely positioned close button. */
38
57
  position: fixed;
@@ -46,7 +65,9 @@
46
65
  dynamic token; only the modal pins the small viewport. */
47
66
  --dialog-viewport-block: var(--viewport-block);
48
67
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
49
- border: var(--border-width) solid var(--border);
68
+ /* Zero rather than absent: the UA stylesheet draws `border: solid` on
69
+ dialog, and an authored edge reads as a hard seam against .bleed bands. */
70
+ border: 0;
50
71
  border-radius: var(--radius-lg);
51
72
  background: var(--surface-raised);
52
73
  color: var(--text);
@@ -63,19 +84,81 @@
63
84
  * both edges, which reads as horizontal movement.
64
85
  */
65
86
  transform: translateY(0.35rem);
66
- transition:
67
- opacity var(--duration) var(--ease-exit),
68
- transform var(--duration) var(--ease-exit);
87
+ transition-property: opacity, transform;
88
+ transition-duration: var(--duration);
89
+ transition-timing-function: var(--ease-exit);
90
+ }
91
+
92
+ @media (forced-colors: active) {
93
+ /* With no authored border the panel edge disappears once colors collapse.
94
+ The browser forces the border color, so only the width is authored. */
95
+ :where(dialog).modal {
96
+ border: var(--border-width) solid CanvasText;
97
+ }
69
98
  }
70
99
 
71
100
  :where(dialog).modal > form,
72
101
  :where(dialog).modal > .stack {
102
+ --surface-pad: var(--modal-pad, var(--space-40));
103
+
73
104
  display: grid;
74
105
  gap: var(--space-40);
75
106
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
107
+ border-radius: var(--dialog-inner-radius);
76
108
  overflow: auto;
77
109
  overscroll-behavior: contain;
78
- padding: var(--space-40);
110
+ padding: var(--surface-pad);
111
+ }
112
+
113
+ /* Dialog chrome beside the padded body wrapper. Wrapping the close in the
114
+ trailing cluster keeps it in flow, not the absolute corner overlay. */
115
+ :where(dialog).modal > header {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: space-between;
119
+ gap: var(--space-30);
120
+ min-block-size: var(--close-size, var(--control-size));
121
+ padding: var(--modal-pad, var(--space-40));
122
+ border-start-start-radius: var(--dialog-inner-radius);
123
+ border-start-end-radius: var(--dialog-inner-radius);
124
+ }
125
+
126
+ /* Paint is not anatomy: the default band fill has zero specificity, so a
127
+ .background-* utility on the header replaces it. The layout above stays at
128
+ the component's weight — a composed .cluster must not reset the title/close
129
+ alignment. */
130
+ :where(dialog.modal > header) {
131
+ background: var(--surface-subtle);
132
+ }
133
+
134
+ :where(dialog).modal > header > .media {
135
+ flex: 1 1 auto;
136
+ min-inline-size: 0;
137
+ }
138
+
139
+ :where(dialog).modal > header > .media > :is(h1, h2, h3, h4, h5, h6) {
140
+ display: flex;
141
+ align-items: center;
142
+ min-block-size: var(--close-size, var(--control-size));
143
+ margin-block: 0;
144
+ }
145
+
146
+ :where(dialog).modal > header > .cluster {
147
+ --cluster-wrap: nowrap;
148
+
149
+ flex: none;
150
+ }
151
+
152
+ /* The header stays fixed while the body wrapper owns overflow. Without :has()
153
+ these drop individually and compact dialogs keep ordinary block flow. */
154
+ :where(dialog).modal[open]:has(> header) {
155
+ display: grid;
156
+ grid-template-rows: auto minmax(0, 1fr);
157
+ }
158
+
159
+ :where(dialog).modal:has(> header) > :is(form, .stack) {
160
+ min-block-size: 0;
161
+ max-block-size: none;
79
162
  }
80
163
 
81
164
  @supports (block-size: 100svh) {
@@ -94,10 +177,21 @@
94
177
 
95
178
  :where(dialog).modal.scrollable > form,
96
179
  :where(dialog).modal.scrollable > .stack {
97
- grid-template-rows: auto minmax(0, 1fr) auto;
180
+ display: flex;
181
+ flex-direction: column;
98
182
  overflow: hidden;
99
183
  }
100
184
 
185
+ /* Regions carry their own behaviour: a structural header or footer keeps its
186
+ size, every other child absorbs the free space and scrolls. Nothing here has
187
+ to detect which regions exist — an external header leaves the wrapper with
188
+ content + footer and this still holds. Each non-chrome child is its own
189
+ scrollport; wrap the body in one region for a single one. */
190
+ :where(dialog).modal.scrollable > form > :is(header, footer),
191
+ :where(dialog).modal.scrollable > .stack > :is(header, footer) {
192
+ flex: none;
193
+ }
194
+
101
195
  :where(dialog).modal.scrollable > form > :not(:is(header, footer)),
102
196
  :where(dialog).modal.scrollable > .stack > :not(:is(header, footer)) {
103
197
  --dialog-focus-bleed: calc(
@@ -105,6 +199,7 @@
105
199
  max(var(--focus-ring-width), var(--border-width) * 2)
106
200
  );
107
201
 
202
+ flex: 1 1 auto;
108
203
  min-block-size: 0;
109
204
  /* Keep focus paint inside the clip without moving the body off the fixed
110
205
  header and footer edges. The reserve covers the thicker of the two
@@ -127,39 +222,36 @@
127
222
  margin-block-end: 0;
128
223
  }
129
224
 
130
- /* The close button is anchored to the dialog at top inline-end, out of the
131
- content flow; headers reserve inline room so titles never run under it.
132
- The form/.stack anatomy is structural, so it stays in :where() and never
133
- lends .stack's specificity to a bare <form>. */
134
- :where(dialog).modal > :where(form, .stack) > header {
135
- padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
225
+ /* A media row inside the structural header is title anatomy, so its heading
226
+ does not bring prose margins into the icon alignment. */
227
+ :where(dialog).modal > :where(form, .stack) > header > .media > :is(h1, h2, h3, h4, h5, h6) {
228
+ margin-block: 0;
136
229
  }
137
230
 
138
- :where(dialog).modal > :where(form, .stack) > footer {
139
- display: flex;
140
- flex-wrap: wrap;
141
- justify-content: flex-end;
142
- gap: var(--gap);
231
+ /* No close anywhere: reclaim the reserve. Deliberately coarse — a close in
232
+ the body keeps the reserve, which costs only room, never overlap. */
233
+ :where(dialog).modal:not(:has(.close)) {
234
+ --dialog-close-reserve: 0px;
143
235
  }
144
236
 
145
- /* Confirmation layout — a focused message followed by a visually distinct
146
- action band. The media primitive owns icon/content alignment; this variant
147
- only removes the standard form inset so both regions can own their padding. */
148
- :where(dialog).modal.dialog-confirmation > form {
149
- gap: 0;
150
- padding: 0;
237
+ /* The close button is anchored to the dialog at top inline-end, out of the
238
+ content flow; headers reserve inline room so titles never run under it. A
239
+ .bleed header keeps at least its band inset. The form/.stack anatomy is
240
+ structural, so it stays in :where() and never lends .stack's specificity to
241
+ a bare <form>. */
242
+ :where(dialog).modal > :where(form, .stack) > header {
243
+ padding-inline-end: var(--dialog-close-reserve);
151
244
  }
152
245
 
153
- :where(dialog).modal.dialog-confirmation > form > .media {
154
- gap: var(--space-40);
155
- padding: var(--space-50);
246
+ :where(dialog).modal > :where(form, .stack) > header:where(.bleed) {
247
+ padding-inline-end: max(var(--bleed-pad, 0px), var(--dialog-close-reserve));
156
248
  }
157
249
 
158
- :where(dialog).modal.dialog-confirmation > form > footer {
159
- padding: var(--space-40) var(--space-50);
160
- border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
161
- border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
162
- background: var(--surface-subtle);
250
+ /* A corner .close that is a direct child of the chrome header needs the same
251
+ reserve, but never below the header's own pad. Kept as its own rule so an
252
+ unsupported :has() drops only this geometry, not the wrapper reserve. */
253
+ :where(dialog).modal > header:has(> .close) {
254
+ padding-inline-end: max(var(--modal-pad, var(--space-40)), var(--dialog-close-reserve));
163
255
  }
164
256
 
165
257
  /* Intent boundary — inherited intents do not tint the icon well. A local
@@ -172,14 +264,17 @@
172
264
  --ui-fg: initial;
173
265
  }
174
266
 
267
+ /* The size hooks are read here, never declared: an author sets them on the
268
+ icon's media row (or the dialog) so both the circle and the title that
269
+ centers on it inherit one value. A local declaration on .dialog-icon would
270
+ shadow that value for the circle while the title kept the inherited one —
271
+ the two centers would drift. An override on the icon alone is deliberately
272
+ local and moves only the circle. */
175
273
  .dialog-icon {
176
- --dialog-icon-size: 3rem;
177
- --dialog-icon-glyph-size: 1.5rem;
178
-
179
274
  display: inline-grid;
180
275
  place-items: center;
181
- inline-size: var(--dialog-icon-size);
182
- block-size: var(--dialog-icon-size);
276
+ inline-size: var(--dialog-icon-size, 3rem);
277
+ block-size: var(--dialog-icon-size, 3rem);
183
278
  flex: none;
184
279
  border-radius: var(--radius-full);
185
280
  background: var(--ui-bg, var(--surface-subtle));
@@ -189,25 +284,27 @@
189
284
  .dialog-icon > * {
190
285
  display: inline-grid;
191
286
  place-items: center;
192
- inline-size: var(--dialog-icon-glyph-size);
193
- block-size: var(--dialog-icon-glyph-size);
194
- font-size: var(--dialog-icon-glyph-size);
287
+ inline-size: var(--dialog-icon-glyph-size, 1.5rem);
288
+ block-size: var(--dialog-icon-glyph-size, 1.5rem);
289
+ font-size: var(--dialog-icon-glyph-size, 1.5rem);
195
290
  line-height: 1;
196
291
  }
197
292
 
198
- @media (max-width: 30rem) {
199
- :where(dialog).modal.dialog-confirmation > form > .media,
200
- :where(dialog).modal.dialog-confirmation > form > footer {
201
- padding: var(--space-40);
202
- }
293
+ /* A leading icon well centers the first heading of the text column beside it:
294
+ the heading occupies the icon row, so its text centers without a
295
+ font-relative nudge, and following copy stays in the same column. Keyed on
296
+ the media anatomy, not on the dialog, so a card or page row composes it too. */
297
+ .media > .dialog-icon + * > :is(h1, h2, h3, h4, h5, h6):first-child {
298
+ display: flex;
299
+ align-items: center;
300
+ min-block-size: var(--dialog-icon-size, 3rem);
301
+ margin-block: 0;
203
302
  }
204
303
 
205
304
  :where(dialog).modal[open] {
206
305
  opacity: 1;
207
306
  transform: none;
208
- transition:
209
- opacity var(--duration) var(--ease-enter),
210
- transform var(--duration) var(--ease-enter);
307
+ transition-timing-function: var(--ease-enter);
211
308
  }
212
309
 
213
310
  @starting-style {
@@ -222,31 +319,22 @@
222
319
  opacity: 0;
223
320
  /* Scrim curve stays neutral `ease`; only the panel carries the character
224
321
  curves. */
225
- transition: opacity var(--duration) ease;
322
+ transition-property: opacity;
323
+ transition-duration: var(--duration);
324
+ transition-timing-function: ease;
226
325
  }
227
326
 
228
327
  @supports (transition-behavior: allow-discrete) {
328
+ /* Add the discrete top-layer pair to the property list. Duration and timing
329
+ stay on the base rules, so [open] still only swaps the easing. */
229
330
  :where(dialog).modal {
230
- transition:
231
- opacity var(--duration) var(--ease-exit),
232
- transform var(--duration) var(--ease-exit),
233
- overlay var(--duration) allow-discrete,
234
- display var(--duration) allow-discrete;
235
- }
236
-
237
- :where(dialog).modal[open] {
238
- transition:
239
- opacity var(--duration) var(--ease-enter),
240
- transform var(--duration) var(--ease-enter),
241
- overlay var(--duration) allow-discrete,
242
- display var(--duration) allow-discrete;
331
+ transition-property: opacity, transform, overlay, display;
332
+ transition-behavior: allow-discrete;
243
333
  }
244
334
 
245
335
  :where(dialog).modal::backdrop {
246
- transition:
247
- opacity var(--duration) ease,
248
- overlay var(--duration) allow-discrete,
249
- display var(--duration) allow-discrete;
336
+ transition-property: opacity, overlay, display;
337
+ transition-behavior: allow-discrete;
250
338
  }
251
339
  }
252
340
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Docs: docs/pages/components/navbar.md
5
5
  *
6
- * .navbar is a horizontal bar; .navbar-brand is the brand link; .navbar-nav is
7
- * the inline link list using .nav-link items. .nav-list is the vertical nav
6
+ * .navbar is a horizontal bar; .navbar-brand is the brand link; the inline
7
+ * links are a .cluster using .nav-link items. .nav-list is the vertical nav
8
8
  * list shared with .drawer, fed by the shared --gap token.
9
9
  *
10
10
  * Internal:
@@ -53,17 +53,6 @@
53
53
  border-radius: var(--radius-sm);
54
54
  }
55
55
 
56
- .navbar-nav {
57
- display: flex;
58
- flex-direction: row;
59
- flex-wrap: wrap;
60
- align-items: center;
61
- gap: var(--space-10);
62
- margin: 0;
63
- padding: 0;
64
- list-style: none;
65
- }
66
-
67
56
  .nav-link {
68
57
  display: inline-flex;
69
58
  align-items: center;
@@ -86,19 +75,16 @@
86
75
  removed everywhere else, never serialized as aria-current="false".
87
76
  Selection never changes text metrics: the shared medium weight is kept and
88
77
  the current link is carried by the selected accent. */
89
- .nav-link[aria-current] {
90
- background: var(--surface-subtle);
91
- color: var(--state-selected);
92
- }
93
-
94
78
  .nav-link:hover {
95
79
  background: var(--surface-subtle);
96
80
  color: var(--text);
97
81
  }
98
82
 
99
- /* A current link keeps its accent on hover: without this, the hover rule above
100
- would drop it back to --text. The surface hover still applies. */
101
- .nav-link[aria-current]:hover {
83
+ /* Current last, equal specificity to :hover (0,2,0): a hovered current link
84
+ keeps the selected accent instead of dropping to --text. The surface fill is
85
+ shared with the hover rule above. */
86
+ .nav-link[aria-current] {
87
+ background: var(--surface-subtle);
102
88
  color: var(--state-selected);
103
89
  }
104
90
 
@@ -118,19 +104,22 @@
118
104
  color: var(--ui-fg, var(--text));
119
105
  }
120
106
 
121
- .nav-link[aria-current],
122
- .nav-link[aria-current]:hover {
107
+ /* After :hover so a hovered current link keeps the selected accent. */
108
+ .nav-link[aria-current] {
123
109
  color: var(--ui-fg, var(--state-selected));
124
110
  }
125
111
  }
126
112
 
127
113
  /* Nav list — a vertical navigation list. Used by .drawer for stacked links.
128
114
  Links fill each row so the whole line is an interactive target; the
129
- horizontal .navbar-nav links keep their intrinsic size. */
115
+ horizontal .cluster links keep their intrinsic size. */
116
+ /* A vertical nav reads as one list, not a stack of separate buttons: each row
117
+ is already a control-height target, so the rows sit a hair apart. The grid
118
+ is the component's own layout; for a horizontal row use .cluster instead. */
130
119
  .nav-list {
131
120
  display: grid;
132
121
  grid-template-columns: minmax(0, 1fr);
133
- --gap: var(--space-20);
122
+ --gap: var(--space-10);
134
123
  gap: var(--gap);
135
124
  margin: 0;
136
125
  padding: 0;
@@ -7,7 +7,7 @@
7
7
  * Public hooks:
8
8
  * --link
9
9
  * --hr-space
10
- * --font-width / --font-width-dense (see variants.css for the dense application)
10
+ * --font-width (the document's font-stretch; a theme typography choice)
11
11
  */
12
12
 
13
13
  body {
@@ -36,7 +36,8 @@ select {
36
36
 
37
37
  *,
38
38
  *::before,
39
- *::after {
39
+ *::after,
40
+ ::backdrop {
40
41
  animation-duration: 0.01ms !important;
41
42
  animation-delay: 0s !important;
42
43
  animation-iteration-count: 1 !important;
@@ -79,6 +79,10 @@
79
79
  fields, a solid band), which rules out the primary itself. */
80
80
  --focus: hsl(268 30% 55%);
81
81
  --hover-overlay: hsl(260 20% 10% / 0.04);
82
+ /* Solid fills take a stronger wash than the subtle shared overlay. Black in
83
+ both schemes on purpose: a solid button darkens on hover even in dark mode
84
+ (see themes/gradient.css), unlike the neutral washes. */
85
+ --hover-overlay-solid: rgb(0 0 0 / 0.14);
82
86
 
83
87
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
84
88
  --radius: 0.5rem;
@@ -20,7 +20,6 @@
20
20
  --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
21
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
22
22
  --font-width: 100%;
23
- --font-width-dense: 95%;
24
23
  --line-height: 1.5;
25
24
  --line-height-tight: 1.25;
26
25
  --line-height-relaxed: 1.75;
@@ -104,8 +103,9 @@
104
103
  --bar-height: 0.65rem;
105
104
 
106
105
  /* Controls — inputs and buttons share --control-size so heights always match.
107
- .compact/.spacious (variants.css) switch geometry without changing
108
- --control-font-size. Local .sm/.lg component sizes switch all three.
106
+ .compact/.spacious (variants.css) switch geometry (--control-size,
107
+ --control-pad-x) without changing --control-font-size. Local .sm/.lg
108
+ component sizes switch all three.
109
109
 
110
110
  Inline choices (checkbox, radio, switch) are typographic, not field-sized:
111
111
  they sit on a line of text, so they follow --control-font-size through
@@ -116,17 +116,35 @@
116
116
 
117
117
  --control-pad-x is the opposite kind of token: a resolved geometry length,
118
118
  not an em ratio. Every part of one control must see the same horizontal
119
- distance, so it is stated in the typographic tokens rather than an em
120
- whose value would change on a descendant with its own font size (the
121
- input-icon glyph, made larger by --input-icon-size). It mirrors
122
- --control-font-size and is rebound beside it at the .sm/.lg boundaries
123
- (variants.css); declaring it only in :root would freeze the md value,
124
- because a var() inside a custom property resolves where it is declared. */
119
+ distance, so it is stated in lengths rather than an em whose value would
120
+ change on a descendant with its own font size (the input-icon glyph, made
121
+ larger by --input-icon-size).
122
+
123
+ Like --control-size, it is a scale plus a selection: themes set the
124
+ -sm/-md/-lg steps, and :root, .sm/.lg and .compact/.spacious (variants.css)
125
+ pick one. A theme that wrote --control-pad-x itself would be overwritten
126
+ by every size and density boundary — bootstrap-v6's 12px became a wider
127
+ 13px inside .compact. The selection is rebound at each boundary because a
128
+ var() inside a custom property resolves where it is declared.
129
+
130
+ The boundaries also write --control-step-sm/-md/-lg: which step is
131
+ selected, as 0/1 weights that mean the same under every theme. A theme
132
+ that sets its own steps rebuilds the selection from them in its own block
133
+ (bootstrap-v6.css), so a nested island of it reads its steps without
134
+ resetting the density it sits in. Never rebind on every [data-theme]: a
135
+ palette-only island then became a geometry boundary that an inherited
136
+ --control-size or --control-pad-x no longer crossed. */
125
137
  --control-size-sm: 2rem;
126
138
  --control-size-md: 2.375rem;
127
139
  --control-size-lg: 2.75rem;
128
140
  --control-size: var(--control-size-md);
129
- --control-pad-x: var(--font-size-md);
141
+ --control-pad-x-sm: var(--font-size-xs);
142
+ --control-pad-x-md: var(--font-size-md);
143
+ --control-pad-x-lg: var(--font-size-lg);
144
+ --control-pad-x: var(--control-pad-x-md);
145
+ --control-step-sm: 0;
146
+ --control-step-md: 1;
147
+ --control-step-lg: 0;
130
148
  --control-font-size: var(--font-size-md);
131
149
  --choice-control-size: 1.125em;
132
150
  --disabled-opacity: 0.65;
@@ -11,17 +11,21 @@
11
11
  * components/badge.css — .badge default soft behind explicit --ui-*
12
12
  * components/alert.css — .alert default soft behind explicit --ui-*
13
13
  *
14
- * .compact/.spacious set inherited density tokens: spacing rhythm and geometry
15
- * only, plus a slight typographic width bonus. Density never changes font size
16
- * or icon size. Controls consume --control-size; non-control components may
14
+ * Three axes, each predictable on its own:
15
+ * .sm/.lg size — typography + geometry, local to a component
16
+ * .compact/.spacious density — rhythm + geometry, inherited by a region
17
+ * .tight/.loose rhythm — --gap only, inherited by a region
18
+ *
19
+ * Density never touches typography: no font size, icon size or font width.
20
+ * A width bonus through font-stretch only showed on typefaces with a wdth axis,
21
+ * so .compact meant something different per OS and font; a condensed table is
22
+ * a typographic decision for the application. Controls consume --control-size
23
+ * and --control-pad-x, so a compact control is shorter and narrower (a height
24
+ * change alone read as the same control squashed); non-control components may
17
25
  * consume --density-* when the context should affect their local rhythm.
18
- * .compact applies --font-width-dense through font-stretch (a variable wdth
19
- * axis when available, otherwise normal width-face matching); layout must
20
- * never depend on the narrowing being available. .spacious restores
21
- * --font-width, so a spacious island inside a compact context returns to the
22
- * normal width exactly as it restores its own gap, spacing, and control size.
23
- * Themes whose typeface has no useful width axis opt out with
24
- * --font-width-dense: 100%.
26
+ *
27
+ * .tight/.loose reuse the density --gap steps, so a tight row and a compact
28
+ * one share one rhythm.
25
29
  *
26
30
  * .sm/.lg are local size roles, not shared measurements. Shared control
27
31
  * participants below map both typography and geometry; components with
@@ -60,18 +64,35 @@
60
64
  --ui-border: var(--border);
61
65
  }
62
66
 
67
+ /* Each boundary selects a control step twice: the values, resolved here, and
68
+ the 0/1 step weights a nested [data-theme] island rebuilds them from
69
+ (tokens.css). */
63
70
  .compact {
64
71
  --gap: var(--space-20);
65
72
  --density-space: var(--space-20);
66
73
  --control-size: var(--control-size-sm);
67
- font-stretch: var(--font-width-dense);
74
+ --control-pad-x: var(--control-pad-x-sm);
75
+ --control-step-sm: 1;
76
+ --control-step-md: 0;
77
+ --control-step-lg: 0;
68
78
  }
69
79
 
70
80
  .spacious {
71
81
  --gap: var(--space-50);
72
82
  --density-space: var(--space-50);
73
83
  --control-size: var(--control-size-lg);
74
- font-stretch: var(--font-width);
84
+ --control-pad-x: var(--control-pad-x-lg);
85
+ --control-step-sm: 0;
86
+ --control-step-md: 0;
87
+ --control-step-lg: 1;
88
+ }
89
+
90
+ .tight {
91
+ --gap: var(--space-20);
92
+ }
93
+
94
+ .loose {
95
+ --gap: var(--space-50);
75
96
  }
76
97
 
77
98
  /* Shared control scale. Wrappers are listed when their descendants form one
@@ -100,7 +121,10 @@
100
121
  ).sm {
101
122
  --control-size: var(--control-size-sm);
102
123
  --control-font-size: var(--font-size-sm);
103
- --control-pad-x: var(--font-size-sm);
124
+ --control-pad-x: var(--control-pad-x-sm);
125
+ --control-step-sm: 1;
126
+ --control-step-md: 0;
127
+ --control-step-lg: 0;
104
128
  }
105
129
 
106
130
  :where(
@@ -124,7 +148,10 @@
124
148
  ).lg {
125
149
  --control-size: var(--control-size-lg);
126
150
  --control-font-size: var(--font-size-lg);
127
- --control-pad-x: var(--font-size-lg);
151
+ --control-pad-x: var(--control-pad-x-lg);
152
+ --control-step-sm: 0;
153
+ --control-step-md: 0;
154
+ --control-step-lg: 1;
128
155
  }
129
156
 
130
157
  /* Modern color enhancement. Baseline variant values above remain usable
@@ -1,30 +1,35 @@
1
1
  /*
2
- * Form actions — bottom form bar with sticky variant.
2
+ * Form actions — the role of a .cluster action row that closes a form.
3
3
  *
4
4
  * Docs: docs/pages/forms/form-actions.md
5
5
  *
6
- * A flex cluster of buttons/actions that closes a form. The sticky variant
7
- * docks to the viewport bottom with a translucent blur where supported; at
8
- * rest it stays visually neutral so it does not double as a separator.
6
+ * Markup: `<div class="cluster form-actions">`. The cluster owns the row
7
+ * (flex, wrap, gap); this role owns the form-specific alignment policy and the
8
+ * opt-in sticky variant. The distance to the fields above belongs to the
9
+ * parent layout (.stack on the form), never to a margin here: a margin would
10
+ * add to a stack's gap. The sticky variant docks to the viewport bottom with a
11
+ * translucent blur where supported; at rest it stays visually neutral so it
12
+ * does not double as a separator.
9
13
  *
10
14
  * Public hooks:
11
15
  * --form-actions-align
12
16
  * --form-actions-justify
13
- * --form-actions-margin-block-start
14
17
  *
15
18
  * Internal:
16
19
  * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
17
20
  * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
18
21
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
19
22
  */
23
+
24
+ /* The policy hooks write the properties themselves rather than relaying
25
+ --cluster-*: a relay would inherit into any .cluster nested in the row.
26
+ These are equal-specificity overrides of .cluster, so they win only because
27
+ forms load after layout in the official bundle (tests/css-audit.test.js
28
+ guards the order); utilities load later and keep the author's last word. On
29
+ this row --cluster-justify/--cluster-align have no effect. */
20
30
  .form-actions {
21
- display: flex;
22
- flex-direction: row;
23
- flex-wrap: wrap;
24
- gap: var(--gap);
25
31
  align-items: var(--form-actions-align, center);
26
32
  justify-content: var(--form-actions-justify, flex-start);
27
- margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
28
33
  }
29
34
 
30
35
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent