actual-css 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,30 @@ Degraded browsers may still run some JavaScript successfully, but that
23
23
  behavior is not tested, documented as supported, or preserved when the
24
24
  runtime evolves.
25
25
 
26
+ ### Native Popover and the tiers
27
+
28
+ Popover is not part of any Actual tier's guarantee below Recommended. It is a
29
+ capability, not a browser generation: the core lifecycle landed around
30
+ Chromium 116, Firefox 125 and Safari 17, which clears Recommended but sits
31
+ above both Minimal and Intermediate on every engine.
32
+
33
+ | Actual tier | Core native Popover guaranteed | Recommended lifecycle owner |
34
+ | ------------ | ------------------------------ | ------------------------------- |
35
+ | Degraded | no | HTML/CSS fallback only |
36
+ | Minimal | no | `surface.js` |
37
+ | Intermediate | no | `surface.js` |
38
+ | Recommended | yes, core lifecycle | native Popover, or `surface.js` |
39
+
40
+ "Core lifecycle" means `popover="auto"`/`"manual"`, `popovertarget`,
41
+ `showPopover`/`hidePopover` and `:popover-open` — not the entire Popover API.
42
+ `popover="hint"` and interest invokers are much newer and are not covered at
43
+ any tier. Safari coverage here is read as the desktop engine; some iOS versions
44
+ through 18.2 still report partial support for parts of the API, so an
45
+ application targeting iOS should verify the specific pieces it relies on.
46
+
47
+ Actual's presentation classes compose with either lifecycle from Recommended
48
+ up. The runtime itself depends on Popover at no tier.
49
+
26
50
  ## JavaScript philosophy
27
51
 
28
52
  > The Degraded tier exists primarily to preserve useful HTML and CSS on older
@@ -83,20 +107,21 @@ The tiers above are Actual's own contract. The table below tracks the Web
83
107
  Platform Baseline of the individual CSS features the framework uses; it is a
84
108
  different notion from an Actual tier.
85
109
 
86
- | Feature area | Baseline | Progressive enhancement |
87
- | ------------------------------------- | ------------- | ------------------------------ |
88
- | Layout (flex, grid, position) | Baseline 2023 | — |
89
- | CSS custom properties | Baseline 2023 | — |
90
- | `:has()` | Baseline 2023 | — |
91
- | `<dialog>` element | Baseline 2023 | — |
92
- | `color-mix()` | Baseline 2023 | Fallback flat color |
93
- | `light-dark()` | 2024+ | Manual theme override |
94
- | `:user-invalid` | 2024+ | `[aria-invalid]` attribute |
95
- | `transition-behavior: allow-discrete` | 2024+ | Native dialog open/close |
96
- | `appearance: base-select` | 2024+ | Native `<select>` |
97
- | `@starting-style` | 2024+ | Instant appearance |
98
- | View Transition API | 2024+ | Dialog open/close transition |
99
- | CSS Anchor Positioning | Future | JS positioning (`floating.js`) |
110
+ | Feature area | Baseline | Progressive enhancement |
111
+ | ------------------------------------- | ------------- | ------------------------------------ |
112
+ | Layout (flex, grid, position) | Baseline 2023 | — |
113
+ | CSS custom properties | Baseline 2023 | — |
114
+ | `:has()` | Baseline 2023 | — |
115
+ | `<dialog>` element | Baseline 2023 | — |
116
+ | `color-mix()` | Baseline 2023 | Fallback flat color |
117
+ | `light-dark()` | 2024+ | Manual theme override |
118
+ | `:user-invalid` | 2024+ | `[aria-invalid]` attribute |
119
+ | `transition-behavior: allow-discrete` | 2024+ | Native dialog open/close |
120
+ | `appearance: base-select` | 2024+ | Native `<select>` |
121
+ | `@starting-style` | 2024+ | Instant appearance |
122
+ | View Transition API | 2024+ | Dialog open/close transition |
123
+ | Popover API (core) | Baseline 2025 | `surface.js` lifecycle |
124
+ | CSS Anchor Positioning | Future | JS positioning (`@lekoala/floating`) |
100
125
 
