actual-css 0.7.0 → 0.9.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 (144) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +13 -11
  3. package/components.json +668 -0
  4. package/dist/actual.css +166 -37
  5. package/dist/actual.full.css +1087 -643
  6. package/dist/actual.full.js +1 -1
  7. package/dist/actual.full.min.css +1 -1
  8. package/dist/actual.js +1 -1
  9. package/dist/actual.min.css +1 -1
  10. package/docs/design-notes/accessibility-decisions.md +33 -0
  11. package/docs/design-notes/browser-support.md +62 -30
  12. package/docs/design-notes/column-layout.md +3 -3
  13. package/docs/design-notes/css-anchor.md +75 -21
  14. package/docs/design-notes/enhancement-contract.md +14 -6
  15. package/docs/design-notes/grid.md +4 -4
  16. package/docs/design-notes/naming.md +6 -1
  17. package/docs/design-notes/platform-alignment.md +53 -7
  18. package/docs/design-notes/popover-manual-poc.md +105 -25
  19. package/docs/design-notes/surface-reparenting.md +56 -47
  20. package/docs/design-notes/tooltip-coordinate-space.md +132 -0
  21. package/docs/design-notes/widget-primitives.md +7 -4
  22. package/docs/pages/components/accordion.md +41 -2
  23. package/docs/pages/components/alert.md +85 -30
  24. package/docs/pages/components/app-navigation.md +6 -0
  25. package/docs/pages/components/avatar.md +6 -6
  26. package/docs/pages/components/badge.md +101 -16
  27. package/docs/pages/components/button.md +16 -8
  28. package/docs/pages/components/card.md +10 -10
  29. package/docs/pages/components/dialog.md +21 -6
  30. package/docs/pages/components/drawer.md +13 -0
  31. package/docs/pages/components/fab.md +4 -3
  32. package/docs/pages/components/list.md +42 -4
  33. package/docs/pages/components/navbar.md +17 -1
  34. package/docs/pages/components/pagination.md +2 -2
  35. package/docs/pages/components/skeleton.md +3 -1
  36. package/docs/pages/components/steps.md +19 -0
  37. package/docs/pages/components/table.md +3 -2
  38. package/docs/pages/enhancements/flyout.md +82 -38
  39. package/docs/pages/enhancements/overview.md +25 -0
  40. package/docs/pages/enhancements/status-bar.md +1 -1
  41. package/docs/pages/enhancements/tabs.md +45 -37
  42. package/docs/pages/enhancements/tooltip.md +36 -10
  43. package/docs/pages/examples/overview.md +38 -5
  44. package/docs/pages/forms/choice-cards.md +11 -6
  45. package/docs/pages/forms/combobox.md +1 -1
  46. package/docs/pages/forms/floating-field.md +4 -3
  47. package/docs/pages/forms/joined-controls.md +22 -0
  48. package/docs/pages/forms/otp.md +2 -1
  49. package/docs/pages/forms/overview.md +1 -1
  50. package/docs/pages/forms/states-and-sizes.md +16 -18
  51. package/docs/pages/forms/sticky-actions.md +2 -2
  52. package/docs/pages/forms/switch.md +1 -1
  53. package/docs/pages/foundations/philosophy.md +57 -26
  54. package/docs/pages/foundations/theming.md +2 -2
  55. package/docs/pages/foundations/tokens.md +118 -21
  56. package/docs/pages/foundations/typography.md +35 -14
  57. package/docs/pages/guides/introduction.md +11 -9
  58. package/docs/pages/guides/progressive-enhancement.md +115 -30
  59. package/docs/pages/guides/tailwind.md +3 -2
  60. package/docs/pages/layout/media.md +1 -1
  61. package/docs/pages/layout/overview.md +11 -1
  62. package/docs/pages/layout/scroll-snap.md +3 -3
  63. package/docs/pages/layout/stack.md +17 -9
  64. package/docs/pages/patterns/icon-input.md +4 -2
  65. package/docs/pages/patterns/icon-slot.md +2 -0
  66. package/docs/pages/patterns/intent-nesting.md +1 -1
  67. package/docs/pages/utilities/overview.md +23 -2
  68. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  69. package/docs/pages/utilities/spacing.md +10 -8
  70. package/llms.txt +2 -1
  71. package/package.json +159 -149
  72. package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
  73. package/src/css/components/accordion.css +42 -3
  74. package/src/css/components/alert.css +25 -13
  75. package/src/css/components/app-nav.css +18 -8
  76. package/src/css/components/avatar.css +13 -1
  77. package/src/css/components/badge.css +78 -12
  78. package/src/css/components/breadcrumb.css +2 -3
  79. package/src/css/components/button.css +9 -16
  80. package/src/css/components/card.css +9 -2
  81. package/src/css/components/chat.css +1 -0
  82. package/src/css/components/data-list.css +1 -1
  83. package/src/css/components/drawer.css +101 -32
  84. package/src/css/components/fab.css +0 -6
  85. package/src/css/components/flyout.css +35 -98
  86. package/src/css/components/index.css +0 -1
  87. package/src/css/components/indicator.css +21 -0
  88. package/src/css/components/key.css +10 -0
  89. package/src/css/components/list.css +40 -4
  90. package/src/css/components/menu.css +2 -1
  91. package/src/css/components/modal.css +48 -33
  92. package/src/css/components/navbar.css +37 -4
  93. package/src/css/components/pagination.css +1 -0
  94. package/src/css/components/skeleton.css +15 -4
  95. package/src/css/components/spinner.css +6 -6
  96. package/src/css/components/status-bar.css +0 -5
  97. package/src/css/components/steps.css +45 -59
  98. package/src/css/components/tab.css +29 -9
  99. package/src/css/components/table.css +13 -5
  100. package/src/css/components/tooltip.css +36 -40
  101. package/src/css/core/base.css +23 -1
  102. package/src/css/core/intents.css +17 -6
  103. package/src/css/core/theme.css +27 -0
  104. package/src/css/core/tokens.css +35 -15
  105. package/src/css/core/variants.css +64 -15
  106. package/src/css/forms/choice-card.css +4 -4
  107. package/src/css/forms/choice.css +76 -29
  108. package/src/css/forms/control.css +2 -1
  109. package/src/css/forms/floating-field.css +3 -3
  110. package/src/css/forms/form.css +4 -3
  111. package/src/css/forms/input-icon.css +20 -14
  112. package/src/css/forms/native.css +6 -1
  113. package/src/css/forms/otp.css +9 -1
  114. package/src/css/forms/range.css +2 -0
  115. package/src/css/forms/select.css +29 -9
  116. package/src/css/forms/switch.css +28 -8
  117. package/src/css/layout/app-layout.css +1 -1
  118. package/src/css/layout/grid.css +50 -76
  119. package/src/css/layout/scroll-snap.css +11 -7
  120. package/src/css/layout/scroller.css +3 -4
  121. package/src/css/layout/stack.css +2 -2
  122. package/src/css/typography/fluid.css +4 -3
  123. package/src/css/typography/prose.css +6 -5
  124. package/src/css/utilities/base.css +2 -1
  125. package/src/css/utilities/extra.css +20 -17
  126. package/src/js/context-menu.js +27 -26
  127. package/src/js/dialog.js +2 -1
  128. package/src/js/dismiss.js +5 -0
  129. package/src/js/enhance.js +34 -2
  130. package/src/js/enhancement-loader.js +36 -43
  131. package/src/js/events.js +0 -3
  132. package/src/js/filter.js +0 -1
  133. package/src/js/floating-space.js +42 -0
  134. package/src/js/flyout.js +9 -44
  135. package/src/js/focus-group.js +7 -0
  136. package/src/js/focus.js +6 -0
  137. package/src/js/menu.js +10 -1
  138. package/src/js/selectors.js +5 -9
  139. package/src/js/status.js +6 -5
  140. package/src/js/surface.js +97 -139
  141. package/src/js/tab.js +6 -1
  142. package/src/js/tooltip.js +150 -51
  143. package/src/js/validation.js +1 -1
  144. package/src/css/components/surface.css +0 -49
