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
@@ -1,8 +1,8 @@
1
1
  /*
2
2
  * Cross-cutting variant token mapping.
3
3
  *
4
- * Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
5
- * shared variants. .inverted is a surface modifier, not a variant: it paints
4
+ * Variant scope: .solid/.soft/.outline/.surface set --ui-* for components that
5
+ * opt into shared variants. .inverted is a surface modifier, not a variant: it paints
6
6
  * the element directly (background/color/border-color) so it works on any bare
7
7
  * block, and remaps the shared --ui-* tokens so participating components
8
8
  * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
@@ -21,8 +21,10 @@
21
21
  * components may consume --density-* when the density context should affect
22
22
  * their local rhythm. Compact components may consume --density-compact-size.
23
23
  * Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
24
- * applying .sm/.lg to card, dialog, accordion, navbar, table is
25
- * a no-op.
24
+ * applying .sm/.lg to card, dialog, accordion, navbar, table, progress, meter
25
+ * is a no-op. Progress and meter are deliberate: a bar keeps its inline size
26
+ * and only changes thickness, which --bar-height expresses better than a
27
+ * three-step scale.
26
28
  *
27
29
  * Intent list source of truth: intents.css. Components consume --intent* tokens
28
30
  * instead of re-listing intent classes.
@@ -45,6 +47,17 @@
45
47
  --ui-border: var(--intent, currentColor);
46
48
  }
47
49
 
50
+ /* Neutral chrome, intent only in the ink: the page surface with the theme
51
+ border. .soft is the intent-tinted treatment (its baseline fallback uses
52
+ --surface-subtle) and .outline has no fill at all, so neither can express
53
+ it. Deliberately literal — no color-mix enhancement — so this stays the one
54
+ variant that reads exactly as the theme's own surface. */
55
+ .surface {
56
+ --ui-bg: var(--surface);
57
+ --ui-fg: var(--intent, var(--text));
58
+ --ui-border: var(--border);
59
+ }
60
+
48
61
  /* Inverse surface treatment. Paints the element directly (so it works on any
49
62
  bare block) and remaps the shared --ui-* tokens so opt-in components
50
63
  (.card, .navbar) invert through their own rules. Because this loads before
@@ -1,12 +1,23 @@
1
1
  /*
2
2
  * Decorative intent-colored frame for one direct child.
3
3
  *
4
+ * The effect is built from three layers with distinct roles: a frame carrying
5
+ * a short accent of light, a small halo that belongs to the frame and keeps
6
+ * that light attached to the edge, and a wide static halo that belongs to the
7
+ * object and gives it a permanent, diffuse presence. Only the small halo
8
+ * follows the animated gradient, so a rotating aura repaints one blurred layer
9
+ * per frame, not two.
10
+ *
4
11
  * Public hooks:
5
12
  * --aura-color
6
13
  * --aura-width
7
14
  * --aura-blur
15
+ * --aura-intensity
8
16
  * --aura-radius
9
17
  * --aura-duration
18
+ *
19
+ * Internal:
20
+ * --aura-angle
10
21
  */
