[[project_v5_ui_requirements]]: "any action in the UI can be assigned to a
slot... full wheel or half wheel... half wheel positioned left or right...
both draggable... default: middle mouse button, anywhere."
app/legacy/actions.js: ported the composer tree's tiny action registry
(app/shell/actions.js, an ES module, unreferenced from boot) as a classic
global window.FunPackActions -- offerAction({id,label,icon,run}), offered(),
run(id). Whatever announces itself is what the wheel (or anything later --
a command palette, a keymap) can show.
app/legacy/wheel.js: middle-mouse (anywhere) opens a radial menu of
whatever's currently on it. Full wheel spaces items evenly around 360°;
half wheel spaces them over a 180° arc on the chosen side, with drag
locked to the vertical axis only (full wheel drags both axes) per the
spec's "half wheel moves up/down, full wheel up/down and left/right" line.
Dragging repositions the CURRENTLY OPEN wheel only, not persisted -- read
"draggable" as "don't get stuck somewhere awkward this time" rather than a
remembered dock position, since the spec doesn't say which and this is the
simpler reading. Customize dialog (reuses pinned_buttons.js's existing
.pin-modal/.pin-slot CSS) toggles shape, side, and which offered actions
are on the wheel. pinned_buttons.js is NOT removed -- the spec says this
supersedes it, but retiring a working control felt like a separate
decision from adding a new one; both coexist for now, confirmed to not
conflict (separate storage keys, no shared DOM, never both open at once).
app/legacy/wheel_actions_seed.js: seeds 9 starter actions (Generate,
Generate selected, Render, Auto Montage, Undo, Redo, Models & Pipeline,
Engine settings, Sampler quick-access) so the wheel isn't empty on first
use.
Two rounds of extensive_testing, plus a bug I found myself in live
testing before either round: the half wheel's fixed radius, sized for a
full wheel's 360° spread, packed the same item count into half the
angular room and produced heavily overlapping items -- fixed by growing
the radius only as far as needed to keep adjacent items from touching,
confirmed visually before/after.
Round 1: no functional break found in outside-click-vs-item-click
ordering, listener leaks on repeated opens, or coexistence with
pinned_buttons.js.
Round 2: found the real defect -- enabledIds()'s first-call snapshot
("default to everything offered so far") could run in the gap between
wheel.js executing and wheel_actions_seed.js finishing (classic <script>
tags block sequentially, and this window widens on a slow/cold-cache
load), permanently persisting an empty or partial action list with no
error, no indication -- the user would just experience "the wheel doesn't
work" and have no way to know why. Fixed by deferring the snapshot to the
window `load` event (guaranteed after every script has run); before that,
enabledIds() shows whatever's offered so far live without writing it
down as the permanent default. Also cleaned up a dead if/else in the
half-wheel angle math flagged as a "looks like a bug that got half-fixed"
smell.
Live-verified after both fixes: real physical drag (manual mousedown/
mousemove/mouseup sequences, not just JS calls) confirms half-wheel drag
is genuinely locked to the y-axis (x stays exactly put) while full-wheel
drag moves both, and neither drag opens Customize -- only a press that
never crosses the drag threshold does. npm test: 691 pass, 1 pre-existing
unrelated flake (shell/tests/projects.test.js:285).
No test file exists yet for actions.js/wheel.js/wheel_actions_seed.js --
a real, named test gap, not fixed here. Real hardware middle-click and
cross-browser autoscroll-blocking are also unverified (browser automation
has no native middle-click) -- declared, not chased further without
physical hardware.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
[[project_v5_ui_requirements]]: "steps, scheduler and sampler are reached
for constantly, and in v4 they are several clicks deep... a thin hovering
window above the timeline, which keeps them visible without spending
timeline height." Nothing in v5 built this yet -- the only existing path
was Settings > Engine > Sampling, a modal several clicks deep.
app/legacy/sampler_quickbar.js: a toggle button in the timeline header
(next to Assets/Preview/Properties/Composer) opens a fixed-position panel
above the timeline showing only category="sampling" module settings.
Hidden until PipelineState confirms at least one installed module has a
sampling setting (the "absent, not greyed" convention this codebase
already uses for a module/role that doesn't apply).
The quickbar needed to read/write the SAME module-values tree
engine_settings.js already edited. That tree gets serialized into one
opaque JSON blob and written into a pipeline slot by the server
(core/graph.py's place()) with no server-side merge -- a second, naive
editor sending a partial tree would silently erase every other module's
settings. To avoid two independent caches of the same server data
diverging (the exact bug class pipeline_state.js's own header comment
already describes for `slots`, from an earlier fix), extracted the
shared pieces instead of duplicating them:
- PipelineState gained currentValues() (always-fresh: defaults +
valuesAlreadyPlaced()) and setModuleValue(moduleId, name, value)
(patches one field, sends the whole tree). Single source of truth,
used by both editors.
- app/legacy/settings_field.js: the field-widget builder (checkbox/
slider/enum/color/text, the `when`-clause visibility gate) extracted
out of engine_settings.js verbatim, parameterized on a values tree and
an onChange callback instead of a private cache -- one place renders a
setting, one place saves one, for every editor of this tree from now on.
- engine_settings.js refactored to use both instead of its own private
values/seeded/setValue/ensureSeeded/commit/controlFor.
Two rounds of extensive_testing. Round 1 found a real, live-reproduced
defect: setModuleValue's "always fresh, no cache" design had its own
race -- two calls fired before either's round-trip resolved could
silently lose the earlier edit, because the second call's "read the
whole tree" snapshot didn't yet include the first call's in-flight
write. Fixed with a `pendingValues` overlay in pipeline_state.js,
written synchronously before every read, reconciled (per-key, not
wholesale) once valuesAlreadyPlaced() actually confirms it server-side.
Round 2 re-verified the original race closes -- checked against
valuesAlreadyPlaced() specifically (decoded straight from the server's
own response, not the optimistic overlay) for both a two-different-
modules race and a three-calls-same-field race -- and found one more
thing, currently inert: setModuleValue's returned promise doesn't mean
"this specific edit is confirmed" for every concurrent caller, only for
whichever one owns save()'s queue at that moment. Documented in place
(both real callers only re-render from shared state, which is already
correct regardless of promise timing) rather than reworking save()'s
shared queue for a guarantee nothing currently needs.
Live-verified: button appears only when a sampling setting exists;
opens positioned above the timeline; editing in the quickbar changes
only the target field (confirmed via full before/after tree diff);
same change visible in the Engine Settings modal and vice versa; real
outside-click closes it; 5 open/close cycles leave no DOM leak.
npm test: 691 pass, 1 pre-existing unrelated flake
(shell/tests/projects.test.js:285). No test file exists yet for
pipeline_state.js/engine_settings.js/settings_field.js/
sampler_quickbar.js -- a real, named test gap, not fixed here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v4 always presented an overlay lane whether or not anything used it.
The user's own v5 UI requirements explicitly reject this: "the
timeline starts with no overlay tracks. One appears when the user
adds it explicitly." store.js's normalizeOverlayLanes() force-created
a default "Overlay 1" lane the moment overlay_lanes was empty -- on
every project load AND every timeline render -- so a fresh, empty
project always showed one anyway.
Fixed at the two real sources:
- normalizeOverlayLanes() now only auto-creates a lane to house
ORPHANED tracks (old-format projects, or a lane removed out from
under its tracks) -- never just because nothing asked for one yet.
- defaultOverlayLaneId() (reached only from addImageOverlay/
addTextOverlay when the user is explicitly adding their first
overlay) now creates the first lane itself at that exact moment --
the "user adds it explicitly" trigger the spec asks for.
Traced every overlay_lanes/overlay_tracks consumer across store.js,
timeline.js, inspector.js, view_bus.js, tour.js: none assume >=1 lane
always exists (removeOverlayLane's own guard already handles "never
below 1 once lanes exist"; drag-drop onto a lane row is naturally
unreachable before one exists, matching intended behavior).
Live-verified: a fresh project (window.Store.newProject) renders zero
overlay rows; addTextOverlay("Hello") creates exactly one lane, wires
the new track to it, and the row appears with a live text-overlay
preview on the canvas. JS suite: 691 pass, 1 pre-existing unrelated
flake (shell/tests/projects.test.js:285).
One round of extensive_testing (adversarial review) came back clean.
Declared, not fixed: a project saved under the old behavior keeps its
already-persisted empty "Overlay 1" lane -- a forward-only fix, no
migration was asked for and the lane is now an ordinary, removable one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 9 emptied demoProject()'s scene_renders to stop a real unmocked
network fetch against the live backend. Round 10's adversarial review
caught the sibling: tour-s1 still had rating: "good" in the demo data,
but the rating badge (timeline.js's sceneRatingRaw -> genUnitHasRender)
is gated on hasRender(), which now reads false for every scene since
scene_renders is empty -- so the badge silently never renders on the
scene the tour spotlights, self-contradicting the stored rating.
Cleared the rating to "" to match the no-render state honestly. Traced
every step/FAQ reference to tour-s1 and ratings: the FAQ answer is
generic ("after a render, rate a generative scene"), no step claims
tour-s1 specifically shows a badge, so this is a pure consistency fix
with no user-visible regression.
Live-verified: fresh tour boot, tour-s1.rating reads "" via
window.Store.get(). JS suite: 691 pass, 1 pre-existing unrelated flake
(shell/tests/projects.test.js:285).
Round 10 of the tour extensive_testing chain otherwise came back clean:
no dangling onCancel overlay (PipelineSetup's cancel-button variant is
unreachable in tour mode -- onboarding.js explicitly skips opening
during the tour), no crash from the empty scene_renders object across
every reader (store/timeline/inspector/player/montage_dialog/view_bus/
history), and the patchApi()/patchStore() mock surfaces still match
their consumers' expected shapes on a fresh full read.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 9's module-definition sweep came back clean (the four standalone
modules found in rounds 5/7/8 are all still correctly covered, and no
fifth exists) -- but it caught two real bugs my own round-8 live
verification missed by not waiting long enough:
- Both restartComfy() implementations call FunPackRestart.waitForReload()
after API.restart() resolves. That function polls API.health() starting
3.5s later and calls location.reload() once it reports ok -- and
API.health is ALREADY mocked to ok:true for the Settings About panel's
own unrelated reasons, so the very first poll after a restart click
reloads the real page mid-tour, silently resetting all tour progress.
Nothing actually restarted, so there's nothing to wait for -- fixed by
short-circuiting waitForReload() in tour mode instead of polling toward
a reload.
- demoProject() pointed a fake scene_renders entry at a real-shaped
ComfyUI /view URL for a file that doesn't exist. Since bootstrap()
notifies immediately on tour start, player.js's render() (a plain Store
subscriber, never blocked or mocked) picks it up and issues a real GET,
then a real HEAD retry, against the live backend -- zero clicks, always
404s, but still a genuine unmocked reach into the real install. Fixed at
the source instead of patching player.js's internals: an always-
unrendered demo scene is an honest trade-off the tour's own FAQ text
already accounts for.
Verified live: clicked both restartComfy implementations, waited the full
5s past the 3.5s poll delay this time (my round-8 verification hadn't
waited long enough to catch this) -- confirmed no reload, still on
?mode=tour; confirmed zero /view?filename= requests; full tour sequence
still completes cleanly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 8's module-definition sweep found a fourth standalone module (after
GenerateBridge, PipelineState): window.FunPackGit (git_actions.js) and an
independent window.FunPackMaintenance.restartComfy (menubar.js) both call
window.MovieEditorAPI.restart() directly -- a REAL server restart, killing
any generation in progress -- one click from the same .settings-win
sidebar rounds 5-7 already proved reachable (Custom Nodes' inline
"Restart ComfyUI" button, and the built-in "Updates & ComfyUI" section,
which also fires a real GET /api/git/status the instant it mounts, zero
clicks needed -- same shape as pipeline_state.js's ensureLoaded()). That
same section's "Switch branch"/"Update FunPack" reach real git
checkout/pull. Worse than any prior finding: not a stray read, not even
round 7's directory-delete, but restarting the live production server and
mutating the FunPack install's own git state.
While fixing this, found two more real, unmocked, irreversible operations
in the same file (menubar.js's FunPackMaintenance, not caught by round 8's
own report): deleteRefinementKey and clearGlobalTaste, both described by
their own confirm dialogs as "This cannot be undone." Currently dormant --
v5's own api.js has these as stubs ("not built in v5 yet") -- same shape
as saveClipToMediaBin/renameMedia, closed pre-emptively rather than
waiting for a future backend to wire them up for real.
Fixed by mocking all ten functions in patchApi(): restart, gitStatus (also
cleanly short-circuits git_actions.js's own _ensureStatus() gate via its
existing alert path), gitUpdate/gitCheckout/gitRollback, and the five
refinement-key/taste functions.
Verified live in a genuinely fresh tab: called both restartComfy
implementations directly (auto-accepting their confirm dialogs) plus
refresh/update/switchBranch/deleteRefinementKey/clearGlobalTaste -- zero
/api/git/* requests in the network log, zero errors, page stayed on
?mode=tour rather than reloading, full tour sequence still completes
cleanly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 7 finished round 6's admittedly-incomplete api.js-vs-patchApi() sweep
and found two more real, live gaps -- both on the same reachable surface
(the models-modal step's sidebar) rounds 5-6 already proved live:
- pipeline_state.js (window.PipelineState) is a standalone module, like
GenerateBridge -- talks to MovieEditorAPI directly, never through
window.Store, invisible to every Store-shaped sweep. ensureLoaded() fires
real, unmocked API.modules()+API.pipeline() the INSTANT the models-modal
step's panel mounts, zero clicks needed. Any ordinary widget edit on that
same screen then calls API.editPipeline(), a real POST.
- custom_nodes.js registers its own Settings section in the same sidebar.
Its panel calls API.customNodes() on mount, and its buttons reach a real
git clone (customNodeInstall), git pull (customNodeUpdate), and an
irreversible directory delete the panel's own confirm dialog describes
as "cannot be undone" (customNodeRemove) -- worse than any prior finding,
since it can destroy real files on disk rather than just make a stray
network call.
Also closed temp_browser.js's listTemp/downloadTempFile (lower severity,
read-only, but still a real read of the live server's actual temp files).
Fixed by mocking all eight functions in patchApi(), matching each one's
real response shape (pipeline_state.js needs slots/incomplete/refused/
queueable; editPipeline's mock echoes back whatever slots were sent so the
demo panel behaves sanely rather than erroring).
Verified live in a genuinely fresh tab (a stale tab's request log falsely
showed these firing before the fix landed -- reproduced clean in a new tab
to be certain): opened the Models modal directly (the zero-click trigger)
and the Custom Nodes panel, zero /api/modules, /api/pipeline, or
custom-node requests in the network log, zero console errors, full tour
sequence still completes cleanly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 6's sweep of raw API.* calls that bypass Store entirely (a layer
none of the prior five rounds checked) found the most easily-triggered
gap yet: models.js's "Export settings..." button calls API.settingsCard
directly, a real unmocked POST /api/settings-card -- and it's on the
FIRST screen the tour's own "models-modal" step shows, no sidebar
navigation needed (unlike round 5's Combo Sweep panel, which needed a
click into Settings first). Fixed by mocking API.settingsCard with a real
Blob (the function resolves a Blob and feeds it straight to
URL.createObjectURL, so a JSON mock like every other entry uses would
break the button instead of blocking it) -- a 1x1 transparent PNG is
enough for the card to render.
Also closed the secondary finding: applyGlobalPromptQuiet has TWO
internal closure-scoped callers (setEditorSetting's anchorEnabled toggle,
applyPromptTemplate) besides its exported Store property -- the same
"block the export, miss the internal call" shape as commit()'s original
vulnerability, so a patchStore() block-list entry alone couldn't have
closed it. It reaches a real, unmocked API.parsePrompt call. Fixed with
the same root-cause pattern as commit()'s guard: a window.__FUNPACK_TOUR__
check directly inside the function, which also covers
scheduleGlobalPromptApply transitively since that just debounces into a
call to this same function.
Verified live: API.settingsCard resolves a real 68-byte PNG Blob (not an
error); toggling anchorEnabled and calling scheduleGlobalPromptApply
directly, waited past both debounce windows, zero prompt/expand or
settings-card requests in the network log; full tour sequence still
completes with zero thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 5's exhaustive sweep of store.js's Store export found the serious
one: runComboSweep ("Run sweep" in the Settings window's "H3 Combo Sweep"
panel) reaches a real ComfyUI job queue completely outside every mock and
guard built so far. It calls window.GenerateBridge -> app/shell/session.js's
real queue-and-poll pipeline directly -- a separate module tree from
window.MovieEditorAPI that patchApi() never touches, and with zero
__FUNPACK_TOUR__ awareness anywhere in that chain. The "models-modal" tour
step spotlights the entire .settings-win element (sidebar included), so
the Combo Sweep panel is genuinely openable and runnable mid-tour -- not
theoretical.
This is a fourth caller of the same generation funnel generate/
generateMontage/generateSelected already block, just reached through
Settings instead of the timeline -- the kind of sibling path this whole
review chain keeps finding one at a time. Also closed renameMedia, a
second dormant landmine of the exact shape saveClipToMediaBin was
(currently inert -- API.renameMedia is a stub -- but already reachable
from the media bin's rename control).
Verified live: both return the block toast directly (confirmed the exact
Generate toast fires for runComboSweep, not a queued job), zero
prompt-queue network requests, full tour sequence still completes with
zero thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 4's review confirmed the commit() guard genuinely closes every
project-save path (keyboard shortcuts included), but caught the fix's own
comment overreaching: "every mutating path in this module funnels through
here" is false. store.js's shortcut/category/transition CRUD functions
(saveShortcut, deleteShortcut, addCategory, importShortcuts,
clearShortcuts, saveTransition, deleteTransition, importTransitions,
clearTransitions) each call their own real, unmocked API.* endpoint
directly and never touch commit() at all -- a second, separate funnel the
guard doesn't cover.
Currently dormant (no Settings > Shortcuts panel is wired up in v5 yet,
confirmed by grep -- these functions have no caller anywhere outside
store.js/api.js today), same shape as the saveClipToMediaBin landmine
rounds 2-3 already closed pre-emptively rather than waiting for it to go
live. These write GLOBAL state (the on-disk shortcut/transition library),
not sandboxed per-project state like everything commit() guards, so if
left open they'd be worse than a stray project PUT once a Shortcuts UI
ships.
Fixed with the same block-list pattern, and corrected both comments
(store.js's commit() guard, tour.js's blocklist) to state their actual
scope instead of the overclaim.
Verified live: all nine now return the block toast instead of reaching
Store's real implementation; full tour sequence still completes with zero
thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 3's review found the enumeration approach (patchStore()'s blocked
list) was still incomplete, and worse, live rather than dormant: keyboard
shortcuts (Delete/Backspace/S) bypass the tour's click-blocking backdrop
entirely, since CSS pointer-events does nothing to a window-level keydown
listener. timeline.js's shortcut handler calls removeSelectedScenes(),
removeAudioTrack(), removeSelectedOverlay(), and splitScene() -- none of
them in the blocked list -- and every one ends in scheduleSave(), whose
setTimeout calls the closure-scoped commit() directly, reaching a real,
unmocked PUT /funpack/api/projects/<id>. tour-s1 is pre-selected from the
moment the tour boots, so pressing Delete at any point immediately
triggers this -- no click needed, no specific step required.
The blocked-list pattern (three rounds of fixes: parsed.scenes crash,
syncFromPreview, saveClipToMediaBin) was closing this one caller at a
time and kept finding another. Fixed at the root instead: every mutating
path in store.js funnels through the single closure-scoped commit()
function before it can reach the network, so that's the one place a
tour-mode guard covers every current AND future caller by construction,
without needing tour.js to know the name of anything that might call it.
Verified live: dispatched a real Delete keydown during the tour with
tour-s1 selected (the default boot state) -- the scene still gets removed
locally (harmless, it's fake demo data), but zero requests to
/api/projects appear in the network log across the full debounce window
after the fix, versus a real PUT before it. Confirmed the guard is a
no-op outside tour mode (window.__FUNPACK_TOUR__ is false there) and the
full 16-step tour sequence still completes with zero thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2's review of the syncFromPreview fix came back clean but flagged an
adjacent, currently-dormant gap while tracing the same pattern: the "Save
video" button (Store.saveClipToMediaBin -> API.importClipToMediaBin) is
neither in patchStore()'s block list nor patchApi()'s mock list, and it
already renders during the tour (tour-s2 satisfies
clipSaveableToMediaBin()). It's harmless today only because
importClipToMediaBin is itself a stub ("no render/stitch stage yet") --
the moment that stage lands, this becomes a real, unblocked PUT during the
tour with nothing prompting anyone to notice. Closing it now with the same
established pattern rather than leaving it for a future regression.
Verified live: Store.saveClipToMediaBin now returns the block toast
directly, and a full tour run completes with zero thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adversarial review of ee2c0d5 (the parsed.scenes crash fix) found the crash
fix's own side effect: stopping the crash meant "Sync scenes from preview"
became clickable for the first time during the tour. That button calls
syncFromPreview() -> scheduleSave() -> a 5s-debounced closure-scoped
commit() -> a real PUT /funpack/api/projects/<id>. patchStore() already
blocks "commit", but only as the exported Store.commit property --
scheduleSave()'s internal setTimeout calls the closure-scoped commit
function directly, bypassing that patch entirely. This directly
contradicts the tour's own welcome copy ("nothing here touches your real
files"). Fixed by blocking syncFromPreview itself, the same way every
other mutating entry point (generate/save/delete/import/...) is already
blocked -- consistent with the existing pattern rather than trying to
patch scheduleSave's internals.
Also fixed a cosmetic mismatch the review caught: the demo preview's
middle scene had prose text while its actual timeline clip (tour-s2, a
video clip) has none -- now both agree.
Verified live: clicked "Sync scenes from preview" mid-tour, confirmed the
block toast fires and no PUT request is made (watched network across the
full 5s+ debounce window), then re-ran the full tour sequence to
completion with zero thrown errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verified the ported Tour feature live end-to-end (entry via ?mode=tour and
via Help > Welcome tour..., all 15 spotlight steps, FAQ landing, exit) and
found the one real bug: tour.js's canned preview.parsed was `{scenes: 3}`
-- a count, not an array. inspector.js's renderSplit() does
`(parsed.scenes || []).forEach(...)`, which throws the moment the Scene
preview panel renders during the tour. Confirmed identical in v4's
original tour.js -- a pre-existing bug carried over verbatim by the
byte-for-byte port, not something the port introduced.
Fixed by giving the demo preview a real 3-scene array matching the demo
project's own scene texts. Re-verified live: zero thrown errors across
the full tour sequence after the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 1 of extensive_testing on 6d49625 found two real defects:
- Unbounded server-side render cost/memory from a client-supplied
pipeline. render_png's image height grows with wrapped-line count,
so one crafted long input value amplified a small request into a
77,400px-tall, ~250MB render taking several seconds of single-
threaded PIL draw calls -- reproduced directly. This route is
unauthenticated, same as every sibling pipeline route, and reachable
over a rental's plain-http IP -- a real DoS surface on the box
actually doing GPU sampling. Fixed with two bounds: _short() now
truncates any one value past 400 chars (a card is a compact summary,
nobody reads a 200k-char row on it), and collect() caps the whole
card at 2000 rows, both declared on the card itself via a "…N more
omitted" note rather than silently dropped. Verified: the 200k-char
reproduction case drops from 3.2s/250MB to 0.09s/22KB; a 200,000-slot
pipeline renders bounded at ~1.7s/5MB with the truncation stated.
- Reopening "Export settings…" via the toolbar button (not the modal's
own ✕/Close) leaked the previous render's blob URL -- close()'s
revoke only runs on ITS OWN overlay, and the top-of-function DOM
cleanup only removed old .sc-overlay nodes, never revoked their
blobs. Carried over unfixed from v4's identical pattern. Fixed by
stashing the blob URL on its own overlay element so the cleanup line
can find and revoke it. Verified live: two clicks without ever
hitting Close produces two distinct blob URLs and exactly one
revokeObjectURL call, one overlay left in the DOM.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>