@@ -7,9 +7,10 @@ negative, which are the ones a future attempt would otherwise repeat.
7
7
  Question asked: can `popover="manual"` replace the DOM reparenting in
8
8
  `surface.js` without changing anything else?
9
9
 
10
- Answer: yes, and the branch demonstrates it. What blocks shipping is not the
11
- architecture; it is the browser contract. See
12
- [browser-support](browser-support.md).
10
+ Answer: yes. The branch demonstrated it, the WebKit gate below closed it, and
11
+ the migration checklist at the end has been executed — so this note is now also
12
+ the record of what the checklist got wrong. The cost was a browser floor, not
13
+ an architecture change; see [browser-support](browser-support.md).
13
14
 
14
15
  ## Branch
15
16
 
@@ -138,14 +139,13 @@ pass, it is no longer a transport swap and the claim does not hold.
138
139
 
139
140
  ## Go / no-go: WebKit verification
140
141
 
141
- Every measurement in this note was taken on Chromium — the browser test harness
142
+ The measurements above were taken on Chromium — the browser test harness
142
143
  hardcodes `backend: "chrome"`. The floor's most notable step is Safari 17, so
143
- the transport has no measured evidence on the engine the raise actually buys.
144
- This is the one open technical gate on the migration, and the only item on the
145
- checklist below that is not already demonstrated.
144
+ the transport needed evidence on the engine the raise actually buys. That was
145
+ the one open technical gate on the migration. It is now closed.
146
146
 
147
- A permanent WebKit matrix is disproportionate for a decision taken once. One
148
- manual pass on a real Safari at the chosen floor, recorded here, is enough:
147
+ A permanent WebKit matrix was disproportionate for a decision taken once. One
148
+ manual pass on a real Safari at the chosen floor:
149
149
 
150
150
  ```text
151
151
  1. showPopover() / hidePopover() in manual mode
@@ -158,24 +158,66 @@ manual pass on a real Safari at the chosen floor, recorded here, is enough:
158
158
  8. the sheet scrim covers and intercepts the click
159
159
  ```