11
22
  @property --aura-angle {
12
23
  syntax: "<angle>";
@@ -23,7 +34,8 @@
23
34
  .aura {
24
35
  --aura-color: var(--intent, var(--primary));
25
36
  --aura-width: 2px;
26
- --aura-blur: 0.75rem;
37
+ --aura-blur: 0.25rem;
38
+ --aura-intensity: 1;
27
39
  --aura-radius: var(--radius-lg);
28
40
  --aura-duration: 6s;
29
41
 
@@ -34,9 +46,19 @@
34
46
  border-radius: calc(var(--aura-radius) + var(--aura-width));
35
47
  border: var(--border-width) solid transparent;
36
48
  background: var(--aura-color);
37
- background: conic-gradient(from var(--aura-angle), transparent 0 65%, var(--aura-color));
49
+
50
+ /* A short feathered accent rather than a long wedge with a hard edge, which
51
+ reads as one lit side. Nothing tints the frame outside it: a constant tint
52
+ across 2px draws an outline, the opposite of the effect. */
53
+ background: conic-gradient(
54
+ from var(--aura-angle),
55
+ transparent 0 78%,
56
+ var(--aura-color) 93%,
57
+ transparent
58
+ );
38
59
  }
39
60
 
61
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
40
62
  .aura::before {
41
63
  position: absolute;
42
64
  z-index: -1;
@@ -44,15 +66,40 @@
44
66
  border-radius: inherit;
45
67
  background: inherit;
46
68
  filter: blur(var(--aura-blur));
47
- opacity: 0.45;
69
+ opacity: calc(0.55 * var(--aura-intensity));
48
70
  pointer-events: none;
49
71
  content: "";
50
72
  }
51
73
 
74
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
75
+ legible, so there is nothing to gain from animating a second wide blurred
76
+ layer. It outgrows the frame, otherwise the child covers it and the whole
77
+ effect reads as a drop shadow. This is the layer that stays lit at all
78
+ times, so it — not the frame — is what keeps the object present between two
79
+ passes of the accent. */
80
+ .aura::after {
81
+ position: absolute;
82
+ z-index: -1;
83
+ inset: calc(-1 * var(--aura-blur));
84
+ border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
85
+ background: var(--aura-color);
86
+ filter: blur(calc(var(--aura-blur) * 4));
87
+ opacity: calc(0.15 * var(--aura-intensity));
88
+ pointer-events: none;
89
+ content: "";
90
+ }
91
+
92
+ /* Lifts the child above both halos. It also has to be opaque: the frame paints
93
+ across the whole box and only its edge is meant to show, so a transparent
94
+ child (an .outline alert, a .ghost button) is filled with the frame color
95
+ rather than framed by it. Documented as a constraint, not worked around —
96
+ masking a ring would cost more than the case is worth. */
52
97
  .aura > * {
53
98
  position: relative;
54
99
  }
55
100
 
101
+ /* Glow lights the whole perimeter instead of a short accent, so both halos
102
+ become a single soft field around the child. */
56
103
  .aura-glow {
57
104
  background: var(--aura-color);
58
105
  }
@@ -61,6 +108,29 @@
61
108
  .aura:not(.aura-glow) {
62
109
  animation: aura-rotate var(--aura-duration) linear infinite;
63
110
  }
111
+
112
+ /* Both halos breathe: moving the near one alone changes a line's brightness,
113
+ which barely registers. Opening the wide field with it is what reads as a
114
+ breath. */
115
+ .aura-glow::before {
116
+ animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
117
+ }
118
+
119
+ .aura-glow::after {
120
+ animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
121
+ }
122
+ }
123
+
124
+ /* Without motion the accent would freeze at an arbitrary angle and read as a
125
+ broken animation, so it becomes a deliberate composition: two opposite
126
+ corners. Fixed radii keep those corners stable across aspect ratios;
127
+ proportional radii turn wide or tall elements into lit edges instead. */
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .aura:not(.aura-glow) {
130
+ background:
131
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
132
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
133
+ }
64
134
  }
65
135
 
66
136
  @keyframes aura-rotate {
@@ -69,16 +139,36 @@
69
139
  }
70
140
  }
71
141
 
