Files
archy/.planning/phases/01-federation-mesh-hardening/01-15-PLAN.md
T

366 lines
25 KiB
Markdown
Raw Normal View History

2026-08-12 10:55:50 +00:00
---
phase: 01-federation-mesh-hardening
plan: 15
type: execute
wave: 7
depends_on: []
files_modified:
- neode-ui/src/composables/usePipSession.ts
- neode-ui/src/utils/pip.ts
- neode-ui/src/components/cloud/MediaLightbox.vue
- neode-ui/src/composables/__tests__/usePipSession.test.ts
- neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
autonomous: true
requirements: [UIFIX-05]
gap_closure: true
must_haves:
truths:
- "Entering picture-in-picture closes the lightbox, and it closes with a deliberate handoff animation rather than blinking out (UIFIX-05)"
- "The video keeps playing in the picture-in-picture window after the lightbox has closed — closing the lightbox no longer takes the session with it"
- "An active picture-in-picture session survives a main-tab change: the playing element is no longer a descendant of any view that a tab switch can detach"
- "Buffering does not end the session — a waiting or stalled event pauses nothing, tears nothing down, and leaves the session active (UIFIX-05 adjacency edge)"
- "Only an explicit stop ends the session: leaving picture-in-picture is the single path that releases the element and cleans up"
- "A normal close, with no picture-in-picture involved, looks and animates exactly as it does today (UIFIX-05 empty edge — the no-session case)"
- "The lightbox's props and emitted events are unchanged, so every existing call site keeps working without edits"
- "The handoff animation is disabled under prefers-reduced-motion, matching the site-wide convention"
prohibitions:
- statement: "A picture-in-picture session MUST NOT keep media playing after the user has ended it, and MUST NOT leave an orphaned video element or a live object URL in the document once released — release always tears down what it adopted"
category: privacy
- statement: "The persistent host MUST NOT be visible, focusable, interactive, or able to affect layout in any state — it is an off-screen custodial element, never a second player UI"
category: safety
- statement: "This plan MUST NOT change MediaLightbox's prop names, prop types, or emitted events — plan 01-14 adds a second instance of this component in parallel, and a contract change would break it"
category: safety
artifacts:
- path: neode-ui/src/composables/usePipSession.ts
provides: "Singleton picture-in-picture session with a body-level custodial host for the playing element"
contains: "adopt"
- path: neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
provides: "Handoff-closes-lightbox, buffering-survives, release-on-leave assertions"
min_lines: 50
key_links:
- from: neode-ui/src/components/cloud/MediaLightbox.vue
to: neode-ui/src/composables/usePipSession.ts
via: "on enterpictureinpicture the lightbox hands its video to the session host before emitting close, so the element outlives its own unmount"
pattern: "usePipSession"
---
<objective>
Make picture-in-picture behave like a handoff: the lightbox gets out of the way with a fluid
animation, and the session then survives everything that used to kill it.
Purpose: two user reports, both classified by phase 2 as pre-existing. `src/utils/pip.ts`'s
`togglePip()` (f72d4b92, 2026-07-23) only toggles the browser API and never touches
`MediaLightbox.vue`'s visibility, so entering PiP leaves a full-screen backdrop sitting over the app.
And the session dies on a tab change because the `<video>` lives inside a view that used to unmount
outright — phase 2's KeepAlive work removed the unmount, which is what makes survival achievable now,
but the element is still a descendant of the view tree and of a `Teleport`, both of which a
deactivation can move. The fix is to stop relying on where the element happens to live: hand it to a
body-level custodial host at the moment PiP begins.
Output: a session composable owning the custodial host, a handoff animation on the lightbox, explicit
buffering tolerance, and tests that pin all three.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
@neode-ui/src/utils/pip.ts
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| `usePipSession()``active`, `adopt`, `release`, `element` | new singleton composable | `neode-ui/src/composables/usePipSession.ts` |
| body-level custodial host element | new runtime DOM node (off-screen) | same |
| `isPipSupported()` | new lazy support probe | `neode-ui/src/utils/pip.ts` |
| PiP handoff close + buffering tolerance | changed component behaviour | `neode-ui/src/components/cloud/MediaLightbox.vue` |
| `.lightbox-pip-handoff` + reduced-motion guard | new scoped CSS | same |
| `usePipSession.test.ts`, `MediaLightboxPip.test.ts` | new vitest suites | new files |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a video handed to the session outlives its owner's unmount</name>
<files>neode-ui/src/composables/usePipSession.ts, neode-ui/src/utils/pip.ts, neode-ui/src/composables/__tests__/usePipSession.test.ts</files>
<read_first>
- `neode-ui/src/utils/pip.ts` — the whole file (19 lines). Note that `pipSupported` is a
module-level `const` evaluated at import time: that is why a test cannot stub support after
import, and why this task adds a lazy probe alongside it rather than replacing it outright.
- `neode-ui/src/components/cloud/MediaLightbox.vue` lines 20-31 (the PiP button, `v-if` gated on
`pipSupported`, calling `togglePip(videoEl)`) and lines 76-87 (the `<video>` element: `ref`,
`:src="currentUrl"`, `:key="currentUrl"`, `controls`, `autoplay`). The `:key` binding matters —
any change to `currentUrl` destroys and recreates the element, which is one of the ways a
session can die.
- `neode-ui/src/App.vue` lines 1-60 — how app-level persistent UI is mounted (`GlobalAudioPlayer`
is the precedent for something that must outlive route changes). Read for the precedent only;
this plan does not modify `App.vue`, because a composable-owned body-level node needs no
template anchor and therefore cannot disturb the dashboard DOM shape that
`src/views/dashboard/__tests__/keepAliveTabs.test.ts` pins.
- `neode-ui/src/composables/useAudioPlayer.ts` — the house convention for a module-singleton
composable holding cross-view media state.
</read_first>
<behavior>
- `adopt(video)` moves the element into the session host, and the host is a child of
`document.body`.
- After `adopt`, unmounting the component that originally rendered the video leaves the element
still connected to the document.
- `release()` removes the element from the host and leaves nothing behind under `document.body`.
- The host is created at most once no matter how many times the composable is called, and its
computed presentation is non-interactive and off-screen.
- `active` is true between adopt and release and false outside that window.
- `isPipSupported()` reads the document at call time, so a test can stub support before or after
the module is imported.
- `togglePip`'s existing behaviour and signature are unchanged.
</behavior>
<action>
Write the test file first and confirm it fails. jsdom has no picture-in-picture API, so stub
`document.pictureInPictureEnabled`, `document.pictureInPictureElement`,
`HTMLVideoElement.prototype.requestPictureInPicture` and `document.exitPictureInPicture` in the
test setup, and drive state by dispatching `enterpictureinpicture` / `leavepictureinpicture`
events on the element.
In `neode-ui/src/utils/pip.ts`: add `export function isPipSupported(): boolean` that performs the
same three checks at call time instead of at import time. Leave the existing `pipSupported` const
and `togglePip` exactly as they are so nothing that imports them today changes behaviour.
Create `neode-ui/src/composables/usePipSession.ts` as a module singleton exporting
`usePipSession()` returning at least `active` (readonly ref), `element` (readonly ref) and the
functions `adopt(video: HTMLVideoElement)` and `release()`.
The host: create it lazily on first `adopt`, once per module, as a plain `div` appended to
`document.body` with an identifying `data-` attribute. Style it so it can never be seen or
interacted with and can never affect layout — fixed position, off-screen, one pixel, zero opacity,
no pointer events, `aria-hidden`, and not focusable. Do not give it a visible size or a z-index
that could ever place it over the app.
`adopt(video)`: append the element into the host (this both keeps it in the document and detaches
it from whatever view owned it), record it as `element`, set `active`, and attach a
`leavepictureinpicture` listener that calls `release()`. Adopting while a session is already
active must release the previous one first rather than leaking it.
`release()`: remove the adopted element from the host, pause it, clear its `src` and call `load()`
so no media keeps buffering, drop the listener, clear `element`, and clear `active`. Leave the
host itself in place for reuse — an empty off-screen div costs nothing and re-creating it on every
session is churn.
Do not import this composable anywhere yet; Task 2 wires it. Keep it free of Vue lifecycle hooks —
it is a module singleton, and a lifecycle hook in a bare composable is exactly the silent-no-op
class phase 2 hit twice.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; test -f src/composables/__tests__/usePipSession.test.ts &amp;&amp; npx vitest run src/composables/__tests__/usePipSession.test.ts</automated>
</verify>
<acceptance_criteria>
- The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePipSession.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
- The suite contains a case asserting the adopted element is still `document.body.contains(...)` after the owning component unmounts.
- The suite contains a case asserting `release()` leaves no adopted element under the host.
- The suite contains a case asserting repeated `usePipSession()` calls create exactly one host node.
- `grep -v '^\s*//' neode-ui/src/utils/pip.ts | grep -c 'isPipSupported'` equals 1.
- `git diff -- neode-ui/src/utils/pip.ts | grep -c '^-'` is at most 1 (only the trailing-context line changes; `togglePip` and `pipSupported` are additions-only edits).
- `cd neode-ui && npx vitest run` exits 0.
</acceptance_criteria>
<done>A video handed to the session stays in the document no matter what happens to the component that rendered it, and release tears it down completely.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: The lightbox hands off — enter PiP, animate closed, keep playing</name>
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
<read_first>
- `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file (449 lines). Specifically:
the `Teleport`/`Transition name="lightbox-fade"` shell and the `v-if="show"` backdrop; the PiP
button; the `<video>` with its `ref` and `:key`; `close()`, which emits and nothing else;
`onUnmounted`, which revokes every URL in `urlCache`; and the `.lightbox-backdrop` /
`lightbox-fade` CSS at the bottom, which is what a normal close animates with today and must
keep animating with.
- `neode-ui/src/composables/usePipSession.ts` as left by Task 1.
- `neode-ui/src/components/SendBitcoinModal.vue` — grep it for `prefers-reduced-motion` and copy
that media-query syntax verbatim for the handoff guard.
- `neode-ui/src/components/__tests__/` — any existing suite in this directory, for the house
mounting and assertion conventions.
</read_first>
<behavior>
- Dispatching `enterpictureinpicture` on the lightbox's video causes the component to emit `close`
exactly once.
- Before that emit, the video has been adopted by the session, so it is no longer a descendant of
the lightbox's own subtree.
- The handoff class is applied to the backdrop for the duration of the animation and only on the
PiP path — closing with the close button or Escape applies no handoff class.
- After the component unmounts following a handoff, the video is still connected to the document.
- Dispatching `leavepictureinpicture` releases the session.
- The component's declared props and emits are unchanged.
</behavior>
<action>
Write the test cases first and confirm they fail.
In `MediaLightbox.vue`, wire the session. On the video element, add `enterpictureinpicture` and
`leavepictureinpicture` handlers — listen for the events rather than inferring from the button
click, so a PiP entered by any route (the browser's own control, a keyboard shortcut) behaves the
same.
On enter: adopt the video into the session, add a `lightbox-pip-handoff` class to the backdrop, and
emit `close` when the handoff animation finishes — drive that off `transitionend` with a bounded
fallback timer so a browser that skips the transition still closes. Order matters and must be
exactly this: adopt first, animate second, emit last. Adopting first is what makes the element
survive the unmount that the emit triggers.
Design the handoff so it reads as the video moving into the picture-in-picture window rather than a
dismissal: the backdrop's blur and opacity fall away while the content scales down slightly and
drifts toward the corner the PiP window occupies, over roughly 300ms on the house easing. Keep it
scoped, keep it on the existing `.lightbox-backdrop`/content elements rather than restructuring the
markup, and guard the motion with the `prefers-reduced-motion` media query copied from
`SendBitcoinModal.vue` — under reduced motion the handoff becomes an immediate close, never a
lingering one.
On leave: call the session's release. Because the lightbox has already unmounted by then, the
session's own listener from Task 1 is the primary path; the component-level handler exists for the
case where PiP is exited while the lightbox is somehow still mounted, and must be idempotent with
it.
Switch the PiP button's `v-if` from the import-time `pipSupported` const to `isPipSupported()` so
the button's presence is testable.
Do not change `props`, `defineEmits`, `close()`'s emitted event, the normal-close transition, the
navigation arrows, the keyboard handler, the media-loading logic, `urlCache`, or the `onUnmounted`
revoke. Plan 01-14 adds a second instance of this component with the same prop set; a contract
change would break it.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; test -f src/components/__tests__/MediaLightboxPip.test.ts &amp;&amp; npx vitest run src/components/__tests__/MediaLightboxPip.test.ts</automated>
</verify>
<acceptance_criteria>
- The test file exists and `cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts` exits 0 (the `test -f` guard is required).
- The suite contains a case asserting exactly one `close` emit on `enterpictureinpicture`, and a case asserting no handoff class is applied on a button-driven close.
- The suite contains a case asserting the video is still document-connected after the post-handoff unmount.
- `grep -c 'usePipSession' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 2.
- `grep -c 'enterpictureinpicture' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
- `grep -c 'prefers-reduced-motion' neode-ui/src/components/cloud/MediaLightbox.vue` equals 1.
- `git diff -- neode-ui/src/components/cloud/MediaLightbox.vue | grep -cE '^-.*(defineProps|defineEmits|fetchBlobUrl|streamUrl|startIndex)'` equals 0 — the public contract is untouched.
- `cd neode-ui && npx vitest run` exits 0.
</acceptance_criteria>
<done>Entering picture-in-picture animates the lightbox away and leaves the video playing; a normal close is unchanged.</done>
</task>
<task type="auto">
<name>Task 3: Buffering and navigation cannot end a session — then prove it in a browser</name>
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) and it serves at least one video — jsdom has no picture-in-picture implementation, so only a Chromium-based browser can prove the session actually survives</precondition>
<read_first>
- `neode-ui/src/components/cloud/MediaLightbox.vue` as left by Task 2 — specifically `prev()`,
`next()`, the `watch(currentItem, …)` that calls `loadMedia` and sets `currentUrl` to null, and
the `:key="currentUrl"` binding on the video. Each of these can destroy the playing element.
- `neode-ui/src/views/dashboard/keepAlive.ts` (or wherever `KEEP_ALIVE_PATHS` is defined — grep for
it) — enough to understand that a main-tab switch now deactivates rather than unmounts the view,
which is the change that makes tab survival reachable at all.
</read_first>
<action>
Close the remaining ways a session can die.
Add explicit `waiting` and `stalled` handlers on the video that do nothing but record that
buffering is happening — no pause, no reload, no src change, no release. Their existence is the
point: they document that buffering is a tolerated state and give a test something to assert
against, so a later change cannot quietly add teardown there. Do not add a `pause` handler that
releases the session; a pause during buffering and a pause by the user are indistinguishable from
the element, and only an explicit exit from picture-in-picture may end a session.
Guard the destroy-the-element paths: while the session is active, `prev()` and `next()` return
early, and the `currentItem` watcher does not reset `currentUrl`. In the normal flow the lightbox
has already closed by then and these are unreachable, but they are cheap insurance against the
exact class of bug this requirement is about.
Add test cases: a `waiting` event leaves the session active; a `stalled` event leaves the session
active; `next()` during an active session does not change the rendered item.
Then prove it in a browser, because jsdom cannot. Start the dev preview in Chromium, open a video
in the lightbox, and record each of these in the SUMMARY:
1. Click the picture-in-picture button. Expected: the lightbox animates away as a handoff — it
should read as the video moving, not as a dismissal — and the video keeps playing in the PiP
window.
2. With PiP playing, switch between main tabs several times. Expected: playback continues
uninterrupted.
3. With PiP playing, force a buffering pause (throttle the network in devtools, or seek far ahead).
Expected: it resumes and the PiP window stays.
4. Close the PiP window explicitly. Expected: playback stops and nothing is left behind — check the
element inspector for a stray video under `document.body`.
5. Open the lightbox again and close it with the close button and with Escape. Expected: exactly
the animation it had before this plan.
If the handoff does not read as a handoff, adjust the animation and re-record; the requirement
asks for a fluid on-brand transition, so a jarring one is a failed task.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/components/__tests__/MediaLightboxPip.test.ts &amp;&amp; npx vitest run &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
- `grep -c 'waiting' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1 and `grep -c 'stalled' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
- The suite contains buffering-tolerance cases for both `waiting` and `stalled`, and a navigation-guard case.
- `grep -rq 'lightbox-pip-handoff' ../web/dist/neode-ui/assets/` succeeds from `neode-ui` after the build (per CLAUDE.md the frontend build can silently no-op).
- The SUMMARY records all five browser observations, naming the browser and version, and states whether the handoff needed adjustment to read correctly.
- The SUMMARY explicitly confirms observation 4 found no orphaned element left under the document.
</acceptance_criteria>
<done>Buffering and navigation cannot end a session, and all five behaviours are confirmed in a real browser.</done>
</task>
</tasks>
## Planner Assumptions (flagged, unresolved)
- **The custodial-host approach was chosen over "keep the lightbox mounted but invisible".** The todo
offered both. Keeping the component mounted would leave the video inside a `Teleport` inside a
`KeepAlive`d view, and both of those move their subtrees on deactivation — a moved element is a
removed element as far as the picture-in-picture spec is concerned. The planner did not verify Vue
3.5's exact teleport-under-deactivation behaviour, and deliberately chose the design that does not
depend on the answer. If the executor establishes that the simpler approach is safe, raise it rather
than switching silently.
- **The PiP window's corner is browser- and user-controlled**, so the handoff's drift direction is a
best-effort convention, not a guaranteed match. Task 3's browser observation is where it is judged.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| in-app media playback → an OS-level window outside the app's own chrome | Picture-in-picture puts content in a surface the app no longer draws |
| adopted element → document lifetime | An element deliberately kept alive past its owner's unmount is state that outlives its normal cleanup |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-67 | Information Disclosure | private media continuing to play in a floating window after the user believes they closed it | high | mitigate | The lightbox closing is now an explicit consequence of the user starting PiP, not a side effect; release pauses, clears `src` and calls `load()`, and Task 3's fourth browser observation requires confirming nothing is left behind |
| T-01-68 | Denial of Service | an orphaned adopted element buffering a large stream forever after its owner is gone | high | mitigate | Release is bound to `leavepictureinpicture` inside the session itself, so it fires even when the component that adopted the element no longer exists; a test asserts the host is empty after release |
| T-01-69 | Tampering | the custodial host being reachable or clickable and intercepting input | medium | mitigate | The host is off-screen, one pixel, zero opacity, pointer-events none, `aria-hidden` and non-focusable, and the prohibition forbids any state in which it can affect layout |
| T-01-70 | Elevation of Privilege | a second component adopting into an already-active session and leaking the first element | medium | mitigate | `adopt` releases any existing session first; a test covers the repeated-adopt path |
| T-01-71 | Repudiation | a contract change to the lightbox silently breaking the parallel plan 01-14 | medium | mitigate | An explicit prohibition plus a diff-based acceptance criterion fail the task if props or emits change |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — two new source files, two edits, two vitest files. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
</threat_model>
<verification>
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
- `cd neode-ui && npm run build` — green, and the built bundle carries the handoff class.
- Five browser observations recorded, including the no-orphan check.
</verification>
<success_criteria>
- Entering picture-in-picture closes the lightbox with a handoff animation and the video keeps playing.
- The session survives main-tab changes and buffering; only an explicit stop ends it.
- Release leaves nothing playing and nothing orphaned.
- A normal close is visually unchanged, and the component's public contract is untouched.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md` when done, recording the five
browser observations, the browser and version used, and any animation adjustment made.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>