160
160
 
161
- Record the result in this section:
161
+ `demo/templates/popover-transport.html` is that pass, scored by the page. It
162
+ loads no Actual JavaScript and measures the platform subset above, so it
163
+ answers on `master` without checking the branch out. Scenarios 1–6 run
164
+ unattended; 7 and 8 need a real gesture. It scores 8/8 on Chromium and
165
+ reproduces the findings above, `::backdrop` included — so a deviation on WebKit
166
+ is the engine, not the bench.
167
+
168
+ ### Result — gate passed
162
169
 
163
170
  ```text
164
171
  Verified manually on:
165
- Safari __._ / macOS __
166
- iOS Safari __._ / iOS __ (if reachable)
167
- Date: ____-__-__
168
- Scenarios: _/8 passed
172
+ iOS Safari 17.0 / iOS 17.0 8/8 passed
173
+ Safari 17.3 / macOS unrecorded 8/8 passed
174
+ Date: 2026-09-04
169
175
  ```
170
176
 
171
- Scenario 5 deserves the most attention. On Chromium the native top layer solved
172
- cleanly the problem that motivated `getSurfaceRoot()` in the first place — a
173
- modal dialog inerts the rest of the document — and that result is what makes
174
- the transport worth making mandatory. Scenario 2 is the second: the old
175
- transport used `[hidden]`, which `isElementVisible()` reads in its first
176
- branch, and the migration shifts the guard onto `checkVisibility()` with
177
- `getClientRects()` behind it. Which branch actually runs depends on what the
178
- bottom of the new floor implements, and the third has never been exercised.
177
+ iOS 17.0 is the bottom of the proposed floor, measured rather than inferred.
178
+ The desktop score is the union of two page loads on one engine, the iOS score a
179
+ single session at 390×745; a second iOS pass at 341×651 reproduced everything
180
+ but scenario 7, whose assertion the bench has since corrected — see below.
181
+ Safari freezes its macOS UA token at `10_15_7`, so the desktop host version is
182
+ not recoverable from a report. macOS Safari 17.0–17.2 stay inferred, on the
183
+ same WebKit that iOS 17.0 exercised.
184
+
185
+ Scenario 7 ran through the synthetic fallback on both iOS passes: iOS fires no
186
+ `contextmenu` from a long press, so that event path is unexercised on the port
187
+ where it matters most, and the scenario attests placement only.
188
+
189
+ Both ports report the same subset: the four runtime primitives and
190
+ `selector(:popover-open)` present; `checkVisibility()`, `overlay`,
191
+ `transition-behavior: allow-discrete` and `@starting-style` absent. That
192
+ matches WebKit's published timeline — Popover in 17.0, the rest in 17.4 and
193
+ 17.5 — which is corroboration that the bench measures rather than guesses.
194
+
195
+ Four results carry consequences:
196
+
197
+ **Scenario 5 passes on both ports**, which is what makes the transport worth
198
+ making mandatory: the native top layer solves on WebKit the problem that
199
+ motivated `getSurfaceRoot()` in the first place, on a 390px phone included.
200
+
201
+ **Scenario 2 passes on the fallback branch.** `checkVisibility()` does not
202
+ exist below Safari 17.4, so `isElementVisible()` runs its `getClientRects()`
203
+ branch at the floor — the branch that had never been exercised. It works, and
204
+ it is load-bearing rather than dead.
205
+
206
+ **`::backdrop` is weaker on WebKit, not stronger.** Author `pointer-events` on
207
+ an open popover's backdrop still computes to `none`, the native backdrop still
208
+ lets the pointer through, and an Actual scrim element still absorbs it,
209
+ confirmed with a real tap. The one thing `::backdrop` did well on Chromium —
210
+ resolving a custom property scoped to an ancestor of the panel — resolves to
211
+ nothing on WebKit. The scrim stays an Actual element on both engines, and not
212
+ by Chromium's accident.
213
+
214
+ **An overflowing top-layer box is adjusted differently.** Asked to place a
215
+ panel at a point that would push it past the right edge of a 341px viewport,
216
+ iOS Safari 17.0 offset it by 11px; Chromium at the identical viewport and
217
+ overflow offset it by 0. Not a gate: `surface.js` positions through floating's
218
+ `shift` middleware with `shiftPadding: 4`, so it never asks for such a point
219
+ and measures the rendered rect afterwards. The bench now picks a point that
220
+ fits, so scenario 7 measures placement rather than the UA's correction.
179
221
 
180
222
  ## Changelog framing for the raise
181
223
 
@@ -202,7 +244,7 @@ For the release that raises Minimal. Items 4–7 are scaffolding built for a flo
202
244
  that will no longer exist; a gate outliving its reason is silent dead weight, so
203
245
  they belong here rather than being found later.
204
246
 
205
- 1. Pass the WebKit verification above and record it. This is the go/no-go.
247
+ 1. WebKit verification — done, 8/8 on Safari 17.3, recorded above. Cleared.
206
248
  2. Raise Minimal to the required subset above. The justification is the defect
