actual-css 0.11.1 → 0.13.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 (123) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -6
  3. package/components.json +12 -18
  4. package/dist/actual.full.css +780 -479
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/brand/favicon.svg +5 -0
  8. package/docs/brand/lockup.svg +12 -0
  9. package/docs/brand/logo-dark.svg +9 -0
  10. package/docs/brand/logo-light.svg +9 -0
  11. package/docs/brand/logo.svg +11 -0
  12. package/docs/brand/mark.svg +11 -0
  13. package/docs/design-notes/architecture.md +67 -0
  14. package/docs/design-notes/steps.md +45 -28
  15. package/docs/navigation.json +3 -2
  16. package/docs/pages/components/alert.md +2 -10
  17. package/docs/pages/components/avatar.md +17 -11
  18. package/docs/pages/components/badge.md +3 -9
  19. package/docs/pages/components/breadcrumb.md +16 -2
  20. package/docs/pages/components/card.md +31 -2
  21. package/docs/pages/components/dialog.md +121 -33
  22. package/docs/pages/components/drawer.md +2 -2
  23. package/docs/pages/components/list.md +9 -13
  24. package/docs/pages/components/navbar.md +49 -30
  25. package/docs/pages/components/pagination.md +19 -0
  26. package/docs/pages/components/steps.md +24 -20
  27. package/docs/pages/components/table.md +3 -3
  28. package/docs/pages/enhancements/flyout.md +2 -2
  29. package/docs/pages/enhancements/scrollspy.md +7 -7
  30. package/docs/pages/enhancements/status-bar.md +2 -2
  31. package/docs/pages/enhancements/tabs.md +74 -3
  32. package/docs/pages/examples/overview.md +2 -1
  33. package/docs/pages/forms/date-picker.md +90 -0
  34. package/docs/pages/forms/detached-actions.md +37 -30
  35. package/docs/pages/forms/floating-field.md +21 -7
  36. package/docs/pages/forms/form-actions.md +58 -28
  37. package/docs/pages/forms/input-policy.md +1 -1
  38. package/docs/pages/forms/overview.md +6 -5
  39. package/docs/pages/forms/states-and-sizes.md +15 -15
  40. package/docs/pages/forms/sticky-actions.md +85 -83
  41. package/docs/pages/forms/validation.md +5 -5
  42. package/docs/pages/foundations/philosophy.md +102 -4
  43. package/docs/pages/foundations/theming.md +15 -4
  44. package/docs/pages/foundations/tokens.md +89 -36
  45. package/docs/pages/foundations/typography.md +4 -7
  46. package/docs/pages/guides/bootstrap.md +2 -2
  47. package/docs/pages/guides/design-tools.md +2 -2
  48. package/docs/pages/guides/integrating-actual-css.md +79 -9
  49. package/docs/pages/guides/introduction.md +5 -5
  50. package/docs/pages/guides/modular-import.md +3 -0
  51. package/docs/pages/guides/progressive-enhancement.md +27 -9
  52. package/docs/pages/guides/tailwind.md +70 -0
  53. package/docs/pages/guides/upgrading.md +151 -0
  54. package/docs/pages/layout/center.md +2 -1
  55. package/docs/pages/layout/cluster.md +37 -4
  56. package/docs/pages/layout/grid.md +2 -0
  57. package/docs/pages/layout/overview.md +1 -1
  58. package/docs/pages/layout/sidebar-layout.md +3 -0
  59. package/docs/pages/layout/stack.md +21 -16
  60. package/docs/pages/layout/switcher.md +2 -0
  61. package/docs/pages/layout/topbar.md +7 -5
  62. package/docs/pages/patterns/icon-slot.md +17 -1
  63. package/docs/pages/patterns/navigation-list.md +16 -46
  64. package/docs/pages/patterns/overview.md +3 -4
  65. package/docs/pages/utilities/flex.md +30 -20
  66. package/docs/pages/utilities/sizing-wrapping.md +52 -7
  67. package/docs/pages/utilities/spacing.md +4 -3
  68. package/package.json +6 -6
  69. package/reserved-classes.json +3 -3
  70. package/skills/actual-css/references/enhancements.md +10 -7
  71. package/src/cli/actual-css.js +39 -13
  72. package/src/css/components/alert.css +34 -34
  73. package/src/css/components/avatar.css +8 -17
  74. package/src/css/components/badge.css +1 -6
  75. package/src/css/components/breadcrumb.css +74 -8
  76. package/src/css/components/button.css +3 -9
  77. package/src/css/components/card.css +18 -10
  78. package/src/css/components/chat.css +4 -5
  79. package/src/css/components/drawer.css +42 -45
  80. package/src/css/components/flyout.css +3 -1
  81. package/src/css/components/index.css +0 -1
  82. package/src/css/components/join.css +2 -2
  83. package/src/css/components/list.css +10 -10
  84. package/src/css/components/modal.css +113 -134
  85. package/src/css/components/navbar.css +22 -31
  86. package/src/css/components/pagination.css +5 -0
  87. package/src/css/components/scrollspy.css +1 -1
  88. package/src/css/components/steps.css +66 -12
  89. package/src/css/components/tab.css +134 -4
  90. package/src/css/core/base.css +1 -1
  91. package/src/css/core/focus.css +8 -6
  92. package/src/css/core/reset.css +2 -1
  93. package/src/css/core/theme.css +10 -1
  94. package/src/css/core/tokens.css +32 -12
  95. package/src/css/core/variants.css +41 -14
  96. package/src/css/forms/choice-card.css +1 -1
  97. package/src/css/forms/choice.css +1 -1
  98. package/src/css/forms/control.css +1 -1
  99. package/src/css/forms/floating-field.css +20 -18
  100. package/src/css/forms/form-actions.css +14 -12
  101. package/src/css/forms/native.css +2 -2
  102. package/src/css/forms/otp.css +4 -5
  103. package/src/css/forms/switch.css +1 -1
  104. package/src/css/forms/validation.css +23 -30
  105. package/src/css/layout/center.css +2 -0
  106. package/src/css/layout/cluster.css +25 -0
  107. package/src/css/layout/grid.css +7 -0
  108. package/src/css/layout/scroller.css +19 -3
  109. package/src/css/layout/sidebar-layout.css +3 -0
  110. package/src/css/layout/switcher.css +3 -0
  111. package/src/css/layout/topbar.css +12 -6
  112. package/src/css/utilities/base.css +5 -0
  113. package/src/css/utilities/extra.css +3 -7
  114. package/src/js/full.js +4 -0
  115. package/src/js/inline-visible.js +54 -0
  116. package/src/js/parsed.js +21 -0
  117. package/src/js/tab.js +33 -7
  118. package/src/tooling/color.js +65 -0
  119. package/src/tooling/design-export.js +1 -1
  120. package/src/tooling/design-tokens.js +53 -34
  121. package/src/tooling/theme-contrast.js +217 -0
  122. package/docs/pages/patterns/actions.md +0 -52
  123. package/src/css/components/actions.css +0 -27
