actual-css 0.14.1 → 0.15.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 (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +4 -4
  3. package/components.json +51 -6
  4. package/dist/actual.full.css +363 -31
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/navigation.json +4 -1
  8. package/docs/pages/components/accordion.md +15 -18
  9. package/docs/pages/components/avatar.md +1 -0
  10. package/docs/pages/components/badge.md +41 -5
  11. package/docs/pages/components/button.md +5 -0
  12. package/docs/pages/components/card.md +34 -0
  13. package/docs/pages/components/diagrams.md +96 -0
  14. package/docs/pages/components/steps.md +1 -1
  15. package/docs/pages/components/table.md +4 -0
  16. package/docs/pages/components/timeline.md +111 -0
  17. package/docs/pages/enhancements/tabs.md +48 -6
  18. package/docs/pages/examples/overview.md +3 -0
  19. package/docs/pages/forms/choice-cards.md +3 -0
  20. package/docs/pages/forms/combobox.md +34 -19
  21. package/docs/pages/forms/controls.md +2 -0
  22. package/docs/pages/forms/date-picker.md +34 -13
  23. package/docs/pages/forms/file-upload.md +120 -0
  24. package/docs/pages/forms/overview.md +8 -0
  25. package/docs/pages/foundations/philosophy.md +6 -0
  26. package/docs/pages/foundations/theming.md +57 -5
  27. package/docs/pages/foundations/typography.md +3 -0
  28. package/docs/pages/guides/introduction.md +1 -1
  29. package/docs/pages/guides/modular-import.md +124 -0
  30. package/docs/pages/guides/upgrading.md +63 -0
  31. package/package.json +2 -2
  32. package/reserved-classes.json +3 -0
  33. package/src/css/components/accordion.css +7 -3
  34. package/src/css/components/alert.css +9 -1
  35. package/src/css/components/badge.css +38 -11
  36. package/src/css/components/button.css +10 -2
  37. package/src/css/components/card.css +9 -0
  38. package/src/css/components/index.css +1 -0
  39. package/src/css/components/skeleton.css +11 -0
  40. package/src/css/components/tab.css +77 -0
  41. package/src/css/components/table.css +7 -1
  42. package/src/css/components/timeline.css +160 -0
  43. package/src/css/core/base.css +3 -1
  44. package/src/css/core/theme.css +6 -1
  45. package/src/css/core/tokens.css +3 -3
  46. package/src/css/forms/choice-card.css +5 -2
  47. package/src/css/forms/control.css +12 -3
  48. package/src/css/forms/custom-select.css +1 -1
  49. package/src/css/typography/prose.css +4 -2
  50. package/src/tooling/design-tokens.js +2 -0
  51. package/src/tooling/theme-contrast.js +1 -1
@@ -232,9 +232,11 @@
232
232
  "switch",
233
233
  "switcher",
234
234
  "tab",
235
+ "tab-panel",
235
236
  "table",
236
237
  "table-wrap",
237
238
  "tabs",
239
+ "tabs-attached",
238
240
  "text-balance",
239
241
  "text-break",
240
242
  "text-capitalize",
@@ -251,6 +253,7 @@
251
253
  "text-wrap",
252
254
  "textarea",
253
255
  "tight",
256
+ "timeline",
254
257
  "tooltip",
255
258
  "topbar",
256
259
  "touch-target",
@@ -18,12 +18,16 @@
18
18
  * --accordion-pad
19
19
  * --accordion-marker-color
20
20
  * --accordion-marker-size
21
+ * --accordion-marker-icon
22
+ * --accordion-marker-open-rotate
21
23
  */
22
24
  .accordion {
23
25
  --accordion-radius: var(--radius);
24
26
  --accordion-pad: var(--space-40);
25
27
  --accordion-marker-color: var(--text-muted);
26
28
  --accordion-marker-size: 1rem;
29
+ --accordion-marker-icon: var(--icon-chevron);
30
+ --accordion-marker-open-rotate: 180deg;
27
31
 
28
32
  border: var(--border-width) solid var(--border);
29
33
  border-radius: var(--accordion-radius);
@@ -68,13 +72,13 @@
68
72
  inline-size: var(--accordion-marker-size);
69
73
  block-size: var(--accordion-marker-size);
70
74
  background-color: var(--accordion-marker-color);
71
- -webkit-mask: var(--icon-chevron) center / contain no-repeat;
72
- mask: var(--icon-chevron) center / contain no-repeat;
75
+ -webkit-mask: var(--accordion-marker-icon) center / contain no-repeat;
76
+ mask: var(--accordion-marker-icon) center / contain no-repeat;
73
77
  transition: transform var(--duration);
74
78
  }
75
79
 
76
80
  .accordion details[open] summary::after {
77
- transform: rotate(180deg);
81
+ transform: rotate(var(--accordion-marker-open-rotate));
78
82
  }
79
83
 
80
84
  /* The panel, not the item, owns the inset here — .separated paints a card on
@@ -172,6 +172,9 @@
172
172
  /* No grid-template-columns: the icon and close branches below always set
173
173
  it, since one of them is what matched. */
174
174
  align-items: var(--items-align, start);
175
+ /* why: a stretched alert (equal-height grid row) spread its extra height
176
+ between the content rows, opening a gap under the title. */
177
+ align-content: start;
175
178
  column-gap: var(--space-30);
176
179
  }