207
249
  and the transparent migration, not a download count: npm figures conflate
208
250
  CI, bots, caches and the author, and say nothing about the end browser.
@@ -213,11 +255,16 @@ they belong here rather than being found later.
213
255
  `scrollbar-gutter`, `scrollbar-width`, `scrollbar-color`) as Minimal
214
256
  baseline and drop their `check-compat` entries.
215
257
  5. Remove the `@supports selector(:popover-open)` gates in `flyout.css` and
216
- `tooltip.css`.
258
+ `tooltip.css`. Those only: the `@supports (transition-behavior:
259
+ allow-discrete)` gate and the `@starting-style` blocks stay, since neither
260
+ is in the floor — Safari 17.4 and 17.5.
217
261
  6. Remove the two `popover` entries from `check-compat`'s feature table.
218
262
  7. Remove the associated `compat-ok:` pragmas, and `hasPragma()` itself if
219
263
  nothing else consumes it.
220
- 8. Move `surface.js` to the manual transport, with no fallback branch.
264
+ 8. Move `surface.js` to the manual transport, with no fallback branch. This
265
+ does not reach `isElementVisible()` in `focus.js`: `checkVisibility()` is
266
+ Safari 17.4, so its `getClientRects()` fallback is the branch that runs at
267
+ the floor and must stay.
221
268
  9. Drop `test.failing` from the reparenting tests in
222
269
  `tests/browser/surface-inherited-context.test.js`, keeping the assertions.
223
270
  10. Update [surface-reparenting](surface-reparenting.md) from a known limitation
@@ -229,6 +276,39 @@ the transport changes — but nothing forces step 9. The test file points at the
229
276
  note, and the note points back here; that is as far as documentation can close
230
277
  the loop.
231
278
 
279
+ ### Executed — where the checklist was wrong
280
+
281
+ All ten items are done. Three needed correcting on contact, and the corrections
282
+ are worth more than the items were.
283
+
284
+ **Item 4 rested on a false premise.** Raising Minimal does not make a CSS
285
+ capability baseline, because Minimal is the *JavaScript* floor: a Degraded
286
+ browser runs no supported runtime and still receives every stylesheet, so CSS
287
+ owes its fallbacks that far down. `check:compat` now states the Degraded floor
288
+ and keeps the entries, relabelled — dropping them would have removed the only
289
+ machine check that keeps those fallbacks honest. Two of the six features were
290
+ also mistagged before the migration: `scrollbar-gutter`, `scrollbar-width` and
291
+ `scrollbar-color` land in Safari 18.2, above every tier including Recommended,
292
+ so they are `optional` rather than absorbed. The same relabelling moved `:has()`
293
+ and `@container` to `minimal` — the new floor genuinely does absorb them, which
294
+ the checklist missed because it enumerated the Intermediate tier's features
295
+ instead of reading the new floor.
296
+
297
+ **Item 7 cost more than it removed.** `hasPragma()` had no consumer left once
298
+ the popover entries went, so it is gone — and with it five tests, including the
299
+ one recording that prose must not excuse an optional structural use. The
300
+ discovery is now a comment in `check-compat.js` naming the pragma to reinstate.
301
+ Deleting a guard whose only proof of value was its own test is the trade this
302
+ item asked for; it should be re-read before the next optional structural
303
+ capability arrives.
304
+
305
+ **Item 8 left one author-facing decision open.** The POC preserved an author's
306
+ own `popover` value and only defaulted a missing one. `prepareSurface()` now
307
+ writes `manual` unconditionally: any other mode hands the UA a dismissal policy,
308
+ and native light dismiss closes on outside clicks only — which silently defeats
309
+ `data-flyout-auto-close="false"`. The old `[popover]` conflict warning had
310
+ caught that case; overwriting the attribute is what replaces it.
311
+
232
312
  ## What was deliberately left out
233
313
 
234
314
  Not attempted, and not because it looked hard:
@@ -1,12 +1,12 @@
1
- # Surface reparenting breaks inherited presentation context
1
+ # Surface reparenting broke inherited presentation context
2
2
 
3
- Known defect, present on the current Minimal floor. Tracked separately from
4
- Popover adoption on purpose: Popover is the likely cure, but the defect exists
5
- now and needs to be understood on its own terms.
3
+ Resolved. The record is kept because the defect had no generic repair, three of
4
+ the obvious ones look right and are wrong, and the transport that fixed it is
5
+ the reason the JavaScript floor sits where it does.
6
6
 
7
- ## What happens
7
+ ## What used to happen
8
8
 
9
- `surface.js` moves an open surface out of its place in the document:
9
+ `surface.js` moved an open surface out of its place in the document:
10
10
 
11
11
  ```text
12
12
  getSurfaceRoot() → nearest <dialog>, else body
@@ -14,15 +14,15 @@ mountSurface() → root.append(panel)
14
14
  restoreSurface() → put it back once the exit transition ends
15
15
  ```
16
16
 
