366 lines
25 KiB
Markdown
366 lines
25 KiB
Markdown
---
|
|||
|
|
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 && test -f src/composables/__tests__/usePipSession.test.ts && 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 && test -f src/components/__tests__/MediaLightboxPip.test.ts && 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 && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts && npx vitest run && 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>
|