@@ -25,7 +25,8 @@
25
25
  * destinations. Anything else focusable in a step is out of contract.
26
26
  *
27
27
  * The framework never hides an authored label to solve a layout problem. A
28
- * labelled row that outgrows its space scrolls. An `actual-container` size
28
+ * labelled row wraps its labels, then scrolls if its markers or authored item
29
+ * minimum outgrow the available space. An `actual-container` size
29
30
  * context buys it one thing: the wide representation, marker beside label.
30
31
  *
31
32
  * See docs/design-notes/steps.md for the threshold, the selector shapes, the
@@ -161,10 +162,11 @@
161
162
  /* Horizontal ------------------------------------------------------------- */
162
163
 
163
164
  .steps-horizontal {
164
- --step-min: 7rem;
165
+ --step-min: var(--step-size);
165
166
  --step-gap: var(--space-20);
166
167
 
167
168
  inline-size: 100%;
169
+ column-gap: var(--step-gap);
168
170
  overflow-x: auto;
169
171
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
170
172
  other to `auto`, and a row hugs fractional content closely enough that a
@@ -173,11 +175,22 @@
173
175
  overscroll-behavior-inline: contain;
174
176
  }
175
177
 
178
+ /* A row that loads mid-flow starts scrolled to its current step; engines
179
+ without the property start at the first. */
180
+ .steps-horizontal > [aria-current="step"] {
181
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
182
+ scroll-initial-target: nearest;
183
+ }
184
+
176
185
  /* A scroll container clips at the padding edge, so a navigable row reserves the
177
- focus ring's own width and offset. Only this composition scrolls, and only a
178
- link has a ring to protect. */
186
+ focus ring's own width and offset, and focus scrolls a link in that far from
187
+ the inline clip. Only this composition scrolls, and only a link has a ring to
188
+ protect. */
179
189
  .steps-horizontal:has(a[href]) {
190
+ /* Wrapped names can fill their cell right up to either inline clip edge. */
191
+ padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
180
192
  padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
193
+ scroll-padding-inline: calc(var(--focus-outline-offset) + var(--focus-ring-width));
181
194
  }