17
- The move exists for good reasons — it escapes `overflow` clipping on any
18
- ancestor and sidesteps `z-index` stacking. But it also takes the panel out of
19
- every scope that reached it *by inheritance*. The panel is styled by one set of
20
- ancestors while it is closed, and by a different set from the moment it becomes
17
+ The move existed for good reasons — it escaped `overflow` clipping on any
18
+ ancestor and sidestepped `z-index` stacking. But it also took the panel out of
19
+ every scope that reached it *by inheritance*. The panel was styled by one set of
20
+ ancestors while closed, and by a different set from the moment it became
21
21
  visible.
22
22
 
23
23
  Actual leans on inheritance deliberately: theme islands, density, `.inverted`,
24
24
  and the custom properties an application scopes to its own containers all
25
- travel that way. So this is not one bug about themes. It is the same bug in as
25
+ travel that way. So this was not one bug about themes. It was the same bug in as
26
26
  many forms as there are inherited contexts.
27
27
 
28
28
  ## Measured
@@ -38,23 +38,24 @@ click that opens it:
38
38
  | app-scoped `--app-accent` | `rgb(1, 2, 3)` | *(unset)* |
39
39
  | `.sm` inside a `<dialog>` | `--control-size: 2rem` | `2.375rem` |
40
40
 
41
- Every context breaks. Three consequences are worth stating plainly:
41
+ Every context broke. Three consequences were worth stating plainly:
42
42
 
43
- - The theme case is visible, not theoretical: a flyout inside a dark island
44
- turns light at the moment it opens.
45
- - The density case is measurable: menu rows render 38px instead of the 32px the
46
- scope asked for.
47
- - The dialog case shows the `getSurfaceRoot()` special case does not help. The
48
- panel does land inside the dialog, and still loses any scope sitting between
43
+ - The theme case was visible, not theoretical: a flyout inside a dark island
44
+ turned light at the moment it opened.
45
+ - The density case was measurable: menu rows rendered 38px instead of the 32px
46
+ the scope asked for.
47
+ - The dialog case showed the `getSurfaceRoot()` special case did not help. The
48
+ panel did land inside the dialog, and still lost any scope sitting between
49
49
  that dialog and its trigger. Choosing a nearer root loses less context; it
50
50
  does not preserve it.
51
51
 
52
- Locked by `tests/browser/surface-inherited-context.test.js`, whose assertions
53
- describe the contract Actual owes and are marked `test.failing` until the
54
- transport stops reparenting.
52
+ `tests/browser/surface-inherited-context.test.js` holds those assertions and
53
+ now passes against the shipped transport.
55
54
 
56
55
  ## Why the obvious repairs are wrong
57
56
 
57
+ Still wrong, and still tempting — this section is the reason the note is kept.
58
+
58
59
  **Copying `data-theme` onto the moved panel.** Fixes the one symptom that was
59
60
  noticed and leaves the other four broken. It also turns an inherited contract
60
61
  into an enumerated one, so every future scope has to be added to a list.
@@ -67,13 +68,13 @@ would also freeze values that should stay live.
67
68
  **Mounting to a nearer root.** The dialog row above is the experiment: it
68
69
  reduces the damage without addressing the cause.
69
70
 
70
- The cause is that the panel is moved at all. The repair is a transport that
71
+ The cause was that the panel was moved at all. The repair is a transport that
71
72
  promotes the element without relocating it.
72
73
 
73
- ## The actual repair
74
+ ## The repair, as shipped
74
75
 
75
76
  A native popover renders in the top layer while staying exactly where the
76
- author put it in the DOM. That removes the reason to reparent, and with it the
77
+ author put it in the DOM. That removed the reason to reparent, and with it the
77
78
  whole mechanism:
78
79
 