101
126
  Core layout and visual tokens work on all Baseline 2023 browsers. Features marked
102
127
  2024+ are gated with `@supports` or degrade gracefully through fallback values.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Context
4
4
 
5
- Currently, the positioning logic for tooltips, flyouts, and context menus is handled by a custom JavaScript engine (in `floating.js`). This engine computes absolute coordinates, handles viewport boundaries, and applies "flip" and "shift" heuristics to prevent overflow.
5
+ Currently, the positioning logic for tooltips, flyouts, and context menus is handled by the `@lekoala/floating` JavaScript dependency. This engine computes fixed coordinates, handles viewport boundaries, and applies "flip" and "shift" heuristics to prevent overflow.
6
6
 
7
7
  As of mid-2024, modern browsers started supporting the **CSS Anchor Positioning API**, which delegates this heavy mathematical lifting directly to the browser's layout engine.
8
8
 
@@ -138,6 +138,57 @@ Actual class + native HTML
138
138
 
139
139
  rather than replacing one framework API with another.
140
140
 
141
+ ## Surface responsibility split
142
+
143
+ Anchored surfaces are the clearest case where the platform is absorbing one
144
+ responsibility at a time rather than a whole widget. Keeping the axes named
145
+ separately is what lets each be replaced on its own schedule:
146
+
147
+ ```text
148
+ Popover = where the surface lives
149
+ floating = where it is placed
150
+ surface.js = when and how it opens
151
+ flyout / tooltip = what it looks like
152
+ menu = how its content behaves
153
+ ```
154
+
155
+ The value of the split is that each line can change owner independently.
156
+ Popover can take over the top layer without touching placement. Anchor
157
+ positioning could later take over placement without touching lifecycle — though
158
+ not yet, because the current positioner also reports *placement validity*, which
159
+ the lifecycle uses to close a surface whose anchor has left the viewport, and
160
+ declarative CSS positioning offers no equivalent signal.
161
+
162
+ ### Ceding the properties a capability is expressed through
163
+
164
+ Keeping presentation and behavior separate is not enough on its own, because
165
+ CSS expresses some behavior *as* presentation.
166
+
167
+ > A presentation class that composes with `popover` may own its visible display
168
+ > type, but must not override the platform's hidden-state display.
169
+
170
+ `.flyout { display: grid }` is entirely legitimate. The problem is that the UA
171
+ closes a popover with `display: none`, and an author declaration beats the UA
172
+ origin whatever the specificity — so that legitimate choice silently leaves a
173
+ closed popover painted on screen. The class was not implementing the lifecycle;
174
+ it was overriding the property the lifecycle is written in.
175
+
176
+ The general rule, then:
177
+
178
+ > A class that is advertised as composing with a platform capability must cede
179
+ > the properties that capability uses to express itself, for the states the
180
+ > platform owns.
181
+
182
+ This is checkable rather than aspirational: any Actual class that carries a
183
+ `[popover]` reset must also restore `display` for `:not(:popover-open)`.
184
+
185
+ The corresponding trap is adopting a capability in a way that adds an axis
186
+ instead of replacing one. A second surface transport living alongside the
187
+ existing one would leave the DOM-reparenting code in place while doubling the
188
+ behavior matrix, which is the opposite of the deletion this note asks for. The
189
+ acceptance criterion for moving Actual’s own surfaces onto the top layer is
190
+ therefore concrete: the change should remove more surface code than it adds.
191
+
141
192
  ## Prefer capabilities over browser generations
142
193
 
143
194
  New platform features should not automatically raise Actual's Minimal browser floor.
