actual-css 0.4.2 → 0.6.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 (117) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/dist/actual.css +54 -14
  4. package/dist/actual.full.css +744 -185
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/dist/actual.min.css +1 -1
  8. package/docs/design-notes/browser-support.md +14 -14
  9. package/docs/design-notes/enhancement-contract.md +30 -13
  10. package/docs/design-notes/forced-colors.md +11 -7
  11. package/docs/design-notes/grid.md +4 -4
  12. package/docs/design-notes/naming.md +15 -9
  13. package/docs/design-notes/platform-alignment.md +9 -0
  14. package/docs/design-notes/references.md +4 -0
  15. package/docs/design-notes/spacing-units.md +286 -0
  16. package/docs/design-notes/steps.md +748 -0
  17. package/docs/design-notes/widget-primitives.md +32 -15
  18. package/docs/navigation.json +2 -0
  19. package/docs/pages/components/alert.md +14 -9
  20. package/docs/pages/components/app-navigation.md +2 -0
  21. package/docs/pages/components/aura.md +102 -6
  22. package/docs/pages/components/avatar.md +9 -6
  23. package/docs/pages/components/badge.md +27 -9
  24. package/docs/pages/components/button.md +25 -18
  25. package/docs/pages/components/card.md +27 -7
  26. package/docs/pages/components/chat.md +1 -1
  27. package/docs/pages/components/data-list.md +72 -0
  28. package/docs/pages/components/dialog.md +12 -7
  29. package/docs/pages/components/drawer.md +3 -1
  30. package/docs/pages/components/fab.md +14 -10
  31. package/docs/pages/components/indicator.md +6 -6
  32. package/docs/pages/components/list.md +11 -2
  33. package/docs/pages/components/meter.md +56 -1
  34. package/docs/pages/components/navbar.md +1 -1
  35. package/docs/pages/components/pagination.md +1 -1
  36. package/docs/pages/components/progress.md +10 -0
  37. package/docs/pages/components/rating.md +4 -4
  38. package/docs/pages/components/skeleton.md +3 -1
  39. package/docs/pages/components/spinner.md +1 -1
  40. package/docs/pages/components/steps.md +379 -47
  41. package/docs/pages/components/table.md +6 -5
  42. package/docs/pages/enhancements/flyout.md +14 -10
  43. package/docs/pages/enhancements/overview.md +2 -2
  44. package/docs/pages/enhancements/scrollspy.md +1 -1
  45. package/docs/pages/enhancements/status-bar.md +10 -1
  46. package/docs/pages/enhancements/tabs.md +1 -1
  47. package/docs/pages/examples/overview.md +4 -1
  48. package/docs/pages/forms/choice-cards.md +4 -4
  49. package/docs/pages/forms/combobox.md +87 -0
  50. package/docs/pages/forms/floating-field.md +5 -3
  51. package/docs/pages/forms/joined-controls.md +6 -4
  52. package/docs/pages/forms/labels.md +8 -5
  53. package/docs/pages/forms/overview.md +26 -19
  54. package/docs/pages/forms/password.md +3 -2
  55. package/docs/pages/forms/range.md +15 -2
  56. package/docs/pages/forms/states-and-sizes.md +7 -5
  57. package/docs/pages/forms/validation.md +15 -9
  58. package/docs/pages/foundations/philosophy.md +11 -1
  59. package/docs/pages/foundations/tokens.md +16 -9
  60. package/docs/pages/foundations/typography.md +11 -3
  61. package/docs/pages/guides/bootstrap.md +3 -3
  62. package/docs/pages/guides/introduction.md +2 -2
  63. package/docs/pages/guides/modular-import.md +21 -10
  64. package/docs/pages/guides/progressive-enhancement.md +75 -54
  65. package/docs/pages/guides/tailwind.md +2 -2
  66. package/docs/pages/layout/app-shell.md +4 -4
  67. package/docs/pages/layout/choosing.md +10 -6
  68. package/docs/pages/layout/grid.md +11 -10
  69. package/docs/pages/layout/scroll-snap.md +2 -0
  70. package/docs/pages/layout/sidebar-layout.md +2 -0
  71. package/docs/pages/layout/stack.md +10 -6
  72. package/docs/pages/layout/topbar.md +2 -0
  73. package/docs/pages/patterns/structured-lists.md +7 -7
  74. package/docs/pages/utilities/accessibility.md +19 -1
  75. package/docs/pages/utilities/flex.md +20 -13
  76. package/docs/pages/utilities/links.md +1 -1
  77. package/docs/pages/utilities/list.md +1 -1
  78. package/docs/pages/utilities/overflow.md +14 -7
  79. package/docs/pages/utilities/overview.md +22 -0
  80. package/docs/pages/utilities/shape.md +1 -1
  81. package/docs/pages/utilities/sizing-wrapping.md +10 -8
  82. package/docs/pages/utilities/spacing.md +26 -19
  83. package/docs/pages/utilities/text.md +1 -1
  84. package/llms.txt +27 -4
  85. package/package.json +3 -2
  86. package/scripts/reserved-classes.json +8 -1
  87. package/src/css/components/data-list.css +27 -0
  88. package/src/css/components/drawer.css +30 -11
  89. package/src/css/components/fab.css +44 -8
  90. package/src/css/components/flyout.css +17 -12
  91. package/src/css/components/index.css +1 -0
  92. package/src/css/components/list.css +3 -1
  93. package/src/css/components/meter.css +28 -1
  94. package/src/css/components/modal.css +28 -11
  95. package/src/css/components/progress.css +5 -0
  96. package/src/css/components/status-bar.css +31 -15
  97. package/src/css/components/steps.css +289 -55
  98. package/src/css/components/surface.css +5 -3
  99. package/src/css/components/tooltip.css +5 -8
  100. package/src/css/core/base.css +7 -1
  101. package/src/css/core/intents.css +6 -0
  102. package/src/css/core/tokens.css +24 -9
  103. package/src/css/core/variants.css +17 -4
  104. package/src/css/effects/aura.css +101 -11
  105. package/src/css/forms/custom-select.css +1 -1
  106. package/src/css/forms/floating-field.css +8 -6
  107. package/src/css/forms/form.css +4 -4
  108. package/src/css/layout/container-query.css +37 -13
  109. package/src/css/layout/grid.css +7 -7
  110. package/src/css/layout/index.css +1 -1
  111. package/src/css/typography/prose.css +2 -3
  112. package/src/css/utilities/base.css +16 -0
  113. package/src/js/password.js +1 -1
  114. package/src/js/selectors.js +1 -0
  115. package/src/js/status.js +88 -7
  116. package/src/js/surface.js +2 -11
  117. package/src/js/transition.js +20 -0
