Zoom effects: elevate and animate the zoomed pane#265
Conversation
Cleanup pass over the zoom-effects branch; no behavior change.
- Delete ZoomedContext. Both pane headers now read ZoomedIdContext, so the
boolean projection had no readers left — only two providers and a public
re-export that did nothing for embedders.
- Collapse the four hand-copied zoom-release guards into releaseZoomExcept,
and route enterTerminalMode through selectPane instead of re-implementing
its body (which is why the guard had to be copied at all). setZoomed
already no-ops on an unchanged id, so the !== null reads guarded nothing.
- Make emitFrame the single frame-emission point (withData), with one
frameMetadata reader for both the large and small paths — they had drifted
to two narrowing styles for one shape. Mix device dims into the dedup hash
numerically: the `${data}@WxH` key copied ~100KB of base64 per frame at
~20Hz just to hash it. AgentBrowserFramePulse now requires both dims,
matching what every construction and consumption site already assumed.
- Drop two provisional-frame rechecks the sink/data guards already cover.
- Use the layers map for the pre-tween zIndex so stacking has one source of
truth with the animator's bands.
- Reuse terminalLeafMeta + LATH_LAYOUT_OPTS in the playground layout; the
hand-written meta could drift silently (LeafMeta fields are typed string)
and the copied opts would assert a stale gap.
- tiling-engine.md row 9: zoom insets by 15px, not the 6px layout gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
fa18c02
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ff931b72.mouseterm.pages.dev |
| Branch Preview URL: | https://zoom-effects.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
One correctness finding on the two-stage browser paint; the zoom-as-layer rework and the focus↔zoom coupling both trace cleanly and the specs are accurate.
Provisional paint can strand the pane on the blurry frame at rest. The screenshot loop skips a decode when a capture's bytes match lastDrawnKey (agent-browser-screenshot-loop.ts display), but lastDrawnKey only ever updates on a crisp draw (line 83). drawProvisionalFrame → paintBitmap overwrites the canvas with the blurry CSS-resolution frame without touching lastDrawnKey, so the loop's dedup no longer reflects what's on screen.
Repro (HiDPI, host has agentBrowserScreenshot):
- Crisp frame
Cis drawn —lastDrawnKey = C, canvas is sharp. - Hover a control → page changes → provisional blurry paint; a crisp capture starts.
- Continued pointer jitter bumps
provisionalPaintGeneration, so that in-flight crisp is dropped as stale (the mechanism you flagged in the follow-up note).lastDrawnKeystaysC. - Un-hover → resting pixels equal
Cagain → provisional paints blurryC; the coalesced crisp capturesC. - That crisp is not stale, but
displaycomputeskey(C) === lastDrawnKeyand returns early — no draw. The stream is quiet, so the pane stays on the blurry provisionalCindefinitely.
This is distinct from the acknowledged pacing follow-up, and would defeat its planned fix: even an explicit trailing crisp flush is swallowed by this byte-dedup when the resting frame matches the last crisp draw. The existing drawGeneration mechanism (bumped on re-attach to force a repaint of a blank canvas over identical bytes) is exactly the right tool here — a provisional paint is the same situation, so bumping it when a provisional lands makes the next crisp always repaint over the blur. Inline suggestion below.
Reviewed and clean
ZoomedContext→ZoomedIdContextrename: all consumers migrated; no surviving import of the removed symbol anywhere in the tree.- Animator layer bands (
Math.max(from.layer, to.layer)during tween,to.layerwhen settled,framesClosenow comparinglayer,markDyingusingMath.max(cur.layer, LATH_LAYER_DYING)): consistent across rise / hold-during-lower / snap / reduced-motion / dying-while-elevated. - Focus↔zoom coupling:
releaseZoomExceptcorrectly threaded throughselectPane/selectDoor/enterTerminalMode/exitTerminalMode; re-selecting the zoom owner no-ops. frameMetadatarequiring both dims> 0, and the inlinePANE_HEADER_STYLEreplacing the removed.lath-leaf-headerCSS: no consumer regressed.- The desktop-playground L-shape seed matches
LathPersistedLayoutand is validated by the new test; specs (toDevice,PANE_HEADER_HEIGHT_PX,LATH_ZOOM_*,presentationTargets,LATH_LAYER_*) point at symbols that exist.
Minor, non-blocking: the large-frame dedup hashes the full JSON envelope (isDuplicateFrame(raw)) on the idle path but the base64 body + dims (isDuplicateFrame(msg.data, metadata)) on the wantFrameData path, so a byte-identical heartbeat straddling a wantFrameData toggle costs one redundant crisp capture. Benign and self-correcting — there's no clean fix without parsing on the idle path, so probably not worth changing.
A provisional paint writes CSS-resolution pixels straight to the canvas, so
the screenshot loop's byte-dedup (`lastDrawnKey`) stopped describing what was
on screen: `paintBitmap` has two callers but only the crisp one records a key.
On a resting page the follow-up capture is byte-identical to the last crisp
draw, so `display` deduped it to a no-op and the pane stayed on the blurry
frame indefinitely — the stream is quiet at rest, so nothing came along to
correct it. That breaks the sharp-at-rest guarantee in dor-browser.md, and
would also have swallowed the trailing flush in the planned pacing fix.
`drawGeneration` already exists for exactly this ("the canvas changed without
the crisp loop drawing it" — bumped on re-attach so a blank canvas repaints
over identical bytes). A provisional paint is the same situation, so bump it
there too.
Found in review by dormouse-bot on #265.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/code-review over the branch. One correctness bug, plus two regressions from my own earlier cleanup pass. - onZoom: only the zoom owner's control toggles zoom off. The 15px inset newly exposes other panes' headers (a top-row header's band pokes above the zoomed rect, and the h-5 button sits inside it), and HeaderActionButton stops mousedown, so no selection runs first to release zoom. `currentZoom !== null` therefore made pane B's "Zoom" control unzoom pane A and stop — B never zoomed. Unreachable before this branch, when the zoomed pane covered the wall full-rect. Now a non-owner's control hands zoom over, focus included. - playground-desktop-layout: drop the `terminalLeafMeta` import I added. This module is imported eagerly by the route, while PlaygroundDesktop deliberately defers the lib behind dynamic imports; the import put a static edge from the 8.4kB route chunk into terminal-registry (376kB / 98kB gzip, its own chunk) to save three field names — downloaded even on a small screen, which never mounts the Wall. The test now pins the literal against the real `terminalLeafMeta`, so a rename still fails loudly. - agent-browser-connection: ask `wantFrameData()` at the two frame paths again instead of once per message, so status/tabs stop paying for it. - Assert `isLathPersistedLayout` on the playground seed: it is honored only while `version === 1`, and a bump would silently seed one generated pane (no tut-main, no runners) with every test still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A capture (~120ms) is slower than the stream (~20Hz), so every capture started inside the provisional window is superseded before it resolves and dropped as stale. During a hover or drag that was ~100% of them: a host round trip (a child-process spawn) per pulse, bytes discarded. A test over 600ms of simulated hover measures 7 such shots. Defer instead: while the window is open, leave the work dirty and take one settled shot at its end. Continued pointer input pushes the window out, so the timer re-enters `schedule` and re-arms rather than firing early. This costs no pixels — the deferred shots never drew anything, and the frame that lands is the first one started after the last provisional paint either way. Deferring alone would strand the pane, so also: a capture dropped as stale now leaves the loop dirty. Nothing else necessarily pulses it again — a single pointer move over a static page pulses once, and that pulse is consumed by the very capture the provisional supersedes, so `if (dirty) schedule()` found nothing pending and the pane kept the blurry frame until the page next changed. That is a second stranding bug, distinct from the byte-dedup one fixed in 2783749: it strands with no capture attempted at all, so the drawGeneration bump could not have caught it. Both are covered by tests verified to fail without their fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes pane zoom a real presentation layer instead of a geometry special case, and couples it to passthrough focus.
What changed
LATH_LAYER_ELEVATED— one of three layer bands (TILED/DYING/ELEVATED) the animator now threads through to the host. This replaces the oldzoomedIdRefspecial case that skipped geometry for one leaf. The exposed perimeter plus a blurred app-background shadow make the stacking relationship visible.Notes for review
The last commit is a
/simplifypass over the branch (no behavior change) — deadZoomedContextremoval, onereleaseZoomExcepthelper in place of four copied guards, a singleemitFramepath, and a dedup hash that stops copying ~100KB of base64 per frame.Specs updated alongside:
tiling-engine.md(layer bands, zoom geometry),layout.md(zoom/focus coupling),dor-browser.md(provisional paint),tutorial.md.Test plan
pnpm testgreen: spec-lint (23 specs), lib 1063, website 51, standalone 47, plus dor/server.Known follow-up, deliberately not in scope: during sustained pointer input the crisp screenshot loop starts captures whose results are then dropped as stale against newer provisional paints. Fixing it means gating
take()on the provisional window plus an explicit trailing flush — a pacing change that needs its own verification, since a naive gate would strand the surface on the provisional frame when the stream goes quiet.🤖 Generated with Claude Code