@@ -0,0 +1,248 @@
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, 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).
13
+
14
+ ## Branch
15
+
16
+ `poc/popover-manual-surfaces`, four commits on top of `0f4f8b7`:
17
+
18
+ | SHA | Step |
19
+ | --------- | ------------------------------------------------- |
20
+ | `bf1e53d` | Pass 1 — manual transport, reparenting removed |
21
+ | `4d598dd` | Extract the popover test stub, cut to the minimum |
22
+ | `ececcd6` | Pass 2 — keep a runtime scrim, fix its placement |
23
+ | `27b34f1` | Finish as a transparent migration |
24
+
25
+ ## What the transport actually needs
26
+
27
+ Derived from the branch, not from a compatibility badge. The distinction between
28
+ the two contracts matters: they fail differently, and only the first gates the
29
+ runtime.
30
+
31
+ ```text
32
+ JS requirement (gates the runtime)
33
+ ----------------------------------
34
+ popover="manual"
35
+ showPopover()
36
+ hidePopover()
37
+ top-layer promotion + closed-state display:none
38
+
39
+ CSS enhancement (already @supports-gated, degrades)
40
+ ---------------------------------------------------
41
+ :popover-open
42
+ overlay
43
+ transition-behavior: allow-discrete
44
+ @starting-style
45
+ ```
46
+
47
+ The runtime never reads `:popover-open`. It was removed from `isSurfaceOpen()`
48
+ and from the `autoUpdate` guard because a partially-implemented selector engine
49
+ answers `false` rather than throwing, which is a silent wrong answer (see the
50
+ happy-dom finding below). `.is-open` is the single state the runtime writes and
51
+ reads, and the class the CSS keys on.
52
+
53
+ Two capabilities that are *not* required, which matters because both are newer
54
+ than Popover and would have pushed the floor much higher:
55
+
56
+ - `overlay` / `allow-discrete` — the exit fade. Gated; without it the exit is
57
+ instant and correct.
58
+ - `::backdrop` — proved unusable, see below.
59
+
60
+ ## Findings
61
+
62
+ **happy-dom implements no Popover element API.** Version 20.11 has the invoker
63
+ attributes (`popoverTargetElement`, `popoverTargetAction` on button and input)
64
+ but not `showPopover` / `hidePopover` / `togglePopover`, and
65
+ `matches(":popover-open")` answers `false` rather than throwing. The silent
66
+ false is the dangerous half: reading the pseudo-class in `isSurfaceOpen()` made
67
+ all 647 unit tests see "never open". Resolved by reading `.is-open` only, plus
68
+ two no-op stubs in `tests/helpers/popover-stub.js`. Transport behavior moved to
69
+ the browser layer, which is where it belonged.
70
+
71
+ **`::backdrop` cannot replace the sheet scrim.** Negative result, measured, and
72
+ the one most likely to be re-attempted. The native backdrop is painted for a
73
+ manual popover, covers the viewport, and does inherit custom properties from
74
+ its originating element — a scoped `--backdrop-fill` on an ancestor of the
75
+ panel resolved into it, which a body-level div cannot do. But the UA sets
76
+ `pointer-events: none` on an open popover's backdrop and it is not overridable
77
+ from author CSS: both `pointer-events: auto` and `pointer-events: auto
78
+ !important` computed as `none`, since a UA `!important` outranks an author one.
79
+ Measured with a real CDP click on the scrim, the element behind it received the
80
+ click and its handler fired. The scrim has two jobs — painting, and absorbing
81
+ the pointer — and the platform only offers the first, so it stays an Actual
82
+ element.
83
+
84
+ **The scrim's placement, not the scrim, was what pass 1 broke.**
85
+ `menu.before(backdrop)` put it beside a panel that no longer moves, stranding
86
+ it inside the author's container. It goes to the document root instead, which
87
+ reproduces exactly where it landed before. This is the one fragment of
88
+ `getSurfaceRoot()` that survives, and it is not the reparenting the transport
89
+ removed: the scrim is runtime-created, has no author identity, and nothing
90
+ inherited to preserve.
91
+
92
+ **A surface inside a modal dialog works.** This was the open question, since a
93
+ modal dialog inerts the rest of the document — which is why the old transport
94
+ moved the panel into the dialog. Measured: the panel is promoted above the
95
+ dialog, stays in the dialog subtree and in its own host element, wins the hit
96
+ test, and takes focus.
97
+
98
+ **`aria-expanded` is computed natively for a `popovertarget` invoker** —
99
+ `expanded: false` closed, `true` open, on the AX tree. Deliberately not
100
+ exploited: the public contract is `aria-controls`, so `popovertarget` would be
101
+ a markup migration for adopters rather than an internal transport detail, and
102
+ `linkedTriggers()` supports several triggers per panel where `popovertarget`
103
+ covers one.
104
+
105
+ ## Measurements
106
+
107
+ | Measure | `master` | POC |
108
+ | ------------------------- | -------: | --------------: |
109
+ | `surface.js` code lines | 358 | **337** |
110
+ | `surface.js` total lines | 431 | 436 |
111
+ | Transport concepts | 4 | **1** |
112
+ | Inherited contexts broken | 3/3 | **0/3** |
113
+ | Unit tests | 647 | **650**, 0 fail |
114
+ | Browser tests | 50 | **55**, 0 fail |
115
+ | JS full, brotli | 16.1 KB | **16.0 KB** |
116
+ | Public contracts changed | — | **none** |
117
+
118
+ Removed: `mountedSurfaces`, `getSurfaceRoot()`, `mountSurface()`,
119
+ `restoreSurface()`, `disconnectSurface`'s `{ restore }` option, and the
120
+ `[popover]` conflict warning — the runtime is that owner now.
121
+
122
+ The total line count *grows* by five. The 21 fewer code lines are outweighed by
123
+ added comments. "Remove more than you add" is a signal, not a rule: what leaves
124
+ is a concept — a component no longer has to leave its place in the DOM to be
125
+ painted above the page — plus a defect with no generic repair and three
126
+ transport abstractions.
127
+
128
+ ## Acceptance criterion
129
+
130
+ > A transport migration may change assertions about transport markers, but must
131
+ > not require changing behavioral assertions.
132
+
133
+ This is stricter and more useful than "no public API change". The 57
134
+ `hidden → .is-open` edits qualify: they replace observing a mechanism with
135
+ observing the lifecycle that owns it. If a future attempt has to modify an
136
+ assertion about focus, dismissal, positioning or events to make the transport
137
+ pass, it is no longer a transport swap and the claim does not hold.
138
+
139
+ ## Go / no-go: WebKit verification
140
+
141
+ Every measurement in this note was taken on Chromium — the browser test harness
142
+ 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.
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:
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
+ Record the result in this section:
162
+
163
+ ```text
164
+ Verified manually on:
165
+ Safari __._ / macOS __
166
+ iOS Safari __._ / iOS __ (if reachable)
167
+ Date: ____-__-__
168
+ Scenarios: _/8 passed
169
+ ```
170
+
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.
179
+
180
+ ## Changelog framing for the raise
181
+
182
+ State what stops working before what it buys, with versions named. Something
183
+ along these lines:
184
+
185
+ > **JavaScript browser floor raised** to Safari 17+, Firefox 125+ and
186
+ > Chromium 116+. Interactive surfaces now use the native manual Popover
187
+ > transport (`popover`, `showPopover()`, `hidePopover()`) instead of
188
+ > physically reparenting author DOM. This preserves inherited themes, density
189
+ > and application custom properties while leaving Actual's dismissal, focus
190
+ > and positioning contracts unchanged.
191
+ >
192
+ > Browsers below this JavaScript floor no longer receive the supported full
193
+ > Actual runtime. Core HTML/CSS degradation remains governed separately by the
194
+ > Degraded tier.
195
+
196
+ Not "Popover is now required": that reads as a dependency on the whole API
197
+ family, when the requirement is the four primitives listed above.
198
+
199
+ ## Migration checklist
200
+
201
+ For the release that raises Minimal. Items 4–7 are scaffolding built for a floor
202
+ that will no longer exist; a gate outliving its reason is silent dead weight, so
203
+ they belong here rather than being found later.
204
+
205
+ 1. Pass the WebKit verification above and record it. This is the go/no-go.
206
+ 2. Raise Minimal to the required subset above. The justification is the defect
207
+ and the transparent migration, not a download count: npm figures conflate
208
+ CI, bots, caches and the author, and say nothing about the end browser.
209
+ 3. Delete the `Intermediate` tier. It is not merely less useful — with a new
210
+ Minimal above it on all three engines it is arithmetically absorbed, so its
211
+ features become floor-guaranteed and no information is lost.
212
+ 4. Reclassify those features (`color-mix()`, `oklch()/oklab()`, `dvh/svh/lvh`,
213
+ `scrollbar-gutter`, `scrollbar-width`, `scrollbar-color`) as Minimal
214
+ baseline and drop their `check-compat` entries.
215
+ 5. Remove the `@supports selector(:popover-open)` gates in `flyout.css` and
216
+ `tooltip.css`.
217
+ 6. Remove the two `popover` entries from `check-compat`'s feature table.
218
+ 7. Remove the associated `compat-ok:` pragmas, and `hasPragma()` itself if
219
+ nothing else consumes it.
220
+ 8. Move `surface.js` to the manual transport, with no fallback branch.
221
+ 9. Drop `test.failing` from the reparenting tests in
222
+ `tests/browser/surface-inherited-context.test.js`, keeping the assertions.
223
+ 10. Update [surface-reparenting](surface-reparenting.md) from a known limitation
224
+ to resolved.
225
+
226
+ Steps 8 and 9 are linked but not enforced. The `test.failing` markers guarantee
227
+ that step 8 becomes unavoidable — they turn red as unexpected passes the moment
228
+ the transport changes — but nothing forces step 9. The test file points at the
229
+ note, and the note points back here; that is as far as documentation can close
230
+ the loop.
231
+
232
+ ## What was deliberately left out
233
+
234
+ Not attempted, and not because it looked hard:
235
+
236
+ - **`popover="auto"`.** It cannot express `data-flyout-auto-close`, whose
237
+ default closes on an inside click; native light-dismiss closes on outside
238
+ clicks only, and `false` is inexpressible altogether. Adopting it would be a
239
+ behavior change to the component, not a transport swap.
240
+ - **Nested surfaces.** `openSurface()` closes every other open surface in the
241
+ document; the runtime is strictly flat and the transport change does not
242
+ alter that. Nesting is a separate feature decision, and the choice between
243
+ extending Actual's own stack and adopting the native one is functional before
244
+ it is technical.
245
+ - **`popover="hint"`, interest invokers, CSS anchor positioning.** Watch.
246
+ - **Further deletion for its own sake.** The branch's value is that it changes
247
+ the transport and nothing else. Broadening it would cost exactly the property
248
+ that makes it usable as a migration.
@@ -0,0 +1,122 @@
1
+ # Surface reparenting breaks inherited presentation context
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.
6
+
7
+ ## What happens
8
+
9
+ `surface.js` moves 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 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
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 is not one bug about themes. It is 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 breaks. Three consequences are worth stating plainly:
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
49
+ that dialog and its trigger. Choosing a nearer root loses less context; it
50
+ does not preserve it.
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.
55
+
56
+ ## Why the obvious repairs are wrong
57
+
58
+ **Copying `data-theme` onto the moved panel.** Fixes the one symptom that was
59
+ noticed and leaves the other four broken. It also turns an inherited contract
60
+ into an enumerated one, so every future scope has to be added to a list.
61
+
62
+ **Snapshotting custom properties before the move.** There is no bounded set to
63
+ snapshot — an application's own hooks are unknowable by definition, and
64
+ `getComputedStyle` cannot enumerate inherited custom properties portably. It
65
+ would also freeze values that should stay live.
66
+
67
+ **Mounting to a nearer root.** The dialog row above is the experiment: it
68
+ reduces the damage without addressing the cause.
69
+
70
+ The cause is that the panel is moved at all. The repair is a transport that
71
+ promotes the element without relocating it.
72
+
73
+ ## The actual repair
74
+
75
+ 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
+ whole mechanism:
78
+
79
+ ```text
80
+ mountedSurfaces
81
+ getSurfaceRoot()
82
+ mountSurface()
83
+ restoreSurface()
84
+ ```
85
+
86
+ `popover="manual"` is the mode that fits: it supplies the top layer and nothing
87
+ else, so `surface.js` keeps its dismissal policy, Escape ordering, focus
88
+ 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.
92
+
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.
97
+
98
+ ## Until then
99
+
100
+ The honest interim position is a documented limitation, not a patch:
101
+
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.
105
+
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.
110
+
111
+ ## Experiment
112
+
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.
117
+
118
+ ## Review trigger
119
+
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.
@@ -8,7 +8,7 @@ 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` |
11
+ | Positioning, flip and shift | `floating` | `@lekoala/floating` |
12
12
  | Open/close, sheet, backdrop, 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` |