177
180
 
@@ -179,9 +182,14 @@
179
182
  grid-template-columns: auto minmax(0, 1fr);
180
183
  }
181
184
 
185
+ /* why span 2, not 1 / -1: the rows are implicit, so -1 is line 1 and the
186
+ icon sat in the title row alone — any icon taller than one line inflated
187
+ that row and pushed the body down. Spanning the title and body rows lets
188
+ its height spread over both; only an icon taller than both together still
189
+ splits its excess between them. */
182
190
  .alert:has(> .alert-icon) > .alert-icon {
183
191
  grid-column: 1;
184
- grid-row: 1 / -1;
192
+ grid-row: 1 / span 2;
185
193
  display: inline-grid;
186
194
  place-items: center;
187
195
  margin-block-start: 0.125em;
@@ -20,6 +20,8 @@
20
20
  * --badge-default-fg
21
21
  * --badge-default-border
22
22
  * --badge-default-hover-bg
23
+ * --badge-slot-inset
24
+ * --badge-slot-size
23
25
  * --badge-bg
24
26
  * --badge-fg
25
27
  * --badge-border
@@ -44,9 +46,13 @@
44
46
  --badge-font-size: var(--font-size-sm);
45
47
  --badge-icon-size: 1em;
46
48
  --badge-dot-size: 0.5rem;
49
+ /* A leading .avatar and a trailing .close are slots: a disc inside the pill
50
+ at the same inset from its block edges and its rounded end. */
51
+ --badge-slot-inset: calc(var(--badge-size) / 12);
52
+ --badge-slot-size: calc(var(--badge-size) - 2 * (var(--border-width) + var(--badge-slot-inset)));
47
53
  /* Declared on the badge, not the button, so an author can still set them
48
54
  on either. Same element as --badge-size, so .sm/.lg carry through. */
49
- --close-size: max(var(--badge-size), 24px);
55
+ --close-size: var(--badge-slot-size);
50
56
  --close-icon-size: 0.875em;
51
57
  --badge-default-bg: var(--surface-subtle);
52
58
  --badge-default-fg: var(--intent, var(--text));
@@ -97,9 +103,11 @@
97
103
  --badge-dot-size: 0.625rem;
98
104
  }
99
105
 
100
- /* Decorative children follow the badge's optical size. Text and the .close
101
- button retain their own boxes. */
102
- .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
106
+ /* Decorative children follow the badge's optical size. Text, the .close
107
+ button and an .avatar retain their own boxes — an aria-hidden avatar would
108
+ otherwise tie with .avatar on specificity and shrink to an icon whenever
109
+ badge.css loads last. */
110
+ .badge > :where(svg, img, [aria-hidden="true"]:not(.dot, .avatar)) {
103
111
  flex: none;
104
112
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
105
113
  the requested optical size; reusing the em-valued hook here would apply its
@@ -164,17 +172,36 @@
164
172
  background-color: currentColor;
165
173
  }