72
- @supports selector(:has(*)) {
73
- .aura:has(> .btn) {
74
- --aura-radius: var(--radius);
142
+ /* Only the peak is declared, so each halo breathes up from its own resting
143
+ opacity and never dips below the static glow. The peaks scale with the same
144
+ intensity as the resting values, which keeps the breath proportional at
145
+ every setting. */
146
+ @keyframes aura-breathe-near {
147
+ 50% {
148
+ opacity: calc(0.95 * var(--aura-intensity));
149
+ transform: scale(1.03);
75
150
  }
151
+ }
76
152
 
77
- .aura:has(> :is(.card, .alert)) {
78
- --aura-radius: var(--radius-lg);
153
+ @keyframes aura-breathe-far {
154
+ 50% {
155
+ opacity: calc(0.28 * var(--aura-intensity));
156
+ transform: scale(1.07);
79
157
  }
158
+ }
80
159
 
81
- .aura:has(> .badge) {
82
- --aura-radius: var(--radius-full);
83
- }
160
+ /* Each rule is a lone :has() setting a decorative radius, so a browser without
161
+ :has() simply drops it and keeps the default. No @supports needed — that
162
+ guard only earns its place around a selector list that mixes :has() with
163
+ selectors worth keeping. */
164
+ .aura:has(> .btn) {
165
+ --aura-radius: var(--radius);
166
+ }
167
+
168
+ .aura:has(> :is(.card, .alert)) {
169
+ --aura-radius: var(--radius-lg);
170
+ }
171
+
172
+ .aura:has(> .badge) {
173
+ --aura-radius: var(--radius-full);
84
174
  }
@@ -24,7 +24,7 @@
24
24
 
25
25
  &::picker(select) {
26
26
  appearance: base-select;
27
- max-height: min(24rem, calc(var(--viewport-block) * 0.7));
27
+ max-block-size: min(24rem, calc(var(--viewport-block) * 0.7));
28
28
  margin-block: var(--space-20);
29
29
  padding: 0;
30
30
  border: var(--border-width) solid var(--border);
@@ -7,8 +7,10 @@
7
7
  * messages stay in the surrounding .field, not inside the cell, so the
8
8
  * resting label can center on the control alone.
9
9
  *
10
- * The control must precede the label: the float is driven by the sibling
11
- * combinator and :placeholder-shown. Every .input/.textarea needs
10
+ * The control must precede the label: the float is driven by the general
11
+ * sibling combinator and :placeholder-shown. Injected nodes may appear between
12
+ * the control and the label (password managers, extension wrappers); the cell
13
+ * contract stays a single control plus one label. Every .input/.textarea needs
12
14
  * placeholder=" " to expose a reliable empty state — without it the label
13
15
  * only reacts to focus.
14
16
  *
@@ -56,14 +58,14 @@
56
58
  }
57
59
 
58
60
  /* Single-line controls rest in the middle until they receive a value. */
59
- .floating-field > :is(.input, .select) + .field-label {
61
+ .floating-field > :is(.input, .select) ~ .field-label {
60
62
  inset-block-start: 50%;
61
63
  transform: translateY(-50%);
62
64
  }
63
65
 
64
66
  /* An empty textarea label follows the control's normal first-line position;
65
67
  the control itself keeps the extra headroom reserved for its floated state. */
66
- .floating-field > .textarea + .field-label {
68
+ .floating-field > .textarea ~ .field-label {
67
69
  inset-block-start: calc(1em + var(--border-width));
68
70
  transform: none;
69
71
  }
@@ -72,7 +74,7 @@
72
74
  not have propagated the value to the placeholder state yet. */
73
75
  .floating-field
74
76
  > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
75
- + .field-label {
77
+ ~ .field-label {
76
78
  inset-block-start: var(--floating-label-top);
77
79
  transform: none;
78
80
  font-size: var(--font-size-xs);
@@ -91,7 +93,7 @@
91
93
  .input[type="month"],
92
94
  .input[type="week"]
93
95
  )
94
- + .field-label {
96
+ ~ .field-label {
95
97
  inset-block-start: var(--floating-label-top);
96
98
  transform: none;
97
99
  font-size: var(--font-size-xs);
@@ -4,6 +4,10 @@
4
4
  * Individual controls keep their own files; .choice label layout lives in
5
5
  * choice.css; .form-actions lives in form-actions.css.
6
6
  *
7
+ * .field-group owns its own box only. Distance between sibling groups is a
8
+ * composition concern (.stack on the form), never a sibling margin here: the
9
+ * component cannot know whether its siblings are stacked or gridded.
10
+ *
7
11
  * Public API: .field, .field-label, .field-help, .field-error, .field-group,
8
12
  * .required-mark.
9
13
  *
@@ -24,10 +28,6 @@
24
28
  border-radius: var(--radius);
25
29
  }
26
30
 
27
- .field-group + .field-group {
28
- margin-block-start: var(--space-40);
29
- }
30
-
31
31
  .field-label,
32
32
  .field-group > legend {
33
33
  color: var(--text);
@@ -1,15 +1,39 @@
1
- /* Convenience helper: establishes a query container named actual-grid for
2
- descendants. Use it for a component that changes representation according
3
- to its allocated inline size, and to give .grid-N the named context it needs
4
- for balanced subdivision (see grid.css). The name matters: grids only respond
5
- to an explicitly actual-grid-named container, never to an incidental
6
- query container. Layout primitives are responsive without it; this adds
7
- precision, it is never a prerequisite.
8
-
9
- The element takes inline-size containment, so put it on a normal
10
- block-level wrapper, never on a shrink-to-fit item (a .cluster child, a
11
- float, an inline-block): its contents would stop contributing to its own
12
- inline size and it would collapse. */
1
+ /* Convenience helper for size-aware components: establishes an inline-size
2
+ query container named actual-container. The name is the contract, not this
3
+ class — an author can declare `container: actual-container / inline-size` on
4
+ their own region and get the same result.
5
+
6
+ actual-container is shared. Granting it to a region may enable size-aware
7
+ enhancements for any Actual component inside that region, not only the one
8
+ that motivated the container: .grid-N reads it for balanced subdivision,
9
+ .steps for its label collapse, and future components will read the same
10
+ context. That is the scope of the API, not a caveat.
11
+
12
+ An anonymous query container is not part of it. A region that declares
13
+ `container-type: inline-size` for the application's own purposes carries no
14
+ actual-container name, so no framework component changes behaviour there.
15
+ Opting into the protocol is always explicit. Nothing here is a prerequisite
16
+ either — every primitive is responsive without it; this adds precision.
17
+
18
+ Place it where the width you want measured lives: usually a wrapper for a
19
+ grid, whose queried children sit inside it, and the component itself for
20
+ .steps, whose own row is the width in question.
21
+
22
+ The rule for where it is safe is the containment rule, not a list of
23
+ primitives. Query containment removes the element's intrinsic inline-size
24
+ contribution, so:
25
+
26
+ safe the element's width is imposed by its context
27
+ (.steps sets inline-size: 100%, so it survives even as a
28
+ .cluster child — it wraps and fills)
29
+
30
+ unsafe the element's width depends on its contents' intrinsic
31
+ contribution (a bare .grid child of .cluster measures 0 once
32
+ contained, as does a float, inline-block, table, grid auto track,
33
+ max-content parent, or an abspos box sized by its contents)
34
+
35
+ So .cluster is neither safe nor unsafe; what matters is where the child's
36
+ width comes from. */
13
37
  .container-query {
14
- container: actual-grid / inline-size;
38
+ container: actual-container / inline-size;
15
39
  }
@@ -86,13 +86,13 @@
86
86
  min-inline-size: 0;
87
87
  }
88
88
 
89
- /* Balanced subdivision, activated by an ancestor container named actual-grid.
89
+ /* Balanced subdivision, activated by an ancestor container named actual-container.
90
90
  * A .grid-N inside such a container enters only divisor states of N. The name
91
91
  * is deliberate: a grid must not key off the nearest anonymous query container,
92
92
  * or a region that sets container-type: inline-size for an unrelated reason
93
93
  * would silently change the grid. The author opts the grid in by naming the
94
94
  * context — .container-query is the official helper, or declare
95
- * container: actual-grid / inline-size on your own region.
95
+ * container: actual-container / inline-size on your own region.
96
96
  *
97
97
  * .grid-2 2 -> 1
98
98
  * .grid-3 3 -> 1
@@ -109,7 +109,7 @@
109
109
  * inline contribution and collapses it inside .cluster, .topbar, floats, and
110
110
  * any shrink-to-fit box. See docs/design-notes/grid.md.
111
111
  *
112
- * Without an actual-grid ancestor none of these @container rules resolve, so
112
+ * Without an actual-container ancestor none of these @container rules resolve, so
113
113
  * the auto-fill baseline above stays. The min-width: 0 rule is the lowest
114
114
  * step: it pins the grid to one column inside a container before any larger
115
115
  * threshold fires (a bare grid would otherwise show a 2 + 1 orphan at 28-48rem).
@@ -125,25 +125,25 @@
125
125
  * Ascending min-width only: the last matching step wins by cascade order.
126
126
  */
127
127
  @supports (container-type: inline-size) {
128
- @container actual-grid (min-width: 0px) {
128
+ @container actual-container (min-width: 0px) {
129
129
  :where(.grid-2, .grid-3, .grid-4, .grid-6) {
130
130
  grid-template-columns: 1fr;
131
131
  }
132
132
  }
133
133
 
134
- @container actual-grid (min-width: 28rem) {
134
+ @container actual-container (min-width: 28rem) {
135
135
  :where(.grid-2, .grid-4, .grid-6) {
136
136
  grid-template-columns: repeat(2, minmax(0, 1fr));
137
137
  }
138
138
  }
139
139
 
140
- @container actual-grid (min-width: 48rem) {
140
+ @container actual-container (min-width: 48rem) {
141
141
  :where(.grid-3, .grid-6) {
142
142
  grid-template-columns: repeat(3, minmax(0, 1fr));
143
143
  }
144
144
  }
145
145
 
146
- @container actual-grid (min-width: 64rem) {
146
+ @container actual-container (min-width: 64rem) {
147
147
  :where(.grid-4) {
148
148
  grid-template-columns: repeat(4, minmax(0, 1fr));
149
149
  }
@@ -14,7 +14,7 @@
14
14
  * .sidebar-layout main content + secondary aside
15
15
  * .grid responsive collection; item width is the contract
16
16
  * .grid-N structural density of N; balanced inside a container named
17
- * actual-grid (in grid.css)
17
+ * actual-container (in grid.css)
18
18
  * .column-layout explicit 12-column region composition; the author owns
19
19
  * narrow-container recomposition
20
20
  * .scroller theme-aware scrollbar density and colour
@@ -83,9 +83,8 @@
83
83
  font-size: var(--prose-h4);
84
84
  }
85
85
 
86
- /* Link ink defaults to --primary. --link is an optional escape hatch: a
87
- theme whose primary is too light or too vivid to read as inline text sets
88
- it without having to restate the rule. */
86
+ /* --link is a theme-level token (core/base.css also reads it for bare links);
87
+ prose falls back to --primary so a theme that never sets it is unaffected. */
89
88
  .prose :where(a) {
90
89
  color: var(--link, var(--primary));
91
90
  text-decoration-line: underline;
@@ -18,6 +18,22 @@
18
18
  border: 0;
19
19
  }
20
20
 
21
+ /* Enlarge a small control's touch target on touch-primary devices without
22
+ growing the visual. Opt-in — apply to genuinely small targets only. The
23
+ ::after exists only under (pointer: coarse), so on fine pointers the class
24
+ is fully neutral. */
25
+ @media (pointer: coarse) {
26
+ .touch-target {
27
+ position: relative;
28
+ }
29
+
30
+ .touch-target::after {
31
+ content: "";
32
+ position: absolute;
33
+ inset: calc(var(--space-20) * -1);
34
+ }
35
+ }
36
+
21
37
  .overflow-auto {
22
38
  overflow: auto;
23
39
  }
@@ -92,7 +92,7 @@ if (typeof document !== "undefined") {
92
92
  submitRoots.add(document);
93
93
  document.addEventListener("submit", handleSubmit);
94
94
 
95
- window.addEventListener("pagehide", () => {
95
+ document.defaultView?.addEventListener("pagehide", () => {
96
96
  for (const input of revealed) {
97
97
  // Opportunistic housekeeping: an input removed from the DOM before
98
98
  // navigation no longer needs reverting and can leave the set.
@@ -25,6 +25,7 @@ export const CLASSES = {
25
25
  // for aria-invalid/danger state. Optional — degrades to no-op.
26
26
  field: "field",
27
27
  // surface.js — state class on the open flyout/menu/dialog surface.
28
+ // status.js writes the same class on the status bar while a message shows.
28
29
  open: "is-open",
29
30
  // surface.js — state class while the surface renders as a mobile sheet.
30
31
  sheet: "is-sheet",
package/src/js/status.js CHANGED
@@ -2,8 +2,15 @@
2
2
  * Status — singleton floating status bar.
3
3
  *
4
4
  * A status bar is a transient, non-critical feedback area. Keep one element in
5
- * the HTML, empty by default; JavaScript only updates its text content. It is
6
- * hidden visually when empty but stays available as a live region.
5
+ * the HTML, empty by default; JavaScript writes its text content and toggles
6
+ * the shared `.is-open` state class, which is the visual state the CSS
7
+ * animates. The element itself is never hidden, so it stays a live region.
8
+ *
9
+ * Showing and clearing are asymmetric on purpose. A show writes content first
10
+ * and opens after; a clear only closes, and empties content and intent once the
11
+ * exit transition has finished, so the bar leaves in the state it was shown in
12
+ * rather than collapsing to a neutral empty pill mid-animation. An empty
13
+ * message is a clear, not a blank pill.
7
14
  *
8
15
  * Importing the module auto-wires it to Actual's validation: a form that fails
9
16
  * to submit shows its data-validation-message in the status bar. The bar is a
@@ -39,17 +46,62 @@
39
46
 
40
47
  import { registerCommands, targetFor } from "./command.js";
41
48
  import { EVENTS } from "./events.js";
49
+ import { CLASSES } from "./selectors.js";
50
+ import { waitForTransitions } from "./transition.js";
42
51
 
43
52
  const STATUS_SELECTOR = '[data-status][role="status"]';
44
53
  const STATUS_CLASSES_ATTR = "statusClasses";
54
+ const VIEWPORT_OFFSET_PROPERTY = "--status-viewport-offset";
45
55
 
46
56
  let statusTimer;
57
+ // Bumped by every show and every clear. A deferred exit cleanup only runs when
58
+ // its own generation is still current, so a message that arrives mid-exit is
59
+ // never emptied by the previous message's cleanup.
60
+ let statusGeneration = 0;
61
+ let viewportTracker = null;
47
62
 
48
63
  function statusTarget() {
49
64
  if (typeof document === "undefined") return null;
50
65
  return document.querySelector(STATUS_SELECTOR);
51
66
  }
52
67
 
68
+ // The bar is fixed to the layout viewport, so the mobile software keyboard
69
+ // covers it. visualViewport reports the occluded band; the CSS adds it to the
70
+ // bottom inset. Tracking runs only while a message is on screen.
71
+ function trackViewport(target) {
72
+ if (viewportTracker?.target === target) return;
73
+ releaseViewport();
74
+
75
+ const view = target.ownerDocument?.defaultView;
76
+ const viewport = view?.visualViewport;
77
+ if (!viewport) return;
78
+
79
+ const controller = new AbortController();
80
+ const update = () => {
81
+ // Self-heal: an app that drops the bar while a message is up never reaches
82
+ // the clear() path, so the tracker would outlive its element.
83
+ if (!target.isConnected) {
84
+ releaseViewport();
85
+ return;
86
+ }
87
+ const occluded = Math.max(0, view.innerHeight - viewport.height - viewport.offsetTop);
88
+ target.style.setProperty(VIEWPORT_OFFSET_PROPERTY, `${occluded}px`);
89
+ };
90
+
91
+ viewport.addEventListener("resize", update, { signal: controller.signal });
92
+ viewport.addEventListener("scroll", update, { signal: controller.signal });
93
+ update();
94
+
95
+ viewportTracker = { target, stop: () => controller.abort() };
96
+ }
97
+
98
+ function releaseViewport() {
99
+ if (!viewportTracker) return;
100
+ viewportTracker.stop();
101
+ viewportTracker.target.style.removeProperty(VIEWPORT_OFFSET_PROPERTY);
102
+ viewportTracker = null;
103
+ }
104
+
53
105
  function intentClasses(value) {
54
106
  return typeof value === "string" ? value.trim().split(/\s+/).filter(Boolean) : [];
55
107
  }
@@ -66,7 +118,17 @@ export function status(message, options = {}) {
66
118
  const target = statusTarget();
67
119
  if (!target || message == null) return;
68
120
 
121
+ // A live region with nothing to announce is not a message. `:empty` used to
122
+ // absorb this case for free; now that the open state is explicit, an empty
123
+ // message has to mean "close" or it would open a blank pill. This also keeps
124
+ // status(await response.text()) from leaving a stale message on screen.
125
+ if (message === "") {
126
+ status.clear();
127
+ return;
128
+ }
129
+
69
130
  clearTimeout(statusTimer);
131
+ statusGeneration++;
70
132
 
71
133
  clearIntentClasses(target);
72
134
  const intent = intentClasses(options.intent);
@@ -75,7 +137,11 @@ export function status(message, options = {}) {
75
137
  target.dataset[STATUS_CLASSES_ATTR] = intent.join(" ");
76
138
  }
77
139
 
140
+ // Content and intent are in place before the bar opens, so it enters at its
141
+ // final size and color.
78
142
  target.textContent = message;
143
+ target.classList.add(CLASSES.open);
144
+ trackViewport(target);
79
145
 
80
146
  if (options.duration !== false) {
81
147
  statusTimer = setTimeout(() => status.clear(), options.duration ?? 3000);
@@ -83,12 +149,26 @@ export function status(message, options = {}) {
83
149
  }
84
150
 
85
151
  status.clear = function clear() {
152
+ clearTimeout(statusTimer);
153
+
86
154
  const target = statusTarget();
87
- if (target) {
155
+ if (!target) {
156
+ // No bar to animate out — but one may have been removed while open, and its
157
+ // viewport listeners and element reference still have to go.
158
+ releaseViewport();
159
+ return;
160
+ }
161
+
162
+ const generation = ++statusGeneration;
163
+ target.classList.remove(CLASSES.open);
164
+
165
+ waitForTransitions(target).then(() => {
166
+ // A show during the exit owns the bar now; leave its message alone.
167
+ if (generation !== statusGeneration) return;
88
168
  target.textContent = "";
89
169
  clearIntentClasses(target);
90
- }
91
- clearTimeout(statusTimer);
170
+ releaseViewport();
171
+ });
92
172
  };
93
173
 
94
174
  const STATUS_SHOW_COMMANDS = ["--status"];
@@ -125,8 +205,9 @@ if (typeof document !== "undefined") {
125
205
  // importing this module. A message shows it; omitting one clears it.
126
206
  document.addEventListener(EVENTS.status, (event) => {
127
207
  const { message, intent, duration } = event.detail ?? {};
128
- // `message != null` keeps "" (an intentional empty message) on the show
129
- // path, consistent with status() itself; an absent message clears.
208
+ // An absent message clears here; "" reaches status() and closes there. Both
209
+ // paths end in the same exit, so a detail that lost its message never
210
+ // strands a stale one on screen.
130
211
  if (message != null) {
131
212
  status(message, { intent, duration });
132
213
  } else {
package/src/js/surface.js CHANGED
@@ -4,6 +4,7 @@ import { EVENTS } from "./events.js";
4
4
  import { autoUpdate, reposition, repositionAt } from "./floating.js";
5
5
 
6
6
  import { CLASSES } from "./selectors.js";
7
+ import { waitForTransitions } from "./transition.js";
7
8
 
8
9
  const openSurfaces = new Set();
9
10
  const surfaceMap = new WeakMap();
@@ -46,16 +47,6 @@ function reaperFor(menu) {
46
47
  return reaper;
47
48
  }
48
49
 
49
- function waitForAnimations(...elements) {
50
- const animations = elements
51
- .filter(Boolean)
52
- .flatMap((el) =>
53
- typeof el.getAnimations === "function" ? el.getAnimations({ subtree: true }) : [],
54
- );
55
-
56
- return Promise.allSettled(animations.map((animation) => animation.finished));
57
- }
58
-
59
50
  function getSurfaceRoot(menu, anchor) {
60
51
  return anchor?.closest("dialog") || menu.closest("dialog") || menu.ownerDocument.body;
61
52
  }
@@ -360,7 +351,7 @@ export function closeSurface(menu, opts = {}) {
360
351
  state.restoreFocusTo.focus({ preventScroll: true });
361
352
  }
362
353
 
363
- waitForAnimations(menu, backdrop).then(() => {
354
+ waitForTransitions(menu, backdrop).then(() => {
364
355
  if (!state || state.closeId !== closeId) return;
365
356
  state.isSheet = false;
366
357
  menu.classList.remove(CLASSES.sheet);