182
195
 
183
196
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -189,6 +202,8 @@
189
202
  .steps-horizontal > li {
190
203
  display: grid;
191
204
  flex: 1 0 var(--step-min);
205
+ /* Bound each label to its share so it cannot change the marker pitch. */
206
+ grid-template-columns: minmax(0, 1fr);
192
207
  grid-template-rows: var(--step-size);
193
208
  gap: var(--step-gap);
194
209
  justify-items: center;
@@ -196,9 +211,42 @@
196
211
  text-align: center;
197
212
  }
198
213
 
199
- /* An empty step is a marker and nothing else, so it needs a marker's width
200
- rather than a reading width. That is what lets five of them fit a phone with
201
- no size context and no variant class.
214
+ .steps-horizontal .step-label {
215
+ /* Even an unbroken name must fit its share instead of overlapping a peer.
216
+ Hyphenation, when `lang` allows it, is tried before an arbitrary break. */
217
+ overflow-wrap: anywhere;
218
+ hyphens: auto;
219
+ }
220
+
221
+ /* Half end cells put the outer markers on the row's edges without changing
222
+ their pitch, so the track lines up with the text around it, as the compact
223
+ and inline forms do. The marker's half-width keeps the first connector equal
224
+ to its peers.
225
+
226
+ Source order is load-bearing: these tie the `:empty` rules below and the
227
+ inline query on specificity, and both must win over them. */
228
+ .steps-horizontal > li:is(:first-child, :last-child) {
229
+ flex: 0.5 0 calc((var(--step-min) + var(--step-size)) / 2);
230
+ min-inline-size: max(var(--step-size), calc((var(--step-min) + var(--step-size)) / 2));
231
+ }
232
+
233
+ .steps-horizontal > li:first-child {
234
+ justify-items: start;
235
+ text-align: start;
236
+ }
237
+
238
+ .steps-horizontal > li:first-child::after {
239
+ inset-inline-start: var(--step-size);
240
+ inline-size: calc(200% - var(--step-size) * 2 + var(--step-gap));
241
+ }
242
+
243
+ .steps-horizontal > li:last-child {
244
+ justify-items: end;
245
+ text-align: end;
246
+ }
247
+
248
+ /* An empty step ignores any authored reading-width floor and needs only its
249
+ marker. That lets five of them fit a phone without a size context.
202
250
 
203
251
  `justify-items` is not an override of the row's centring: the marker is
204
252
  centred because a label sits under it and the two share a centre, and with no
@@ -208,9 +256,8 @@
208
256
  the text around it, the way the inline representation does.
209
257
 
210
258
  `:empty` says exactly what the markup says, the way `.badge:empty` does, and
211
- it reads the item's own content: bare text in an `<li>` keeps a reading width
212
- like its wrapped peers, where `> li:has(.step-label)` would give it a marker
213
- floor and skew the row. `:empty` also keeps the compact form reachable on the
259
+ it reads the item's own content: bare text keeps the same authored floor as
260
+ its wrapped peers. `:empty` also keeps the compact form reachable on the
214
261
  Degraded tier, where only the inline bonus needs `:has()`. */
