actual-css 0.14.0 → 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 (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +8 -8
  3. package/components.json +51 -6
  4. package/dist/actual.full.css +367 -31
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/widget-primitives.md +4 -0
  8. package/docs/navigation.json +4 -1
  9. package/docs/pages/components/accordion.md +15 -18
  10. package/docs/pages/components/avatar.md +1 -0
  11. package/docs/pages/components/badge.md +41 -5
  12. package/docs/pages/components/button.md +5 -0
  13. package/docs/pages/components/card.md +34 -0
  14. package/docs/pages/components/diagrams.md +96 -0
  15. package/docs/pages/components/steps.md +1 -1
  16. package/docs/pages/components/table.md +4 -0
  17. package/docs/pages/components/timeline.md +111 -0
  18. package/docs/pages/enhancements/flyout.md +4 -3
  19. package/docs/pages/enhancements/overview.md +2 -1
  20. package/docs/pages/enhancements/tabs.md +48 -6
  21. package/docs/pages/enhancements/tooltip.md +3 -1
  22. package/docs/pages/examples/overview.md +5 -2
  23. package/docs/pages/forms/choice-cards.md +3 -0
  24. package/docs/pages/forms/combobox.md +34 -19
  25. package/docs/pages/forms/controls.md +2 -0
  26. package/docs/pages/forms/date-picker.md +34 -13
  27. package/docs/pages/forms/file-upload.md +120 -0
  28. package/docs/pages/forms/overview.md +8 -0
  29. package/docs/pages/foundations/philosophy.md +6 -0
  30. package/docs/pages/foundations/theming.md +57 -5
  31. package/docs/pages/foundations/typography.md +3 -0
  32. package/docs/pages/guides/introduction.md +1 -1
  33. package/docs/pages/guides/modular-import.md +124 -0
  34. package/docs/pages/guides/upgrading.md +63 -0
  35. package/package.json +4 -3
  36. package/reserved-classes.json +3 -0
  37. package/src/css/components/accordion.css +7 -3
  38. package/src/css/components/alert.css +9 -1
  39. package/src/css/components/badge.css +38 -11
  40. package/src/css/components/button.css +10 -2
  41. package/src/css/components/card.css +9 -0
  42. package/src/css/components/index.css +1 -0
  43. package/src/css/components/skeleton.css +11 -0
  44. package/src/css/components/tab.css +77 -0
  45. package/src/css/components/table.css +7 -1
  46. package/src/css/components/timeline.css +160 -0
  47. package/src/css/core/base.css +3 -1
  48. package/src/css/core/theme.css +6 -1
  49. package/src/css/core/tokens.css +3 -3
  50. package/src/css/forms/choice-card.css +5 -2
  51. package/src/css/forms/color-swatch.css +4 -0
  52. package/src/css/forms/control.css +12 -3
  53. package/src/css/forms/custom-select.css +1 -1
  54. package/src/css/typography/prose.css +4 -2
  55. package/src/js/enhance.js +14 -0
  56. package/src/js/floating-space.js +26 -0
  57. package/src/js/flyout.js +3 -1
  58. package/src/js/parse-config.js +5 -2
  59. package/src/js/tooltip.js +2 -2
  60. package/src/tooling/design-tokens.js +2 -0
  61. 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
  }
