actual-css 0.5.0 → 0.7.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 (101) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/actual.css +1 -0
  3. package/dist/actual.full.css +589 -131
  4. package/dist/actual.full.js +1 -1
  5. package/dist/actual.full.min.css +1 -1
  6. package/dist/actual.min.css +1 -1
  7. package/docs/design-notes/browser-support.md +39 -14
  8. package/docs/design-notes/css-anchor.md +1 -1
  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 +60 -0
  14. package/docs/design-notes/popover-manual-poc.md +248 -0
  15. package/docs/design-notes/references.md +4 -0
  16. package/docs/design-notes/steps.md +748 -0
  17. package/docs/design-notes/surface-reparenting.md +122 -0
  18. package/docs/design-notes/widget-primitives.md +32 -15
  19. package/docs/navigation.json +1 -0
  20. package/docs/pages/components/alert.md +13 -8
  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 +11 -7
  24. package/docs/pages/components/button.md +17 -17
  25. package/docs/pages/components/card.md +15 -8
  26. package/docs/pages/components/chat.md +1 -1
  27. package/docs/pages/components/dialog.md +10 -7
  28. package/docs/pages/components/drawer.md +1 -1
  29. package/docs/pages/components/fab.md +12 -10
  30. package/docs/pages/components/indicator.md +6 -6
  31. package/docs/pages/components/list.md +11 -2
  32. package/docs/pages/components/navbar.md +1 -1
  33. package/docs/pages/components/pagination.md +1 -1
  34. package/docs/pages/components/rating.md +4 -4
  35. package/docs/pages/components/skeleton.md +1 -1
  36. package/docs/pages/components/spinner.md +1 -1
  37. package/docs/pages/components/steps.md +376 -52
  38. package/docs/pages/components/table.md +5 -5
  39. package/docs/pages/enhancements/flyout.md +104 -12
  40. package/docs/pages/enhancements/overview.md +2 -2
  41. package/docs/pages/enhancements/scrollspy.md +1 -1
  42. package/docs/pages/enhancements/status-bar.md +20 -0
  43. package/docs/pages/enhancements/tabs.md +1 -1
  44. package/docs/pages/enhancements/tooltip.md +12 -0
  45. package/docs/pages/examples/overview.md +2 -0
  46. package/docs/pages/forms/choice-cards.md +4 -4
  47. package/docs/pages/forms/combobox.md +87 -0
  48. package/docs/pages/forms/floating-field.md +5 -3
  49. package/docs/pages/forms/joined-controls.md +4 -4
  50. package/docs/pages/forms/labels.md +8 -5
  51. package/docs/pages/forms/overview.md +25 -18
  52. package/docs/pages/forms/password.md +3 -2
  53. package/docs/pages/forms/range.md +13 -2
  54. package/docs/pages/forms/states-and-sizes.md +6 -4
  55. package/docs/pages/forms/validation.md +13 -9
  56. package/docs/pages/foundations/philosophy.md +15 -1
  57. package/docs/pages/foundations/theming.md +8 -0
  58. package/docs/pages/foundations/tokens.md +9 -6
  59. package/docs/pages/foundations/typography.md +1 -1
  60. package/docs/pages/guides/bootstrap.md +3 -3
  61. package/docs/pages/guides/introduction.md +1 -1
  62. package/docs/pages/guides/modular-import.md +21 -10
  63. package/docs/pages/guides/progressive-enhancement.md +96 -73
  64. package/docs/pages/guides/tailwind.md +2 -2
  65. package/docs/pages/layout/app-shell.md +4 -4
  66. package/docs/pages/layout/choosing.md +10 -6
  67. package/docs/pages/layout/grid.md +11 -10
  68. package/docs/pages/layout/stack.md +10 -6
  69. package/docs/pages/patterns/structured-lists.md +7 -7
  70. package/docs/pages/utilities/accessibility.md +1 -1
  71. package/docs/pages/utilities/flex.md +20 -13
  72. package/docs/pages/utilities/links.md +1 -1
  73. package/docs/pages/utilities/list.md +1 -1
  74. package/docs/pages/utilities/overflow.md +14 -7
  75. package/docs/pages/utilities/overview.md +22 -0
  76. package/docs/pages/utilities/shape.md +1 -1
  77. package/docs/pages/utilities/sizing-wrapping.md +10 -8
  78. package/docs/pages/utilities/spacing.md +26 -19
  79. package/docs/pages/utilities/text.md +1 -1
  80. package/llms.txt +27 -4
  81. package/package.json +7 -4
  82. package/scripts/reserved-classes.json +4 -1
  83. package/src/css/components/fab.css +44 -8
  84. package/src/css/components/flyout.css +49 -3
  85. package/src/css/components/join.css +7 -0
  86. package/src/css/components/list.css +3 -1
  87. package/src/css/components/menu.css +16 -10
  88. package/src/css/components/steps.css +277 -69
  89. package/src/css/components/tooltip.css +37 -1
  90. package/src/css/core/tokens.css +1 -0
  91. package/src/css/effects/aura.css +101 -11
  92. package/src/css/forms/custom-select.css +1 -1
  93. package/src/css/forms/floating-field.css +8 -6
  94. package/src/css/layout/container-query.css +37 -13
  95. package/src/css/layout/grid.css +7 -7
  96. package/src/css/layout/index.css +1 -1
  97. package/src/js/password.js +1 -1
  98. package/src/js/status.js +38 -2
  99. package/src/js/surface.js +28 -12
  100. package/src/js/tooltip.js +2 -2
  101. package/src/js/floating.js +0 -432