79
80
  ```text
@@ -86,37 +87,45 @@ restoreSurface()
86
87
  `popover="manual"` is the mode that fits: it supplies the top layer and nothing
87
88
  else, so `surface.js` keeps its dismissal policy, Escape ordering, focus
88
89
  restoration and placement orchestration unchanged. See
89
- [platform-alignment](platform-alignment.md) for the responsibility split and
90
- the criteria a component must meet before adopting Popover, and
91
- [browser-support](browser-support.md) for which tier guarantees it.
90
+ [platform-alignment](platform-alignment.md) for the responsibility split, and
91
+ [browser-support](browser-support.md) for the floor it requires.
92
+
93
+ Adopting it behind a capability branch was rejected rather than overlooked: the
94
+ fallback path *is* the defect, so a branch would have kept the bug alive for
95
+ exactly the browsers it was written to serve. Raising the floor was the cheaper
96
+ half of the decision.
92
97
 
93
- That change is gated on Popover being available across Minimal. It is not
94
- available there today, and adopting it behind a capability branch would keep
95
- the reparenting code alive for the fallback path — so the defect would remain
96
- for exactly the browsers the branch was written to serve.
98
+ ## The contract now
97
99
 
98
- ## Until then
100
+ > A surface managed by `surface.js` is presented in its trigger's context. A
101
+ > scoped theme, density, or custom property reaches an anchored surface through
102
+ > inheritance, open or closed.
99
103
 
100
- The honest interim position is a documented limitation, not a patch:
104
+ The two workarounds this note used to recommend — declaring the scope on `body`
105
+ or on the panel itself — are no longer needed and can be removed from
106
+ application code.
101
107
 
102
- > A surface managed by `surface.js` is presented in the document's own context,
103
- > not its trigger's. Do not rely on a scoped theme, density, or custom property
104
- > reaching an anchored surface through inheritance.
108
+ `tooltip.js` reached the same contract later, and for the same reason: it moved
109
+ an explicit tooltip to the dialog or the body on first hover, which is this
110
+ defect in a second component. An explicit tooltip now stays where it was
111
+ written and inherits accordingly.
105
112
 
106
- Applications that need a scoped surface today have two workarounds, both of
107
- which put the scope where the panel lands rather than where it starts: declare
108
- the scope on an ancestor that survives the move (`body`, or the dialog), or
109
- carry the scope on the panel element itself.
113
+ A generated tooltip (`data-tooltip="text"`) is deliberately outside the
114
+ contract. Actual creates that element, so Actual places it, and it goes to
115
+ document level rather than beside the trigger — a generated sibling would take
116
+ `.join > :last-child` from a trigger that ends a group, since structural
117
+ pseudo-classes are DOM-based and `position: fixed` does not exempt an element
118
+ from them. The shorthand therefore promises no inheritance; the explicit form
119
+ is the one to use when local context matters.
110
120
 
111
121
  ## Experiment
112
122
 
113
- A branch has demonstrated the repair without changing any other contract:
114
- [popover-manual-poc](popover-manual-poc.md) records what it measured, what it
115
- refuted, and the checklist for the release that adopts it. Read that before
116
- attempting this again — it also holds the negative results.
123
+ [popover-manual-poc](popover-manual-poc.md) records what the branch measured,
124
+ what it refuted, and the checklist the migration followed. Read that before
125
+ touching the transport again — it holds the negative results, `::backdrop`
126
+ included.
117
127
 
118
128
  ## Review trigger
119
129
 
120
- Revisit when Popover is guaranteed across Minimal, when a further inherited
121
- context is found to break, or if a transport that preserves DOM ancestry
122
- becomes available by another route.
130
+ Revisit if a further inherited context is found to break, or if a transport
131
+ that promotes without the top layer becomes available by another route.
@@ -0,0 +1,132 @@
1
+ # Tooltip coordinate space, and what an unpositionable tip means
2
+
3
+ Two changes to `tooltip.js`, both consequences of one question: what a floating
4
+ element's coordinates are relative to, and what it means when there are none.
5
+
6
+ ## The lag
7
+
8
+ Scrolling is asynchronous. The compositor can move the page before any script
9
+ runs, so a `position: fixed` element written from JavaScript is always one frame
10
+ behind the content it is attached to. `autoUpdate()` coalesces to a frame by
11
+ design, which is correct for the work and still visible as a wobble on touch
12
+ devices, where scrolling is driven off the main thread.
13
+
14
+ `@lekoala/floating` 0.2.0 adds `coordinateSpace: "document"`: the same viewport
15
+ measurement, written as `left`/`top` plus the page scroll, for an absolutely
16
+ positioned box whose containing block is the initial one. The browser then
17
+ carries the element with the page and there is nothing to correct.
18
+
19
+ Two candidates were rejected before this one:
20
+
21
+ - **Popper's `strategy: "absolute"`.** Relative to a *containing block*, not to
22
+ the reference — which means resolving the offset parent, its borders, its
23
+ scroll offsets, its transforms, and its own nested scrollers. That is the
24
+ machinery `floating` exists without.
25
+ - **Repositioning synchronously on scroll** instead of at the next frame.
26
+ Cheaper to write, and it only shortens the lag; it cannot remove it, because
27
+ the scroll that moved the page never reached the main thread in time.
28
+
29
+ ## The containing block is not a problem here
30
+
31
+ Document coordinates assume the initial containing block. A tip is an open
32
+ popover, so it is in the top layer, where an absolutely positioned box resolves
33
+ against the initial containing block whatever its ancestors do. Measured in
34
+ Chrome, writing viewport point `(200, 300)` in document coordinates at
35
+ `scrollY: 640` and reading the resulting rect back:
36
+
37
+ | Ancestor of the tip | Rect |
38
+ | -------------------- | --------- |
39
+ | `position: relative` | `200,300` |
40
+ | `transform` | `200,300` |
41
+ | `filter` | `200,300` |
42
+ | `contain: paint` | `200,300` |
43
+ | `dialog` (`show()`) | `200,300` |
44
+ | `dialog` (modal) | `200,300` |
45
+
46
+ So where the tip lives never enters into it, and the placement contract — an
47
+ explicit tip stays where the author wrote it, a generated one goes to the
48
+ dialog subtree or the body — is untouched.
49
+
50
+ ## The trigger is the problem
51
+
52
+ Document coordinates are only right while the *trigger* scrolls with the page.
53
+ Against a trigger that does not, the browser carries the tip away on every
54
+ scroll and the next frame snaps it back: a worse artefact than the lag it
55
+ removes, and a real one — `.fab`, `.status-bar` and `.drawer` are fixed,
56
+ `.navbar`, `.topbar`, `.app-nav` and `.form-actions` are sticky.
57
+
58
+ `isViewportAnchored()` therefore walks up from the trigger for a computed
59
+ `fixed` or `sticky`, and separately asks for `dialog:modal, :popover-open`,
60
+ which the walk cannot see: a modal dialog computes `position: absolute` and is
61
+ laid out against the viewport by the top layer. Sticky counts even while
62
+ unstuck, because nothing in the DOM changes when it becomes stuck.
63
+
64
+ The modal case is the one worth naming, because a modal dialog does **not**
65
+ stop the page from scrolling behind it — measured, wheel included. A trigger
66
+ inside one is as viewport-anchored as a fixed bar, and keeps `position: fixed`.
67
+
68
+ ## The scrollable overflow case
69
+
70
+ An absolutely positioned box in the top layer joins the document's scrollable
71
+ overflow; a fixed one never does. The scrollable height becomes the greater of
72
+ the content's own bottom and the tip's, and the threshold is exact: walking a
73
+ 20px tip down a 2000px document in Chrome, `scrollHeight` reads 2000 for every
74
+ tip bottom through 2000, and the tip's own bottom from 2001 on.
75
+
76
+ A tip past the end of the content therefore raises the scroll maximum while it
77
+ is up, and hiding it clamps back any position only that tip made reachable —
78
+ the page appears to jump upwards.
79
+
80
+ Placement is what prevents this, not anything in `tooltip.js`. Flip and shift
81
+ put the tip inside the viewport, which at any scroll position is inside the
82
+ document, so only overflow the engine explicitly accepts — a tip it could not
83
+ fit anywhere — reaches past the end of the content. Nor does the demote and
84
+ promote path add a case: the coordinates are a document point, so a tip that
85
+ was inside the document when it went down is still inside it when it comes
86
+ back up, exit fade included.
87
+
88
+ It is a failure mode to test for, then, not a standing cost of document
89
+ coordinates. If the page ever does jump, `documentElement.scrollHeight` across
90
+ a show and a hide separates this from everything else that moves a page: it
91
+ moves with the tip here, and holds steady for anything else.
92
+
93
+ ## `false` is a geometry answer
94
+
95
+ `reposition()` returns `false` when it cannot place the element, an anchor
96
+ outside the boundary included. `tooltip.js` used to read that as the end of the
97
+ interaction and call `hideTip()`, which stopped position tracking.
98
+
99
+ On a touch device that loses the tooltip for good: tap a trigger, it takes
100
+ focus and the tip appears; scroll the trigger past the boundary and the tip is
101
+ torn down; scroll back and nothing is watching any more. Tapping again does not
102
+ help either — the trigger is already focused, so no `focusin` fires, and a
103
+ repeat tap on the same element fires no `mouseover`.
104
+
105
+ So the runtime now keeps two states. `wanted` is what the interaction asks for
106
+ and only `hideTip()` clears it; `visible` is what is promoted. An
107
+ unpositionable tip is demoted and stays tracked, and the next tick that can
108
+ place it brings it back up.
109
+
110
+ `floating` was deliberately left out of this: no `{ positioned, reason }`
111
+ return, no option. Its README already documents the pattern — a failed
112
+ positioning may be temporary without `autoUpdate()` being stopped.
113
+
114
+ ## Surfaces share geometry, not lifecycle
115
+
116
+ Element-anchored surfaces use the same coupled coordinate and CSS position mode
117
+ as tooltips. Point-positioned surfaces stay fixed because their `x` and `y` are
118
+ client coordinates. A nested scroll container remains a separate boundary:
119
+ document coordinates move natively with page scroll, while container scroll
120
+ still waits for `autoUpdate()` to correct the placement.
121
+
122
+ A surface that fits inside its boundary must not create scrollable overflow
123
+ merely because it uses document coordinates. This is not a guarantee for an
124
+ arbitrarily large custom surface: the positioner deliberately accepts overflow
125
+ when no placement can physically contain it.
126
+
127
+ `surface.js` still closes on `false`, and that stays a separate decision. A
128
+ flyout close restores focus, fires an event and releases an Escape entry;
129
+ hiding one while its trigger is off-boundary would leave a panel the user
130
+ cannot see holding all three. The prerequisite named in
131
+ [css-anchor](css-anchor.md) — "an out-of-view surface hides instead of closing"
132
+ — is a component decision, and it has been taken for tooltips only.
@@ -9,7 +9,7 @@ The JS runtime doubles as a primitive kit for building custom widgets.
9
9
  | Discovery, lifecycle, cleanup | `enhance` | `actual-css/js/enhance` |
10
10
  | Per-document LIFO Escape dismissal | `escape` | `actual-css/js/escape` |
11
11
  | Positioning, flip and shift | `floating` | `@lekoala/floating` |
12
- | Open/close, sheet, backdrop, focus | `surface` | `actual-css/js/surface` |
12
+ | Open/close, dismissal, focus | `surface` | `actual-css/js/surface` |
13
13
  | Directional item lookup | `keys` | `actual-css/js/keys` |
14
14
  | Menu-item vocabulary | `menu` | `actual-css/js/menu` |
15
15
  | Focusable lookup, visibility | `focus` | `actual-css/js/focus` |
@@ -22,7 +22,7 @@ The JS runtime doubles as a primitive kit for building custom widgets.
22
22
 
23
23
  The named exports each module carries:
24
24
 
25
- - `enhance` — `enhance`, `registerEnhancement`
25
+ - `enhance` — `enhance`, `registerEnhancement`, `applyEnhancement`
26
26
  - `escape` — `registerEscapeDismissal`
27
27
  - `floating` — `autoUpdate`, `reposition`, `repositionAt`, plus
28
28
  `--available-height` and `data-placement`
@@ -89,12 +89,12 @@ an inline autocomplete that should survive an outside click).
89
89
 
90
90
  ```