@@ -3627,6 +3648,10 @@ small {
3627
3648
  its invisible reach. */
3628
3649
  margin: calc(var(--color-swatch-reach) / -2);
3629
3650
  padding: 0;
3651
+ /* No in-flow text, so the baseline would be the box's bottom edge and an
3652
+ inline swatch would ride above the line (a flyout trigger beside its
3653
+ label). A palette in .cluster or .grid ignores it. */
3654
+ vertical-align: middle;
3630
3655
  border: 0;
3631
3656
  background: transparent;
3632
3657
  color: inherit;
@@ -3776,7 +3801,7 @@ small {
3776
3801
  --control-bg: var(--surface-raised);
3777
3802
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
3778
3803
  outline-offset: calc(var(--focus-ring-width) * -1);
3779
- box-shadow: none;
3804
+ box-shadow: var(--control-shadow, none);
3780
3805
  }
3781
3806
 
3782
3807
  &::picker-icon {
@@ -4116,6 +4141,7 @@ small {
4116
4141
  * --btn-radius
4117
4142
  * --btn-gap
4118
4143
  * --btn-hover-overlay
4144
+ * --btn-shadow
4119
4145
  * --btn-default-bg
4120
4146
  * --btn-default-fg
4121
4147
  * --btn-default-border
@@ -4182,6 +4208,11 @@ small {
4182
4208
  border-radius: var(--btn-radius);
4183
4209
  background: var(--btn-bg);
4184
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);
4185
4216
  font: inherit;
4186
4217
  font-size: var(--btn-font-size);
4187
4218
  font-weight: var(--font-weight-medium);
@@ -4266,8 +4297,10 @@ small {
4266
4297
  background: var(--ui-hover-bg, var(--btn-bg));
4267
4298
  /* Huge inset spread paints the overlay over any border-radius without a
4268
4299
  pseudo-element; a plain background would replace the fill instead of
4269
- darkening it. */
4270
- 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);
4271
4304
  }
4272
4305
 
4273
4306
  .btn:not(:where(:disabled, [aria-disabled="true"])):active {
@@ -4482,6 +4515,8 @@ a.btn[aria-disabled="true"] {
4482
4515
  * --badge-default-fg
4483
4516
  * --badge-default-border
4484
4517
  * --badge-default-hover-bg
4518
+ * --badge-slot-inset
4519
+ * --badge-slot-size
4485
4520
  * --badge-bg
4486
4521
  * --badge-fg
4487
4522
  * --badge-border
@@ -4506,9 +4541,13 @@ a.btn[aria-disabled="true"] {
4506
4541
  --badge-font-size: var(--font-size-sm);
4507
4542
  --badge-icon-size: 1em;
4508
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)));
4509
4548
  /* Declared on the badge, not the button, so an author can still set them
4510
4549
  on either. Same element as --badge-size, so .sm/.lg carry through. */
4511
- --close-size: max(var(--badge-size), 24px);
4550
+ --close-size: var(--badge-slot-size);
4512
4551
  --close-icon-size: 0.875em;
4513
4552
  --badge-default-bg: var(--surface-subtle);
4514
4553
  --badge-default-fg: var(--intent, var(--text));
@@ -4559,9 +4598,11 @@ a.btn[aria-disabled="true"] {
4559
4598
  --badge-dot-size: 0.625rem;
4560
4599
  }
4561
4600
 
4562
- /* Decorative children follow the badge's optical size. Text and the .close
4563
- button retain their own boxes. */
4564
- .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)) {
4565
4606
  flex: none;
4566
4607
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
4567
4608
  the requested optical size; reusing the em-valued hook here would apply its
@@ -4626,17 +4667,36 @@ a.btn[aria-disabled="true"] {
4626
4667
  background-color: currentColor;
4627
4668
  }
4628
4669
 
4629
- /* Removable tag — a .close (components/close.css) inside the pill. WCAG 2.5.8
4630
- floors pointer targets at 24 CSS px: density and local size may keep the
4631
- badge visually compact through the negative margins, but never shrink the
4632
- 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. */
4633
4682
  .badge > .close {
4634
- margin-block: -0.25rem;
4635
- margin-inline-start: -0.15em;
4636
- margin-inline-end: -0.45em;
4683
+ position: relative;
4684
+ margin-inline-end: calc(var(--badge-slot-inset) - var(--badge-pad-x));
4637
4685
  border-radius: var(--radius-full);
4638
4686
  }
4639
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
+
4640
4700
  /* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
4641
4701
  Declared outside :hover like button.css for the same reason. */
4642
4702
  :is(a.badge, button.badge).outline {
@@ -4877,6 +4937,9 @@ a.badge[aria-disabled="true"] {
4877
4937
  /* No grid-template-columns: the icon and close branches below always set
4878
4938
  it, since one of them is what matched. */
4879
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;
4880
4943
  column-gap: var(--space-30);
4881
4944
  }
4882
4945
 
@@ -4884,9 +4947,14 @@ a.badge[aria-disabled="true"] {
4884
4947
  grid-template-columns: auto minmax(0, 1fr);
4885
4948
  }
4886
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. */
4887
4955
  .alert:has(> .alert-icon) > .alert-icon {
4888
4956
  grid-column: 1;
4889
- grid-row: 1 / -1;
4957
+ grid-row: 1 / span 2;
4890
4958
  display: inline-grid;
4891
4959
  place-items: center;
4892
4960
  margin-block-start: 0.125em;
@@ -5054,6 +5122,7 @@ a.badge[aria-disabled="true"] {
5054
5122
  * --card-fg
5055
5123
  * --heading
5056
5124
  * --busy-overlay-bg
5125
+ * --surface-link
5057
5126
  */
5058
5127
  /* Intent boundary — inherited intents do not tint this component.
5059
5128
  Local intent classes still win because :where() has zero specificity.
@@ -5062,6 +5131,7 @@ a.badge[aria-disabled="true"] {
5062
5131
  when they paint this surface, so an ancestor intent would otherwise leak
5063
5132
  through a variant that has no local intent class. */
5064
5133
  :where(.card) {
5134
+ --surface-link: initial;
5065
5135
  --intent: initial;
5066
5136
  --intent-fg: initial;
5067
5137
  --intent-soft-fg: initial;
@@ -5097,6 +5167,13 @@ a.badge[aria-disabled="true"] {
5097
5167
  border-radius: var(--card-radius);
5098
5168
  }
5099
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
+
5100
5177
  /* The box lives at zero specificity so a layout primitive applied to the same
5101
5178
  element owns it. components/ is imported after layout/, so a plain .card
5102
5179
  declaration would beat .media (and any other non-column primitive) on source
@@ -6339,12 +6416,16 @@ picture.bleed > img {
6339
6416
  * --accordion-pad
6340
6417
  * --accordion-marker-color
6341
6418
  * --accordion-marker-size
6419
+ * --accordion-marker-icon
6420
+ * --accordion-marker-open-rotate
6342
6421
  */
6343
6422
  .accordion {
6344
6423
  --accordion-radius: var(--radius);
6345
6424
  --accordion-pad: var(--space-40);
6346
6425
  --accordion-marker-color: var(--text-muted);
6347
6426
  --accordion-marker-size: 1rem;
6427
+ --accordion-marker-icon: var(--icon-chevron);
6428
+ --accordion-marker-open-rotate: 180deg;
6348
6429
 
6349
6430
  border: var(--border-width) solid var(--border);
6350
6431
  border-radius: var(--accordion-radius);
@@ -6389,13 +6470,13 @@ picture.bleed > img {
6389
6470
  inline-size: var(--accordion-marker-size);
6390
6471
  block-size: var(--accordion-marker-size);
6391
6472
  background-color: var(--accordion-marker-color);
6392
- -webkit-mask: var(--icon-chevron) center / contain no-repeat;
6393
- 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;
6394
6475
  transition: transform var(--duration);
6395
6476
  }
6396
6477
 
6397
6478
  .accordion details[open] summary::after {
6398
- transform: rotate(180deg);
6479
+ transform: rotate(var(--accordion-marker-open-rotate));
6399
6480
  }
6400
6481
 
6401
6482
  /* The panel, not the item, owns the inset here — .separated paints a card on
@@ -6592,6 +6673,17 @@ picture.bleed > img {
6592
6673
  0 0 / 200% 100%;
6593
6674
  }
6594
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
+
6595
6687
  @media (prefers-reduced-motion: no-preference) {
6596
6688
  .skeleton {
6597
6689
  animation: actual-skeleton var(--duration-shimmer) ease-in-out infinite;
@@ -7981,6 +8073,167 @@ a.avatar[aria-disabled="true"] {
7981
8073
  no-repeat;
7982
8074
  }
7983
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
+
7984
8237
 
7985
8238
  /* Navigation */
7986
8239
  /*
@@ -8435,6 +8688,9 @@ a.avatar[aria-disabled="true"] {
8435
8688
  * Public hooks:
8436
8689
  * --tab-flex
8437
8690
  * --tab-gap
8691
+ * --tab-panel-bg
8692
+ * --tab-panel-pad
8693
+ * --tab-panel-shadow
8438
8694
  *
8439
8695
  * Internal:
8440
8696
  * --tab-edge
@@ -8665,6 +8921,80 @@ a.avatar[aria-disabled="true"] {
8665
8921
  inline-size: 100%;
8666
8922
  }
8667
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
+
8668
8998
  /*
8669
8999
  * Segmented control — one compact choice among a few, on a shared track.
8670
9000
  *
@@ -9088,6 +9418,11 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
9088
9418
  * Public hooks:
9089
9419
  * --table-min
9090
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
9091
9426
  */
9092
9427
  .table-wrap {
9093
9428
  /* Containing block for anything absolutely positioned inside the table —
@@ -9138,11 +9473,12 @@ a.list-item:is([aria-current="page"], [aria-current="true"])::before {
9138
9473
  }
9139
9474
 
9140
9475
  .table thead th {
9476
+ color: var(--table-header-fg, var(--text-muted));
9141
9477
  vertical-align: bottom;
9142
9478
  }
9143
9479
 
9144
9480
  .table thead {
9145
- background: var(--surface-subtle);
9481
+ background: var(--table-header-bg, var(--surface-subtle));
9146
9482
  }
9147
9483
 
9148
9484
  .table tbody tr:hover {