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
@@ -178,14 +178,14 @@ select {
178
178
  Like --control-size, it is a scale plus a selection: themes set the
179
179
  -sm/-md/-lg steps, and :root, .sm/.lg and .compact/.spacious (variants.css)
180
180
  pick one. A theme that wrote --control-pad-x itself would be overwritten
181
- by every size and density boundary — bootstrap-v6's 12px became a wider
182
- 13px inside .compact. The selection is rebound at each boundary because a
181
+ by every size and density boundary — a theme's 12px became a wider 13px
182
+ inside .compact. The selection is rebound at each boundary because a
183
183
  var() inside a custom property resolves where it is declared.
184
184
 
185
185
  The boundaries also write --control-step-sm/-md/-lg: which step is
186
186
  selected, as 0/1 weights that mean the same under every theme. A theme
187
187
  that sets its own steps rebuilds the selection from them in its own block
188
- (bootstrap-v6.css), so a nested island of it reads its steps without
188
+ (docs: Tokens · Controls), so a nested island of it reads its steps without
189
189
  resetting the density it sits in. Never rebind on every [data-theme]: a
190
190
  palette-only island then became a geometry boundary that an inherited
191
191
  --control-size or --control-pad-x no longer crossed. */
@@ -335,7 +335,7 @@ select {
335
335
  * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
336
336
  * translucent surfaces overrides them with opaque values. --border-control is
337
337
  * public too: the resting edge of a neutral control, apart from structural
338
- * separators.
338
+ * separators. --control-default-bg is the inherited resting field fill.
339
339
  * Internal: --hgroup-gap.
340
340
  */
341
341
  :root {
@@ -428,6 +428,8 @@ select {
428
428
  their own tokens instead of inheriting :root's resolved values. */
429
429
  :root,
430
430
  [data-theme] {
431
+ /* A painted parent's link context must not cross into a new palette. */
432
+ --surface-link: initial;
431
433
  --heading: var(--text);
432
434
  --selection-bg: var(--primary);
433
435
  --selection-fg: var(--primary-fg);
@@ -440,6 +442,9 @@ select {
440
442
  /* The edge that tells a control apart from the surface, where --border only
441
443
  separates surfaces: fields, choices, switch, OTP cells, choice cards. */
442
444
  --border-control: var(--border);
445
+ /* Inherited resting field fill, shared with external control bridges.
446
+ Component state rules still own readonly, disabled and invalid fills. */
447
+ --control-default-bg: var(--surface);
443
448
  }
444
449
 
445
450
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -576,7 +581,7 @@ body {
576
581
  opts in by setting --link. Because this is an element selector, any component
577
582
  rule that colors its own anchors still wins. */
578
583
  a {
579
- color: var(--link, inherit);
584
+ color: var(--surface-link, var(--link, inherit));
580
585
  text-underline-offset: 0.18em;
581
586
  }
582
587
 
@@ -645,6 +650,8 @@ small {
645
650
  ::selection {
646
651
  background: var(--selection-bg, var(--primary));
647
652
  color: var(--selection-fg, var(--primary-fg));
653
+ /* SVG text paints with fill, including labels with an explicit fill. */
654
+ fill: currentColor;
648
655
  }
649
656
 
650
657
  /*
@@ -1040,7 +1047,7 @@ small {
1040
1047
  /* --link is a theme-level token (core/base.css also reads it for bare links);
1041
1048
  prose falls back to --primary so a theme that never sets it is unaffected. */
1042
1049
  .prose :where(a) {
1043
- color: var(--link, var(--primary));
1050
+ color: var(--surface-link, var(--link, var(--primary)));
1044
1051
  text-decoration-line: underline;
1045
1052
  text-decoration-thickness: 0.08em;
1046
1053
  text-underline-offset: 0.18em;
@@ -1052,7 +1059,9 @@ small {
1052
1059
 
1053
1060
  @supports (color: color-mix(in oklch, red, white)) {
1054
1061
  .prose :where(a:hover, a:focus-visible) {
1055
- color: color-mix(in oklab, var(--link, var(--primary)), var(--text) 15%);
1062
+ /* A solid surface owns its contrast pair in every state; mixing in the
1063
+ page's --text would pull its links toward the wrong background. */
1064
+ color: var(--surface-link, color-mix(in oklab, var(--link, var(--primary)), var(--text) 15%));
1056
1065
  }
1057
1066
  }
1058
1067
 
@@ -2221,8 +2230,10 @@ small {
2221
2230
  * Public hooks:
2222
2231
  * --control-border
2223
2232
  * --control-bg
2233
+ * --control-default-bg
2224
2234
  * --control-fg
2225
2235
  * --control-cursor
2236
+ * --control-shadow
2226
2237
  * --control-border-block-end-color
2227
2238
  * --control-border-block-end-width
2228
2239
  * --control-hover-border (input and textarea)
@@ -2232,7 +2243,7 @@ small {
2232
2243
  .textarea,
2233
2244
  .select {
2234
2245
  --control-border: var(--border-control);
2235
- --control-bg: var(--surface);
2246
+ --control-bg: var(--control-default-bg, var(--surface));
2236
2247
  --control-fg: var(--text);
2237
2248
  --control-cursor: auto;
2238
2249
 
@@ -2250,6 +2261,11 @@ small {
2250
2261
  border-radius: var(--radius);
2251
2262
  background: var(--control-bg);
2252
2263
  color: var(--control-fg);
2264
+ /* why: an unset hook, so a theme can give every field a resting elevation
2265
+ that focus keeps (see the focus rule). The fallback is none, the same as
2266
+ the focus rule's: a transparent 0 0 shadow here made focus transition
2267
+ between two values and expose a phantom shadow mid-way. */
2268
+ box-shadow: var(--control-shadow, none);
2253
2269
  font-size: var(--control-font-size);
2254
2270
  cursor: var(--control-cursor);
2255
2271
  /* No outline: none here — the core :focus fallback must survive for browsers
@@ -2282,13 +2298,15 @@ small {
2282
2298
  the control's border box (any smaller offset bleeds outward), so it covers
2283
2299
  the border, never touches a .join neighbour or a scrollport edge, and needs
2284
2300
  no group treatment. Width follows --focus-ring-width, including its
2285
- prefers-contrast step. Border = shape and validation, outline = focus. */
2301
+ prefers-contrast step. Border = shape and validation, outline = focus.
2302
+ The box-shadow reset drops any halo but keeps the field's own elevation:
2303
+ a plain none erased --control-shadow on keyboard focus. */
2286
2304
  .input:focus-visible:not(:disabled),
2287
2305
  .textarea:focus-visible:not(:disabled),
2288
2306
  .select:focus-visible:not(:disabled) {
2289
2307
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
2290
2308
  outline-offset: calc(var(--focus-ring-width) * -1);
2291
- box-shadow: none;
2309
+ box-shadow: var(--control-shadow, none);
2292
2310
  }
2293
2311
 
2294
2312
  .input[readonly],
@@ -3532,14 +3550,17 @@ small {
3532
3550
  clip-path: circle(0 at var(--choice-chip-dot));
3533
3551
  }
3534
3552
 
3535
- /* The check keeps the card's scale-in, in flow ahead of the label. */
3553
+ /* The check keeps the card's scale-in, in flow ahead of the label. It is ink
3554
+ on the chip, not a fill: a theme whose selected fill cannot be text (a
3555
+ yellow or lime accent) lends its selected ink here, as for tabs and steps.
3556
+ It outranks explicit intents too: their pastel fills are still not ink. */
3536
3557
  .choice-card.chip::after {
3537
3558
  order: -1;
3538
3559
  flex: none;
3539
3560
  align-self: center;
3540
3561
  margin-block: 0;
3541
3562
  margin-inline: 0 0.375em;
3542
- background: var(--intent, var(--state-selected));
3563
+ background: var(--state-selected-text, var(--intent, var(--state-selected)));
3543
3564
  -webkit-mask-size: contain;
3544
3565
  mask-size: contain;
3545
3566
  }
@@ -3780,7 +3801,7 @@ small {
3780
3801
  --control-bg: var(--surface-raised);
3781
3802
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
3782
3803
  outline-offset: calc(var(--focus-ring-width) * -1);
3783
- box-shadow: none;
3804
+ box-shadow: var(--control-shadow, none);
3784
3805
  }
3785
3806
 
3786
3807
  &::picker-icon {
@@ -4120,6 +4141,7 @@ small {
4120
4141
  * --btn-radius
4121
4142
  * --btn-gap
4122
4143
  * --btn-hover-overlay
4144
+ * --btn-shadow
4123
4145
  * --btn-default-bg
4124
4146
  * --btn-default-fg
4125
4147
  * --btn-default-border
@@ -4186,6 +4208,11 @@ small {
4186
4208
  border-radius: var(--btn-radius);
4187
4209
  background: var(--btn-bg);
4188
4210
  color: var(--btn-fg);
4211
+ /* why: an unset hook rather than none, so a theme or container can give
4212
+ every button an elevation the hover overlay composes with instead of
4213
+ replacing (see the hover rule). The hook accepts shadow-list items:
4214
+ use 0 0 #0000 to remove elevation, since none invalidates that list. */
4215
+ box-shadow: var(--btn-shadow, 0 0 #0000);
4189
4216
  font: inherit;
4190
4217
  font-size: var(--btn-font-size);
4191
4218
  font-weight: var(--font-weight-medium);
@@ -4270,8 +4297,10 @@ small {
4270
4297
  background: var(--ui-hover-bg, var(--btn-bg));
4271
4298
  /* Huge inset spread paints the overlay over any border-radius without a
4272
4299
  pseudo-element; a plain background would replace the fill instead of
4273
- darkening it. */
4274
- box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
4300
+ darkening it. Listed after --btn-shadow, which it must not drop. */
4301
+ box-shadow:
4302
+ var(--btn-shadow, 0 0 #0000),
4303
+ inset 0 0 0 999px var(--btn-hover-overlay);
4275
4304
  }
4276
4305
 
4277
4306
  .btn:not(:where(:disabled, [aria-disabled="true"])):active {
@@ -4486,6 +4515,8 @@ a.btn[aria-disabled="true"] {
4486
4515
  * --badge-default-fg
4487
4516
  * --badge-default-border
4488
4517
  * --badge-default-hover-bg
4518
+ * --badge-slot-inset
4519
+ * --badge-slot-size
4489
4520
  * --badge-bg
4490
4521
  * --badge-fg
4491
4522
  * --badge-border
@@ -4510,9 +4541,13 @@ a.btn[aria-disabled="true"] {
4510
4541
  --badge-font-size: var(--font-size-sm);
4511
4542
  --badge-icon-size: 1em;
4512
4543
  --badge-dot-size: 0.5rem;
4544
+ /* A leading .avatar and a trailing .close are slots: a disc inside the pill
4545
+ at the same inset from its block edges and its rounded end. */
4546
+ --badge-slot-inset: calc(var(--badge-size) / 12);
4547
+ --badge-slot-size: calc(var(--badge-size) - 2 * (var(--border-width) + var(--badge-slot-inset)));
4513
4548
  /* Declared on the badge, not the button, so an author can still set them
4514
4549
  on either. Same element as --badge-size, so .sm/.lg carry through. */
4515
- --close-size: max(var(--badge-size), 24px);
4550
+ --close-size: var(--badge-slot-size);
4516
4551
  --close-icon-size: 0.875em;
4517
4552
  --badge-default-bg: var(--surface-subtle);
4518
4553
  --badge-default-fg: var(--intent, var(--text));
@@ -4563,9 +4598,11 @@ a.btn[aria-disabled="true"] {
4563
4598
  --badge-dot-size: 0.625rem;
4564
4599
  }
4565
4600
 
4566
- /* Decorative children follow the badge's optical size. Text and the .close
4567
- button retain their own boxes. */
4568
- .badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
4601
+ /* Decorative children follow the badge's optical size. Text, the .close
4602
+ button and an .avatar retain their own boxes — an aria-hidden avatar would
4603
+ otherwise tie with .avatar on specificity and shrink to an icon whenever
4604
+ badge.css loads last. */
4605
+ .badge > :where(svg, img, [aria-hidden="true"]:not(.dot, .avatar)) {
4569
4606
  flex: none;
4570
4607
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
4571
4608
  the requested optical size; reusing the em-valued hook here would apply its
@@ -4630,17 +4667,36 @@ a.btn[aria-disabled="true"] {
4630
4667
  background-color: currentColor;
4631
4668
  }
4632
4669
 
4633
- /* Removable tag — a .close (components/close.css) inside the pill. WCAG 2.5.8
4634
- floors pointer targets at 24 CSS px: density and local size may keep the
4635
- badge visually compact through the negative margins, but never shrink the
4636
- target below that literal floor. */
4670
+ /* Person chip (mention, assignee, contributor) — a leading .avatar follows the
4671
+ pill instead of its own scale, so the badge keeps its height. */
4672
+ .badge > .avatar {
4673
+ --avatar-size: var(--badge-slot-size);
4674
+ /* Initials scale from the label, not the diameter: a diameter ratio left
4675
+ them under 6px on .sm. em resolves against the badge's font size here. */
4676
+ --avatar-font-size: 0.7em;
4677
+ margin-inline-start: calc(var(--badge-slot-inset) - var(--badge-pad-x));
4678
+ }
4679
+
4680
+ /* Removable tag — a trailing .close (components/close.css), the avatar's
4681
+ mirror: a fill or the hover wash stays a disc inside the pill. */
4637
4682
  .badge > .close {
4638
- margin-block: -0.25rem;
4639
- margin-inline-start: -0.15em;
4640
- margin-inline-end: -0.45em;
4683
+ position: relative;
4684
+ margin-inline-end: calc(var(--badge-slot-inset) - var(--badge-pad-x));
4641
4685
  border-radius: var(--radius-full);
4642
4686
  }
4643
4687
 
4688
+ /* WCAG 2.5.8 floors pointer targets at 24 CSS px. The disc is smaller on the
4689
+ default and .sm badges, so a transparent pseudo-element — part of the
4690
+ button's hit area — extends the target to the floor around it, past the pill
4691
+ if need be, without painting anything there. */
4692
+ .badge > .close::after {
4693
+ content: "";
4694
+ position: absolute;
4695
+ /* Square on purpose: nothing paints it, and a round one would miss the
4696
+ corners of the 24px target. */
4697
+ inset: min(0px, calc((var(--close-size) - 24px) / 2));
4698
+ }
4699
+
4644
4700
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
4645
4701
  Declared outside :hover like button.css for the same reason. */
4646
4702
  :is(a.badge, button.badge).outline {
@@ -4881,6 +4937,9 @@ a.badge[aria-disabled="true"] {
4881
4937
  /* No grid-template-columns: the icon and close branches below always set
4882
4938
  it, since one of them is what matched. */
4883
4939
  align-items: var(--items-align, start);
4940
+ /* why: a stretched alert (equal-height grid row) spread its extra height
4941
+ between the content rows, opening a gap under the title. */
4942
+ align-content: start;
4884
4943
  column-gap: var(--space-30);
4885
4944
  }
4886
4945
 
@@ -4888,9 +4947,14 @@ a.badge[aria-disabled="true"] {
4888
4947
  grid-template-columns: auto minmax(0, 1fr);
4889
4948
  }
4890
4949
 
4950
+ /* why span 2, not 1 / -1: the rows are implicit, so -1 is line 1 and the
4951
+ icon sat in the title row alone — any icon taller than one line inflated
4952
+ that row and pushed the body down. Spanning the title and body rows lets
4953
+ its height spread over both; only an icon taller than both together still
4954
+ splits its excess between them. */
4891
4955
  .alert:has(> .alert-icon) > .alert-icon {
4892
4956
  grid-column: 1;
4893
- grid-row: 1 / -1;
4957
+ grid-row: 1 / span 2;
4894
4958
  display: inline-grid;
4895
4959
  place-items: center;
4896
4960
  margin-block-start: 0.125em;
@@ -5058,6 +5122,7 @@ a.badge[aria-disabled="true"] {
5058
5122
  * --card-fg
5059
5123
  * --heading
5060
5124
  * --busy-overlay-bg
5125
+ * --surface-link
5061
5126
  */
5062
5127
  /* Intent boundary — inherited intents do not tint this component.
5063
5128
  Local intent classes still win because :where() has zero specificity.
@@ -5066,6 +5131,7 @@ a.badge[aria-disabled="true"] {
5066
5131
  when they paint this surface, so an ancestor intent would otherwise leak
5067
5132
  through a variant that has no local intent class. */
5068
5133
  :where(.card) {
5134
+ --surface-link: initial;
5069
5135
  --intent: initial;
5070
5136
  --intent-fg: initial;
5071
5137
  --intent-soft-fg: initial;
@@ -5101,6 +5167,13 @@ a.badge[aria-disabled="true"] {
5101
5167
  border-radius: var(--card-radius);
5102
5168
  }
5103
5169
 
5170
+ /* Theme link ink is tuned for the page, not an intent fill. currentColor
5171
+ follows the surrounding copy without freezing a parent's resolved ink;
5172
+ the card and theme boundaries reset this internal relay above/in theme.css. */
5173
+ .card.solid {
5174
+ --surface-link: currentColor;
5175
+ }
5176
+
5104
5177
  /* The box lives at zero specificity so a layout primitive applied to the same
5105
5178
  element owns it. components/ is imported after layout/, so a plain .card
5106
5179
  declaration would beat .media (and any other non-column primitive) on source
@@ -6343,12 +6416,16 @@ picture.bleed > img {
6343
6416
  * --accordion-pad
6344
6417
  * --accordion-marker-color
6345
6418
  * --accordion-marker-size
6419
+ * --accordion-marker-icon
6420
+ * --accordion-marker-open-rotate
6346
6421
  */
6347
6422
  .accordion {
6348
6423
  --accordion-radius: var(--radius);
6349
6424
  --accordion-pad: var(--space-40);
6350
6425
  --accordion-marker-color: var(--text-muted);
6351
6426
  --accordion-marker-size: 1rem;
6427
+ --accordion-marker-icon: var(--icon-chevron);
6428
+ --accordion-marker-open-rotate: 180deg;
6352
6429
 
6353
6430
  border: var(--border-width) solid var(--border);
6354
6431
  border-radius: var(--accordion-radius);
@@ -6393,13 +6470,13 @@ picture.bleed > img {
6393
6470
  inline-size: var(--accordion-marker-size);
6394
6471
  block-size: var(--accordion-marker-size);
6395
6472
  background-color: var(--accordion-marker-color);
6396
- -webkit-mask: var(--icon-chevron) center / contain no-repeat;
6397
- mask: var(--icon-chevron) center / contain no-repeat;
6473
+ -webkit-mask: var(--accordion-marker-icon) center / contain no-repeat;
6474
+ mask: var(--accordion-marker-icon) center / contain no-repeat;
6398
6475
  transition: transform var(--duration);
6399
6476
  }
6400
6477
 
6401
6478
  .accordion details[open] summary::after {
6402
- transform: rotate(180deg);
6479
+ transform: rotate(var(--accordion-marker-open-rotate));
6403
6480
  }
6404
6481
 
6405
6482
  /* The panel, not the item, owns the inset here — .separated paints a card on
@@ -6596,6 +6673,17 @@ picture.bleed > img {
6596
6673
  0 0 / 200% 100%;
6597
6674
  }
6598
6675
 
6676
+ /* Translucent ink tints show the surface they sit on. Fixed surfaces did
6677
+ not: the --surface-raised highlight matched a card or a light page, so the
6678
+ shape lost its edges mid-sweep, and a soft card or dark page made the track
6679
+ inverted or invisible. The highlight keeps a tint so the edge never goes. */
6680
+ @supports (color: color-mix(in oklab, red, blue)) {
6681
+ .skeleton {
6682
+ --skeleton-track: color-mix(in oklab, currentColor 10%, transparent);
6683
+ --skeleton-highlight: color-mix(in oklab, currentColor 4%, transparent);
6684
+ }
6685
+ }
6686
+
6599
6687
  @media (prefers-reduced-motion: no-preference) {
6600
6688
  .skeleton {
6601
6689
  animation: actual-skeleton var(--duration-shimmer) ease-in-out infinite;
@@ -7985,6 +8073,167 @@ a.avatar[aria-disabled="true"] {
7985
8073
  no-repeat;
7986
8074
  }
7987
8075
 
8076
+ /*
8077
+ * Timeline — a vertical history of dated events: activity feed, audit log,
8078
+ * order tracking.
8079
+ *
8080
+ * Docs: docs/pages/components/timeline.md
8081
+ *
8082
+ * <ol class="timeline">
8083
+ * <li class="success">
8084
+ * <time datetime="2026-10-18T09:30">Today, 09:30</time>
8085
+ * Appointment confirmed
8086
+ * </li>
8087
+ * </ol>
8088
+ *
8089
+ * Each event draws a dot on a rail; an intent class on the <li> colors it. An
8090
+ * .avatar or an aria-hidden icon written first in the <li> takes the dot's
8091
+ * place. A direct <time> child is the event's dateline. The content stays in
8092
+ * block flow, so a sentence mixing text and links is never split into items.
8093
+ *
8094
+ * Progress through a process is .steps, not a timeline.
8095
+ *
8096
+ * Public hooks:
8097
+ * --timeline-dot-size
8098
+ * --timeline-slot-size
8099
+ * --timeline-gap
8100
+ * --timeline-inline-gap
8101
+ * --timeline-line-size
8102
+ *
8103
+ * Internal:
8104
+ * --timeline-rail-size
8105
+ * --timeline-line-box
8106
+ */
8107
+
8108
+ /* Registered so the event's line box computes to a length on the <li> and
8109
+ inherits as one: an avatar slot sets its own font size and line-height, and
8110
+ an unregistered em would resolve against those. Unregistered, the dot still
8111
+ lines up and the slot drifts by the font difference.
8112
+
8113
+ The initial value must be computationally independent: a rem or em there
8114
+ invalidates the whole @property rule, silently. Every <li> sets its own. */
8115
+ @property --timeline-line-box {
8116
+ syntax: "<length>";
8117
+ inherits: true;
8118
+ initial-value: 0px;
8119
+ }
8120
+
8121
+ .timeline {
8122
+ --timeline-dot-size: 0.625rem;
8123
+ --timeline-slot-size: 1.75rem;
8124
+ --timeline-gap: var(--density-space);
8125
+ --timeline-inline-gap: var(--space-30);
8126
+ --timeline-line-size: var(--border-width);
8127
+ --timeline-rail-size: var(--timeline-dot-size);
8128
+
8129
+ display: grid;
8130
+ /* A long URL or token in an event wraps instead of widening the list. */
8131
+ grid-template-columns: minmax(0, 1fr);
8132
+ /* A list stretched by its parent (a grid cell, an equal-height card) keeps
8133
+ its rhythm instead of spreading the events over the extra height. */
8134
+ align-content: start;
8135
+ gap: var(--timeline-gap);
8136
+ margin: 0;
8137
+ padding: 0;
8138
+ list-style: none;
8139
+ }
8140
+
8141
+ /* One slotted event widens the rail of the whole list, so every line and every
8142
+ text column stays aligned when dots and avatars mix. Without :has() the rail
8143
+ keeps the dot's width and a slot shrinks to it: the degraded list stays
8144
+ aligned and readable, with a dot-sized avatar. */
8145
+ .timeline:has(> li > :is(.avatar, [aria-hidden="true"]):first-child) {
8146
+ --timeline-rail-size: var(--timeline-slot-size);
8147
+ }
8148
+
8149
+ /* The line box is a length, not the unitless ratio: a smaller <time> opening
8150
+ the event keeps the same first line as text would, so the marker centred on
8151
+ it lines up whichever element comes first. */
8152
+ .timeline > li {
8153
+ --timeline-line-box: calc(var(--line-height) * 1em);
8154
+
8155
+ position: relative;
8156
+ padding-inline-start: calc(var(--timeline-rail-size) + var(--timeline-inline-gap));
8157
+ line-height: var(--timeline-line-box);
8158
+ }
8159
+
8160
+ /* The event owns no rhythm beyond its dateline: block children drop their own
8161
+ margins, and an author who needs more spacing composes a .stack inside. */
8162
+ .timeline > li > * {
8163
+ margin-block: 0;
8164
+ }
8165
+
8166
+ .timeline > li > time {
8167
+ display: block;
8168
+ color: var(--text-muted);
8169
+ font-size: var(--font-size-sm);
8170
+ }
8171
+
8172
+ .timeline > li::before {
8173
+ content: "";
8174
+ position: absolute;
8175
+ inset-block-start: calc((var(--timeline-line-box) - var(--timeline-dot-size)) / 2);
8176
+ inset-inline-start: calc((var(--timeline-rail-size) - var(--timeline-dot-size)) / 2);
8177
+ inline-size: var(--timeline-dot-size);
8178
+ block-size: var(--timeline-dot-size);
8179
+ border-radius: var(--radius-full);
8180
+ background: var(--intent, var(--neutral));
8181
+ }
8182
+
8183
+ /* Stated as an undo, not as `:not(:has(...))` on the dot rule: an engine
8184
+ without :has() would drop that whole rule and lose every dot, where this way
8185
+ it only keeps a dot under the slot. */
8186
+ .timeline > li:has(> :is(.avatar, [aria-hidden="true"]):first-child)::before {
8187
+ content: none;
8188
+ }
8189
+
8190
+ /* The segment clears each marker by half the rail, so it never shows through
8191
+ a translucent avatar fill, and a dot in a list widened by a slot keeps the
8192
+ slot's clearance. A border rather than a background: forced colors keep a
8193
+ border and wipe a background. */
8194
+ .timeline > li:not(:last-child)::after {
8195
+ content: "";
8196
+ position: absolute;
8197
+ inset-block-start: calc((var(--timeline-line-box) + var(--timeline-rail-size)) / 2);
8198
+ inset-inline-start: calc((var(--timeline-rail-size) - var(--timeline-line-size)) / 2);
8199
+ block-size: calc(100% + var(--timeline-gap) - var(--timeline-rail-size));
8200
+ border-inline-start: var(--timeline-line-size) solid var(--border);
8201
+ }
8202
+
8203
+ .timeline > li > :is(.avatar, [aria-hidden="true"]):first-child {
8204
+ --avatar-size: var(--timeline-rail-size);
8205
+
8206
+ position: absolute;
8207
+ inset-block-start: calc((var(--timeline-line-box) - var(--timeline-rail-size)) / 2);
8208
+ inset-inline-start: 0;
8209
+ }
8210
+
8211
+ /* An icon slot gets a disc of the avatar's size, so both slot kinds read as one
8212
+ rail. The event's intent colors the glyph. */
8213
+ .timeline > li > [aria-hidden="true"]:first-child:not(.avatar) {
8214
+ display: grid;
8215
+ place-items: center;
8216
+ inline-size: var(--timeline-rail-size);
8217
+ block-size: var(--timeline-rail-size);
8218
+ border: var(--border-width) solid var(--border);
8219
+ border-radius: var(--radius-full);
8220
+ background: var(--surface-opaque);
8221
+ color: var(--intent, var(--text-muted));
8222
+ font-size: var(--font-size-sm);
8223
+ line-height: 1;
8224
+ }
8225
+
8226
+ .timeline > li > [aria-hidden="true"]:first-child:not(.avatar) > svg {
8227
+ inline-size: 1em;
8228
+ block-size: 1em;
8229
+ }
8230
+
8231
+ @media (forced-colors: active) {
8232
+ .timeline > li::before {
8233
+ background: CanvasText;
8234
+ }
8235
+ }
8236
+
7988
8237
 
7989
8238
  /* Navigation */
7990
8239
  /*
@@ -8439,6 +8688,9 @@ a.avatar[aria-disabled="true"] {
8439
8688
  * Public hooks:
8440
8689
  * --tab-flex
8441
8690
  * --tab-gap
8691
+ * --tab-panel-bg
8692
+ * --tab-panel-pad
8693
+ * --tab-panel-shadow
8442
8694
  *
8443
8695
  * Internal:
8444
8696
  * --tab-edge
@@ -8669,6 +8921,80 @@ a.avatar[aria-disabled="true"] {
8669
8921
  inline-size: 100%;
8670
8922
  }
8671
8923
 
8924
+ /* Attached horizontal tabs share a surface with their panels. The strip
8925
+ draws the panel's top edge inside its scrollport: no overlapping layers
8926
+ or outside ink that overflow clipping could cut off. Direct children
8927
+ keep an ordinary or nested tablist inside a panel independent. */
8928
+ .tabs-attached {
8929
+ --tab-panel-bg: var(--surface-raised);
8930
+ --tab-panel-pad: var(--space-40);
8931
+ --tab-panel-shadow: none;
8932
+
8933
+ display: grid;
8934
+ gap: 0;
8935
+ min-inline-size: 0;
8936
+ }
8937
+
8938
+ .tabs-attached > .tabs {
8939
+ gap: 0;
8940
+ border: 0;
8941
+ background:
8942
+ linear-gradient(var(--border), var(--border)) bottom / 100% var(--border-width) no-repeat;
8943
+ }
8944
+
8945
+ .tabs-attached > .tabs > .tab {
8946
+ border: var(--border-width) solid var(--border);
8947
+ background: var(--surface-subtle);
8948
+ }
8949
+
8950
+ .tabs-attached > .tabs > .tab + .tab {
8951
+ margin-inline-start: calc(-1 * var(--border-width));
8952
+ }
8953
+
8954
+ .tabs-attached > .tabs > .tab[aria-selected="true"] {
8955
+ background: var(--tab-panel-bg);
8956
+ color: var(--text);
8957
+ }
8958
+
8959
+ .tabs-attached > .tabs > .tab[aria-selected="true"]::after {
8960
+ /* Keep all four border colors equal: differently colored bottom borders
8961
+ meet the sides diagonally. Erase only the inner bottom edge so the
8962
+ vertical strokes reach the panel with square ends. */
8963
+ inset-block-end: calc(-1 * var(--border-width));
8964
+ block-size: var(--border-width);
8965
+ border: 0;
8966
+ background: var(--tab-panel-bg);
8967
+ }
8968
+
8969
+ .tabs-attached > .tab-panel {
8970
+ min-inline-size: 0;
8971
+ margin: 0;
8972
+ padding: var(--tab-panel-pad);
8973
+ border: var(--border-width) solid var(--border);
8974
+ border-block-start: 0;
8975
+ background: var(--tab-panel-bg);
8976
+ box-shadow: var(--tab-panel-shadow);
8977
+ }
8978
+
8979
+ /* Forced colors remove gradients and shadows. A real strip edge and the
8980
+ user's selection colors preserve both the frame and the selected state. */
8981
+ @media (forced-colors: active) {
8982
+ .tabs-attached > .tabs {
8983
+ border-block-end: var(--border-width) solid CanvasText;
8984
+ }
8985
+
8986
+ .tabs-attached > .tabs > .tab[aria-selected="true"] {
8987
+ forced-color-adjust: none;
8988
+ border-color: CanvasText;
8989
+ background: Highlight;
8990
+ color: HighlightText;
8991
+ }
8992
+
8993
+ .tabs-attached > .tabs > .tab[aria-selected="true"]::after {
8994
+ content: none;
8995
+ }
8996
+ }
8997
+
8672
8998
  /*
8673
8999
  * Segmented control — one compact choice among a few, on a shared track.
8674
9000
  *
@@ -9092,6 +9418,11 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
9092
9418
  * Public hooks:
9093
9419
  * --table-min
9094
9420
  * --table-cell-pad
9421
+ *
9422
+ * Theme hooks (unset by default, read with a fallback, so a theme sets them
9423
+ * once on its root and third-party grid bridges can read the same choice):
9424
+ * --table-header-bg
9425
+ * --table-header-fg
9095
9426
  */
9096
9427
  .table-wrap {
9097
9428
  /* Containing block for anything absolutely positioned inside the table —
@@ -9142,11 +9473,12 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
9142
9473
  }
9143
9474
 
9144
9475
  .table thead th {
9476
+ color: var(--table-header-fg, var(--text-muted));
9145
9477
  vertical-align: bottom;
9146
9478
  }
9147
9479
 
9148
9480
  .table thead {
9149
- background: var(--surface-subtle);
9481
+ background: var(--table-header-bg, var(--surface-subtle));
9150
9482
  }
9151
9483
 
9152
9484
  .table tbody tr:hover {