@@ -24,7 +24,7 @@ The named exports each module carries:
24
24
 
25
25
  - `enhance` — `enhance`, `registerEnhancement`
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
@@ -74,8 +74,98 @@ placement strings supported by the floating runtime, such as `bottom-start`,
74
74
 
75
75
  Use `data-flyout-distance` for the trigger gap in pixels. The default is `4`.
76
76
 
77
- Use `data-flyout-auto-close` when the default click dismissal behavior is not
78
- right. Escape still follows the shared surface lifecycle.
77
+ ### Native popover lifecycle
78
+
79
+ The `.flyout` presentation also composes with an element carrying `popover`.
80
+ Actual neutralizes the native popover geometry that conflicts with anchored
81
+ positioning and recognizes `:popover-open` as the visible state. The browser or
82
+ another library may therefore own opening, dismissal, and focus without
83
+ requiring `.is-open` or `[hidden]`.
84
+
85
+ Actual CSS does not position that independently managed popover. Its positioner
86
+ must write fixed viewport coordinates (and may write `--available-height` and
87
+ `--surface-anchor-width`); the built-in `surface.js` runtime remains the full
88
+ fallback when Actual owns the lifecycle too. A bare `[popover]` without
89
+ `.flyout` or `.tooltip` keeps its platform appearance.
90
+
91
+ Native Popover is optional and is not part of Actual's Minimal contract:
92
+ browsers within the Minimal range may not support it. Actual gates its own
93
+ geometry reset behind `@supports selector(:popover-open)`, so an engine without
94
+ Popover no longer gets a mispositioned panel — but that engine also never hides
95
+ or promotes a `popover` element, so nothing dismisses the panel either.
96
+
97
+ When the lifecycle must work across the full Minimal range, use `surface.js`.
98
+ Otherwise using the native lifecycle is an application support decision. The
99
+ two are alternatives, not layers.
100
+
101
+ #### Choose one lifecycle owner
102
+
103
+ The Actual runtime path, where `surface.js` owns opening, dismissal, focus and
104
+ placement:
105
+
106
+ ```html
107
+ <div class="flyout-trigger">
108
+ <button data-enhance="flyout" aria-controls="actions" aria-expanded="false">
109
+ Actions
110
+ </button>
111
+
112
+ <ul id="actions" class="flyout menu" hidden>
113
+ <li><button class="menu-item" type="button">Rename</button></li>
114
+ </ul>
115
+ </div>
116
+ ```
117
+
118
+ The native path, where the browser owns the lifecycle and the top layer:
119
+
120
+ ```html
121
+ <button popovertarget="actions">Actions</button>
122
+
123
+ <ul id="actions" class="flyout menu" popover>
124
+ <li><button class="menu-item" type="button">Rename</button></li>
125
+ </ul>
126
+ ```
127
+
128
+ Do not combine them. `surface.js` opens a panel by clearing `[hidden]` and
129
+ adding `.is-open`; it never calls `showPopover()`, so a `popover` element handed
130
+ to the runtime has two owners and stays closed. The runtime logs a warning if it
131
+ is asked to retain one.
132
+
133
+ **The native path still needs a positioner.** `popovertarget` gives the panel an
134
+ implicit anchor reference to its invoker, but it does not place it: Actual's
135
+ geometry reset clears the UA's centering, so an unpositioned native flyout is
136
+ laid out at its static position rather than under its trigger. Supply
137
+ coordinates the same way `surface.js` does:
138
+
139
+ ```js
140
+ import { autoUpdate, reposition } from "@lekoala/floating";
141
+
142
+ const trigger = document.querySelector("[popovertarget='actions']");
143
+ const panel = document.getElementById("actions");
144
+
145
+ // One tracker at a time. autoUpdate returns its own cleanup; releasing it
146
+ // before every state change keeps repeated opens from stacking trackers, and
147
+ // stops the scroll/resize work as soon as the panel closes.
148
+ let stop;
149
+
150
+ panel.addEventListener("toggle", (event) => {
151
+ stop?.();
152
+ stop = undefined;
153
+ if (event.newState !== "open") return;
154
+
155
+ stop = autoUpdate(trigger, panel, () => {
156
+ reposition(trigger, panel, { placement: "bottom-start", distance: 4 });
157
+ });
158
+ });
159
+ ```
160
+
161
+ CSS Anchor Positioning will eventually cover the simple anchored case from the
162
+ stylesheet alone, using that implicit anchor reference. It is still only
163
+ partially supported and is not the documented recipe yet.
164
+
165
+ **When `surface.js` owns the lifecycle**, use `data-flyout-auto-close` if the
166
+ default click dismissal behavior is not right. Escape still follows the shared
167
+ surface lifecycle. These options do not reach a natively managed
168
+ `popover="auto"`, whose dismissal belongs to the browser.
79
169
 