166
174
 
167
- /* Removable tag — a .close (components/close.css) inside the pill. WCAG 2.5.8
168
- floors pointer targets at 24 CSS px: density and local size may keep the
169
- badge visually compact through the negative margins, but never shrink the
170
- target below that literal floor. */
175
+ /* Person chip (mention, assignee, contributor) — a leading .avatar follows the
176
+ pill instead of its own scale, so the badge keeps its height. */
177
+ .badge > .avatar {
178
+ --avatar-size: var(--badge-slot-size);
179
+ /* Initials scale from the label, not the diameter: a diameter ratio left
180
+ them under 6px on .sm. em resolves against the badge's font size here. */
181
+ --avatar-font-size: 0.7em;
182
+ margin-inline-start: calc(var(--badge-slot-inset) - var(--badge-pad-x));
183
+ }
184
+
185
+ /* Removable tag — a trailing .close (components/close.css), the avatar's
186
+ mirror: a fill or the hover wash stays a disc inside the pill. */
171
187
  .badge > .close {
172
- margin-block: -0.25rem;
173
- margin-inline-start: -0.15em;
174
- margin-inline-end: -0.45em;
188
+ position: relative;
189
+ margin-inline-end: calc(var(--badge-slot-inset) - var(--badge-pad-x));
175
190
  border-radius: var(--radius-full);
176
191
  }
177
192
 
193
+ /* WCAG 2.5.8 floors pointer targets at 24 CSS px. The disc is smaller on the
194
+ default and .sm badges, so a transparent pseudo-element — part of the
195
+ button's hit area — extends the target to the floor around it, past the pill
196
+ if need be, without painting anything there. */
197
+ .badge > .close::after {
198
+ content: "";
199
+ position: absolute;
200
+ /* Square on purpose: nothing paints it, and a round one would miss the
201
+ corners of the 24px target. */
202
+ inset: min(0px, calc((var(--close-size) - 24px) / 2));
203
+ }
204
+
178
205
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
179
206
  Declared outside :hover like button.css for the same reason. */
