actual-css 0.6.0 → 0.8.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 (143) hide show
  1. package/CHANGELOG.md +214 -1
  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 +1005 -550
  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 +82 -25
  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 +99 -2
  18. package/docs/design-notes/popover-manual-poc.md +328 -0
  19. package/docs/design-notes/surface-reparenting.md +131 -0
  20. package/docs/design-notes/tooltip-coordinate-space.md +132 -0
  21. package/docs/design-notes/widget-primitives.md +9 -6
  22. package/docs/pages/components/accordion.md +41 -2
  23. package/docs/pages/components/alert.md +80 -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 +16 -6
  30. package/docs/pages/components/fab.md +4 -3
  31. package/docs/pages/components/list.md +42 -4
  32. package/docs/pages/components/navbar.md +17 -1
  33. package/docs/pages/components/pagination.md +2 -2
  34. package/docs/pages/components/steps.md +19 -0
  35. package/docs/pages/components/table.md +3 -2
  36. package/docs/pages/enhancements/flyout.md +144 -24
  37. package/docs/pages/enhancements/overview.md +25 -0
  38. package/docs/pages/enhancements/status-bar.md +20 -0
  39. package/docs/pages/enhancements/tabs.md +45 -37
  40. package/docs/pages/enhancements/tooltip.md +40 -2
  41. package/docs/pages/examples/overview.md +38 -5
  42. package/docs/pages/forms/choice-cards.md +6 -2
  43. package/docs/pages/forms/combobox.md +1 -1
  44. package/docs/pages/forms/floating-field.md +4 -3
  45. package/docs/pages/forms/joined-controls.md +22 -0
  46. package/docs/pages/forms/otp.md +2 -1
  47. package/docs/pages/forms/overview.md +1 -1
  48. package/docs/pages/forms/states-and-sizes.md +16 -18
  49. package/docs/pages/forms/sticky-actions.md +2 -2
  50. package/docs/pages/forms/switch.md +1 -1
  51. package/docs/pages/foundations/philosophy.md +67 -22
  52. package/docs/pages/foundations/theming.md +10 -2
  53. package/docs/pages/foundations/tokens.md +118 -21
  54. package/docs/pages/foundations/typography.md +35 -14
  55. package/docs/pages/guides/introduction.md +11 -9
  56. package/docs/pages/guides/progressive-enhancement.md +136 -49
  57. package/docs/pages/guides/tailwind.md +3 -2
  58. package/docs/pages/layout/media.md +1 -1
  59. package/docs/pages/layout/overview.md +11 -1
  60. package/docs/pages/layout/scroll-snap.md +3 -3
  61. package/docs/pages/layout/stack.md +17 -9
  62. package/docs/pages/patterns/icon-input.md +4 -2
  63. package/docs/pages/patterns/icon-slot.md +2 -0
  64. package/docs/pages/patterns/intent-nesting.md +1 -1
  65. package/docs/pages/utilities/overview.md +23 -2
  66. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  67. package/docs/pages/utilities/spacing.md +10 -8
  68. package/llms.txt +2 -1
  69. package/package.json +19 -8
  70. package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
  71. package/src/css/components/accordion.css +42 -3
  72. package/src/css/components/alert.css +25 -13
  73. package/src/css/components/app-nav.css +18 -8
  74. package/src/css/components/avatar.css +13 -1
  75. package/src/css/components/badge.css +78 -12
  76. package/src/css/components/breadcrumb.css +2 -3
  77. package/src/css/components/button.css +9 -16
  78. package/src/css/components/card.css +9 -2
  79. package/src/css/components/chat.css +1 -0
  80. package/src/css/components/data-list.css +1 -1
  81. package/src/css/components/drawer.css +79 -25
  82. package/src/css/components/fab.css +0 -6
  83. package/src/css/components/flyout.css +46 -63
  84. package/src/css/components/index.css +0 -1
  85. package/src/css/components/indicator.css +21 -0
  86. package/src/css/components/join.css +7 -0
  87. package/src/css/components/key.css +10 -0
  88. package/src/css/components/list.css +40 -4
  89. package/src/css/components/menu.css +18 -11
  90. package/src/css/components/modal.css +48 -33
  91. package/src/css/components/navbar.css +37 -4
  92. package/src/css/components/pagination.css +1 -0
  93. package/src/css/components/spinner.css +6 -6
  94. package/src/css/components/status-bar.css +0 -5
  95. package/src/css/components/steps.css +45 -59
  96. package/src/css/components/tab.css +29 -9
  97. package/src/css/components/table.css +5 -5
  98. package/src/css/components/tooltip.css +45 -13
  99. package/src/css/core/base.css +23 -1
  100. package/src/css/core/intents.css +17 -6
  101. package/src/css/core/theme.css +27 -0
  102. package/src/css/core/tokens.css +35 -15
  103. package/src/css/core/variants.css +64 -15
  104. package/src/css/forms/choice-card.css +4 -4
  105. package/src/css/forms/choice.css +6 -3
  106. package/src/css/forms/control.css +2 -1
  107. package/src/css/forms/floating-field.css +3 -3
  108. package/src/css/forms/form.css +4 -3
  109. package/src/css/forms/input-icon.css +20 -14
  110. package/src/css/forms/native.css +6 -1
  111. package/src/css/forms/otp.css +9 -1
  112. package/src/css/forms/range.css +2 -0
  113. package/src/css/forms/select.css +29 -9
  114. package/src/css/forms/switch.css +19 -5
  115. package/src/css/layout/app-layout.css +1 -1
  116. package/src/css/layout/grid.css +50 -76
  117. package/src/css/layout/scroll-snap.css +11 -7
  118. package/src/css/layout/scroller.css +3 -4
  119. package/src/css/layout/stack.css +2 -2
  120. package/src/css/typography/fluid.css +4 -3
  121. package/src/css/typography/prose.css +6 -5
  122. package/src/css/utilities/base.css +2 -1
  123. package/src/css/utilities/extra.css +20 -17
  124. package/src/js/context-menu.js +27 -26
  125. package/src/js/dialog.js +2 -1
  126. package/src/js/dismiss.js +5 -0
  127. package/src/js/enhance.js +34 -2
  128. package/src/js/enhancement-loader.js +36 -43
  129. package/src/js/events.js +0 -3
  130. package/src/js/filter.js +0 -1
  131. package/src/js/floating-space.js +42 -0
  132. package/src/js/flyout.js +9 -44
  133. package/src/js/focus-group.js +7 -0
  134. package/src/js/focus.js +6 -0
  135. package/src/js/menu.js +10 -1
  136. package/src/js/selectors.js +5 -9
  137. package/src/js/status.js +44 -7
  138. package/src/js/surface.js +106 -132
  139. package/src/js/tab.js +6 -1
  140. package/src/js/tooltip.js +152 -53
  141. package/src/js/validation.js +1 -1
  142. package/src/css/components/surface.css +0 -49
  143. package/src/js/floating.js +0 -432