215
262
  .steps-horizontal > li:empty {
216
263
  flex: 1 1 0;
@@ -220,6 +267,8 @@
220
267
 
221
268
  .steps-horizontal > li:empty::after {
222
269
  inset-inline-start: var(--step-size);
270
+ /* Marker-only end cells keep full shares, unlike labelled end cells. */
271
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
223
272
  }
224
273
 
225
274
  .steps-horizontal > li:empty:last-child {
@@ -227,12 +276,12 @@
227
276
  }
228
277
 
229
278
  /* The pseudo-element is marker-height so --step-connector may be any complete
230
- background value. */
279
+ background value; its length also crosses the gap to the next equal track. */
231
280
  .steps-horizontal > li::after {
232
281
  position: absolute;
233
282
  inset-block-start: 0;
234
283
  inset-inline-start: calc(50% + var(--step-size) / 2);
235
- inline-size: calc(100% - var(--step-size));
284
+ inline-size: calc(100% - var(--step-size) + var(--step-gap));
236
285
  block-size: var(--step-size);
237
286
  background: var(
238
287
  --step-connector,
@@ -251,6 +300,11 @@
251
300
  * written — a row out of contract is not owed a curated fallback.
252
301
  */
253
302
  @container actual-container (inline-size >= 60rem) {
303
+ .steps-horizontal:has(.step-label) {
304
+ /* Inline connectors already own the spacing between marker-label groups. */
305
+ column-gap: 0;
306
+ }
307
+
254
308
  .steps-horizontal:has(.step-label) > li {
255
309
  display: flex;
256
310
  flex: 1 1 auto;
@@ -12,8 +12,18 @@
12
12
  * author class can then adjust a vertical strip without restating the
13
13
  * attribute to reach its specificity.
14
14
  *
15
+ * A horizontal strip is one axis of navigation, so it never wraps: a second
16
+ * line no longer matches the Left/Right model and reads as a nested level.
17
+ * When the tabs do not fit, the strip scrolls without a scrollbar; focus
18
+ * scrolls the active tab into view.
19
+ *
15
20
  * Public hooks:
21
+ * --tab-flex
16
22
  * --tab-gap
23
+ *
24
+ * Internal:
25
+ * --tab-edge
26
+ * --tab-fade-dir (read inside @keyframes, so it resolves per element)
17
27
  */
18
28
  /* Intent boundary — inherited intents do not tint this component.
19
29
  Local intent classes still win because :where() has zero specificity. */
@@ -24,27 +34,129 @@
24
34
  .tabs {
25
35
  display: flex;
26
36
  flex-direction: row;
27
- flex-wrap: wrap;
37
+ flex-wrap: nowrap;
28
38
  gap: var(--space-10);
29
39
  margin: 0;
30
40
  padding: 0;
41
+ /* Block overflow is hidden, not auto: overflow-x: auto alone computes the
42
+ block axis to auto, so a sub-pixel line box would add a vertical
43
+ scrollbar inside the strip. Nothing paints outside it: the focus outline
44
+ is inset and the indicator sits inside the tab. */
45
+ overflow: auto hidden;
46
+ overscroll-behavior-inline: contain;
47
+ /* why: a visible bar sits on the indicator and the hairline and reads as a
48
+ second underline. The strip has its own scroll controls: selecting the
49
+ clipped tab, or focusing it, brings it into view with the next one
50
+ hinted by scroll-padding; arrow keys and swipes do the same. */
51
+ scrollbar-width: none;
52
+ /* Focus scrolls a tab in with room to spare, hinting at the next one; the
53
+ edge fade below spans the same inset, so a revealed tab stays clear of it. */
54
+ --tab-edge: var(--space-60);
55
+
56
+ scroll-padding-inline: var(--tab-edge);
31
57
  border-block-end: var(--border-width) solid var(--border);
32
58
  list-style: none;
33
59
  }
34
60
 
61
+ /* The landmark around link tabs is not a scroll container, so as a flex or
62
+ grid item it would keep the strip's full width and push the page into
63
+ scrolling. Letting it shrink hands the overflow back to the strip.
64
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
65
+ wrapper is the fallback. */
66
+ :where(nav:has(> .tabs)) {
67
+ min-inline-size: 0;
68
+ }
69
+
70
+ /* Items keep their size and the strip scrolls instead. Covers both
71
+ anatomies: .tab directly in a tablist, or <li> in a navigation list.
72
+ --tab-flex: 1 1 0 shares the width equally; an item still never shrinks
73
+ below its label (flex items keep min-content), so a strip that cannot fit
74
+ scrolls as before. */
75
+ .tabs > * {
76
+ flex: var(--tab-flex, none);
77
+ }
78
+
35
79
  .tabs:where([aria-orientation="vertical"]) {
36
80
  display: grid;
81
+ overflow: visible;
37
82
  align-content: start;
38
83
  justify-items: stretch;
39
84
  border-block-end: 0;
40
85
  border-inline-end: var(--border-width) solid var(--border);
41
86
  }
42
87
 
88
+ /* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
89
+ toward. It runs on the strip's own inline timeline, which is inactive on a
90
+ strip that fits or a vertical rail, so those stay unmasked. Keep the
91
+ @supports: without scroll timelines the animation would end at once and pin
92
+ its last mask. It follows scroll, not time, so reduced motion keeps it. */
93
+ @supports (animation-timeline: scroll()) {
94
+ .tabs {
95
+ --tab-fade-dir: to right;
96
+
97
+ animation: actual-tabs-fade linear both;
98
+ animation-timeline: scroll(self inline);
99
+ }
100
+
101
+ /* Gradient keywords are physical; :dir() follows the strip's own
102
+ direction, so an LTR island in an RTL page keeps its fade on the right. */
103
+ .tabs:dir(rtl) {
104
+ --tab-fade-dir: to left;
105
+ }
106
+ }
107
+
108
+ /* A strip that loads on a current link tab starts scrolled to it. Horizontal
109
+ only: the strip is then its nearest scroll container, while a vertical
110
+ rail would hand the target to the page. Engines without the property start
111
+ at the first tab, and the fade says there is more. */
112
+ :where(.tabs:not([aria-orientation="vertical"]))
113
+ .tab:is([aria-current="page"], [aria-current="true"]) {
114
+ /* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
115
+ scroll-initial-target: nearest;
116
+ }
117
+
118
+ /* Forced colors keep every label at full contrast. */
119
+ @media (forced-colors: active) {
120
+ .tabs {
121
+ animation: none;
122
+ }
123
+ }
124
+
125
+ /* mask-image does not interpolate, so each mask holds until halfway to the
126
+ next keyframe: the start fade appears within the first pixels of scroll and
127
+ the end fade drops at the very end. */
128
+ @keyframes actual-tabs-fade {
129
+ 0% {
130
+ mask-image: linear-gradient(
131
+ var(--tab-fade-dir),
132
+ #000 calc(100% - var(--tab-edge)),
133
+ transparent
134
+ );
135
+ }
136
+
137
+ 1%,
138
+ 99% {
139
+ mask-image: linear-gradient(
140
+ var(--tab-fade-dir),
141
+ transparent,
142
+ #000 var(--tab-edge) calc(100% - var(--tab-edge)),
143
+ transparent
144
+ );
145
+ }
146
+
147
+ 100% {
148
+ mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
149
+ }
150
+ }
151
+
43
152
  .tab {
44
153
  /* Positioning context for the selected indicator pseudo. */
45
154
  position: relative;
46
155
  display: inline-flex;
47
156
  align-items: center;
157
+ /* A tab wider than its label (--tab-flex) centers it; at natural width this
158
+ changes nothing, and a vertical rail resets it to the start below. */
159
+ justify-content: center;
48
160
  gap: var(--tab-gap, 0.375em);
49
161
  min-block-size: var(--control-size);
50
162
  padding-inline: var(--space-30);
@@ -58,12 +170,18 @@
58
170
  the strip. The active state is carried by color and the indicator line. */
59
171
  font-weight: var(--font-weight-semibold);
60
172
  text-decoration: none;
173
+ /* A label breaking inside a one-line strip is the same defect as the strip
174
+ wrapping. */
175
+ white-space: nowrap;
61
176
  cursor: pointer;
62
177
  }
63
178
 
179
+ /* A vertical strip has no inline axis to protect: long labels wrap within the
180
+ rail instead of overflowing it. */
64
181
  .tabs:where([aria-orientation="vertical"]) .tab {
65
182
  justify-content: flex-start;
66
183
  padding-inline-end: var(--space-40);
184
+ white-space: normal;
67
185
  }
68
186
 
69
187
  .tab:is(:disabled, [aria-disabled="true"]) {
@@ -94,8 +212,13 @@
94
212
  border-end-end-radius: 0;
95
213
  }
96
214
 
215
+ /* A link tab is current on aria-current="page". "true" is for the one place
216
+ where no page token fits: a navigation flyout trigger whose panel holds the
217
+ current page. State selectors name their values, never the attribute's
218
+ presence, so "false" (what React and Vue serialize for a false binding)
219
+ stays inert. */
97
220
  .tab[aria-selected="true"],
98
- .tab[aria-current="page"] {
221
+ .tab:is([aria-current="page"], [aria-current="true"]) {
99
222
  color: var(--intent, var(--state-selected));
100
223
  }
101
224
 
@@ -104,7 +227,7 @@
104
227
  forced colors and erase the distinction. currentColor follows the palette the
105
228
  UA forces, so no forced-colors block is needed. */
106
229
  .tab[aria-selected="true"]::after,
107
- .tab[aria-current="page"]::after {
230
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
108
231
  content: "";
109
232
  position: absolute;
110
233
  inset-inline: 0;
@@ -113,9 +236,16 @@
113
236
  }
114
237
 
115
238
  .tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
116
- .tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
239
+ .tabs:where([aria-orientation="vertical"])
240
+ .tab:is([aria-current="page"], [aria-current="true"])::after {
117
241
  inset-block: 0;
118
242
  inset-inline: auto 0;
119
243
  border-block-end: 0;
120
244
  border-inline-end: 2px solid currentColor;
121
245
  }
246
+
247
+ /* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
248
+ it, so the indicator and the hit area span the whole share. */
249
+ .tabs > li > .tab {
250
+ inline-size: 100%;
251
+ }
@@ -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 {
@@ -21,14 +21,16 @@
21
21
  Documented exception: an invalid field focuses in --form-invalid-border
22
22
  (danger), so the error stays visible at the point of correction. The border
23
23
  carries the same hook; report:theme-contrast measures that line at 3:1
24
- against the field's background alongside --focus. */
24
+ against the field's background alongside --focus.
25
+
26
+ Public hooks:
27
+ --focus-outline (fallback-only: replaces the whole generic line)
28
+ */
25
29
  :focus {
26
- outline: var(--focus-outline);
30
+ outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
27
31
  outline-offset: var(--focus-outline-offset);
28
32
  }
29
33
 
30
- @supports selector(:focus-visible) {
31
- :focus:not(:focus-visible) {
32
- outline: none;
33
- }
34
+ :focus:not(:focus-visible) {
35
+ outline: none;
34
36
  }
@@ -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;
@@ -8,7 +8,9 @@
8
8
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
9
9
  * other tokens; hard-coded overrides on a custom theme will break the intent.
10
10
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
11
- * translucent surfaces overrides them with opaque values.
11
+ * translucent surfaces overrides them with opaque values. --border-control is
12
+ * public too: the resting edge of a neutral control, apart from structural
13
+ * separators.
12
14
  * Internal: --hgroup-gap.
13
15
  */
14
16
  :root {
@@ -79,6 +81,10 @@
79
81
  fields, a solid band), which rules out the primary itself. */
80
82
  --focus: hsl(268 30% 55%);
81
83
  --hover-overlay: hsl(260 20% 10% / 0.04);
84
+ /* Solid fills take a stronger wash than the subtle shared overlay. Black in
85
+ both schemes on purpose: a solid button darkens on hover even in dark mode
86
+ (see themes/gradient.css), unlike the neutral washes. */
87
+ --hover-overlay-solid: rgb(0 0 0 / 0.14);
82
88
 
83
89
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
84
90
  --radius: 0.5rem;
@@ -106,6 +112,9 @@
106
112
  never a fill. --surface-solid-fg is the ink on --surface-solid. */
107
113
  --surface-opaque: var(--surface);
108
114
  --surface-solid-fg: var(--surface);
115
+ /* The edge that tells a control apart from the surface, where --border only
116
+ separates surfaces: fields, choices, switch, OTP cells, choice cards. */
117
+ --border-control: var(--border);
109
118
  }
110
119
 
111
120
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -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;
@@ -82,9 +81,11 @@
82
81
  without a component ring (links, summary, plain items). Components draw
83
82
  the themed ring instead: --focus-ring-width solid --focus. See focus.css
84
83
  for the two contracts. Both share --focus-ring-width: they differ in
85
- color only. */
84
+ color only. No --focus-outline alias here: declared on :root it would be
85
+ inherited already computed, so a theme island or a region setting
86
+ --focus-ring-width would not reach it. focus.css composes the line on the
87
+ focused element instead. */
86
88
  --focus-outline-color: currentColor;
87
- --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
88
89
  --focus-outline-offset: 2px;
89
90
  --focus-ring-width: 2px;
90
91
 
@@ -104,8 +105,9 @@
104
105
  --bar-height: 0.65rem;
105
106
 
106
107
  /* 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.
108
+ .compact/.spacious (variants.css) switch geometry (--control-size,
109
+ --control-pad-x) without changing --control-font-size. Local .sm/.lg
110
+ component sizes switch all three.
109
111
 
110
112
  Inline choices (checkbox, radio, switch) are typographic, not field-sized:
111
113
  they sit on a line of text, so they follow --control-font-size through
@@ -116,17 +118,35 @@
116
118
 
117
119
  --control-pad-x is the opposite kind of token: a resolved geometry length,
118
120
  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. */
121
+ distance, so it is stated in lengths rather than an em whose value would
122
+ change on a descendant with its own font size (the input-icon glyph, made
123
+ larger by --input-icon-size).
124
+
125
+ Like --control-size, it is a scale plus a selection: themes set the
126
+ -sm/-md/-lg steps, and :root, .sm/.lg and .compact/.spacious (variants.css)
127
+ pick one. A theme that wrote --control-pad-x itself would be overwritten
128
+ by every size and density boundary — bootstrap-v6's 12px became a wider
129
+ 13px inside .compact. The selection is rebound at each boundary because a
130
+ var() inside a custom property resolves where it is declared.
131
+
132
+ The boundaries also write --control-step-sm/-md/-lg: which step is
133
+ selected, as 0/1 weights that mean the same under every theme. A theme
134
+ that sets its own steps rebuilds the selection from them in its own block
135
+ (bootstrap-v6.css), so a nested island of it reads its steps without
136
+ resetting the density it sits in. Never rebind on every [data-theme]: a
137
+ palette-only island then became a geometry boundary that an inherited
138
+ --control-size or --control-pad-x no longer crossed. */
125
139
  --control-size-sm: 2rem;
126
140
  --control-size-md: 2.375rem;
127
141
  --control-size-lg: 2.75rem;
128
142
  --control-size: var(--control-size-md);
129
- --control-pad-x: var(--font-size-md);
143
+ --control-pad-x-sm: var(--font-size-xs);
144
+ --control-pad-x-md: var(--font-size-md);
145
+ --control-pad-x-lg: var(--font-size-lg);
146
+ --control-pad-x: var(--control-pad-x-md);
147
+ --control-step-sm: 0;
148
+ --control-step-md: 1;
149
+ --control-step-lg: 0;
130
150
  --control-font-size: var(--font-size-md);
131
151
  --choice-control-size: 1.125em;
132
152
  --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
@@ -38,7 +38,7 @@
38
38
  --choice-card-pad: var(--density-space, var(--space-40));
39
39
  --choice-card-gap: var(--space-30);
40
40
  --choice-card-radius: var(--radius);
41
- --choice-card-border: var(--border);
41
+ --choice-card-border: var(--border-control);
42
42
  --choice-card-bg: var(--surface);
43
43
  --choice-card-check-size: 1.25em;
44
44
 
@@ -144,7 +144,7 @@
144
144
  /* Control geometry. --choice-* are the per-state tokens. */
145
145
  .check,
146
146
  .radio {
147
- --choice-border: var(--border);
147
+ --choice-border: var(--border-control);
148
148
  --choice-bg: var(--surface);
149
149
  --choice-mark: var(--primary-fg);
150
150
 
@@ -30,7 +30,7 @@
30
30
  .input,
31
31
  .textarea,
32
32
  .select {
33
- --control-border: var(--border);
33
+ --control-border: var(--border-control);
34
34
  --control-bg: var(--surface);
35
35
  --control-fg: var(--text);
36
36
  --control-cursor: auto;