180
207
  :is(a.badge, button.badge).outline {
@@ -19,6 +19,7 @@
19
19
  * --btn-radius
20
20
  * --btn-gap
21
21
  * --btn-hover-overlay
22
+ * --btn-shadow
22
23
  * --btn-default-bg
23
24
  * --btn-default-fg
24
25
  * --btn-default-border
@@ -85,6 +86,11 @@
85
86
  border-radius: var(--btn-radius);
86
87
  background: var(--btn-bg);
87
88
  color: var(--btn-fg);
89
+ /* why: an unset hook rather than none, so a theme or container can give
90
+ every button an elevation the hover overlay composes with instead of
91
+ replacing (see the hover rule). The hook accepts shadow-list items:
92
+ use 0 0 #0000 to remove elevation, since none invalidates that list. */
93
+ box-shadow: var(--btn-shadow, 0 0 #0000);
88
94
  font: inherit;
89
95
  font-size: var(--btn-font-size);
90
96
  font-weight: var(--font-weight-medium);
@@ -169,8 +175,10 @@
169
175
  background: var(--ui-hover-bg, var(--btn-bg));
170
176
  /* Huge inset spread paints the overlay over any border-radius without a
171
177
  pseudo-element; a plain background would replace the fill instead of
172
- darkening it. */
173
- box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
178
+ darkening it. Listed after --btn-shadow, which it must not drop. */
179
+ box-shadow:
180
+ var(--btn-shadow, 0 0 #0000),
181
+ inset 0 0 0 999px var(--btn-hover-overlay);
174
182
  }
175
183
 
176
184
  .btn:not(:where(:disabled, [aria-disabled="true"])):active {
@@ -28,6 +28,7 @@
28
28
  * --card-fg
29
29
  * --heading
30
30
  * --busy-overlay-bg
31
+ * --surface-link
31
32
  */
32
33
  /* Intent boundary — inherited intents do not tint this component.
33
34
  Local intent classes still win because :where() has zero specificity.
@@ -36,6 +37,7 @@
36
37
  when they paint this surface, so an ancestor intent would otherwise leak
37
38
  through a variant that has no local intent class. */
38
39
  :where(.card) {
40
+ --surface-link: initial;
39
41
  --intent: initial;
40
42
  --intent-fg: initial;
41
43
  --intent-soft-fg: initial;
@@ -71,6 +73,13 @@
71
73
  border-radius: var(--card-radius);
72
74
  }
73
75
 
76
+ /* Theme link ink is tuned for the page, not an intent fill. currentColor
77
+ follows the surrounding copy without freezing a parent's resolved ink;
78
+ the card and theme boundaries reset this internal relay above/in theme.css. */
79
+ .card.solid {
80
+ --surface-link: currentColor;
81
+ }
82
+
74
83
  /* The box lives at zero specificity so a layout primitive applied to the same
75
84
  element owns it. components/ is imported after layout/, so a plain .card
76
85
  declaration would beat .media (and any other non-column primitive) on source
@@ -33,6 +33,7 @@
33
33
  @import "./fab.css";
34
34
  @import "./rating.css";
35
35
  @import "./steps.css";
36
+ @import "./timeline.css";
36
37
 
37
38
  /* Navigation */
38
39
  @import "./app-nav.css";
@@ -38,6 +38,17 @@
38
38
  0 0 / 200% 100%;
39
39
  }
40
40
 
41
+ /* Translucent ink tints show the surface they sit on. Fixed surfaces did
42
+ not: the --surface-raised highlight matched a card or a light page, so the
43
+ shape lost its edges mid-sweep, and a soft card or dark page made the track
44
+ inverted or invisible. The highlight keeps a tint so the edge never goes. */
45
+ @supports (color: color-mix(in oklab, red, blue)) {
46
+ .skeleton {
47
+ --skeleton-track: color-mix(in oklab, currentColor 10%, transparent);
48
+ --skeleton-highlight: color-mix(in oklab, currentColor 4%, transparent);
49
+ }
50
+ }
51
+
41
52
  @media (prefers-reduced-motion: no-preference) {
42
53
  .skeleton {
43
54
  animation: actual-skeleton var(--duration-shimmer) ease-in-out infinite;
@@ -20,6 +20,9 @@
20
20
  * Public hooks:
21
21
  * --tab-flex
22
22
  * --tab-gap
23
+ * --tab-panel-bg
24
+ * --tab-panel-pad
25
+ * --tab-panel-shadow
23
26
  *
24
27
  * Internal:
25
28
  * --tab-edge
@@ -249,3 +252,77 @@
249
252
  .tabs > li > .tab {
250
253
  inline-size: 100%;
251
254
  }
255
+
256
+ /* Attached horizontal tabs share a surface with their panels. The strip
257
+ draws the panel's top edge inside its scrollport: no overlapping layers
258
+ or outside ink that overflow clipping could cut off. Direct children
259
+ keep an ordinary or nested tablist inside a panel independent. */
260
+ .tabs-attached {
261
+ --tab-panel-bg: var(--surface-raised);
262
+ --tab-panel-pad: var(--space-40);
263
+ --tab-panel-shadow: none;
264
+
265
+ display: grid;
266
+ gap: 0;
267
+ min-inline-size: 0;
268
+ }
269
+
270
+ .tabs-attached > .tabs {
271
+ gap: 0;
272
+ border: 0;
273
+ background:
274
+ linear-gradient(var(--border), var(--border)) bottom / 100% var(--border-width) no-repeat;
275
+ }
276
+
277
+ .tabs-attached > .tabs > .tab {
278
+ border: var(--border-width) solid var(--border);
279
+ background: var(--surface-subtle);
280
+ }
281
+
282
+ .tabs-attached > .tabs > .tab + .tab {
283
+ margin-inline-start: calc(-1 * var(--border-width));
284
+ }
285
+
286
+ .tabs-attached > .tabs > .tab[aria-selected="true"] {
287
+ background: var(--tab-panel-bg);
288
+ color: var(--text);
289
+ }
290
+
291
+ .tabs-attached > .tabs > .tab[aria-selected="true"]::after {
292
+ /* Keep all four border colors equal: differently colored bottom borders
293
+ meet the sides diagonally. Erase only the inner bottom edge so the
294
+ vertical strokes reach the panel with square ends. */
295
+ inset-block-end: calc(-1 * var(--border-width));
296
+ block-size: var(--border-width);
297
+ border: 0;
298
+ background: var(--tab-panel-bg);
299
+ }
300
+
301
+ .tabs-attached > .tab-panel {
302
+ min-inline-size: 0;
303
+ margin: 0;
304
+ padding: var(--tab-panel-pad);
305
+ border: var(--border-width) solid var(--border);
306
+ border-block-start: 0;
307
+ background: var(--tab-panel-bg);
308
+ box-shadow: var(--tab-panel-shadow);
309
+ }
310
+
311
+ /* Forced colors remove gradients and shadows. A real strip edge and the
312
+ user's selection colors preserve both the frame and the selected state. */
313
+ @media (forced-colors: active) {
314
+ .tabs-attached > .tabs {
315
+ border-block-end: var(--border-width) solid CanvasText;
316
+ }
317
+
318
+ .tabs-attached > .tabs > .tab[aria-selected="true"] {
319
+ forced-color-adjust: none;
320
+ border-color: CanvasText;
321
+ background: Highlight;
322
+ color: HighlightText;
323
+ }
324
+
325
+ .tabs-attached > .tabs > .tab[aria-selected="true"]::after {
326
+ content: none;
327
+ }
328
+ }
@@ -15,6 +15,11 @@
15
15
  * Public hooks:
16
16
  * --table-min
17
17
  * --table-cell-pad
18
+ *
19
+ * Theme hooks (unset by default, read with a fallback, so a theme sets them
20
+ * once on its root and third-party grid bridges can read the same choice):
21
+ * --table-header-bg
22
+ * --table-header-fg
18
23
  */
19
24
  .table-wrap {
20
25
  /* Containing block for anything absolutely positioned inside the table —
@@ -65,11 +70,12 @@
65
70
  }
66
71
 
67
72
  .table thead th {
73
+ color: var(--table-header-fg, var(--text-muted));
68
74
  vertical-align: bottom;
69
75
  }
70
76
 
71
77
  .table thead {
72
- background: var(--surface-subtle);
78
+ background: var(--table-header-bg, var(--surface-subtle));
73
79
  }
74
80
 
75
81
  .table tbody tr:hover {
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Timeline — a vertical history of dated events: activity feed, audit log,
3
+ * order tracking.
4
+ *
5
+ * Docs: docs/pages/components/timeline.md
6
+ *
7
+ * <ol class="timeline">
8
+ * <li class="success">
9
+ * <time datetime="2026-10-18T09:30">Today, 09:30</time>
10
+ * Appointment confirmed
11
+ * </li>
12
+ * </ol>
13
+ *
14
+ * Each event draws a dot on a rail; an intent class on the <li> colors it. An
15
+ * .avatar or an aria-hidden icon written first in the <li> takes the dot's
16
+ * place. A direct <time> child is the event's dateline. The content stays in
17
+ * block flow, so a sentence mixing text and links is never split into items.
18
+ *
19
+ * Progress through a process is .steps, not a timeline.
20
+ *
21
+ * Public hooks:
22
+ * --timeline-dot-size
23
+ * --timeline-slot-size
24
+ * --timeline-gap
25
+ * --timeline-inline-gap
26
+ * --timeline-line-size
27
+ *
28
+ * Internal:
29
+ * --timeline-rail-size
30
+ * --timeline-line-box
31
+ */
32
+
33
+ /* Registered so the event's line box computes to a length on the <li> and
34
+ inherits as one: an avatar slot sets its own font size and line-height, and
35
+ an unregistered em would resolve against those. Unregistered, the dot still
36
+ lines up and the slot drifts by the font difference.
37
+
38
+ The initial value must be computationally independent: a rem or em there
39
+ invalidates the whole @property rule, silently. Every <li> sets its own. */
40
+ @property --timeline-line-box {
41
+ syntax: "<length>";
42
+ inherits: true;
43
+ initial-value: 0px;
44
+ }
45
+
46
+ .timeline {
47
+ --timeline-dot-size: 0.625rem;
48
+ --timeline-slot-size: 1.75rem;
49
+ --timeline-gap: var(--density-space);
50
+ --timeline-inline-gap: var(--space-30);
51
+ --timeline-line-size: var(--border-width);
52
+ --timeline-rail-size: var(--timeline-dot-size);
53
+
54
+ display: grid;
55
+ /* A long URL or token in an event wraps instead of widening the list. */
56
+ grid-template-columns: minmax(0, 1fr);
57
+ /* A list stretched by its parent (a grid cell, an equal-height card) keeps
58
+ its rhythm instead of spreading the events over the extra height. */
59
+ align-content: start;
60
+ gap: var(--timeline-gap);
61
+ margin: 0;
62
+ padding: 0;
63
+ list-style: none;
64
+ }
65
+
66
+ /* One slotted event widens the rail of the whole list, so every line and every
67
+ text column stays aligned when dots and avatars mix. Without :has() the rail
68
+ keeps the dot's width and a slot shrinks to it: the degraded list stays
69
+ aligned and readable, with a dot-sized avatar. */
70
+ .timeline:has(> li > :is(.avatar, [aria-hidden="true"]):first-child) {
71
+ --timeline-rail-size: var(--timeline-slot-size);
72
+ }
73
+
74
+ /* The line box is a length, not the unitless ratio: a smaller <time> opening
75
+ the event keeps the same first line as text would, so the marker centred on
76
+ it lines up whichever element comes first. */
77
+ .timeline > li {
78
+ --timeline-line-box: calc(var(--line-height) * 1em);
79
+
80
+ position: relative;
81
+ padding-inline-start: calc(var(--timeline-rail-size) + var(--timeline-inline-gap));
82
+ line-height: var(--timeline-line-box);
83
+ }
84
+
85
+ /* The event owns no rhythm beyond its dateline: block children drop their own
86
+ margins, and an author who needs more spacing composes a .stack inside. */
87
+ .timeline > li > * {
88
+ margin-block: 0;
89
+ }
90
+
91
+ .timeline > li > time {
92
+ display: block;
93
+ color: var(--text-muted);
94
+ font-size: var(--font-size-sm);
95
+ }
96
+
97
+ .timeline > li::before {
98
+ content: "";
99
+ position: absolute;
100
+ inset-block-start: calc((var(--timeline-line-box) - var(--timeline-dot-size)) / 2);
101
+ inset-inline-start: calc((var(--timeline-rail-size) - var(--timeline-dot-size)) / 2);
102
+ inline-size: var(--timeline-dot-size);
103
+ block-size: var(--timeline-dot-size);
104
+ border-radius: var(--radius-full);
105
+ background: var(--intent, var(--neutral));
106
+ }
107
+
108
+ /* Stated as an undo, not as `:not(:has(...))` on the dot rule: an engine
109
+ without :has() would drop that whole rule and lose every dot, where this way
110
+ it only keeps a dot under the slot. */
111
+ .timeline > li:has(> :is(.avatar, [aria-hidden="true"]):first-child)::before {
112
+ content: none;
113
+ }
114
+
115
+ /* The segment clears each marker by half the rail, so it never shows through
116
+ a translucent avatar fill, and a dot in a list widened by a slot keeps the
117
+ slot's clearance. A border rather than a background: forced colors keep a
118
+ border and wipe a background. */
119
+ .timeline > li:not(:last-child)::after {
120
+ content: "";
121
+ position: absolute;
122
+ inset-block-start: calc((var(--timeline-line-box) + var(--timeline-rail-size)) / 2);
123
+ inset-inline-start: calc((var(--timeline-rail-size) - var(--timeline-line-size)) / 2);
124
+ block-size: calc(100% + var(--timeline-gap) - var(--timeline-rail-size));
125
+ border-inline-start: var(--timeline-line-size) solid var(--border);
126
+ }
127
+
128
+ .timeline > li > :is(.avatar, [aria-hidden="true"]):first-child {
129
+ --avatar-size: var(--timeline-rail-size);
130
+
131
+ position: absolute;
132
+ inset-block-start: calc((var(--timeline-line-box) - var(--timeline-rail-size)) / 2);
133
+ inset-inline-start: 0;
134
+ }
135
+
136
+ /* An icon slot gets a disc of the avatar's size, so both slot kinds read as one
137
+ rail. The event's intent colors the glyph. */
138
+ .timeline > li > [aria-hidden="true"]:first-child:not(.avatar) {
139
+ display: grid;
140
+ place-items: center;
141
+ inline-size: var(--timeline-rail-size);
142
+ block-size: var(--timeline-rail-size);
143
+ border: var(--border-width) solid var(--border);
144
+ border-radius: var(--radius-full);
145
+ background: var(--surface-opaque);
146
+ color: var(--intent, var(--text-muted));
147
+ font-size: var(--font-size-sm);
148
+ line-height: 1;
149
+ }
150
+
151
+ .timeline > li > [aria-hidden="true"]:first-child:not(.avatar) > svg {
152
+ inline-size: 1em;
153
+ block-size: 1em;
154
+ }
155
+
156
+ @media (forced-colors: active) {
157
+ .timeline > li::before {
158
+ background: CanvasText;
159
+ }
160
+ }
@@ -22,7 +22,7 @@ body {
22
22
  opts in by setting --link. Because this is an element selector, any component
23
23
  rule that colors its own anchors still wins. */
24
24
  a {
25
- color: var(--link, inherit);
25
+ color: var(--surface-link, var(--link, inherit));
26
26
  text-underline-offset: 0.18em;
27
27
  }
28
28
 
@@ -91,4 +91,6 @@ small {
91
91
  ::selection {
92
92
  background: var(--selection-bg, var(--primary));
93
93
  color: var(--selection-fg, var(--primary-fg));
94
+ /* SVG text paints with fill, including labels with an explicit fill. */
95
+ fill: currentColor;
94
96
  }
@@ -10,7 +10,7 @@
10
10
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
11
11
  * translucent surfaces overrides them with opaque values. --border-control is
12
12
  * public too: the resting edge of a neutral control, apart from structural
13
- * separators.
13
+ * separators. --control-default-bg is the inherited resting field fill.
14
14
  * Internal: --hgroup-gap.
15
15
  */
16
16
  :root {
@@ -103,6 +103,8 @@
103
103
  their own tokens instead of inheriting :root's resolved values. */
104
104
  :root,
105
105
  [data-theme] {
106
+ /* A painted parent's link context must not cross into a new palette. */
107
+ --surface-link: initial;
106
108
  --heading: var(--text);
107
109
  --selection-bg: var(--primary);
108
110
  --selection-fg: var(--primary-fg);
@@ -115,6 +117,9 @@
115
117
  /* The edge that tells a control apart from the surface, where --border only
116
118
  separates surfaces: fields, choices, switch, OTP cells, choice cards. */
117
119
  --border-control: var(--border);
120
+ /* Inherited resting field fill, shared with external control bridges.
121
+ Component state rules still own readonly, disabled and invalid fills. */
122
+ --control-default-bg: var(--surface);
118
123
  }
119
124
 
120
125
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -125,14 +125,14 @@
125
125
  Like --control-size, it is a scale plus a selection: themes set the
126
126
  -sm/-md/-lg steps, and :root, .sm/.lg and .compact/.spacious (variants.css)
127
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
128
+ by every size and density boundary — a theme's 12px became a wider 13px
129
+ inside .compact. The selection is rebound at each boundary because a
130
130
  var() inside a custom property resolves where it is declared.
131
131
 
132
132
  The boundaries also write --control-step-sm/-md/-lg: which step is
133
133
  selected, as 0/1 weights that mean the same under every theme. A theme
134
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
135
+ (docs: Tokens · Controls), so a nested island of it reads its steps without
136
136
  resetting the density it sits in. Never rebind on every [data-theme]: a
137
137
  palette-only island then became a geometry boundary that an inherited
138
138
  --control-size or --control-pad-x no longer crossed. */
@@ -225,14 +225,17 @@
225
225
  clip-path: circle(0 at var(--choice-chip-dot));
226
226
  }
227
227
 
228
- /* The check keeps the card's scale-in, in flow ahead of the label. */
228
+ /* The check keeps the card's scale-in, in flow ahead of the label. It is ink
229
+ on the chip, not a fill: a theme whose selected fill cannot be text (a
230
+ yellow or lime accent) lends its selected ink here, as for tabs and steps.
231
+ It outranks explicit intents too: their pastel fills are still not ink. */
229
232
  .choice-card.chip::after {
230
233
  order: -1;
231
234
  flex: none;
232
235
  align-self: center;
233
236
  margin-block: 0;
234
237
  margin-inline: 0 0.375em;
235
- background: var(--intent, var(--state-selected));
238
+ background: var(--state-selected-text, var(--intent, var(--state-selected)));
236
239
  -webkit-mask-size: contain;
237
240
  mask-size: contain;
238
241
  }
@@ -20,8 +20,10 @@
20
20
  * Public hooks:
21
21
  * --control-border
22
22
  * --control-bg
23
+ * --control-default-bg
23
24
  * --control-fg
24
25
  * --control-cursor
26
+ * --control-shadow
25
27
  * --control-border-block-end-color
26
28
  * --control-border-block-end-width
27
29
  * --control-hover-border (input and textarea)
@@ -31,7 +33,7 @@
31
33
  .textarea,
32
34
  .select {
33
35
  --control-border: var(--border-control);
34
- --control-bg: var(--surface);
36
+ --control-bg: var(--control-default-bg, var(--surface));
35
37
  --control-fg: var(--text);
36
38
  --control-cursor: auto;
37
39
 
@@ -49,6 +51,11 @@
49
51
  border-radius: var(--radius);
50
52
  background: var(--control-bg);
51
53
  color: var(--control-fg);
54
+ /* why: an unset hook, so a theme can give every field a resting elevation
55
+ that focus keeps (see the focus rule). The fallback is none, the same as
56
+ the focus rule's: a transparent 0 0 shadow here made focus transition
57
+ between two values and expose a phantom shadow mid-way. */
58
+ box-shadow: var(--control-shadow, none);
52
59
  font-size: var(--control-font-size);
53
60
  cursor: var(--control-cursor);
54
61
  /* No outline: none here — the core :focus fallback must survive for browsers
@@ -81,13 +88,15 @@
81
88
  the control's border box (any smaller offset bleeds outward), so it covers
82
89
  the border, never touches a .join neighbour or a scrollport edge, and needs
83
90
  no group treatment. Width follows --focus-ring-width, including its
84
- prefers-contrast step. Border = shape and validation, outline = focus. */
91
+ prefers-contrast step. Border = shape and validation, outline = focus.
92
+ The box-shadow reset drops any halo but keeps the field's own elevation:
93
+ a plain none erased --control-shadow on keyboard focus. */
85
94
  .input:focus-visible:not(:disabled),
86
95
  .textarea:focus-visible:not(:disabled),
87
96
  .select:focus-visible:not(:disabled) {
88
97
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
89
98
  outline-offset: calc(var(--focus-ring-width) * -1);
90
- box-shadow: none;
99
+ box-shadow: var(--control-shadow, none);
91
100
  }
92
101
 
93
102
  .input[readonly],
@@ -38,7 +38,7 @@
38
38
  --control-bg: var(--surface-raised);
39
39
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
40
40
  outline-offset: calc(var(--focus-ring-width) * -1);
41
- box-shadow: none;
41
+ box-shadow: var(--control-shadow, none);
42
42
  }
43
43
 
44
44
  &::picker-icon {