91
91
  enhance
92
- surface (Escape, outside click, focus restore, sheet mode)
92
+ surface (Escape, outside click, focus restore)
93
93
  floating
94
94
  keys
95
95
  ```
96
96
 
97
- Less code. Inherits the runtime's dismissal semantics and mobile sheet. Use for
97
+ Less code. Inherits the runtime's dismissal semantics. Use for
98
98
  anything that behaves like a popup.
99
99
 
100
100
  > A `[role="listbox"]` surface with `[role="option"]` children gets no key handling and
@@ -130,6 +130,9 @@ state to style or ignore.
130
130
 
131
131
  ## Honest gaps
132
132
 
133
+ - No nested surfaces — one Actual surface is open per document, and opening one
134
+ closes the rest. A widget that needs a second level owns its own popup
135
+ (composition A above).
133
136
  - No typeahead helper — filter, debounce, render are author concerns.
134
137
  - No `aria-activedescendant` helper — the author manages the composite-widget pattern.
135
138
  - No virtual-list support.
@@ -5,7 +5,8 @@
5
5
  - Use native `<details>` and `<summary>` for collapsible content.
6
6
  - Use the `name` attribute for exclusive accordions.
7
7
  - Supports any valid body element (`div`, `p`, `ul`, ...).
8
- - The toggle marker color is customizable with `--accordion-marker-color`; the marker icon comes from the shared `--icon-chevron` token.
8
+ - Three container treatments: the default common shell, `.flush` (bare separators, for embedding inside an existing surface), and `.separated` (independent items).
9
+ - The toggle marker color is customizable with `--accordion-marker-color`; it reinforces to the summary text color on hover, and the marker icon comes from the shared `--icon-chevron` token.
9
10
 
10
11
  ```html demo