@@ -201,6 +201,7 @@ select {
201
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");
202
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");
203
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");
204
205
 
205
206
  /* Viewport size tokens — fallback vh/vw with dvh/vi enhancement.
206
207
  Consumed by full-viewport components (app-shell, drawer, modal, flyout).
@@ -1073,7 +1074,7 @@ small {
1073
1074
  * .sidebar-layout main content + secondary aside
1074
1075
  * .grid responsive collection; item width is the contract
1075
1076
  * .grid-N structural density of N; balanced inside a container named
1076
- * actual-grid (in grid.css)
1077
+ * actual-container (in grid.css)
1077
1078
  * .column-layout explicit 12-column region composition; the author owns
1078
1079
  * narrow-container recomposition
1079
1080
  * .scroller theme-aware scrollbar density and colour
@@ -1084,20 +1085,44 @@ small {
1084
1085
  *
1085
1086
  * Prefer these compositions over viewport breakpoint utilities.
1086
1087
  */
1087
- /* Convenience helper: establishes a query container named actual-grid for
1088
- descendants. Use it for a component that changes representation according
1089
- to its allocated inline size, and to give .grid-N the named context it needs
1090
- for balanced subdivision (see grid.css). The name matters: grids only respond
1091
- to an explicitly actual-grid-named container, never to an incidental
1092
- query container. Layout primitives are responsive without it; this adds
1093
- precision, it is never a prerequisite.
1094
-
1095
- The element takes inline-size containment, so put it on a normal
1096
- block-level wrapper, never on a shrink-to-fit item (a .cluster child, a
1097
- float, an inline-block): its contents would stop contributing to its own
1098
- 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. */
1099
1124
  .container-query {
1100
- container: actual-grid / inline-size;
1125
+ container: actual-container / inline-size;
1101
1126
  }
1102
1127
 
1103
1128
  /* Vertical flow. .stack owns the block axis only: block margins reset, inline
@@ -1468,13 +1493,13 @@ small {
1468
1493
  min-inline-size: 0;
1469
1494
  }
1470
1495
 
1471
- /* Balanced subdivision, activated by an ancestor container named actual-grid.
1496
+ /* Balanced subdivision, activated by an ancestor container named actual-container.
1472
1497
  * A .grid-N inside such a container enters only divisor states of N. The name
1473
1498
  * is deliberate: a grid must not key off the nearest anonymous query container,
1474
1499
  * or a region that sets container-type: inline-size for an unrelated reason
1475
1500
  * would silently change the grid. The author opts the grid in by naming the
1476
1501
  * context — .container-query is the official helper, or declare
1477
- * container: actual-grid / inline-size on your own region.
1502
+ * container: actual-container / inline-size on your own region.
1478
1503
  *
1479
1504
  * .grid-2 2 -> 1
1480
1505
  * .grid-3 3 -> 1
@@ -1491,7 +1516,7 @@ small {
1491
1516
  * inline contribution and collapses it inside .cluster, .topbar, floats, and
1492
1517
  * any shrink-to-fit box. See docs/design-notes/grid.md.
1493
1518
  *
1494
- * 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
1495
1520
  * the auto-fill baseline above stays. The min-width: 0 rule is the lowest
1496
1521
  * step: it pins the grid to one column inside a container before any larger
1497
1522
  * threshold fires (a bare grid would otherwise show a 2 + 1 orphan at 28-48rem).
@@ -1507,25 +1532,25 @@ small {
1507
1532
  * Ascending min-width only: the last matching step wins by cascade order.
1508
1533
  */
1509
1534
  @supports (container-type: inline-size) {
1510
- @container actual-grid (min-width: 0px) {
1535
+ @container actual-container (min-width: 0px) {
1511
1536
  :where(.grid-2, .grid-3, .grid-4, .grid-6) {
1512
1537
  grid-template-columns: 1fr;
1513
1538
  }
1514
1539
  }
1515
1540
 
1516
- @container actual-grid (min-width: 28rem) {
1541
+ @container actual-container (min-width: 28rem) {
1517
1542
  :where(.grid-2, .grid-4, .grid-6) {
1518
1543
  grid-template-columns: repeat(2, minmax(0, 1fr));
1519
1544
  }
1520
1545
  }
1521
1546
 
1522
- @container actual-grid (min-width: 48rem) {
1547
+ @container actual-container (min-width: 48rem) {
1523
1548
  :where(.grid-3, .grid-6) {
1524
1549
  grid-template-columns: repeat(3, minmax(0, 1fr));
1525
1550
  }
1526
1551
  }
1527
1552
 
1528
- @container actual-grid (min-width: 64rem) {
1553
+ @container actual-container (min-width: 64rem) {
1529
1554
  :where(.grid-4) {
1530
1555
  grid-template-columns: repeat(4, minmax(0, 1fr));
1531
1556
  }
@@ -2975,7 +3000,7 @@ small {
2975
3000
 
2976
3001
  &::picker(select) {
2977
3002
  appearance: base-select;
2978
- max-height: min(24rem, calc(var(--viewport-block) * 0.7));
3003
+ max-block-size: min(24rem, calc(var(--viewport-block) * 0.7));
2979
3004
  margin-block: var(--space-20);
2980
3005
  padding: 0;
2981
3006
  border: var(--border-width) solid var(--border);
@@ -3052,8 +3077,10 @@ small {
3052
3077
  * messages stay in the surrounding .field, not inside the cell, so the
3053
3078
  * resting label can center on the control alone.
3054
3079
  *
3055
- * The control must precede the label: the float is driven by the sibling
3056
- * 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
3057
3084
  * placeholder=" " to expose a reliable empty state — without it the label
3058
3085
  * only reacts to focus.
3059
3086
  *
@@ -3101,14 +3128,14 @@ small {
3101
3128
  }
3102
3129
 
3103
3130
  /* Single-line controls rest in the middle until they receive a value. */
3104
- .floating-field > :is(.input, .select) + .field-label {
3131
+ .floating-field > :is(.input, .select) ~ .field-label {
3105
3132
  inset-block-start: 50%;
3106
3133
  transform: translateY(-50%);
3107
3134
  }
3108
3135
 
3109
3136
  /* An empty textarea label follows the control's normal first-line position;
3110
3137
  the control itself keeps the extra headroom reserved for its floated state. */
3111
- .floating-field > .textarea + .field-label {
3138
+ .floating-field > .textarea ~ .field-label {
3112
3139
  inset-block-start: calc(1em + var(--border-width));
3113
3140
  transform: none;
3114
3141
  }
@@ -3117,7 +3144,7 @@ small {
3117
3144
  not have propagated the value to the placeholder state yet. */
3118
3145
  .floating-field
3119
3146
  > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
3120
- + .field-label {
3147
+ ~ .field-label {
3121
3148
  inset-block-start: var(--floating-label-top);
3122
3149
  transform: none;
3123
3150
  font-size: var(--font-size-xs);
@@ -3136,7 +3163,7 @@ small {
3136
3163
  .input[type="month"],
3137
3164
  .input[type="week"]
3138
3165
  )
3139
- + .field-label {
3166
+ ~ .field-label {
3140
3167
  inset-block-start: var(--floating-label-top);
3141
3168
  transform: none;
3142
3169
  font-size: var(--font-size-xs);
@@ -4812,12 +4839,50 @@ dialog.drawer > form > header {
4812
4839
  transition: opacity var(--duration-fast) ease;
4813
4840
  }
4814
4841
 
4815
- .flyout.is-open:not(.is-sheet) {
4842
+ /* Native popovers enter the top layer with UA geometry that conflicts with
4843
+ anchored positioning. The reset is gated on real Popover support because an
4844
+ attribute selector matches whether or not the engine implements Popover:
4845
+ without it the UA neither hides nor promotes the element, so an ungated
4846
+ reset would strand a permanently visible panel at inset: auto. Popover is
4847
+ not part of Actual's Minimal contract — engines inside the Minimal range
4848
+ may lack it — so the gate is about the capability, not the floor. Kept
4849
+ scoped to Actual surfaces so bare [popover] elements retain their platform
4850
+ presentation. */
4851
+ @supports selector(:popover-open) {
4852
+ .flyout[popover] {
4853
+ position: fixed;
4854
+ inset: auto;
4855
+ margin: 0;
4856
+ }
4857
+
4858
+ /* A presentation class may own its *visible* display type, but must not
4859
+ override the platform's hidden state. The UA closes a popover with
4860
+ display: none, and an author declaration beats the UA origin whatever the
4861
+ specificity — so .flyout's own display: grid would leave a closed popover
4862
+ painted at its static position. Restoring both properties here also gives
4863
+ the native path the exit target the runtime path already has in
4864
+ .flyout[hidden]: without an opacity to animate, the overlay/display
4865
+ allow-discrete transition below would hold a fully opaque panel in the top
4866
+ layer and then snap it away. */
4867
+ .flyout[popover]:not(:popover-open) {
4868
+ display: none;
4869
+ opacity: 0;
4870
+ }
4871
+ }
4872
+
4873
+ /* compat-ok: :popover-open rides in a forgiving :is(), where an engine without
4874
+ Popover discards just that member and .is-open alone keeps driving the open
4875
+ state. This is a property of the forgiving list, not of the pseudo-class: in
4876
+ a plain selector list an unsupported member would invalidate the whole rule.
4877
+ Keep it inside :is()/:where() or gate it. */
4878
+ .flyout:is(.is-open, :popover-open):not(.is-sheet) {
4816
4879
  inset: auto;
4817
4880
  }
4818
4881
 
4882
+ /* compat-ok: forgiving :is() again, and @starting-style is itself unsupported
4883
+ across part of the Minimal range, so the rule simply does not exist there. */
4819
4884
  @starting-style {
4820
- .flyout.is-open {
4885
+ .flyout:is(.is-open, :popover-open) {
4821
4886
  opacity: 0;
4822
4887
  }
4823
4888
  }
@@ -4856,16 +4921,23 @@ dialog.drawer > form > header {
4856
4921
  translate: 0 var(--space-30);
4857
4922
  }
4858
4923
 
4924
+ /* compat-ok: forgiving :is() inside @starting-style, as above. */
4859
4925
  @starting-style {
4860
- .flyout.is-sheet.is-open {
4926
+ .flyout.is-sheet:is(.is-open, :popover-open) {
4861
4927
  translate: 0 var(--space-30);
4862
4928
  }
4863
4929
  }
4864
4930
 
4931
+ /* display keeps the exit transition alive for the [hidden] path; overlay does
4932
+ the same for the native popover path, where hidePopover() would otherwise
4933
+ drop the panel out of the top layer on the first frame and let a parent
4934
+ overflow or a dialog clip the fade. An engine that cannot transition either
4935
+ property ignores it and the exit is instant. */
4865
4936
  @supports (transition-behavior: allow-discrete) {
4866
4937
  .flyout {
4867
4938
  transition:
4868
4939
  opacity var(--duration-fast) ease,
4940
+ overlay var(--duration-fast) allow-discrete,
4869
4941
  display var(--duration-fast) allow-discrete;
4870
4942
  }
4871
4943
 
@@ -4873,6 +4945,7 @@ dialog.drawer > form > header {
4873
4945
  transition:
4874
4946
  opacity var(--duration-fast) ease,
4875
4947
  translate var(--duration-fast) ease,
4948
+ overlay var(--duration-fast) allow-discrete,
4876
4949
  display var(--duration-fast) allow-discrete;
4877
4950
  }
4878
4951
  }
@@ -4991,30 +5064,36 @@ dialog.drawer > form > header {
4991
5064
  /* Checkable menu items are one command with one accessible state. Their
4992
5065
  indicator is generated so authors do not add a second interactive input. */
4993
5066
  .menu-item:is([role="menuitemcheckbox"], [role="menuitemradio"])::before {
4994
- display: grid;
5067
+ content: "";
5068
+ display: block;
4995
5069
  flex: 0 0 var(--menu-item-icon-size);
4996
5070
  inline-size: var(--menu-item-icon-size);
4997
- place-items: center;
4998
- }
4999
-
5000
- .menu-item[role="menuitemcheckbox"]::before {
5001
- content: "";
5071
+ block-size: var(--menu-item-icon-size);
5002
5072
  }
5003
5073
 
5004
5074
  .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before {
5005
- content: "✓";
5075
+ background: currentColor;
5076
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
5077
+ mask: var(--icon-check) center / contain no-repeat;
5006
5078
  }
5007
5079
 
5008
5080
  .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before {
5009
- content: "−";
5081
+ background:
5082
+ linear-gradient(currentColor 0 0) center / 60% max(var(--border-width), 0.125em) no-repeat;
5010
5083
  }
5011
5084
 
5012
5085
  .menu-item[role="menuitemradio"]::before {
5013
- content: "○";
5086
+ background: radial-gradient(circle, transparent 0 46%, currentColor 48% 58%, transparent 60%);
5014
5087
  }
5015
5088
 
5016
5089
  .menu-item[role="menuitemradio"][aria-checked="true"]::before {
5017
- content: "●";
5090
+ background: radial-gradient(
5091
+ circle,
5092
+ currentColor 0 25%,
5093
+ transparent 27% 46%,
5094
+ currentColor 48% 58%,
5095
+ transparent 60%
5096
+ );
5018
5097
  }
5019
5098
 
5020
5099
  .menu-item:focus-visible {
@@ -5084,21 +5163,57 @@ dialog.drawer > form > header {
5084
5163
  transition: opacity var(--duration-fast) ease;
5085
5164
  }
5086
5165
 
5166
+ /* Neutralize only the native geometry that conflicts with a positioned
5167
+ tooltip. In particular, the UA overflow would turn the arrow into scroll
5168
+ overflow instead of letting it project beyond the bubble. Gated on real
5169
+ Popover support: the attribute selector matches whether or not the engine
5170
+ implements Popover, and without it nothing hides the element, so an ungated
5171
+ reset would leave a stray bubble parked at inset: auto. Popover sits outside
5172
+ Actual's Minimal contract, so the gate tracks the capability rather than the
5173
+ floor. */
5174
+ @supports selector(:popover-open) {
5175
+ .tooltip[popover] {
5176
+ inset: auto;
5177
+ margin: 0;
5178
+ overflow: visible;
5179
+ }
5180
+
5181
+ /* .tooltip declares no display of its own, so the UA already hides a closed
5182
+ popover here. The rule is written anyway: both components advertised as
5183
+ Popover-compatible should cede the hidden state the same way, and the
5184
+ opacity target is what actually makes the native exit fade — see the
5185
+ overlay note below. */
5186
+ .tooltip[popover]:not(:popover-open) {
5187
+ display: none;
5188
+ opacity: 0;
5189
+ }
5190
+ }
5191
+
5087
5192
  .tooltip[hidden] {
5088
5193
  display: none;
5089
5194
  opacity: 0;
5090
5195
  }
5091
5196
 
5197
+ /* Entry opacity for both lifecycles: a runtime-driven tip is visible when it
5198
+ is neither a popover nor [hidden], a native one when it is :popover-open.
5199
+ compat-ok: [popover] appears here only under negation, which every engine
5200
+ evaluates correctly whether or not it implements Popover, :popover-open
5201
+ rides in a forgiving :is(), and @starting-style is itself unsupported across
5202
+ part of the Minimal range — so the rule does not exist there at all. */
5092
5203
  @starting-style {
5093
- .tooltip:not([hidden]) {
5204
+ .tooltip:is(:not([popover]):not([hidden]), :popover-open) {
5094
5205
  opacity: 0;
5095
5206
  }
5096
5207
  }
5097
5208
 
5209
+ /* overlay is the native popover counterpart of display: it holds the tip in
5210
+ the top layer until the fade finishes, instead of letting hidePopover()
5211
+ demote it on the first frame. Ignored where unsupported. */
5098
5212
  @supports (transition-behavior: allow-discrete) {
5099
5213
  .tooltip {
5100
5214
  transition:
5101
5215
  opacity var(--duration-fast) ease,
5216
+ overlay var(--duration-fast) allow-discrete,
5102
5217
  display var(--duration-fast) allow-discrete;
5103
5218
  }
5104
5219
  }
@@ -6033,15 +6148,18 @@ a.avatar[aria-disabled="true"] {
6033
6148
 
6034
6149
  /*
6035
6150
  * Fixed floating action placement and a native-details speed dial.
6036
- * Button owns the action's size, shape, and intent; FAB only owns placement.
6151
+ * Button owns the trigger's size, shape, and intent; FAB owns placement and
6152
+ * the speed dial's structural open/close markers (--icon-plus / --icon-close).
6037
6153
  *
6038
6154
  * Public hooks:
6039
6155
  * --fab-offset
6040
6156
  * --fab-gap
6157
+ * --fab-icon-size
6041
6158
  */
6042
6159
  .fab {
6043
6160
  --fab-offset: var(--space-40);
6044
6161
  --fab-gap: var(--space-20);
6162
+ --fab-icon-size: 1.5rem;
6045
6163
 
6046
6164
  position: fixed;
6047
6165
  z-index: var(--z-menu);
@@ -6050,6 +6168,9 @@ a.avatar[aria-disabled="true"] {
6050
6168
  }
6051
6169
 
6052
6170
  .fab > summary {
6171
+ /* Centers the toggle markers: the summary is the markers' positioned
6172
+ context, not just the app-button class. */
6173
+ position: relative;
6053
6174
  list-style: none;
6054
6175
  cursor: pointer;
6055
6176
  }
@@ -6058,11 +6179,35 @@ a.avatar[aria-disabled="true"] {
6058
6179
  display: none;
6059
6180
  }
6060
6181
 
6061
- /* A lone decorative plus can communicate the open/close state without
6062
- requiring a second icon or JavaScript-managed content. */
6063
- .fab > summary > [aria-hidden="true"]:only-child {
6064
- display: inline-block;
6065
- transition: transform var(--duration);
6182
+ /* The speed dial trigger is an icon-only toggle. FAB owns both structural
6183
+ markers: masked pseudo-elements swap the shared plus (closed) and close
6184
+ (open) glyphs at the same size, so the two states never drift apart. */
6185
+ .fab > summary::before,
6186
+ .fab > summary::after {
6187
+ content: "";
6188
+ position: absolute;
6189
+ inset: 0;
6190
+ margin: auto;
6191
+ inline-size: var(--fab-icon-size);
6192
+ block-size: var(--fab-icon-size);
6193
+ background: currentColor;
6194
+ opacity: 0;
6195
+ transform: scale(0.8);
6196
+ transition:
6197
+ opacity var(--duration),
6198
+ transform var(--duration);
6199
+ }
6200
+
6201
+ .fab > summary::before {
6202
+ -webkit-mask: var(--icon-plus) center / contain no-repeat;
6203
+ mask: var(--icon-plus) center / contain no-repeat;
6204
+ opacity: 1;
6205
+ transform: none;
6206
+ }
6207
+
6208
+ .fab > summary::after {
6209
+ -webkit-mask: var(--icon-close) center / contain no-repeat;
6210
+ mask: var(--icon-close) center / contain no-repeat;
6066
6211
  }
6067
6212
 
6068
6213
  .fab-actions {
@@ -6120,8 +6265,14 @@ a.avatar[aria-disabled="true"] {
6120
6265
  transition-delay: 0s;
6121
6266
  }
6122
6267
 
6123
- .fab[open] > summary > [aria-hidden="true"]:only-child {
6124
- transform: rotate(45deg);
6268
+ .fab[open] > summary::before {
6269
+ opacity: 0;
6270
+ transform: scale(0.8);
6271
+ }
6272
+
6273
+ .fab[open] > summary::after {
6274
+ opacity: 1;
6275
+ transform: none;
6125
6276
  }
6126
6277
 
6127
6278
  @media print {
@@ -6233,22 +6384,56 @@ a.avatar[aria-disabled="true"] {
6233
6384
  }
6234
6385
 
6235
6386
  /*
6236
- * Steps — semantic ordered-list progression. One intrinsic layout: items fill
6237
- * the row when they fit and hold a minimum width otherwise, so the sequence
6238
- * scrolls horizontally only when it genuinely cannot fit. No breakpoint decides
6239
- * this — the item minimum tracks the real content, not an arbitrary width.
6387
+ * Steps — semantic ordered-list progression for a multi-step process.
6388
+ *
6389
+ * One primitive, two explicit compositions. Both classes are required:
6390
+ *
6391
+ * <ol class="steps steps-horizontal"> horizontal, 2 to 5 stages
6392
+ * <ol class="steps steps-vertical"> vertical, any number of stages
6393
+ *
6394
+ * `.steps` is what a step *is* — the counter, the marker, `.step-label`,
6395
+ * `.step-complete`, `aria-current="step"`. It carries no orientation, so it is
6396
+ * never written alone. `.steps-horizontal` and `.steps-vertical` are what a
6397
+ * sequence *looks like*, and they are peers: neither is the default the other
6398
+ * has to exclude, so no rule says `:not(...)` and no rule undoes another's
6399
+ * layout.
6400
+ *
6401
+ * Orientation and labels are independent dimensions. `.step-label` is optional
6402
+ * in either orientation; when a sequence carries names, every step carries
6403
+ * one:
6404
+ *
6405
+ * empty steps .step-label on every step
6406
+ * horizontal compact progression named sequence, stacked or inline
6407
+ * vertical compact rail named sequence, labels beside markers
6408
+ *
6409
+ * An empty step is a marker and nothing else, so it is compact by structure —
6410
+ * `> li:empty` in both orientations, no variant class, and nothing authored
6411
+ * only to be hidden again. The page around it owns the step name.
6412
+ *
6413
+ * A label may be an `a[href]`, which is the whole interactive surface: the
6414
+ * marker is never navigable, so navigating a flow means naming its
6415
+ * destinations. Anything else focusable in a step is out of contract.
6416
+ *
6417
+ * The framework never hides an authored label to solve a layout problem. A
6418
+ * labelled row that outgrows its space scrolls. An `actual-container` size
6419
+ * context buys it one thing: the wide representation, marker beside label.
6420
+ *
6421
+ * See docs/design-notes/steps.md for the threshold, the selector shapes, the
6422
+ * rejected alternatives, and why there is no forced-colors branch.
6240
6423
  *
6241
- * Markers come from the list counter; state stays in .complete and
6242
- * aria-current="step", so the component needs no runtime.
6424
+ * Each public hook is followed by the class that owns it.
6243
6425
  *
6244
6426
  * Public hooks:
6245
- * --step-size
6246
- * --step-min
6247
- * --step-gap
6248
- * --step-line-size
6249
- * --step-marker-radius
6250
- * --step-complete-mark
6251
- * --step-connector
6427
+ * --step-size .steps
6428
+ * --step-inline-gap .steps
6429
+ * --step-line-size .steps
6430
+ * --step-marker-radius .steps
6431
+ * --step-complete-mark .steps
6432
+ * --step-min .steps-horizontal
6433
+ * --step-gap .steps-horizontal
6434
+ * --step-connector .steps-horizontal
6435
+ * --step-inline-connector .steps-horizontal
6436
+ * --step-vertical-gap .steps-vertical
6252
6437
  *
6253
6438
  * Internal:
6254
6439
  * --step-marker-bg
@@ -6256,10 +6441,12 @@ a.avatar[aria-disabled="true"] {
6256
6441
  * --step-marker-fg
6257
6442
  * --step-line
6258
6443
  */
6444
+
6445
+ /* Step primitive --------------------------------------------------------- */
6446
+
6259
6447
  .steps {
6260
6448
  --step-size: 1.75rem;
6261
- --step-min: 7rem;
6262
- --step-gap: var(--space-20);
6449
+ --step-inline-gap: var(--space-20);
6263
6450
  --step-line-size: var(--border-width);
6264
6451
  --step-marker-bg: var(--surface);
6265
6452
  --step-marker-border: var(--border);
@@ -6269,29 +6456,14 @@ a.avatar[aria-disabled="true"] {
6269
6456
 
6270
6457
  counter-reset: actual-step;
6271
6458
  display: flex;
6272
- flex-direction: row;
6273
- inline-size: 100%;
6274
6459
  margin: 0;
6275
6460
  padding: 0;
6276
6461
  list-style: none;
6277
- /* Fill when it fits, scroll when it cannot: items never shrink below
6278
- --step-min, so labels keep a readable width and the sequence scrolls
6279
- horizontally instead of squishing. overscroll-behavior keeps the scroll
6280
- from chaining to the page. */
6281
- overflow-x: auto;
6282
- overscroll-behavior-inline: contain;
6283
6462
  }
6284
6463
 
6285
6464
  .steps > li {
6286
6465
  position: relative;
6287
- display: grid;
6288
- flex: 1 0 var(--step-min);
6289
- grid-template-rows: var(--step-size) auto;
6290
- gap: var(--step-gap);
6291
- justify-items: center;
6292
- min-inline-size: max(var(--step-size), var(--step-min));
6293
6466
  color: var(--text-muted);
6294
- text-align: center;
6295
6467
  counter-increment: actual-step;
6296
6468
  }
6297
6469
 
@@ -6312,30 +6484,38 @@ a.avatar[aria-disabled="true"] {
6312
6484
  line-height: 1;
6313
6485
  }
6314
6486
 
6315
- .steps > li::after {
6316
- content: "";
6317
- position: absolute;
6318
- inset-block-start: calc((var(--step-size) - var(--step-line-size)) / 2);
6319
- inset-inline-start: calc(50% + var(--step-size) / 2);
6320
- inline-size: calc(100% - var(--step-size));
6321
- block-size: var(--step-line-size);
6322
- /* A custom property resolves its var() before inheritance, so a default
6323
- --step-connector: var(--step-line) on .steps would freeze the parent line
6324
- color and a .complete child's connector would never follow its own line.
6325
- Falling back to the local --step-line here keeps each connector in sync;
6326
- an explicit --step-connector still wins when the author sets one. */
6327
- background: var(--step-connector, var(--step-line));
6487
+ /* The marker carries the state, so the label reads a notch below it — set on
6488
+ the component, never per representation: a size that changed with the layout
6489
+ would resize the text under a reader dragging a window. `color: inherit` is
6490
+ here rather than in a rule of its own for `a.step-label`, because a span
6491
+ inherits its ink anyway; only a link has anything to take back, and taking it
6492
+ back for every label costs nothing and one rule less. A navigable label keeps
6493
+ its underline, hover and focus ring — just not a link color, which would read
6494
+ ahead of the marker states.
6495
+
6496
+ Scoped to `.steps`, unlike `.menu-item-text` and the framework's other
6497
+ sub-part classes: a bare `.step-label` weighs (0,1,0), exactly tying
6498
+ `.prose :where(a)`, so a stepper in prose would be decided by bundle order. */
6499
+ .steps .step-label {
6500
+ font-size: var(--font-size-sm);
6501
+ line-height: var(--line-height-tight);
6502
+ color: inherit;
6328
6503
  }
6329
6504
 
6330
- .steps > li:last-child::after {
6331
- content: none;
6505
+ /* Each composition gives the connector its own geometry and background, so the
6506
+ only shared fact is which steps get one — every step but the last. Stated as
6507
+ the condition rather than as a `content: none` rule undoing a `content: ""`
6508
+ rule: a pseudo-element with no content generates no box, so a composition can
6509
+ address `> li::after` freely and still not trail a line past the last step. */
6510
+ .steps > li:not(:last-child)::after {
6511
+ content: "";
6332
6512
  }
6333
6513
 
6334
- /* Three states must be readable without a check glyph: future stays neutral,
6335
- complete is a filled marker, current is an outlined one. Filled reads as
6336
- "done", outlined as "you are here" — the distinction survives a numbered
6337
- stepper that never sets --step-complete-mark. */
6338
- .steps > .complete {
6514
+ /* Prefixed like `.step-label`, unlike the framework's bare `.primary` /
6515
+ `.outline` modifiers: those qualify a component on its own element, this
6516
+ marks one item inside one. A bare `.complete` would also claim a very
6517
+ ordinary English word globally, for a single component's single state. */
6518
+ .steps > .step-complete {
6339
6519
  --step-marker-bg: var(--state-selected);
6340
6520
  --step-marker-border: var(--state-selected);
6341
6521
  --step-marker-fg: var(--state-selected-fg);
@@ -6344,10 +6524,9 @@ a.avatar[aria-disabled="true"] {
6344
6524
  color: var(--text);
6345
6525
  }
6346
6526
 
6347
- /* Declared after .complete at equal specificity, and it wins on every channel
6348
- including the connector: the accented line means "already walked", so the
6349
- segment leaving the current step must stay neutral. The line reaching the
6350
- current step is owned by the previous step, which is complete. */
6527
+ /* Complete owns the accented connector that reaches the current step, so the
6528
+ connector leaving current goes back to neutral. Declared after
6529
+ .step-complete at equal specificity: current wins on every channel. */
6351
6530
  .steps > [aria-current="step"] {
6352
6531
  --step-marker-bg: var(--surface);
6353
6532
  --step-marker-border: var(--state-selected);
@@ -6357,31 +6536,211 @@ a.avatar[aria-disabled="true"] {
6357
6536
  color: var(--text);
6358
6537
  }
6359
6538
 
6360
- /* Thicker border is the current affordance in every mode — structural, so it
6361
- survives forced colors, and it never competes with the outline channel owned
6362
- by focus. */
6363
6539
  .steps > [aria-current="step"]::before {
6364
6540
  border-width: calc(var(--border-width) * 2);
6365
6541
  }
6366
6542
 
6367
- /* A finished step may replace its number with a mark (e.g. a check glyph):
6368
- --step-complete-mark accepts any content value and falls back to the
6369
- counter, so existing numbered steppers are unchanged. Excluded on the
6370
- current step so "current wins" holds on the glyph too, not only on color. */
6371
- .steps > .complete:not([aria-current="step"])::before {
6543
+ /* Weight marks "where I am now". Complete needs none: its filled disc already
6544
+ reads as strongly. */
6545
+ .steps > [aria-current="step"] .step-label {
6546
+ font-weight: var(--font-weight-strong);
6547
+ }
6548
+
6549
+ .steps > .step-complete:not([aria-current="step"])::before {
6372
6550
  content: var(--step-complete-mark, counter(actual-step));
6373
6551
  }
6374
6552
 
6375
- /* Forced colors drop the accent fill, which would leave complete and future
6376
- markers identical — current keeps its thicker ring, complete needs a system
6377
- color to stay filled. Same exclusion, for the same reason. */
6378
- @media (forced-colors: active) {
6379
- .steps > .complete:not([aria-current="step"])::before {
6380
- background: Highlight;
6381
- color: HighlightText;
6553
+ /* Horizontal ------------------------------------------------------------- */
6554
+
6555
+ .steps-horizontal {
6556
+ --step-min: 7rem;
6557
+ --step-gap: var(--space-20);
6558
+
6559
+ inline-size: 100%;
6560
+ overflow-x: auto;
6561
+ /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
6562
+ other to `auto`, and a row hugs fractional content closely enough that a
6563
+ rounding difference then raises a phantom vertical scrollbar. */
6564
+ overflow-y: hidden;
6565
+ overscroll-behavior-inline: contain;
6566
+ }
6567
+
6568
+ /* A scroll container clips at the padding edge, so a navigable row reserves the
6569
+ focus ring's own width and offset. Only this composition scrolls, and only a
6570
+ link has a ring to protect. */
6571
+ .steps-horizontal:has(a[href]) {
6572
+ padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
6573
+ }
6574
+
6575
+ /* One explicit row, the marker's: a `.step-label` is auto-placed into an
6576
+ implicit second row, and two tracks are also the only thing `gap` has to sit
6577
+ between. So an empty step measures exactly `--step-size`, and the rule below
6578
+ takes back nothing. `var(--step-size) auto` would build that second row
6579
+ whether or not a label ever arrives, and the compact form would then have to
6580
+ remove both the track and the gap. */
6581
+ .steps-horizontal > li {
6582
+ display: grid;
6583
+ flex: 1 0 var(--step-min);
6584
+ grid-template-rows: var(--step-size);
6585
+ gap: var(--step-gap);
6586
+ justify-items: center;
6587
+ min-inline-size: max(var(--step-size), var(--step-min));
6588
+ text-align: center;
6589
+ }
6590
+
6591
+ /* An empty step is a marker and nothing else, so it needs a marker's width
6592
+ rather than a reading width. That is what lets five of them fit a phone with
6593
+ no size context and no variant class.
6594
+
6595
+ `justify-items` is not an override of the row's centring: the marker is
6596
+ centred because a label sits under it and the two share a centre, and with no
6597
+ label there is nothing to centre against. The marker takes the start of its
6598
+ own share instead, the connector below fills the rest of it, and the last
6599
+ step has no share to fill — so the track runs edge to edge and lines up with
6600
+ the text around it, the way the inline representation does.
6601
+
6602
+ `:empty` says exactly what the markup says, the way `.badge:empty` does, and
6603
+ it reads the item's own content: bare text in an `<li>` keeps a reading width
6604
+ like its wrapped peers, where `> li:has(.step-label)` would give it a marker
6605
+ floor and skew the row. It also predates `:has()` by a decade, so the compact
6606
+ form reaches the Degraded tier and only the inline bonus needs `:has()`. */
6607
+ .steps-horizontal > li:empty {
6608
+ flex: 1 1 0;
6609
+ justify-items: start;
6610
+ min-inline-size: var(--step-size);
6611
+ }
6612
+
6613
+ .steps-horizontal > li:empty::after {
6614
+ inset-inline-start: var(--step-size);
6615
+ }
6616
+
6617
+ .steps-horizontal > li:empty:last-child {
6618
+ flex: 0 0 auto;
6619
+ }
6620
+
6621
+ /* The pseudo-element is marker-height so --step-connector may be any complete
6622
+ background value. */
6623
+ .steps-horizontal > li::after {
6624
+ position: absolute;
6625
+ inset-block-start: 0;
6626
+ inset-inline-start: calc(50% + var(--step-size) / 2);
6627
+ inline-size: calc(100% - var(--step-size));
6628
+ block-size: var(--step-size);
6629
+ background: var(
6630
+ --step-connector,
6631
+ linear-gradient(var(--step-line), var(--step-line)) center /
6632
+ 100% var(--step-line-size) no-repeat
6633
+ );
6634
+ }
6635
+
6636
+ /*
6637
+ * The one horizontal container query. Past 60rem a labelled row has the room
6638
+ * to put marker and label inline and still leave the connector a real region
6639
+ * between groups, rather than a stub. Calibrated on five steps — the widest
6640
+ * supported row, and therefore the binding case — and it counts nothing, so it
6641
+ * serves every count. Below it, stacked; a marker row is already the shape
6642
+ * this query produces and stays out of it.
6643
+ *
6644
+ * Horizontal is a 2-5 step component. Outside that domain the rule simply
6645
+ * applies as written, because a row that is out of contract is not owed a
6646
+ * curated fallback.
6647
+ */
6648
+ @supports (container-type: inline-size) {
6649
+ @container actual-container (inline-size >= 60rem) {
6650
+ .steps-horizontal:has(.step-label) > li {
6651
+ display: flex;
6652
+ flex: 1 1 auto;
6653
+ align-items: center;
6654
+ gap: var(--step-inline-gap);
6655
+ min-inline-size: 0;
6656
+ text-align: start;
6657
+ white-space: nowrap;
6658
+ }
6659
+
6660
+ .steps-horizontal:has(.step-label) > li::after {
6661
+ position: static;
6662
+ flex: 1 1 2rem;
6663
+ inline-size: auto;
6664
+ block-size: var(--step-size);
6665
+ margin-inline-end: var(--step-inline-gap);
6666
+ background: var(
6667
+ --step-inline-connector,
6668
+ var(
6669
+ --step-connector,
6670
+ linear-gradient(var(--step-line), var(--step-line)) center /
6671
+ 100% var(--step-line-size) no-repeat
6672
+ )
6673
+ );
6674
+ }
6675
+
6676
+ .steps-horizontal:has(.step-label) > li:last-child {
6677
+ flex: 0 0 auto;
6678
+ }
6382
6679
  }
6383
6680
  }
6384
6681
 
6682
+ /* Vertical --------------------------------------------------------------- */
6683
+
6684
+ .steps-vertical {
6685
+ --step-vertical-gap: var(--space-40);
6686
+
6687
+ flex-direction: column;
6688
+ gap: var(--step-vertical-gap);
6689
+ }
6690
+
6691
+ /* `justify-items` looks like a reset and is not one — nothing here resets
6692
+ anything, since the horizontal rules never applied. Grid items blockify, so
6693
+ without it a label stretches the whole 1fr track: 264px against 75px for an
6694
+ `a.step-label`, a full-width focus ring the row does not give the same link. */
6695
+ .steps-vertical > li {
6696
+ display: grid;
6697
+ grid-template-columns: var(--step-size) minmax(0, 1fr);
6698
+ align-items: start;
6699
+ justify-items: start;
6700
+ gap: var(--step-inline-gap);
6701
+ min-block-size: var(--step-size);
6702
+ }
6703
+
6704
+ /* An empty step is a marker rail. The column cannot use the row's implicit
6705
+ second track, because `grid-auto-flow` is `row`: an auto-placed label would
6706
+ land *under* its marker. Its second track is therefore explicit, and this
6707
+ takes it back.
6708
+
6709
+ Silent in a stretched column, where the item fills its line whatever its
6710
+ tracks say. It bites in a shrink-to-fit box — which is what a sticky
6711
+ progress rail usually is — where the label track and the gap to it make the
6712
+ rail `--step-inline-gap` wider than the markers it draws: measured 36px
6713
+ against 28px. No `gap: 0` needed; one track has nothing to sit beside. */
6714
+ .steps-vertical > li:empty {
6715
+ grid-template-columns: var(--step-size);
6716
+ }
6717
+
6718
+ /* The row starts at the top so a wrapped label begins level with its marker.
6719
+ align-self only bites on a single line, which would otherwise float above
6720
+ the marker's centre.
6721
+
6722
+ The one `.step-label` rule that keeps a child combinator: `align-self` is a
6723
+ grid-item property, so this declaration is meaningless on anything but a
6724
+ direct child of the row. Elsewhere the label is addressed as a descendant —
6725
+ the framework owns the structure, and repeating it buys nothing. */
6726
+ .steps-vertical > li > .step-label {
6727
+ align-self: center;
6728
+ }
6729
+
6730
+ /* The vertical track publishes no hook of its own: a horizontal connector value
6731
+ cannot transpose onto this geometry, and this selector is already a reachable
6732
+ override for the design that finally asks. */
6733
+ .steps-vertical > li::after {
6734
+ position: absolute;
6735
+ inset-block-start: var(--step-size);
6736
+ inset-inline-start: calc((var(--step-size) - var(--step-line-size)) / 2);
6737
+ inline-size: var(--step-line-size);
6738
+ block-size: calc(100% - var(--step-size) + var(--step-vertical-gap));
6739
+ background:
6740
+ linear-gradient(var(--step-line), var(--step-line)) center / var(--step-line-size) 100%
6741
+ no-repeat;
6742
+ }
6743
+
6385
6744
 
6386
6745
  /* Navigation */
6387
6746
  /*
@@ -6846,7 +7205,9 @@ a.avatar[aria-disabled="true"] {
6846
7205
  .list > li > .list-item {
6847
7206
  display: grid;
6848
7207
  grid-template-columns: auto minmax(0, 1fr) auto;
6849
- align-items: center;
7208
+ /* Regions align to the first line, so leading, title, and trailing controls
7209
+ share one horizontal line while supporting text wraps beneath. */
7210
+ align-items: start;
6850
7211
  gap: var(--list-item-gap);
6851
7212
  min-block-size: var(--list-item-min-size);
6852
7213
  padding-block: var(--list-item-pad-block);
@@ -7125,6 +7486,13 @@ a.list-item:hover {
7125
7486
  border-end-start-radius: var(--join-es);
7126
7487
  }
7127
7488
 
7489
+ /* A square icon button has an explicit block size, which otherwise prevents
7490
+ align-items: stretch from matching a taller labelled sibling. The button's
7491
+ existing min-block-size keeps the standalone square-size invariant intact. */
7492
+ .join > .btn.icon-only {
7493
+ block-size: auto;
7494
+ }
7495
+
7128
7496
  .join > :first-child {
7129
7497
  --join-ss: var(--join-radius);
7130
7498
  --join-es: var(--join-radius);
@@ -7189,12 +7557,23 @@ a.list-item:hover {
7189
7557
  /*
7190
7558
  * Decorative intent-colored frame for one direct child.
7191
7559
  *
7560
+ * The effect is built from three layers with distinct roles: a frame carrying
7561
+ * a short accent of light, a small halo that belongs to the frame and keeps
7562
+ * that light attached to the edge, and a wide static halo that belongs to the
7563
+ * object and gives it a permanent, diffuse presence. Only the small halo
7564
+ * follows the animated gradient, so a rotating aura repaints one blurred layer
7565
+ * per frame, not two.
7566
+ *
7192
7567
  * Public hooks:
7193
7568
  * --aura-color
7194
7569
  * --aura-width
7195
7570
  * --aura-blur
7571
+ * --aura-intensity
7196
7572
  * --aura-radius
7197
7573
  * --aura-duration
7574
+ *
7575
+ * Internal:
7576
+ * --aura-angle
7198
7577
  */
7199
7578
  @property --aura-angle {
7200
7579
  syntax: "<angle>";
@@ -7211,7 +7590,8 @@ a.list-item:hover {
7211
7590
  .aura {
7212
7591
  --aura-color: var(--intent, var(--primary));
7213
7592
  --aura-width: 2px;
7214
- --aura-blur: 0.75rem;
7593
+ --aura-blur: 0.25rem;
7594
+ --aura-intensity: 1;
7215
7595
  --aura-radius: var(--radius-lg);
7216
7596
  --aura-duration: 6s;
7217
7597
 
@@ -7222,9 +7602,19 @@ a.list-item:hover {
7222
7602
  border-radius: calc(var(--aura-radius) + var(--aura-width));
7223
7603
  border: var(--border-width) solid transparent;
7224
7604
  background: var(--aura-color);
7225
- background: conic-gradient(from var(--aura-angle), transparent 0 65%, var(--aura-color));
7605
+
7606
+ /* A short feathered accent rather than a long wedge with a hard edge, which
7607
+ reads as one lit side. Nothing tints the frame outside it: a constant tint
7608
+ across 2px draws an outline, the opposite of the effect. */
7609
+ background: conic-gradient(
7610
+ from var(--aura-angle),
7611
+ transparent 0 78%,
7612
+ var(--aura-color) 93%,
7613
+ transparent
7614
+ );
7226
7615
  }
7227
7616
 
7617
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
7228
7618
  .aura::before {
7229
7619
  position: absolute;
7230
7620
  z-index: -1;
@@ -7232,15 +7622,40 @@ a.list-item:hover {
7232
7622
  border-radius: inherit;
7233
7623
  background: inherit;
7234
7624
  filter: blur(var(--aura-blur));
7235
- opacity: 0.45;
7625
+ opacity: calc(0.55 * var(--aura-intensity));
7626
+ pointer-events: none;
7627
+ content: "";
7628
+ }
7629
+
7630
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
7631
+ legible, so there is nothing to gain from animating a second wide blurred
7632
+ layer. It outgrows the frame, otherwise the child covers it and the whole
7633
+ effect reads as a drop shadow. This is the layer that stays lit at all
7634
+ times, so it — not the frame — is what keeps the object present between two
7635
+ passes of the accent. */
7636
+ .aura::after {
7637
+ position: absolute;
7638
+ z-index: -1;
7639
+ inset: calc(-1 * var(--aura-blur));
7640
+ border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
7641
+ background: var(--aura-color);
7642
+ filter: blur(calc(var(--aura-blur) * 4));
7643
+ opacity: calc(0.15 * var(--aura-intensity));
7236
7644
  pointer-events: none;
7237
7645
  content: "";
7238
7646
  }
7239
7647
 
7648
+ /* Lifts the child above both halos. It also has to be opaque: the frame paints
7649
+ across the whole box and only its edge is meant to show, so a transparent
7650
+ child (an .outline alert, a .ghost button) is filled with the frame color
7651
+ rather than framed by it. Documented as a constraint, not worked around —
7652
+ masking a ring would cost more than the case is worth. */
7240
7653
  .aura > * {
7241
7654
  position: relative;
7242
7655
  }
7243
7656
 
7657
+ /* Glow lights the whole perimeter instead of a short accent, so both halos
7658
+ become a single soft field around the child. */
7244
7659
  .aura-glow {
7245
7660
  background: var(--aura-color);
7246
7661
  }
@@ -7249,6 +7664,29 @@ a.list-item:hover {
7249
7664
  .aura:not(.aura-glow) {
7250
7665
  animation: aura-rotate var(--aura-duration) linear infinite;
7251
7666
  }
7667
+
7668
+ /* Both halos breathe: moving the near one alone changes a line's brightness,
7669
+ which barely registers. Opening the wide field with it is what reads as a
7670
+ breath. */
7671
+ .aura-glow::before {
7672
+ animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
7673
+ }
7674
+
7675
+ .aura-glow::after {
7676
+ animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
7677
+ }
7678
+ }
7679
+
7680
+ /* Without motion the accent would freeze at an arbitrary angle and read as a
7681
+ broken animation, so it becomes a deliberate composition: two opposite
7682
+ corners. Fixed radii keep those corners stable across aspect ratios;
7683
+ proportional radii turn wide or tall elements into lit edges instead. */
7684
+ @media (prefers-reduced-motion: reduce) {
7685
+ .aura:not(.aura-glow) {
7686
+ background:
7687
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
7688
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
7689
+ }
7252
7690
  }
7253
7691
 
7254
7692
  @keyframes aura-rotate {
@@ -7257,18 +7695,38 @@ a.list-item:hover {
7257
7695
  }
7258
7696
  }
7259
7697
 
7260
- @supports selector(:has(*)) {
7261
- .aura:has(> .btn) {
7262
- --aura-radius: var(--radius);
7698
+ /* Only the peak is declared, so each halo breathes up from its own resting
7699
+ opacity and never dips below the static glow. The peaks scale with the same
7700
+ intensity as the resting values, which keeps the breath proportional at
7701
+ every setting. */
7702
+ @keyframes aura-breathe-near {
7703
+ 50% {
7704
+ opacity: calc(0.95 * var(--aura-intensity));
7705
+ transform: scale(1.03);
7263
7706
  }
7707
+ }
7264
7708
 
7265
- .aura:has(> :is(.card, .alert)) {
7266
- --aura-radius: var(--radius-lg);
7709
+ @keyframes aura-breathe-far {
7710
+ 50% {
7711
+ opacity: calc(0.28 * var(--aura-intensity));
7712
+ transform: scale(1.07);
7267
7713
  }
7714
+ }
7268
7715
 
7269
- .aura:has(> .badge) {
7270
- --aura-radius: var(--radius-full);
7271
- }
7716
+ /* Each rule is a lone :has() setting a decorative radius, so a browser without
7717
+ :has() simply drops it and keeps the default. No @supports needed — that
7718
+ guard only earns its place around a selector list that mixes :has() with
7719
+ selectors worth keeping. */
7720
+ .aura:has(> .btn) {
7721
+ --aura-radius: var(--radius);
7722
+ }
7723
+
7724
+ .aura:has(> :is(.card, .alert)) {
7725
+ --aura-radius: var(--radius-lg);
7726
+ }
7727
+
7728
+ .aura:has(> .badge) {
7729
+ --aura-radius: var(--radius-full);
7272
7730
  }
7273
7731
 
7274
7732
  /* Utilities — small, single-purpose escape hatches. */