80
170
  - `true` is the default. Clicks inside or outside close the flyout.
81
171
  - `inside` closes on inside clicks only.
@@ -15,6 +15,7 @@ runtime targets, empty by default.
15
15
  - Use it for transient status (`Saved.`, `Reconnected.`, `Copied.`). Critical, persistent, or actionable information belongs in `.alert`, inline messages, or dialogs.
16
16
  - Intents: `danger`, `success`, `warning`, `neutral`. The default (no intent) is a neutral dark pill.
17
17
  - On mobile, the runtime keeps the bar above the software keyboard by tracking `visualViewport` while a message is showing.
18
+ - A message originating inside an open modal dialog temporarily moves the singleton bar into that dialog. This keeps it in the active top-layer and accessibility subtree; the bar returns to its original DOM position after its exit.
18
19
 
19
20
  Clearing is deferred, not immediate: the bar closes first and only empties its
20
21
  text and intent once the exit transition has finished, so it leaves at the size
@@ -77,6 +78,25 @@ document.dispatchEvent(new CustomEvent("actual:status", {
77
78
  }));
78
79
  ```
79
80
 
81
+ Dispatch from the control or region that produced the feedback when it lives
82
+ inside a dialog; bubbling lets the runtime infer the active surface:
83
+
84
+ ```js
85
+ saveButton.dispatchEvent(new CustomEvent("actual:status", {
86
+ bubbles: true,
87
+ detail: { message: "Saved.", intent: "success" },
88
+ }));
89
+ ```
90
+
91
+ Code importing `status()` directly passes the same origin as `source`:
92
+
93
+ ```js
94
+ status("Saved.", { intent: "success", source: saveButton });
95
+ ```
96
+
97
+ Validation and declarative status commands propagate their source
98
+ automatically.
99
+
80
100
  ## CSS hooks
81
101
 
82
102
  - `--status-bg` — bar background.