@@ -0,0 +1,328 @@
1
+ # POC: native popover as the surface transport
2
+
3
+ A record of an experiment, not a plan of record. It exists so the reasoning
4
+ outlives the branch that produced it — including the parts that came out
5
+ negative, which are the ones a future attempt would otherwise repeat.
6
+
7
+ Question asked: can `popover="manual"` replace the DOM reparenting in
8
+ `surface.js` without changing anything else?
9
+
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).
14
+
15
+ ## Branch
16
+
17
+ `poc/popover-manual-surfaces`, four commits on top of `0f4f8b7`:
18
+
19
+ | SHA | Step |
20
+ | --------- | ------------------------------------------------- |
21
+ | `bf1e53d` | Pass 1 — manual transport, reparenting removed |
22
+ | `4d598dd` | Extract the popover test stub, cut to the minimum |
23
+ | `ececcd6` | Pass 2 — keep a runtime scrim, fix its placement |
24
+ | `27b34f1` | Finish as a transparent migration |
25
+
26
+ ## What the transport actually needs
27
+
28
+ Derived from the branch, not from a compatibility badge. The distinction between
29
+ the two contracts matters: they fail differently, and only the first gates the
30
+ runtime.
31
+
32
+ ```text
33
+ JS requirement (gates the runtime)
34
+ ----------------------------------
35
+ popover="manual"
36
+ showPopover()
37
+ hidePopover()
38
+ top-layer promotion + closed-state display:none
39
+
40
+ CSS enhancement (already @supports-gated, degrades)
41
+ ---------------------------------------------------
42
+ :popover-open
43
+ overlay
44
+ transition-behavior: allow-discrete
45
+ @starting-style
46
+ ```
47
+
48
+ The runtime never reads `:popover-open`. It was removed from `isSurfaceOpen()`
49
+ and from the `autoUpdate` guard because a partially-implemented selector engine
50
+ answers `false` rather than throwing, which is a silent wrong answer (see the
51
+ happy-dom finding below). `.is-open` is the single state the runtime writes and
52
+ reads, and the class the CSS keys on.
53
+
54
+ Two capabilities that are *not* required, which matters because both are newer
55
+ than Popover and would have pushed the floor much higher:
56
+
57
+ - `overlay` / `allow-discrete` — the exit fade. Gated; without it the exit is
58
+ instant and correct.
59
+ - `::backdrop` — proved unusable, see below.
60
+
61
+ ## Findings
62
+
63
+ **happy-dom implements no Popover element API.** Version 20.11 has the invoker
64
+ attributes (`popoverTargetElement`, `popoverTargetAction` on button and input)
65
+ but not `showPopover` / `hidePopover` / `togglePopover`, and
66
+ `matches(":popover-open")` answers `false` rather than throwing. The silent
67
+ false is the dangerous half: reading the pseudo-class in `isSurfaceOpen()` made
68
+ all 647 unit tests see "never open". Resolved by reading `.is-open` only, plus
69
+ two no-op stubs in `tests/helpers/popover-stub.js`. Transport behavior moved to
70
+ the browser layer, which is where it belonged.
71
+
72
+ **`::backdrop` cannot replace the sheet scrim.** Negative result, measured, and
73
+ the one most likely to be re-attempted. The native backdrop is painted for a
74
+ manual popover, covers the viewport, and does inherit custom properties from
75
+ its originating element — a scoped `--backdrop-fill` on an ancestor of the
76
+ panel resolved into it, which a body-level div cannot do. But the UA sets
77
+ `pointer-events: none` on an open popover's backdrop and it is not overridable
78
+ from author CSS: both `pointer-events: auto` and `pointer-events: auto
79
+ !important` computed as `none`, since a UA `!important` outranks an author one.
80
+ Measured with a real CDP click on the scrim, the element behind it received the
81
+ click and its handler fired. The scrim has two jobs — painting, and absorbing
82
+ the pointer — and the platform only offers the first, so it stays an Actual
83
+ element.
84
+
85
+ **The scrim's placement, not the scrim, was what pass 1 broke.**
86
+ `menu.before(backdrop)` put it beside a panel that no longer moves, stranding
87
+ it inside the author's container. It goes to the document root instead, which
88
+ reproduces exactly where it landed before. This is the one fragment of
89
+ `getSurfaceRoot()` that survives, and it is not the reparenting the transport
90
+ removed: the scrim is runtime-created, has no author identity, and nothing
91
+ inherited to preserve.
92
+
93
+ **A surface inside a modal dialog works.** This was the open question, since a
94
+ modal dialog inerts the rest of the document — which is why the old transport
95
+ moved the panel into the dialog. Measured: the panel is promoted above the
96
+ dialog, stays in the dialog subtree and in its own host element, wins the hit
97
+ test, and takes focus.
98
+
99
+ **`aria-expanded` is computed natively for a `popovertarget` invoker** —
100
+ `expanded: false` closed, `true` open, on the AX tree. Deliberately not
101
+ exploited: the public contract is `aria-controls`, so `popovertarget` would be
102
+ a markup migration for adopters rather than an internal transport detail, and
103
+ `linkedTriggers()` supports several triggers per panel where `popovertarget`
104
+ covers one.
105
+
106
+ ## Measurements
107
+
108
+ | Measure | `master` | POC |
109
+ | ------------------------- | -------: | --------------: |
110
+ | `surface.js` code lines | 358 | **337** |
111
+ | `surface.js` total lines | 431 | 436 |
112
+ | Transport concepts | 4 | **1** |
113
+ | Inherited contexts broken | 3/3 | **0/3** |
114
+ | Unit tests | 647 | **650**, 0 fail |
115
+ | Browser tests | 50 | **55**, 0 fail |
116
+ | JS full, brotli | 16.1 KB | **16.0 KB** |
117
+ | Public contracts changed | — | **none** |
118
+
119
+ Removed: `mountedSurfaces`, `getSurfaceRoot()`, `mountSurface()`,
120
+ `restoreSurface()`, `disconnectSurface`'s `{ restore }` option, and the
121
+ `[popover]` conflict warning — the runtime is that owner now.
122
+
123
+ The total line count *grows* by five. The 21 fewer code lines are outweighed by
124
+ added comments. "Remove more than you add" is a signal, not a rule: what leaves
125
+ is a concept — a component no longer has to leave its place in the DOM to be
126
+ painted above the page — plus a defect with no generic repair and three
127
+ transport abstractions.
128
+
129
+ ## Acceptance criterion
130
+
131
+ > A transport migration may change assertions about transport markers, but must
132
+ > not require changing behavioral assertions.
133
+
134
+ This is stricter and more useful than "no public API change". The 57
135
+ `hidden → .is-open` edits qualify: they replace observing a mechanism with
136
+ observing the lifecycle that owns it. If a future attempt has to modify an
137
+ assertion about focus, dismissal, positioning or events to make the transport
138
+ pass, it is no longer a transport swap and the claim does not hold.
139
+
140
+ ## Go / no-go: WebKit verification
141
+
142
+ The measurements above were taken on Chromium — the browser test harness
143
+ hardcodes `backend: "chrome"`. The floor's most notable step is Safari 17, so
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
+
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
+
150
+ ```text
151
+ 1. showPopover() / hidePopover() in manual mode
152
+ 2. a closed panel is genuinely invisible and unfocusable
153
+ 3. the DOM parent is unchanged before, during and after opening
154
+ 4. inherited theme, density and custom properties survive opening
155
+ 5. a surface inside <dialog open> and inside a modal dialog
156
+ 6. reopening during the exit transition
157
+ 7. a context menu at pointer coordinates
158
+ 8. the sheet scrim covers and intercepts the click
159
+ ```
160
+
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
169
+
170
+ ```text
171
+ Verified manually on:
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
175
+ ```
176
+
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.
221
+
222
+ ## Changelog framing for the raise
223
+
224
+ State what stops working before what it buys, with versions named. Something
225
+ along these lines:
226
+
227
+ > **JavaScript browser floor raised** to Safari 17+, Firefox 125+ and
228
+ > Chromium 116+. Interactive surfaces now use the native manual Popover
229
+ > transport (`popover`, `showPopover()`, `hidePopover()`) instead of
230
+ > physically reparenting author DOM. This preserves inherited themes, density
231
+ > and application custom properties while leaving Actual's dismissal, focus
232
+ > and positioning contracts unchanged.
233
+ >
234
+ > Browsers below this JavaScript floor no longer receive the supported full
235
+ > Actual runtime. Core HTML/CSS degradation remains governed separately by the
236
+ > Degraded tier.
237
+
238
+ Not "Popover is now required": that reads as a dependency on the whole API
239
+ family, when the requirement is the four primitives listed above.
240
+
241
+ ## Migration checklist
242
+
243
+ For the release that raises Minimal. Items 4–7 are scaffolding built for a floor
244
+ that will no longer exist; a gate outliving its reason is silent dead weight, so
245
+ they belong here rather than being found later.
246
+
247
+ 1. WebKit verification — done, 8/8 on Safari 17.3, recorded above. Cleared.
248
+ 2. Raise Minimal to the required subset above. The justification is the defect
249
+ and the transparent migration, not a download count: npm figures conflate
250
+ CI, bots, caches and the author, and say nothing about the end browser.
251
+ 3. Delete the `Intermediate` tier. It is not merely less useful — with a new
252
+ Minimal above it on all three engines it is arithmetically absorbed, so its
253
+ features become floor-guaranteed and no information is lost.
254
+ 4. Reclassify those features (`color-mix()`, `oklch()/oklab()`, `dvh/svh/lvh`,
255
+ `scrollbar-gutter`, `scrollbar-width`, `scrollbar-color`) as Minimal
256
+ baseline and drop their `check-compat` entries.
257
+ 5. Remove the `@supports selector(:popover-open)` gates in `flyout.css` and
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.
261
+ 6. Remove the two `popover` entries from `check-compat`'s feature table.
262
+ 7. Remove the associated `compat-ok:` pragmas, and `hasPragma()` itself if
263
+ nothing else consumes it.
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.
268
+ 9. Drop `test.failing` from the reparenting tests in
269
+ `tests/browser/surface-inherited-context.test.js`, keeping the assertions.
270
+ 10. Update [surface-reparenting](surface-reparenting.md) from a known limitation
271
+ to resolved.
272
+
273
+ Steps 8 and 9 are linked but not enforced. The `test.failing` markers guarantee
274
+ that step 8 becomes unavoidable — they turn red as unexpected passes the moment
275
+ the transport changes — but nothing forces step 9. The test file points at the
276
+ note, and the note points back here; that is as far as documentation can close
277
+ the loop.
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
+
312
+ ## What was deliberately left out
313
+
314
+ Not attempted, and not because it looked hard:
315
+
316
+ - **`popover="auto"`.** It cannot express `data-flyout-auto-close`, whose
317
+ default closes on an inside click; native light-dismiss closes on outside
318
+ clicks only, and `false` is inexpressible altogether. Adopting it would be a
319
+ behavior change to the component, not a transport swap.
320
+ - **Nested surfaces.** `openSurface()` closes every other open surface in the
321
+ document; the runtime is strictly flat and the transport change does not
322
+ alter that. Nesting is a separate feature decision, and the choice between
323
+ extending Actual's own stack and adopting the native one is functional before
324
+ it is technical.
325
+ - **`popover="hint"`, interest invokers, CSS anchor positioning.** Watch.
326
+ - **Further deletion for its own sake.** The branch's value is that it changes
327
+ the transport and nothing else. Broadening it would cost exactly the property
328
+ that makes it usable as a migration.
@@ -0,0 +1,131 @@
1
+ # Surface reparenting broke inherited presentation context
2
+
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
+
7
+ ## What used to happen
8
+
9
+ `surface.js` moved an open surface out of its place in the document:
10
+
11
+ ```text
12
+ getSurfaceRoot() → nearest <dialog>, else body
13
+ mountSurface() → root.append(panel)
14
+ restoreSurface() → put it back once the exit transition ends
15
+ ```
16
+
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
+ visible.
22
+
23
+ Actual leans on inheritance deliberately: theme islands, density, `.inverted`,
24
+ and the custom properties an application scopes to its own containers all
25
+ travel that way. So this was not one bug about themes. It was the same bug in as
26
+ many forms as there are inherited contexts.
27
+
28
+ ## Measured
29
+
30
+ Chrome, one `.flyout` per scope, values read off the panel before and after the
31
+ click that opens it:
32
+
33
+ | Scope | Closed | Open |
34
+ | ------------------------- | ---------------------------------------- | -------------------- |
35
+ | `[data-theme="dark"]` | `background: rgb(30, 26, 35)` | `rgb(255, 255, 255)` |
36
+ | `.sm` | `--control-size: 2rem` | `2.375rem` |
37
+ | `.inverted` | `--ui-fg: light-dark(hsl(0 0% 100%), …)` | *(unset)* |
38
+ | app-scoped `--app-accent` | `rgb(1, 2, 3)` | *(unset)* |
39
+ | `.sm` inside a `<dialog>` | `--control-size: 2rem` | `2.375rem` |
40
+
41
+ Every context broke. Three consequences were worth stating plainly:
42
+
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
+ that dialog and its trigger. Choosing a nearer root loses less context; it
50
+ does not preserve it.
51
+
52
+ `tests/browser/surface-inherited-context.test.js` holds those assertions and
53
+ now passes against the shipped transport.
54
+
55
+ ## Why the obvious repairs are wrong
56
+
57
+ Still wrong, and still tempting — this section is the reason the note is kept.
58
+
59
+ **Copying `data-theme` onto the moved panel.** Fixes the one symptom that was
60
+ noticed and leaves the other four broken. It also turns an inherited contract
61
+ into an enumerated one, so every future scope has to be added to a list.
62
+
63
+ **Snapshotting custom properties before the move.** There is no bounded set to
64
+ snapshot — an application's own hooks are unknowable by definition, and
65
+ `getComputedStyle` cannot enumerate inherited custom properties portably. It
66
+ would also freeze values that should stay live.
67
+
68
+ **Mounting to a nearer root.** The dialog row above is the experiment: it
69
+ reduces the damage without addressing the cause.
70
+
71
+ The cause was that the panel was moved at all. The repair is a transport that
72
+ promotes the element without relocating it.
73
+
74
+ ## The repair, as shipped
75
+
76
+ A native popover renders in the top layer while staying exactly where the
77
+ author put it in the DOM. That removed the reason to reparent, and with it the
78
+ whole mechanism:
79
+
80
+ ```text
81
+ mountedSurfaces
82
+ getSurfaceRoot()
83
+ mountSurface()
84
+ restoreSurface()
85
+ ```
86
+
87
+ `popover="manual"` is the mode that fits: it supplies the top layer and nothing
88
+ else, so `surface.js` keeps its dismissal policy, Escape ordering, focus
89
+ restoration and placement orchestration unchanged. See
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.
97
+
98
+ ## The contract now
99
+
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.
103
+
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.
107
+
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.
112
+
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.
120
+
121
+ ## Experiment
122
+
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.
127
+
128
+ ## Review trigger
129
+
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.
@@ -8,8 +8,8 @@ The JS runtime doubles as a primitive kit for building custom widgets.
8
8
  | ---------------------------------- | ------------- | --------------------------- |
9
9
  | Discovery, lifecycle, cleanup | `enhance` | `actual-css/js/enhance` |
10
10
  | Per-document LIFO Escape dismissal | `escape` | `actual-css/js/escape` |
11
- | Positioning, flip and shift | `floating` | `actual-css/js/floating` |
12
- | Open/close, sheet, backdrop, focus | `surface` | `actual-css/js/surface` |
11
+ | Positioning, flip and shift | `floating` | `@lekoala/floating` |
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,9 +22,9 @@ 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
- - `floating` — `track`, `reposition`, `repositionAt`, plus
27
+ - `floating` — `autoUpdate`, `reposition`, `repositionAt`, plus
28
28
  `--available-height` and `data-placement`
29
29
  - `keys` — `itemForKey`, `firstItem`, `lastItem`, `nextItem`, with edge,
30
30
  wrapping and RTL handling
@@ -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.