@@ -60,8 +60,8 @@ select {
60
60
  *
61
61
  * Derived: "Theme-derived aliases" and "State tokens" reference intent and
62
62
  * surface tokens and follow the theme; do not hard-code values into them.
63
- * Internal: indicator offsets, --bar-height, --disabled-opacity and the
64
- * like are consumed by components, not authored.
63
+ * Internal: indicator offsets, --disabled-opacity and the like are
64
+ * consumed by components, not authored.
65
65
  *
66
66
  * Component-local hooks and internal state relays live with their
67
67
  * components. See docs/pages/foundations/tokens.md for the public/internal
@@ -85,9 +85,10 @@ select {
85
85
  --font-size-md: 1rem;
86
86
  --font-size-lg: 1.125rem;
87
87
 
88
- /* Spacing — official steps are named in tens so the scale stays open for
89
- application themes to intercalate intermediate values (--space-15,
90
- --space-45…) without colliding with future framework steps. */
88
+ /* Spacing — official steps are named in tens so the scale stays open.
89
+ Actual CSS will never define a --space-* that is not a multiple of 10:
90
+ the intermediate names (--space-15, --space-45…) are reserved for
91
+ applications, and no future framework step can collide with them. */
91
92
  --gap: 0.75rem;
92
93
  --density-space: var(--space-40);
93
94
  --density-compact-size: 1.5rem;
@@ -105,14 +106,25 @@ select {
105
106
  --shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);
106
107
 
107
108
  /* Motion — see reset.css for the prefers-reduced-motion hard reset.
108
- --duration drives component transitions (hover/focus); the dedicated
109
- animation rates below tune one-shot open/close and decorative loops. */
109
+ --duration scales component transitions (hover/focus); the dedicated
110
+ animation rates below tune one-shot open/close and decorative loops.
111
+ Generic state/interaction motion uses the CSS default `ease` directly
112
+ (registered nowhere). --ease-enter/--ease-exit express the character of
113
+ one-shot presence changes, owned by the open/closed state rules that
114
+ separate them (status-bar, drawer, modal). The pair follows the standard
115
+ M3 rhythm; the more aggressive emphasized-decelerate entrance is
116
+ deliberately not offered because it only pays off on 250–400ms motions,
117
+ beyond this vocabulary. Toggles that share one transition list at
118
+ --duration-fast (flyout, tooltip) and pure-opacity backdrops stay on
119
+ `ease`: at 100ms the curve has almost no resolving time, and a scrim is
120
+ not traveling anywhere. */
110
121
  --duration: 150ms;
111
122
  --duration-fast: 100ms;
112
123
  --duration-slow: 200ms;
113
124
  --duration-spin: 0.75s;
114
125
  --duration-shimmer: 1.2s;
115
- --ease: ease;
126
+ --ease-enter: cubic-bezier(0.2, 0, 0, 1);
127
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
116
128
 
117
129
  /* Focus ring — shared by every interactive component's :focus-visible */
118
130
  --focus-outline-color: currentColor;
@@ -130,7 +142,9 @@ select {
130
142
  --backdrop-opacity: 0.45;
131
143
  --backdrop-fill: rgb(0 0 0 / var(--backdrop-opacity));
132
144
 
133
- /* Progress / meter track height */
145
+ /* Bar thickness shared by .progress and .meter — two conceptual variants of
146
+ the same geometry. Public: set it on the element or on an application
147
+ class rather than reaching for a size variant. */
134
148
  --bar-height: 0.65rem;
135
149
 
136
150
  /* Controls — inputs and buttons share --control-size so heights always match.
@@ -187,6 +201,7 @@ select {
187
201
  --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238a8594' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
188
202
  --icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
189
203
  --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3C/svg%3E");
204
+ --icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='12' y1='5' x2='12' y2='19'/%3E%3Cline x1='5' y1='12' x2='19' y2='12'/%3E%3C/svg%3E");
190
205
 
191
206
  /* Viewport size tokens — fallback vh/vw with dvh/vi enhancement.
192
207
  Consumed by full-viewport components (app-shell, drawer, modal, flyout).
@@ -453,6 +468,9 @@ select {
453
468
  *
454
469
  * Sits between reset (browser defaults) and prose (rich content scope).
455
470
  * These styles apply everywhere — cards, dialogs, heroes — not only in prose.
471
+ *
472
+ * Public hooks:
473
+ * --link
456
474
  */
457
475
 
458
476
  body {
@@ -462,8 +480,11 @@ body {
462
480
  line-height: var(--line-height);
463
481
  }
464
482
 
483
+ /* Bare links inherit their ink by default, so nothing changes unless a theme
484
+ opts in by setting --link. Because this is an element selector, any component
485
+ rule that colors its own anchors still wins. */
465
486
  a {
466
- color: inherit;
487
+ color: var(--link, inherit);
467
488
  text-underline-offset: 0.18em;
468
489
  }
469
490
 
@@ -547,11 +568,17 @@ small {
547
568
  --intent-fg: var(--neutral-fg);
548
569
  }
549
570
 
571
+ /* Paint an inherited or local intent on a simple foreground element without
572
+ turning the intent classes into general-purpose color utilities. */
573
+ .intent-color {
574
+ color: var(--intent);
575
+ }
576
+
550
577
  /*
551
578
  * Cross-cutting variant token mapping.
552
579
  *
553
- * Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
554
- * shared variants. .inverted is a surface modifier, not a variant: it paints
580
+ * Variant scope: .solid/.soft/.outline/.surface set --ui-* for components that
581
+ * opt into shared variants. .inverted is a surface modifier, not a variant: it paints
555
582
  * the element directly (background/color/border-color) so it works on any bare
556
583
  * block, and remaps the shared --ui-* tokens so participating components
557
584
  * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
@@ -570,8 +597,10 @@ small {
570
597
  * components may consume --density-* when the density context should affect
571
598
  * their local rhythm. Compact components may consume --density-compact-size.
572
599
  * Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
573
- * applying .sm/.lg to card, dialog, accordion, navbar, table is
574
- * a no-op.
600
+ * applying .sm/.lg to card, dialog, accordion, navbar, table, progress, meter
601
+ * is a no-op. Progress and meter are deliberate: a bar keeps its inline size
602
+ * and only changes thickness, which --bar-height expresses better than a
603
+ * three-step scale.
575
604
  *
576
605
  * Intent list source of truth: intents.css. Components consume --intent* tokens
577
606
  * instead of re-listing intent classes.
@@ -594,6 +623,17 @@ small {
594
623
  --ui-border: var(--intent, currentColor);
595
624
  }
596
625
 
626
+ /* Neutral chrome, intent only in the ink: the page surface with the theme
627
+ border. .soft is the intent-tinted treatment (its baseline fallback uses
628
+ --surface-subtle) and .outline has no fill at all, so neither can express
629
+ it. Deliberately literal — no color-mix enhancement — so this stays the one
630
+ variant that reads exactly as the theme's own surface. */
631
+ .surface {
632
+ --ui-bg: var(--surface);
633
+ --ui-fg: var(--intent, var(--text));
634
+ --ui-border: var(--border);
635
+ }
636
+
597
637
  /* Inverse surface treatment. Paints the element directly (so it works on any
598
638
  bare block) and remaps the shared --ui-* tokens so opt-in components
599
639
  (.card, .navbar) invert through their own rules. Because this loads before
@@ -776,9 +816,8 @@ small {
776
816
  font-size: var(--prose-h4);
777
817
  }
778
818
 
779
- /* Link ink defaults to --primary. --link is an optional escape hatch: a
780
- theme whose primary is too light or too vivid to read as inline text sets
781
- it without having to restate the rule. */
819
+ /* --link is a theme-level token (core/base.css also reads it for bare links);
820
+ prose falls back to --primary so a theme that never sets it is unaffected. */
782
821
  .prose :where(a) {
783
822
  color: var(--link, var(--primary));
784
823
  text-decoration-line: underline;
@@ -1035,7 +1074,7 @@ small {
1035
1074
  * .sidebar-layout main content + secondary aside
1036
1075
  * .grid responsive collection; item width is the contract
1037
1076
  * .grid-N structural density of N; balanced inside a container named
1038
- * actual-grid (in grid.css)
1077
+ * actual-container (in grid.css)
1039
1078
  * .column-layout explicit 12-column region composition; the author owns
1040
1079
  * narrow-container recomposition
1041
1080
  * .scroller theme-aware scrollbar density and colour
@@ -1046,20 +1085,44 @@ small {
1046
1085
  *
1047
1086
  * Prefer these compositions over viewport breakpoint utilities.
1048
1087
  */
1049
- /* Convenience helper: establishes a query container named actual-grid for
1050
- descendants. Use it for a component that changes representation according
1051
- to its allocated inline size, and to give .grid-N the named context it needs
1052
- for balanced subdivision (see grid.css). The name matters: grids only respond
1053
- to an explicitly actual-grid-named container, never to an incidental
1054
- query container. Layout primitives are responsive without it; this adds
1055
- precision, it is never a prerequisite.
1056
-
1057
- The element takes inline-size containment, so put it on a normal
1058
- block-level wrapper, never on a shrink-to-fit item (a .cluster child, a
1059
- float, an inline-block): its contents would stop contributing to its own
1060
- inline size and it would collapse. */
1088
+ /* Convenience helper for size-aware components: establishes an inline-size
1089
+ query container named actual-container. The name is the contract, not this
1090
+ class — an author can declare `container: actual-container / inline-size` on
1091
+ their own region and get the same result.
1092
+
1093
+ actual-container is shared. Granting it to a region may enable size-aware
1094
+ enhancements for any Actual component inside that region, not only the one
1095
+ that motivated the container: .grid-N reads it for balanced subdivision,
1096
+ .steps for its label collapse, and future components will read the same
1097
+ context. That is the scope of the API, not a caveat.
1098
+
1099
+ An anonymous query container is not part of it. A region that declares
1100
+ `container-type: inline-size` for the application's own purposes carries no
1101
+ actual-container name, so no framework component changes behaviour there.
1102
+ Opting into the protocol is always explicit. Nothing here is a prerequisite
1103
+ either — every primitive is responsive without it; this adds precision.
1104
+
1105
+ Place it where the width you want measured lives: usually a wrapper for a
1106
+ grid, whose queried children sit inside it, and the component itself for
1107
+ .steps, whose own row is the width in question.
1108
+
1109
+ The rule for where it is safe is the containment rule, not a list of
1110
+ primitives. Query containment removes the element's intrinsic inline-size
1111
+ contribution, so:
1112
+
1113
+ safe the element's width is imposed by its context
1114
+ (.steps sets inline-size: 100%, so it survives even as a
1115
+ .cluster child — it wraps and fills)
1116
+
1117
+ unsafe the element's width depends on its contents' intrinsic
1118
+ contribution (a bare .grid child of .cluster measures 0 once
1119
+ contained, as does a float, inline-block, table, grid auto track,
1120
+ max-content parent, or an abspos box sized by its contents)
1121
+
1122
+ So .cluster is neither safe nor unsafe; what matters is where the child's
1123
+ width comes from. */
1061
1124
  .container-query {
1062
- container: actual-grid / inline-size;
1125
+ container: actual-container / inline-size;
1063
1126
  }
1064
1127
 
1065
1128
  /* Vertical flow. .stack owns the block axis only: block margins reset, inline
@@ -1430,13 +1493,13 @@ small {
1430
1493
  min-inline-size: 0;
1431
1494
  }
1432
1495
 
1433
- /* Balanced subdivision, activated by an ancestor container named actual-grid.
1496
+ /* Balanced subdivision, activated by an ancestor container named actual-container.
1434
1497
  * A .grid-N inside such a container enters only divisor states of N. The name
1435
1498
  * is deliberate: a grid must not key off the nearest anonymous query container,
1436
1499
  * or a region that sets container-type: inline-size for an unrelated reason
1437
1500
  * would silently change the grid. The author opts the grid in by naming the
1438
1501
  * context — .container-query is the official helper, or declare
1439
- * container: actual-grid / inline-size on your own region.
1502
+ * container: actual-container / inline-size on your own region.
1440
1503
  *
1441
1504
  * .grid-2 2 -> 1
1442
1505
  * .grid-3 3 -> 1
@@ -1453,7 +1516,7 @@ small {
1453
1516
  * inline contribution and collapses it inside .cluster, .topbar, floats, and
1454
1517
  * any shrink-to-fit box. See docs/design-notes/grid.md.
1455
1518
  *
1456
- * Without an actual-grid ancestor none of these @container rules resolve, so
1519
+ * Without an actual-container ancestor none of these @container rules resolve, so
1457
1520
  * the auto-fill baseline above stays. The min-width: 0 rule is the lowest
1458
1521
  * step: it pins the grid to one column inside a container before any larger
1459
1522
  * threshold fires (a bare grid would otherwise show a 2 + 1 orphan at 28-48rem).
@@ -1469,25 +1532,25 @@ small {
1469
1532
  * Ascending min-width only: the last matching step wins by cascade order.
1470
1533
  */
1471
1534
  @supports (container-type: inline-size) {
1472
- @container actual-grid (min-width: 0px) {
1535
+ @container actual-container (min-width: 0px) {
1473
1536
  :where(.grid-2, .grid-3, .grid-4, .grid-6) {
1474
1537
  grid-template-columns: 1fr;
1475
1538
  }
1476
1539
  }
1477
1540
 
1478
- @container actual-grid (min-width: 28rem) {
1541
+ @container actual-container (min-width: 28rem) {
1479
1542
  :where(.grid-2, .grid-4, .grid-6) {
1480
1543
  grid-template-columns: repeat(2, minmax(0, 1fr));
1481
1544
  }
1482
1545
  }
1483
1546
 
1484
- @container actual-grid (min-width: 48rem) {
1547
+ @container actual-container (min-width: 48rem) {
1485
1548
  :where(.grid-3, .grid-6) {
1486
1549
  grid-template-columns: repeat(3, minmax(0, 1fr));
1487
1550
  }
1488
1551
  }
1489
1552
 
1490
- @container actual-grid (min-width: 64rem) {
1553
+ @container actual-container (min-width: 64rem) {
1491
1554
  :where(.grid-4) {
1492
1555
  grid-template-columns: repeat(4, minmax(0, 1fr));
1493
1556
  }
@@ -1822,6 +1885,10 @@ small {
1822
1885
  * Individual controls keep their own files; .choice label layout lives in
1823
1886
  * choice.css; .form-actions lives in form-actions.css.
1824
1887
  *
1888
+ * .field-group owns its own box only. Distance between sibling groups is a
1889
+ * composition concern (.stack on the form), never a sibling margin here: the
1890
+ * component cannot know whether its siblings are stacked or gridded.
1891
+ *
1825
1892
  * Public API: .field, .field-label, .field-help, .field-error, .field-group,
1826
1893
  * .required-mark.
1827
1894
  *
@@ -1842,10 +1909,6 @@ small {
1842
1909
  border-radius: var(--radius);
1843
1910
  }
1844
1911
 
1845
- .field-group + .field-group {
1846
- margin-block-start: var(--space-40);
1847
- }
1848
-
1849
1912
  .field-label,
1850
1913
  .field-group > legend {
1851
1914
  color: var(--text);
@@ -2937,7 +3000,7 @@ small {
2937
3000
 
2938
3001
  &::picker(select) {
2939
3002
  appearance: base-select;
2940
- max-height: min(24rem, calc(var(--viewport-block) * 0.7));
3003
+ max-block-size: min(24rem, calc(var(--viewport-block) * 0.7));
2941
3004
  margin-block: var(--space-20);
2942
3005
  padding: 0;
2943
3006
  border: var(--border-width) solid var(--border);
@@ -3014,8 +3077,10 @@ small {
3014
3077
  * messages stay in the surrounding .field, not inside the cell, so the
3015
3078
  * resting label can center on the control alone.
3016
3079
  *
3017
- * The control must precede the label: the float is driven by the sibling
3018
- * combinator and :placeholder-shown. Every .input/.textarea needs
3080
+ * The control must precede the label: the float is driven by the general
3081
+ * sibling combinator and :placeholder-shown. Injected nodes may appear between
3082
+ * the control and the label (password managers, extension wrappers); the cell
3083
+ * contract stays a single control plus one label. Every .input/.textarea needs
3019
3084
  * placeholder=" " to expose a reliable empty state — without it the label
3020
3085
  * only reacts to focus.
3021
3086
  *
@@ -3063,14 +3128,14 @@ small {
3063
3128
  }
3064
3129
 
3065
3130
  /* Single-line controls rest in the middle until they receive a value. */
3066
- .floating-field > :is(.input, .select) + .field-label {
3131
+ .floating-field > :is(.input, .select) ~ .field-label {
3067
3132
  inset-block-start: 50%;
3068
3133
  transform: translateY(-50%);
3069
3134
  }
3070
3135
 
3071
3136
  /* An empty textarea label follows the control's normal first-line position;
3072
3137
  the control itself keeps the extra headroom reserved for its floated state. */
3073
- .floating-field > .textarea + .field-label {
3138
+ .floating-field > .textarea ~ .field-label {
3074
3139
  inset-block-start: calc(1em + var(--border-width));
3075
3140
  transform: none;
3076
3141
  }
@@ -3079,7 +3144,7 @@ small {
3079
3144
  not have propagated the value to the placeholder state yet. */
3080
3145
  .floating-field
3081
3146
  > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
3082
- + .field-label {
3147
+ ~ .field-label {
3083
3148
  inset-block-start: var(--floating-label-top);
3084
3149
  transform: none;
3085
3150
  font-size: var(--font-size-xs);
@@ -3098,7 +3163,7 @@ small {
3098
3163
  .input[type="month"],
3099
3164
  .input[type="week"]
3100
3165
  )
3101
- + .field-label {
3166
+ ~ .field-label {
3102
3167
  inset-block-start: var(--floating-label-top);
3103
3168
  transform: none;
3104
3169
  font-size: var(--font-size-xs);
@@ -4161,7 +4226,9 @@ a.badge[aria-disabled="true"] {
4161
4226
  z-index: calc(var(--z-menu) - 1);
4162
4227
  background: var(--backdrop-fill);
4163
4228
  opacity: 1;
4164
- transition: opacity var(--duration) var(--ease);
4229
+ /* A scrim never travels; it fades on the neutral `ease`. Duration matches
4230
+ the flyout sheet it accompanies so it never lags the panel. */
4231
+ transition: opacity var(--duration-fast) ease;
4165
4232
  }
4166
4233
 
4167
4234
  .surface-backdrop[hidden] {
@@ -4178,8 +4245,8 @@ a.badge[aria-disabled="true"] {
4178
4245
  @supports (transition-behavior: allow-discrete) {
4179
4246
  .surface-backdrop {
4180
4247
  transition:
4181
- opacity var(--duration) var(--ease),
4182
- display var(--duration) allow-discrete;
4248
+ opacity var(--duration-fast) ease,
4249
+ display var(--duration-fast) allow-discrete;
4183
4250
  }
4184
4251
  }
4185
4252
  @media print {
@@ -4229,12 +4296,14 @@ dialog.modal {
4229
4296
  * Keep the open dialog root untransformed. Fixed overlays mounted inside a
4230
4297
  * modal dialog use viewport coordinates; transform/translate/scale on this
4231
4298
  * element would make them position against the dialog instead. The closed
4232
- * state can still carry a small transform for enter/exit motion.
4299
+ * state can still carry a small transform for enter/exit motion. The lift
4300
+ * is a pure vertical offset: scaling a wide panel around its center drags
4301
+ * both edges, which reads as horizontal movement.
4233
4302
  */
4234
- transform: translateY(0.35rem) scale(0.98);
4303
+ transform: translateY(0.35rem);
4235
4304
  transition:
4236
- opacity var(--duration),
4237
- transform var(--duration);
4305
+ opacity var(--duration) var(--ease-exit),
4306
+ transform var(--duration) var(--ease-exit);
4238
4307
  }
4239
4308
 
4240
4309
  dialog.modal > form,
@@ -4353,33 +4422,46 @@ dialog.modal.dialog-confirmation > form > footer {
4353
4422
  dialog.modal[open] {
4354
4423
  opacity: 1;
4355
4424
  transform: none;
4425
+ transition:
4426
+ opacity var(--duration) var(--ease-enter),
4427
+ transform var(--duration) var(--ease-enter);
4356
4428
  }
4357
4429
 
4358
4430
  @starting-style {
4359
4431
  dialog.modal[open] {
4360
4432
  opacity: 0;
4361
- transform: translateY(0.35rem) scale(0.98);
4433
+ transform: translateY(0.35rem);
4362
4434
  }
4363
4435
  }
4364
4436
 
4365
4437
  dialog.modal::backdrop {
4366
4438
  background: var(--backdrop-fill);
4367
4439
  opacity: 0;
4368
- transition: opacity var(--duration);
4440
+ /* Scrim curve stays neutral `ease`; only the panel carries the character
4441
+ curves. */
4442
+ transition: opacity var(--duration) ease;
4369
4443
  }
4370
4444
 
4371
4445
  @supports (transition-behavior: allow-discrete) {
4372
4446
  dialog.modal {
4373
4447
  transition:
4374
- opacity var(--duration),
4375
- transform var(--duration),
4448
+ opacity var(--duration) var(--ease-exit),
4449
+ transform var(--duration) var(--ease-exit),
4450
+ overlay var(--duration) allow-discrete,
4451
+ display var(--duration) allow-discrete;
4452
+ }
4453
+
4454
+ dialog.modal[open] {
4455
+ transition:
4456
+ opacity var(--duration) var(--ease-enter),
4457
+ transform var(--duration) var(--ease-enter),
4376
4458
  overlay var(--duration) allow-discrete,
4377
4459
  display var(--duration) allow-discrete;
4378
4460
  }
4379
4461
 
4380
4462
  dialog.modal::backdrop {
4381
4463
  transition:
4382
- opacity var(--duration),
4464
+ opacity var(--duration) ease,
4383
4465
  overlay var(--duration) allow-discrete,
4384
4466
  display var(--duration) allow-discrete;
4385
4467
  }
@@ -4404,13 +4486,15 @@ html.has-modal-open.had-scrollbar {
4404
4486
  }
4405
4487
 
4406
4488
  @media (prefers-reduced-motion: no-preference) {
4489
+ /* Wrong-close feedback and view-transition transport are motion quirks, not
4490
+ presence enter/exit, so they stay on the plain CSS default easing. */
4407
4491
  dialog.modal.is-static {
4408
- animation: actual-dialog-static var(--duration-slow) var(--ease);
4492
+ animation: actual-dialog-static var(--duration-slow) ease;
4409
4493
  }
4410
4494
 
4411
4495
  ::view-transition-group(actual-dialog) {
4412
4496
  animation-duration: var(--duration);
4413
- animation-timing-function: var(--ease);
4497
+ animation-timing-function: ease;
4414
4498
  }
4415
4499
  }
4416
4500
 
@@ -4509,11 +4593,16 @@ html.has-modal-open.had-scrollbar {
4509
4593
  *
4510
4594
  * Internal:
4511
4595
  * --drawer-closed-translate (side/RTL positioning state)
4596
+ * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
4597
+ * changes in one place; entry state redefines it longer)
4512
4598
  */
4513
4599
  dialog.drawer {
4514
4600
  --drawer-size: 20rem;
4515
4601
  --drawer-pad: var(--space-40);
4516
4602
  --drawer-closed-translate: -100%;
4603
+ /* Exit is shorter than entry so the panel clears quickly; the scrim reads
4604
+ the same variable through inheritance and never lags the panel. */
4605
+ --drawer-motion-duration: var(--duration);
4517
4606
 
4518
4607
  /* Keep the surface anchored to the viewport in the top layer. This also
4519
4608
  provides the containing block for the absolutely positioned close button.
@@ -4537,16 +4626,20 @@ dialog.drawer {
4537
4626
  opacity: 0;
4538
4627
  transform: translateX(var(--drawer-closed-translate));
4539
4628
  transition:
4540
- opacity var(--duration-slow),
4541
- transform var(--duration-slow);
4629
+ opacity var(--drawer-motion-duration) var(--ease-exit),
4630
+ transform var(--drawer-motion-duration) var(--ease-exit);
4542
4631
  }
4543
4632
 
4544
4633
  dialog.drawer[open] {
4634
+ --drawer-motion-duration: var(--duration-slow);
4545
4635
  display: grid;
4546
4636
  grid-template-rows: auto minmax(0, 1fr) auto;
4547
4637
  gap: var(--space-40);
4548
4638
  opacity: 1;
4549
4639
  transform: translateX(0);
4640
+ transition:
4641
+ opacity var(--drawer-motion-duration) var(--ease-enter),
4642
+ transform var(--drawer-motion-duration) var(--ease-enter);
4550
4643
  }
4551
4644
 
4552
4645
  /* Backdrop color sourced from components/surface.css so it matches .modal
@@ -4554,7 +4647,9 @@ dialog.drawer[open] {
4554
4647
  dialog.drawer::backdrop {
4555
4648
  background: var(--backdrop-fill);
4556
4649
  opacity: 0;
4557
- transition: opacity var(--duration-slow);
4650
+ /* Scrim curve stays neutral `ease`; only the panel's character curves
4651
+ apply. Duration follows --drawer-motion-duration through inheritance. */
4652
+ transition: opacity var(--drawer-motion-duration) ease;
4558
4653
  }
4559
4654
 
4560
4655
  dialog.drawer[data-side="end"] {
@@ -4590,17 +4685,25 @@ dialog.drawer nav {
4590
4685
  @supports (transition-behavior: allow-discrete) {
4591
4686
  dialog.drawer {
4592
4687
  transition:
4593
- opacity var(--duration-slow),
4594
- transform var(--duration-slow),
4595
- overlay var(--duration-slow) allow-discrete,
4596
- display var(--duration-slow) allow-discrete;
4688
+ opacity var(--drawer-motion-duration) var(--ease-exit),
4689
+ transform var(--drawer-motion-duration) var(--ease-exit),
4690
+ overlay var(--drawer-motion-duration) allow-discrete,
4691
+ display var(--drawer-motion-duration) allow-discrete;
4692
+ }
4693
+
4694
+ dialog.drawer[open] {
4695
+ transition:
4696
+ opacity var(--drawer-motion-duration) var(--ease-enter),
4697
+ transform var(--drawer-motion-duration) var(--ease-enter),
4698
+ overlay var(--drawer-motion-duration) allow-discrete,
4699
+ display var(--drawer-motion-duration) allow-discrete;
4597
4700
  }
4598
4701
 
4599
4702
  dialog.drawer::backdrop {
4600
4703
  transition:
4601
- opacity var(--duration-slow),
4602
- overlay var(--duration-slow) allow-discrete,
4603
- display var(--duration-slow) allow-discrete;
4704
+ opacity var(--drawer-motion-duration) ease,
4705
+ overlay var(--drawer-motion-duration) allow-discrete,
4706
+ display var(--drawer-motion-duration) allow-discrete;
4604
4707
  }
4605
4708
  }
4606
4709
 
@@ -4636,7 +4739,7 @@ dialog.drawer nav {
4636
4739
 
4637
4740
  @media (prefers-reduced-motion: no-preference) {
4638
4741
  dialog.drawer.is-static {
4639
- animation: actual-dialog-static var(--duration-slow) var(--ease);
4742
+ animation: actual-dialog-static var(--duration-slow) ease;
4640
4743
  }
4641
4744
  }
4642
4745
 
@@ -4730,11 +4833,10 @@ dialog.drawer > form > header {
4730
4833
  box-shadow: var(--shadow-popout);
4731
4834
  list-style: none;
4732
4835
  opacity: 1;
4733
- scale: 1;
4734
- transition:
4735
- opacity var(--duration-fast) var(--ease),
4736
- scale var(--duration-fast) var(--ease),
4737
- translate var(--duration-fast) var(--ease);
4836
+ /* Anchored flyouts fade in and out; scaling around the center would detach
4837
+ the anchored edge from its trigger on wide panels. The sheet presentation
4838
+ below adds its own translate motion. */
4839
+ transition: opacity var(--duration-fast) ease;
4738
4840
  }
4739
4841
 
4740
4842
  .flyout.is-open:not(.is-sheet) {
@@ -4744,7 +4846,6 @@ dialog.drawer > form > header {
4744
4846
  @starting-style {
4745
4847
  .flyout.is-open {
4746
4848
  opacity: 0;
4747
- scale: 0.98;
4748
4849
  }
4749
4850
  }
4750
4851
 
@@ -4766,22 +4867,24 @@ dialog.drawer > form > header {
4766
4867
  max(var(--space-30), env(safe-area-inset-bottom));
4767
4868
  border-end-start-radius: 0;
4768
4869
  border-end-end-radius: 0;
4870
+ /* Sheet motion is a directional slide from the viewport edge; it replaces
4871
+ the base fade-only recipe. */
4872
+ transition:
4873
+ opacity var(--duration-fast) ease,
4874
+ translate var(--duration-fast) ease;
4769
4875
  }
4770
4876
 
4771
4877
  .flyout[hidden] {
4772
4878
  display: none;
4773
4879
  opacity: 0;
4774
- scale: 0.98;
4775
4880
  }
4776
4881
 
4777
4882
  .flyout.is-sheet[hidden] {
4778
- scale: 1;
4779
4883
  translate: 0 var(--space-30);
4780
4884
  }
4781
4885
 
4782
4886
  @starting-style {
4783
4887
  .flyout.is-sheet.is-open {
4784
- scale: 1;
4785
4888
  translate: 0 var(--space-30);
4786
4889
  }
4787
4890
  }
@@ -4789,9 +4892,14 @@ dialog.drawer > form > header {
4789
4892
  @supports (transition-behavior: allow-discrete) {
4790
4893
  .flyout {
4791
4894
  transition:
4792
- opacity var(--duration-fast) var(--ease),
4793
- scale var(--duration-fast) var(--ease),
4794
- translate var(--duration-fast) var(--ease),
4895
+ opacity var(--duration-fast) ease,
4896
+ display var(--duration-fast) allow-discrete;
4897
+ }
4898
+
4899
+ .flyout.is-sheet {
4900
+ transition:
4901
+ opacity var(--duration-fast) ease,
4902
+ translate var(--duration-fast) ease,
4795
4903
  display var(--duration-fast) allow-discrete;
4796
4904
  }
4797
4905
  }
@@ -4996,31 +5104,28 @@ dialog.drawer > form > header {
4996
5104
  overflow-wrap: anywhere;
4997
5105
  pointer-events: auto;
4998
5106
  user-select: none;
5107
+ /* Tooltips fade only: the anchor edge, marked by the arrow, must stay put
5108
+ during motion, and a 4% scale was perceptible on small text. Scaling moves
5109
+ the arrow tip away from its trigger on every placement. */
4999
5110
  opacity: 1;
5000
- scale: 1;
5001
- transition:
5002
- opacity var(--duration-fast) var(--ease),
5003
- scale var(--duration-fast) var(--ease);
5111
+ transition: opacity var(--duration-fast) ease;
5004
5112
  }
5005
5113
 
5006
5114
  .tooltip[hidden] {
5007
5115
  display: none;
5008
5116
  opacity: 0;
5009
- scale: 0.96;
5010
5117
  }
5011
5118
 
5012
5119
  @starting-style {
5013
5120
  .tooltip:not([hidden]) {
5014
5121
  opacity: 0;
5015
- scale: 0.96;
5016
5122
  }
5017
5123
  }
5018
5124
 
5019
5125
  @supports (transition-behavior: allow-discrete) {
5020
5126
  .tooltip {
5021
5127
  transition:
5022
- opacity var(--duration-fast) var(--ease),
5023
- scale var(--duration-fast) var(--ease),
5128
+ opacity var(--duration-fast) ease,
5024
5129
  display var(--duration-fast) allow-discrete;
5025
5130
  }
5026
5131
  }
@@ -5361,9 +5466,14 @@ dialog.drawer > form > header {
5361
5466
  * Consumes the shared --intent/--primary for the value bar and --bar-height
5362
5467
  * for thickness. Indeterminate state animates a shimmer using --duration-shimmer.
5363
5468
  *
5469
+ * Thickness is a dimension, not a density step: the bar always spans its
5470
+ * container and only gets thinner or thicker, so --bar-height is the API and
5471
+ * .sm/.lg are deliberately not supported.
5472
+ *
5364
5473
  * Public hooks:
5365
5474
  * --progress-track
5366
5475
  * --progress-value
5476
+ * --bar-height
5367
5477
  */
5368
5478
  /* Intent boundary — inherited intents do not tint this component.
5369
5479
  Local intent classes still win because :where() has zero specificity. */
@@ -5467,9 +5577,12 @@ dialog.drawer > form > header {
5467
5577
  /*
5468
5578
  * Status bar — singleton floating status area.
5469
5579
  *
5470
- * One element in the HTML, empty by default, hidden when :empty but kept in the
5471
- * accessibility tree so it stays a reliable live region. JavaScript only sets
5472
- * text content; the visual shows and hides with the content.
5580
+ * One element in the HTML, empty by default. .is-open is the visual state, set
5581
+ * by status.js like any other surface: it lands after the message is written
5582
+ * and lifts before the bar is cleared, so the bar leaves in the exact state it
5583
+ * was shown in — same text, same width, same intent color — instead of snapping
5584
+ * to a neutral empty pill mid-exit. Emptying the text is the *end* of the exit,
5585
+ * not its trigger.
5473
5586
  *
5474
5587
  * Consumes intent like badge or alert. No need for status-bar-specific modifier
5475
5588
  * classes — .danger / .success / .warning / .neutral from intents.css drive
@@ -5483,6 +5596,7 @@ dialog.drawer > form > header {
5483
5596
  * Internal:
5484
5597
  * --status-bg
5485
5598
  * --status-fg
5599
+ * --status-viewport-offset (written by status.js while the bar is open)
5486
5600
  */
5487
5601
 
5488
5602
  /* Intent boundary — inherited intents do not tint this component.
@@ -5497,7 +5611,9 @@ dialog.drawer > form > header {
5497
5611
  --status-fg: var(--intent-fg, var(--surface));
5498
5612
 
5499
5613
  position: fixed;
5500
- inset-block-end: var(--space-40);
5614
+ /* Fixed positioning tracks the layout viewport, which the mobile software
5615
+ keyboard covers. status.js writes the occluded band into the offset. */
5616
+ inset-block-end: calc(var(--space-40) + var(--status-viewport-offset, 0px));
5501
5617
  inset-inline: var(--space-40);
5502
5618
  z-index: var(--z-status);
5503
5619
 
@@ -5519,22 +5635,32 @@ dialog.drawer > form > header {
5519
5635
  color: var(--status-fg);
5520
5636
  box-shadow: var(--shadow-popout);
5521
5637
 
5522
- opacity: 1;
5523
- scale: 1;
5524
- translate: 0 0;
5638
+ /* Closed by default. Opacity, not visibility or display: the bar has to stay
5639
+ in the accessibility tree to work as a live region. */
5640
+ opacity: 0;
5641
+ translate: 0 var(--space-30);
5525
5642
  pointer-events: none;
5526
5643
 
5644
+ /* Exit is shorter than entry so dismissed feedback gets out of the way.
5645
+ background stays a shorthand so --status-bg still accepts gradients;
5646
+ solid colors expose an interpolable computed background-color. The
5647
+ intent color follows the state/interaction `ease`, not the presence
5648
+ curves. */
5527
5649
  transition:
5528
- opacity var(--duration-slow) var(--ease),
5529
- scale var(--duration-slow) var(--ease),
5530
- translate var(--duration-slow) var(--ease);
5650
+ opacity var(--duration-fast) var(--ease-exit),
5651
+ translate var(--duration) var(--ease-exit),
5652
+ background-color var(--duration) ease;
5531
5653
  }
5532
5654
 
5533
- .status-bar:empty {
5534
- opacity: 0;
5535
- scale: 0.96;
5536
- translate: 0 var(--space-20);
5537
- pointer-events: none;
5655
+ .status-bar.is-open {
5656
+ opacity: 1;
5657
+ translate: 0 0;
5658
+ /* Entry takes a little longer and decelerates, making the message legible
5659
+ before its motion settles. */
5660
+ transition:
5661
+ opacity var(--duration) var(--ease-enter),
5662
+ translate var(--duration-slow) var(--ease-enter),
5663
+ background-color var(--duration) ease;
5538
5664
  }
5539
5665
  @media print {
5540
5666
  .status-bar {
@@ -5934,15 +6060,18 @@ a.avatar[aria-disabled="true"] {
5934
6060
 
5935
6061
  /*
5936
6062
  * Fixed floating action placement and a native-details speed dial.
5937
- * Button owns the action's size, shape, and intent; FAB only owns placement.
6063
+ * Button owns the trigger's size, shape, and intent; FAB owns placement and
6064
+ * the speed dial's structural open/close markers (--icon-plus / --icon-close).
5938
6065
  *
5939
6066
  * Public hooks:
5940
6067
  * --fab-offset
5941
6068
  * --fab-gap
6069
+ * --fab-icon-size
5942
6070
  */
5943
6071
  .fab {
5944
6072
  --fab-offset: var(--space-40);
5945
6073
  --fab-gap: var(--space-20);
6074
+ --fab-icon-size: 1.5rem;
5946
6075
 
5947
6076
  position: fixed;
5948
6077
  z-index: var(--z-menu);
@@ -5951,6 +6080,9 @@ a.avatar[aria-disabled="true"] {
5951
6080
  }
5952
6081
 
5953
6082
  .fab > summary {
6083
+ /* Centers the toggle markers: the summary is the markers' positioned
6084
+ context, not just the app-button class. */
6085
+ position: relative;
5954
6086
  list-style: none;
5955
6087
  cursor: pointer;
5956
6088
  }
@@ -5959,11 +6091,35 @@ a.avatar[aria-disabled="true"] {
5959
6091
  display: none;
5960
6092
  }
5961
6093
 
5962
- /* A lone decorative plus can communicate the open/close state without
5963
- requiring a second icon or JavaScript-managed content. */
5964
- .fab > summary > [aria-hidden="true"]:only-child {
5965
- display: inline-block;
5966
- transition: transform var(--duration);
6094
+ /* The speed dial trigger is an icon-only toggle. FAB owns both structural
6095
+ markers: masked pseudo-elements swap the shared plus (closed) and close
6096
+ (open) glyphs at the same size, so the two states never drift apart. */
6097
+ .fab > summary::before,
6098
+ .fab > summary::after {
6099
+ content: "";
6100
+ position: absolute;
6101
+ inset: 0;
6102
+ margin: auto;
6103
+ inline-size: var(--fab-icon-size);
6104
+ block-size: var(--fab-icon-size);
6105
+ background: currentColor;
6106
+ opacity: 0;
6107
+ transform: scale(0.8);
6108
+ transition:
6109
+ opacity var(--duration),
6110
+ transform var(--duration);
6111
+ }
6112
+
6113
+ .fab > summary::before {
6114
+ -webkit-mask: var(--icon-plus) center / contain no-repeat;
6115
+ mask: var(--icon-plus) center / contain no-repeat;
6116
+ opacity: 1;
6117
+ transform: none;
6118
+ }
6119
+
6120
+ .fab > summary::after {
6121
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
6122
+ mask: var(--icon-close) center / contain no-repeat;
5967
6123
  }
5968
6124
 
5969
6125
  .fab-actions {
@@ -6021,8 +6177,14 @@ a.avatar[aria-disabled="true"] {
6021
6177
  transition-delay: 0s;
6022
6178
  }
6023
6179
 
6024
- .fab[open] > summary > [aria-hidden="true"]:only-child {
6025
- transform: rotate(45deg);
6180
+ .fab[open] > summary::before {
6181
+ opacity: 0;
6182
+ transform: scale(0.8);
6183
+ }
6184
+
6185
+ .fab[open] > summary::after {
6186
+ opacity: 1;
6187
+ transform: none;
6026
6188
  }
6027
6189
 
6028
6190
  @media print {
@@ -6134,22 +6296,56 @@ a.avatar[aria-disabled="true"] {
6134
6296
  }
6135
6297
 
6136
6298
  /*
6137
- * Steps — semantic ordered-list progression. One intrinsic layout: items fill
6138
- * the row when they fit and hold a minimum width otherwise, so the sequence
6139
- * scrolls horizontally only when it genuinely cannot fit. No breakpoint decides
6140
- * this — the item minimum tracks the real content, not an arbitrary width.
6299
+ * Steps — semantic ordered-list progression for a multi-step process.
6300
+ *
6301
+ * One primitive, two explicit compositions. Both classes are required:
6302
+ *
6303
+ * <ol class="steps steps-horizontal"> horizontal, 2 to 5 stages
6304
+ * <ol class="steps steps-vertical"> vertical, any number of stages
6305
+ *
6306
+ * `.steps` is what a step *is* — the counter, the marker, `.step-label`,
6307
+ * `.step-complete`, `aria-current="step"`. It carries no orientation, so it is
6308
+ * never written alone. `.steps-horizontal` and `.steps-vertical` are what a
6309
+ * sequence *looks like*, and they are peers: neither is the default the other
6310
+ * has to exclude, so no rule says `:not(...)` and no rule undoes another's
6311
+ * layout.
6312
+ *
6313
+ * Orientation and labels are independent dimensions. `.step-label` is optional
6314
+ * in either orientation; when a sequence carries names, every step carries
6315
+ * one:
6316
+ *
6317
+ * empty steps .step-label on every step
6318
+ * horizontal compact progression named sequence, stacked or inline
6319
+ * vertical compact rail named sequence, labels beside markers
6320
+ *
6321
+ * An empty step is a marker and nothing else, so it is compact by structure —
6322
+ * `> li:empty` in both orientations, no variant class, and nothing authored
6323
+ * only to be hidden again. The page around it owns the step name.
6141
6324
  *
6142
- * Markers come from the list counter; state stays in .complete and
6143
- * aria-current="step", so the component needs no runtime.
6325
+ * A label may be an `a[href]`, which is the whole interactive surface: the
6326
+ * marker is never navigable, so navigating a flow means naming its
6327
+ * destinations. Anything else focusable in a step is out of contract.
6328
+ *
6329
+ * The framework never hides an authored label to solve a layout problem. A
6330
+ * labelled row that outgrows its space scrolls. An `actual-container` size
6331
+ * context buys it one thing: the wide representation, marker beside label.
6332
+ *
6333
+ * See docs/design-notes/steps.md for the threshold, the selector shapes, the
6334
+ * rejected alternatives, and why there is no forced-colors branch.
6335
+ *
6336
+ * Each public hook is followed by the class that owns it.
6144
6337
  *
6145
6338
  * Public hooks:
6146
- * --step-size
6147
- * --step-min
6148
- * --step-gap
6149
- * --step-line-size
6150
- * --step-marker-radius
6151
- * --step-complete-mark
6152
- * --step-connector
6339
+ * --step-size .steps
6340
+ * --step-inline-gap .steps
6341
+ * --step-line-size .steps
6342
+ * --step-marker-radius .steps
6343
+ * --step-complete-mark .steps
6344
+ * --step-min .steps-horizontal
6345
+ * --step-gap .steps-horizontal
6346
+ * --step-connector .steps-horizontal
6347
+ * --step-inline-connector .steps-horizontal
6348
+ * --step-vertical-gap .steps-vertical
6153
6349
  *
6154
6350
  * Internal:
6155
6351
  * --step-marker-bg
@@ -6157,10 +6353,12 @@ a.avatar[aria-disabled="true"] {
6157
6353
  * --step-marker-fg
6158
6354
  * --step-line
6159
6355
  */
6356
+
6357
+ /* Step primitive --------------------------------------------------------- */
6358
+
6160
6359
  .steps {
6161
6360
  --step-size: 1.75rem;
6162
- --step-min: 7rem;
6163
- --step-gap: var(--space-20);
6361
+ --step-inline-gap: var(--space-20);
6164
6362
  --step-line-size: var(--border-width);
6165
6363
  --step-marker-bg: var(--surface);
6166
6364
  --step-marker-border: var(--border);
@@ -6170,29 +6368,14 @@ a.avatar[aria-disabled="true"] {
6170
6368
 
6171
6369
  counter-reset: actual-step;
6172
6370
  display: flex;
6173
- flex-direction: row;
6174
- inline-size: 100%;
6175
6371
  margin: 0;
6176
6372
  padding: 0;
6177
6373
  list-style: none;
6178
- /* Fill when it fits, scroll when it cannot: items never shrink below
6179
- --step-min, so labels keep a readable width and the sequence scrolls
6180
- horizontally instead of squishing. overscroll-behavior keeps the scroll
6181
- from chaining to the page. */
6182
- overflow-x: auto;
6183
- overscroll-behavior-inline: contain;
6184
6374
  }
6185
6375
 
6186
6376
  .steps > li {
6187
6377
  position: relative;
6188
- display: grid;
6189
- flex: 1 0 var(--step-min);
6190
- grid-template-rows: var(--step-size) auto;
6191
- gap: var(--step-gap);
6192
- justify-items: center;
6193
- min-inline-size: max(var(--step-size), var(--step-min));
6194
6378
  color: var(--text-muted);
6195
- text-align: center;
6196
6379
  counter-increment: actual-step;
6197
6380
  }
6198
6381
 
@@ -6213,50 +6396,263 @@ a.avatar[aria-disabled="true"] {
6213
6396
  line-height: 1;
6214
6397
  }
6215
6398
 
6216
- .steps > li::after {
6217
- content: "";
6218
- position: absolute;
6219
- inset-block-start: calc((var(--step-size) - var(--step-line-size)) / 2);
6220
- inset-inline-start: calc(50% + var(--step-size) / 2);
6221
- inline-size: calc(100% - var(--step-size));
6222
- block-size: var(--step-line-size);
6223
- /* A custom property resolves its var() before inheritance, so a default
6224
- --step-connector: var(--step-line) on .steps would freeze the parent line
6225
- color and a .complete child's connector would never follow its own line.
6226
- Falling back to the local --step-line here keeps each connector in sync;
6227
- an explicit --step-connector still wins when the author sets one. */
6228
- background: var(--step-connector, var(--step-line));
6399
+ /* The marker carries the state, so the label reads a notch below it — set on
6400
+ the component, never per representation: a size that changed with the layout
6401
+ would resize the text under a reader dragging a window. `color: inherit` is
6402
+ here rather than in a rule of its own for `a.step-label`, because a span
6403
+ inherits its ink anyway; only a link has anything to take back, and taking it
6404
+ back for every label costs nothing and one rule less. A navigable label keeps
6405
+ its underline, hover and focus ring — just not a link color, which would read
6406
+ ahead of the marker states.
6407
+
6408
+ Scoped to `.steps`, unlike `.menu-item-text` and the framework's other
6409
+ sub-part classes: a bare `.step-label` weighs (0,1,0), exactly tying
6410
+ `.prose :where(a)`, so a stepper in prose would be decided by bundle order. */
6411
+ .steps .step-label {
6412
+ font-size: var(--font-size-sm);
6413
+ line-height: var(--line-height-tight);
6414
+ color: inherit;
6229
6415
  }
6230
6416
 
6231
- .steps > li:last-child::after {
6232
- content: none;
6417
+ /* Each composition gives the connector its own geometry and background, so the
6418
+ only shared fact is which steps get one — every step but the last. Stated as
6419
+ the condition rather than as a `content: none` rule undoing a `content: ""`
6420
+ rule: a pseudo-element with no content generates no box, so a composition can
6421
+ address `> li::after` freely and still not trail a line past the last step. */
6422
+ .steps > li:not(:last-child)::after {
6423
+ content: "";
6233
6424
  }
6234
6425
 
6235
- .steps > :is(.complete, [aria-current="step"]) {
6426
+ /* Prefixed like `.step-label`, unlike the framework's bare `.primary` /
6427
+ `.outline` modifiers: those qualify a component on its own element, this
6428
+ marks one item inside one. A bare `.complete` would also claim a very
6429
+ ordinary English word globally, for a single component's single state. */
6430
+ .steps > .step-complete {
6236
6431
  --step-marker-bg: var(--state-selected);
6237
6432
  --step-marker-border: var(--state-selected);
6238
6433
  --step-marker-fg: var(--state-selected-fg);
6434
+ --step-line: var(--state-selected);
6239
6435
 
6240
6436
  color: var(--text);
6241
6437
  }
6242
6438
 
6243
- /* Thicker border is the selected affordance in every mode — structural, and it
6244
- never competes with the outline channel owned by focus. */
6245
- .steps > :is(.complete, [aria-current="step"])::before {
6439
+ /* Complete owns the accented connector that reaches the current step, so the
6440
+ connector leaving current goes back to neutral. Declared after
6441
+ .step-complete at equal specificity: current wins on every channel. */
6442
+ .steps > [aria-current="step"] {
6443
+ --step-marker-bg: var(--surface);
6444
+ --step-marker-border: var(--state-selected);
6445
+ --step-marker-fg: var(--state-selected);
6446
+ --step-line: var(--border);
6447
+
6448
+ color: var(--text);
6449
+ }
6450
+
6451
+ .steps > [aria-current="step"]::before {
6246
6452
  border-width: calc(var(--border-width) * 2);
6247
6453
  }
6248
6454
 
6249
- .steps > .complete {
6250
- --step-line: var(--state-selected);
6455
+ /* Weight marks "where I am now". Complete needs none: its filled disc already
6456
+ reads as strongly. */
6457
+ .steps > [aria-current="step"] .step-label {
6458
+ font-weight: var(--font-weight-strong);
6251
6459
  }
6252
6460
 
6253
- /* A finished step may replace its number with a mark (e.g. a check glyph):
6254
- --step-complete-mark accepts any content value and falls back to the
6255
- counter, so existing numbered steppers are unchanged. */
6256
- .steps > .complete::before {
6461
+ .steps > .step-complete:not([aria-current="step"])::before {
6257
6462
  content: var(--step-complete-mark, counter(actual-step));
6258
6463
  }
6259
6464
 
6465
+ /* Horizontal ------------------------------------------------------------- */
6466
+
6467
+ .steps-horizontal {
6468
+ --step-min: 7rem;
6469
+ --step-gap: var(--space-20);
6470
+
6471
+ inline-size: 100%;
6472
+ overflow-x: auto;
6473
+ /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
6474
+ other to `auto`, and a row hugs fractional content closely enough that a
6475
+ rounding difference then raises a phantom vertical scrollbar. */
6476
+ overflow-y: hidden;
6477
+ overscroll-behavior-inline: contain;
6478
+ }
6479
+
6480
+ /* A scroll container clips at the padding edge, so a navigable row reserves the
6481
+ focus ring's own width and offset. Only this composition scrolls, and only a
6482
+ link has a ring to protect. */
6483
+ .steps-horizontal:has(a[href]) {
6484
+ padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
6485
+ }
6486
+
6487
+ /* One explicit row, the marker's: a `.step-label` is auto-placed into an
6488
+ implicit second row, and two tracks are also the only thing `gap` has to sit
6489
+ between. So an empty step measures exactly `--step-size`, and the rule below
6490
+ takes back nothing. `var(--step-size) auto` would build that second row
6491
+ whether or not a label ever arrives, and the compact form would then have to
6492
+ remove both the track and the gap. */
6493
+ .steps-horizontal > li {
6494
+ display: grid;
6495
+ flex: 1 0 var(--step-min);
6496
+ grid-template-rows: var(--step-size);
6497
+ gap: var(--step-gap);
6498
+ justify-items: center;
6499
+ min-inline-size: max(var(--step-size), var(--step-min));
6500
+ text-align: center;
6501
+ }
6502
+
6503
+ /* An empty step is a marker and nothing else, so it needs a marker's width
6504
+ rather than a reading width. That is what lets five of them fit a phone with
6505
+ no size context and no variant class.
6506
+
6507
+ `justify-items` is not an override of the row's centring: the marker is
6508
+ centred because a label sits under it and the two share a centre, and with no
6509
+ label there is nothing to centre against. The marker takes the start of its
6510
+ own share instead, the connector below fills the rest of it, and the last
6511
+ step has no share to fill — so the track runs edge to edge and lines up with
6512
+ the text around it, the way the inline representation does.
6513
+
6514
+ `:empty` says exactly what the markup says, the way `.badge:empty` does, and
6515
+ it reads the item's own content: bare text in an `<li>` keeps a reading width
6516
+ like its wrapped peers, where `> li:has(.step-label)` would give it a marker
6517
+ floor and skew the row. It also predates `:has()` by a decade, so the compact
6518
+ form reaches the Degraded tier and only the inline bonus needs `:has()`. */
6519
+ .steps-horizontal > li:empty {
6520
+ flex: 1 1 0;
6521
+ justify-items: start;
6522
+ min-inline-size: var(--step-size);
6523
+ }
6524
+
6525
+ .steps-horizontal > li:empty::after {
6526
+ inset-inline-start: var(--step-size);
6527
+ }
6528
+
6529
+ .steps-horizontal > li:empty:last-child {
6530
+ flex: 0 0 auto;
6531
+ }
6532
+
6533
+ /* The pseudo-element is marker-height so --step-connector may be any complete
6534
+ background value. */
6535
+ .steps-horizontal > li::after {
6536
+ position: absolute;
6537
+ inset-block-start: 0;
6538
+ inset-inline-start: calc(50% + var(--step-size) / 2);
6539
+ inline-size: calc(100% - var(--step-size));
6540
+ block-size: var(--step-size);
6541
+ background: var(
6542
+ --step-connector,
6543
+ linear-gradient(var(--step-line), var(--step-line)) center /
6544
+ 100% var(--step-line-size) no-repeat
6545
+ );
6546
+ }
6547
+
6548
+ /*
6549
+ * The one horizontal container query. Past 60rem a labelled row has the room
6550
+ * to put marker and label inline and still leave the connector a real region
6551
+ * between groups, rather than a stub. Calibrated on five steps — the widest
6552
+ * supported row, and therefore the binding case — and it counts nothing, so it
6553
+ * serves every count. Below it, stacked; a marker row is already the shape
6554
+ * this query produces and stays out of it.
6555
+ *
6556
+ * Horizontal is a 2-5 step component. Outside that domain the rule simply
6557
+ * applies as written, because a row that is out of contract is not owed a
6558
+ * curated fallback.
6559
+ */
6560
+ @supports (container-type: inline-size) {
6561
+ @container actual-container (inline-size >= 60rem) {
6562
+ .steps-horizontal:has(.step-label) > li {
6563
+ display: flex;
6564
+ flex: 1 1 auto;
6565
+ align-items: center;
6566
+ gap: var(--step-inline-gap);
6567
+ min-inline-size: 0;
6568
+ text-align: start;
6569
+ white-space: nowrap;
6570
+ }
6571
+
6572
+ .steps-horizontal:has(.step-label) > li::after {
6573
+ position: static;
6574
+ flex: 1 1 2rem;
6575
+ inline-size: auto;
6576
+ block-size: var(--step-size);
6577
+ margin-inline-end: var(--step-inline-gap);
6578
+ background: var(
6579
+ --step-inline-connector,
6580
+ var(
6581
+ --step-connector,
6582
+ linear-gradient(var(--step-line), var(--step-line)) center /
6583
+ 100% var(--step-line-size) no-repeat
6584
+ )
6585
+ );
6586
+ }
6587
+
6588
+ .steps-horizontal:has(.step-label) > li:last-child {
6589
+ flex: 0 0 auto;
6590
+ }
6591
+ }
6592
+ }
6593
+
6594
+ /* Vertical --------------------------------------------------------------- */
6595
+
6596
+ .steps-vertical {
6597
+ --step-vertical-gap: var(--space-40);
6598
+
6599
+ flex-direction: column;
6600
+ gap: var(--step-vertical-gap);
6601
+ }
6602
+
6603
+ /* `justify-items` looks like a reset and is not one — nothing here resets
6604
+ anything, since the horizontal rules never applied. Grid items blockify, so
6605
+ without it a label stretches the whole 1fr track: 264px against 75px for an
6606
+ `a.step-label`, a full-width focus ring the row does not give the same link. */
6607
+ .steps-vertical > li {
6608
+ display: grid;
6609
+ grid-template-columns: var(--step-size) minmax(0, 1fr);
6610
+ align-items: start;
6611
+ justify-items: start;
6612
+ gap: var(--step-inline-gap);
6613
+ min-block-size: var(--step-size);
6614
+ }
6615
+
6616
+ /* An empty step is a marker rail. The column cannot use the row's implicit
6617
+ second track, because `grid-auto-flow` is `row`: an auto-placed label would
6618
+ land *under* its marker. Its second track is therefore explicit, and this
6619
+ takes it back.
6620
+
6621
+ Silent in a stretched column, where the item fills its line whatever its
6622
+ tracks say. It bites in a shrink-to-fit box — which is what a sticky
6623
+ progress rail usually is — where the label track and the gap to it make the
6624
+ rail `--step-inline-gap` wider than the markers it draws: measured 36px
6625
+ against 28px. No `gap: 0` needed; one track has nothing to sit beside. */
6626
+ .steps-vertical > li:empty {
6627
+ grid-template-columns: var(--step-size);
6628
+ }
6629
+
6630
+ /* The row starts at the top so a wrapped label begins level with its marker.
6631
+ align-self only bites on a single line, which would otherwise float above
6632
+ the marker's centre.
6633
+
6634
+ The one `.step-label` rule that keeps a child combinator: `align-self` is a
6635
+ grid-item property, so this declaration is meaningless on anything but a
6636
+ direct child of the row. Elsewhere the label is addressed as a descendant —
6637
+ the framework owns the structure, and repeating it buys nothing. */
6638
+ .steps-vertical > li > .step-label {
6639
+ align-self: center;
6640
+ }
6641
+
6642
+ /* The vertical track publishes no hook of its own: a horizontal connector value
6643
+ cannot transpose onto this geometry, and this selector is already a reachable
6644
+ override for the design that finally asks. */
6645
+ .steps-vertical > li::after {
6646
+ position: absolute;
6647
+ inset-block-start: var(--step-size);
6648
+ inset-inline-start: calc((var(--step-size) - var(--step-line-size)) / 2);
6649
+ inline-size: var(--step-line-size);
6650
+ block-size: calc(100% - var(--step-size) + var(--step-vertical-gap));
6651
+ background:
6652
+ linear-gradient(var(--step-line), var(--step-line)) center / var(--step-line-size) 100%
6653
+ no-repeat;
6654
+ }
6655
+
6260
6656
 
6261
6657
  /* Navigation */
6262
6658
  /*
@@ -6658,6 +7054,34 @@ a.avatar[aria-disabled="true"] {
6658
7054
 
6659
7055
 
6660
7056
  /* Data */
7057
+ /*
7058
+ * Data list — term/value pairs for the properties of a single object.
7059
+ *
7060
+ * Expects alternating <dt>/<dd> children: one term, one value per property.
7061
+ * A plain <dl> handles richer relationships (several terms or descriptions)
7062
+ * and stays untouched by this component.
7063
+ *
7064
+ * Why margin resets: neutralizes .prose list margins if .data-list appears
7065
+ * inside an authored rich-text scope; the grid owns row/column rhythm.
7066
+ */
7067
+ .data-list {
7068
+ display: grid;
7069
+ grid-template-columns: max-content minmax(0, 1fr);
7070
+ align-items: baseline;
7071
+ gap: var(--space-20) var(--space-40);
7072
+ }
7073
+
7074
+ .data-list > dt {
7075
+ min-inline-size: 0;
7076
+ margin-block-start: 0;
7077
+ font-weight: var(--font-weight-strong);
7078
+ }
7079
+
7080
+ .data-list > dd {
7081
+ min-inline-size: 0;
7082
+ margin: 0;
7083
+ }
7084
+
6661
7085
  /*
6662
7086
  * Application list — repeated rows with optional leading and trailing regions.
6663
7087
  * Content determines the number of lines; the component only owns the row
@@ -6693,7 +7117,9 @@ a.avatar[aria-disabled="true"] {
6693
7117
  .list > li > .list-item {
6694
7118
  display: grid;
6695
7119
  grid-template-columns: auto minmax(0, 1fr) auto;
6696
- align-items: center;
7120
+ /* Regions align to the first line, so leading, title, and trailing controls
7121
+ share one horizontal line while supporting text wraps beneath. */
7122
+ align-items: start;
6697
7123
  gap: var(--list-item-gap);
6698
7124
  min-block-size: var(--list-item-min-size);
6699
7125
  padding-block: var(--list-item-pad-block);
@@ -6816,10 +7242,18 @@ a.list-item:hover {
6816
7242
  }
6817
7243
 
6818
7244
  /*
6819
- * Meter — native gauge for known-range values.
7245
+ * Meter — native and segmented gauges for known-range values.
6820
7246
  *
6821
7247
  * Maps the three standard meter zones (optimum / suboptimum / even-less-good)
6822
7248
  * to the shared success / warning / danger intent colors.
7249
+ *
7250
+ * Thickness is a dimension, not a density step: the bar always spans its
7251
+ * container and only gets thinner or thicker, so --bar-height is the API and
7252
+ * .sm/.lg are deliberately not supported.
7253
+ *
7254
+ * Public hooks:
7255
+ * --meter-value
7256
+ * --bar-height
6823
7257
  */
6824
7258
 
6825
7259
  .meter {
@@ -6834,6 +7268,25 @@ a.list-item:hover {
6834
7268
  background: var(--surface-subtle);
6835
7269
  }
6836
7270
 
7271
+ /* A non-native meter is a weighted flex container. The element type keeps the
7272
+ native <meter> API and the composed segmented API distinct without another
7273
+ public variant class. */
7274
+ .meter:not(meter) {
7275
+ --intent: initial;
7276
+
7277
+ display: flex;
7278
+ }
7279
+
7280
+ .meter:not(meter) > * {
7281
+ flex: var(--meter-value, 1) 1 0;
7282
+ min-inline-size: 0;
7283
+ background: var(--intent);
7284
+ }
7285
+
7286
+ .meter:not(meter) > * + * {
7287
+ border-inline-start: var(--border-width) solid var(--surface);
7288
+ }
7289
+
6837
7290
  .meter::-webkit-meter-bar {
6838
7291
  -webkit-appearance: none;
6839
7292
  block-size: var(--bar-height);
@@ -7009,12 +7462,23 @@ a.list-item:hover {
7009
7462
  /*
7010
7463
  * Decorative intent-colored frame for one direct child.
7011
7464
  *
7465
+ * The effect is built from three layers with distinct roles: a frame carrying
7466
+ * a short accent of light, a small halo that belongs to the frame and keeps
7467
+ * that light attached to the edge, and a wide static halo that belongs to the
7468
+ * object and gives it a permanent, diffuse presence. Only the small halo
7469
+ * follows the animated gradient, so a rotating aura repaints one blurred layer
7470
+ * per frame, not two.
7471
+ *
7012
7472
  * Public hooks:
7013
7473
  * --aura-color
7014
7474
  * --aura-width
7015
7475
  * --aura-blur
7476
+ * --aura-intensity
7016
7477
  * --aura-radius
7017
7478
  * --aura-duration
7479
+ *
7480
+ * Internal:
7481
+ * --aura-angle
7018
7482
  */
7019
7483
  @property --aura-angle {
7020
7484
  syntax: "<angle>";
@@ -7031,7 +7495,8 @@ a.list-item:hover {
7031
7495
  .aura {
7032
7496
  --aura-color: var(--intent, var(--primary));
7033
7497
  --aura-width: 2px;
7034
- --aura-blur: 0.75rem;
7498
+ --aura-blur: 0.25rem;
7499
+ --aura-intensity: 1;
7035
7500
  --aura-radius: var(--radius-lg);
7036
7501
  --aura-duration: 6s;
7037
7502
 
@@ -7042,9 +7507,19 @@ a.list-item:hover {
7042
7507
  border-radius: calc(var(--aura-radius) + var(--aura-width));
7043
7508
  border: var(--border-width) solid transparent;
7044
7509
  background: var(--aura-color);
7045
- background: conic-gradient(from var(--aura-angle), transparent 0 65%, var(--aura-color));
7510
+
7511
+ /* A short feathered accent rather than a long wedge with a hard edge, which
7512
+ reads as one lit side. Nothing tints the frame outside it: a constant tint
7513
+ across 2px draws an outline, the opposite of the effect. */
7514
+ background: conic-gradient(
7515
+ from var(--aura-angle),
7516
+ transparent 0 78%,
7517
+ var(--aura-color) 93%,
7518
+ transparent
7519
+ );
7046
7520
  }
7047
7521
 
7522
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
7048
7523
  .aura::before {
7049
7524
  position: absolute;
7050
7525
  z-index: -1;
@@ -7052,15 +7527,40 @@ a.list-item:hover {
7052
7527
  border-radius: inherit;
7053
7528
  background: inherit;
7054
7529
  filter: blur(var(--aura-blur));
7055
- opacity: 0.45;
7530
+ opacity: calc(0.55 * var(--aura-intensity));
7056
7531
  pointer-events: none;
7057
7532
  content: "";
7058
7533
  }
7059
7534
 
7535
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
7536
+ legible, so there is nothing to gain from animating a second wide blurred
7537
+ layer. It outgrows the frame, otherwise the child covers it and the whole
7538
+ effect reads as a drop shadow. This is the layer that stays lit at all
7539
+ times, so it — not the frame — is what keeps the object present between two
7540
+ passes of the accent. */
7541
+ .aura::after {
7542
+ position: absolute;
7543
+ z-index: -1;
7544
+ inset: calc(-1 * var(--aura-blur));
7545
+ border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
7546
+ background: var(--aura-color);
7547
+ filter: blur(calc(var(--aura-blur) * 4));
7548
+ opacity: calc(0.15 * var(--aura-intensity));
7549
+ pointer-events: none;
7550
+ content: "";
7551
+ }
7552
+
7553
+ /* Lifts the child above both halos. It also has to be opaque: the frame paints
7554
+ across the whole box and only its edge is meant to show, so a transparent
7555
+ child (an .outline alert, a .ghost button) is filled with the frame color
7556
+ rather than framed by it. Documented as a constraint, not worked around —
7557
+ masking a ring would cost more than the case is worth. */
7060
7558
  .aura > * {
7061
7559
  position: relative;
7062
7560
  }
7063
7561
 
7562
+ /* Glow lights the whole perimeter instead of a short accent, so both halos
7563
+ become a single soft field around the child. */
7064
7564
  .aura-glow {
7065
7565
  background: var(--aura-color);
7066
7566
  }
@@ -7069,6 +7569,29 @@ a.list-item:hover {
7069
7569
  .aura:not(.aura-glow) {
7070
7570
  animation: aura-rotate var(--aura-duration) linear infinite;
7071
7571
  }
7572
+
7573
+ /* Both halos breathe: moving the near one alone changes a line's brightness,
7574
+ which barely registers. Opening the wide field with it is what reads as a
7575
+ breath. */
7576
+ .aura-glow::before {
7577
+ animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
7578
+ }
7579
+
7580
+ .aura-glow::after {
7581
+ animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
7582
+ }
7583
+ }
7584
+
7585
+ /* Without motion the accent would freeze at an arbitrary angle and read as a
7586
+ broken animation, so it becomes a deliberate composition: two opposite
7587
+ corners. Fixed radii keep those corners stable across aspect ratios;
7588
+ proportional radii turn wide or tall elements into lit edges instead. */
7589
+ @media (prefers-reduced-motion: reduce) {
7590
+ .aura:not(.aura-glow) {
7591
+ background:
7592
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
7593
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
7594
+ }
7072
7595
  }
7073
7596
 
7074
7597
  @keyframes aura-rotate {
@@ -7077,18 +7600,38 @@ a.list-item:hover {
7077
7600
  }
7078
7601
  }
7079
7602
 
7080
- @supports selector(:has(*)) {
7081
- .aura:has(> .btn) {
7082
- --aura-radius: var(--radius);
7603
+ /* Only the peak is declared, so each halo breathes up from its own resting
7604
+ opacity and never dips below the static glow. The peaks scale with the same
7605
+ intensity as the resting values, which keeps the breath proportional at
7606
+ every setting. */
7607
+ @keyframes aura-breathe-near {
7608
+ 50% {
7609
+ opacity: calc(0.95 * var(--aura-intensity));
7610
+ transform: scale(1.03);
7083
7611
  }
7612
+ }
7084
7613
 
7085
- .aura:has(> :is(.card, .alert)) {
7086
- --aura-radius: var(--radius-lg);
7614
+ @keyframes aura-breathe-far {
7615
+ 50% {
7616
+ opacity: calc(0.28 * var(--aura-intensity));
7617
+ transform: scale(1.07);
7087
7618
  }
7619
+ }
7088
7620
 
7089
- .aura:has(> .badge) {
7090
- --aura-radius: var(--radius-full);
7091
- }
7621
+ /* Each rule is a lone :has() setting a decorative radius, so a browser without
7622
+ :has() simply drops it and keeps the default. No @supports needed — that
7623
+ guard only earns its place around a selector list that mixes :has() with
7624
+ selectors worth keeping. */
7625
+ .aura:has(> .btn) {
7626
+ --aura-radius: var(--radius);
7627
+ }
7628
+
7629
+ .aura:has(> :is(.card, .alert)) {
7630
+ --aura-radius: var(--radius-lg);
7631
+ }
7632
+
7633
+ .aura:has(> .badge) {
7634
+ --aura-radius: var(--radius-full);
7092
7635
  }
7093
7636
 
7094
7637
  /* Utilities — small, single-purpose escape hatches. */
@@ -7112,6 +7655,22 @@ a.list-item:hover {
7112
7655
  border: 0;
7113
7656
  }
7114
7657
 
7658
+ /* Enlarge a small control's touch target on touch-primary devices without
7659
+ growing the visual. Opt-in — apply to genuinely small targets only. The
7660
+ ::after exists only under (pointer: coarse), so on fine pointers the class
7661
+ is fully neutral. */
7662
+ @media (pointer: coarse) {
7663
+ .touch-target {
7664
+ position: relative;
7665
+ }
7666
+
7667
+ .touch-target::after {
7668
+ content: "";
7669
+ position: absolute;
7670
+ inset: calc(var(--space-20) * -1);
7671
+ }
7672
+ }
7673
+
7115
7674
  .overflow-auto {
7116
7675
  overflow: auto;
7117
7676
  }