11
12
  <div class="accordion">
@@ -34,6 +35,44 @@
34
35
  </div>
35
36
  ```
36
37
 
38
+ ## Variants
39
+
40
+ The default builds one raised shell around the whole group. `.flush` and `.separated` only change that chrome — they are integration modes between the component and its container, not decorative skins.
41
+
42
+ - **`flush`** — the ambient box disappears and only the item separators remain, so the group sits edge to edge inside a surface that already exists: a card, a drawer, a page section. A FAQ list without a box inside a box.
43
+
44
+ ```html demo
45
+ <div class="accordion flush">
46
+ <details open>
47
+ <summary>Flush keeps just the separators</summary>
48
+ <p>No shell: embed the group in a card, drawer, or page section.</p>
49
+ </details>
50
+
51
+ <details>
52
+ <summary>Edge to edge</summary>
53
+ <p>The summary and panel follow the surrounding surface's own gutter.</p>
54
+ </details>
55
+ </div>
56
+ ```
57
+
58
+ - **`separated`** — each `<details>` becomes its own card, for groups of standalone items.
59
+
60
+ ```html demo
61
+ <div class="accordion separated">
62
+ <details open>
63
+ <summary>Each item is its own card</summary>
64
+ <p>Standalone items share the same gap, border, radius, and raised surface.</p>
65
+ </details>
66
+
67
+ <details>
68
+ <summary>Still native details</summary>
69
+ <p>Exclusive <code>name</code> groups and per-item icons work exactly as in the base component.</p>
70
+ </details>
71
+ </div>
72
+ ```
73
+
74
+ The accordion owns its disclosure marker; the trigger content belongs to the markup. Item accents, per-item shadows, an underlined trigger label, and alternative markers stay application recipes, not framework variants.
75
+
37
76
  ## Exclusive groups
38
77
 
39
78
  ```html demo
@@ -77,4 +116,4 @@ The end marker is a shared token-based chevron; swapping it for a local icon is
77
116
  ## CSS hooks
78
117
 
79
118
  - `--accordion-radius` — outer corner radius.
80
- - `--accordion-marker-color` — color of the end marker; forced-colors mode overrides it to `CanvasText`.
119
+ - `--accordion-marker-color` — color of the end marker; it reinforces to the summary text color on hover, and forced-colors mode overrides it to `CanvasText`.