fix(workspace): animate the voice-call column swap; the orb follows its box (#2640) - #2647
Conversation
…ts box (#2640) Two defects, one report. **The layout jumped.** `Portal.vue` toggled `<main>` between `flex-1` and `sm:flex-[2_1_0%]` on `voiceCall.active` and swapped `PortalRail` for `PortalVoiceCanvas` in the same frame. Nothing transitioned, so both columns landed at their new shares in one paint, and End call jumped back. `flex-grow` is a `<number>` and therefore animatable, so the share now transitions — 300 ms ease-out on `<main>`, and the canvas column ramps its own grow 0 → 3 over the same curve. The ramp is expressed as Vue enter/leave classes rather than a class toggle because a newly inserted element has no value to transition FROM. Opacity rides the same transition so the canvas's content is not re-wrapping in view while the column is still moving. `motion-reduce:transition-none` on every transitioning element. The shares stay shares: reverting to `w-[40%]` / `w-[60%]` would animate just as well and re-open #2581, where those summed to 100% + an 18rem sidebar and the shell clipped the canvas column off the right edge. Two consequences, both deliberate: * The `v-if` / `v-else-if` chain is gone — a `<Transition>` wrapper breaks the adjacency a chain needs. The exclusivity it guaranteed by construction is now a named computed both arms read, so they cannot drift into both claiming the column. * The rail waits for the canvas to finish leaving. Vue keeps a leaving element in the DOM for its transition; without the gate the rail would mount at full fixed width beside a canvas that is still shrinking — three columns in a row sized for two, `<main>` squeezed by flex for 300 ms, a worse jump than the one being fixed. **The orb rendered squashed.** `VoiceOverlay.vue::resizeCanvas` sized the bitmap ONCE, from the `watch(canvasEl)` that fires on mount — no ResizeObserver, no window listener, no per-frame check — while the canvas is `absolute inset-0 w-full h-full`. Every later width change left CSS stretching a stale bitmap into an ellipse, and the overlay mounts in the same tick the call re-lays out the columns, so the single measurement could capture the pre-call width on its own. It now observes both: a ResizeObserver for the box moving under a stable window (the column swap, a rail drag), and a window `resize` for a devicePixelRatio change, which resizes no box and so fires no observer. The bitmap is sized at `css × dpr` (capped at 2) and the render loop draws in CSS pixels via `ctx.setTransform`, so the 45px core and the particles' fixed radii keep meaning what they meant. Resizing re-scales and never re-seeds — the particle field is seeded once and lives in a fixed space around (0,0), so the orb does not restart when the column moves. A zero-sized box is ignored rather than throwing the last good size away, and a same-size measurement does not touch the bitmap, because assigning to `canvas.width` clears the canvas. Tests: `portalVoiceLayoutMotion.spec.js` (17) — the resize contract EXECUTED against a stub canvas whose box changes (DPR scaling, the cap, a missing DPR, the zero-box and same-size guards), mutation-checked by pinning the bitmap to its first measurement, which fails exactly the "FOLLOWS a changed box" case; plus the transition classes, the reduced-motion fallback counted over every transitioning element, and the leave gate. Two existing guards in `portalVoiceMode.spec.js` / `portalRail.spec.js` were rewritten to read the shared condition instead of the retired chain — the property they protect is exclusivity, not which construct expresses it. Frontend suite: 113 files, 2532 tests, all passing; both ratchets green; vite build clean. Related to #2640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
|
merge-train 2026-09-09: not on this train. No criticals, and the orb half is genuinely fixed and genuinely tested — but two of the PR's own acceptance criteria are unmet, and both need your decision rather than a patch on the train. 1. Reduced motion is not instant; the rail's return is gated ~300ms — Tailwind's 2. "One continuous motion" is two-thirds met — the rail column still steps discretely — I checked: the rail's Accept the rail step, animate the rail too, or file a follow-up — any of those is fine, but it should be an explicit decision, since it is the headline AC. Also: Rides the next train once decided. |
… step (#2640) Two review findings. 1. **Reduced motion was not instant.** Tailwind's `transition-none` emits only `transition-property: none` — the `duration-300` beside it still applies, so `transitionDuration` stays `.3s`. That is the exact property Vue's `getTransitionInfo` reads to size the fallback timer it resolves `@after-leave` on, so under `prefers-reduced-motion` nothing animated and every leave was still gated for 300ms: the canvas vanished, the right column sat empty, then the rail popped in. `motion-reduce:duration-0` on every transitioning element drives that timeout to 0. Verified against Tailwind's own output — the variant emits `transition-duration: 0s` inside the media query and after `duration-300`, so it wins. The comment on `voiceCanvasLeaving` claimed "`after-leave` fires immediately and this is never observably true", which was false as written; it now says what makes it true. Same correction in the feature flow. 2. **The rail column still steps** — accepted here, tracked at #2676. Its `<aside>` carries no width transition and it is a `shrink-0` flex sibling of `<main>`, so on call end it mounts at full width in one frame: 48px collapsed, 384px open, or the dragged `--ws-rail`, which on a wide rail is a bigger step than the 211px snap this PR removes. The honest fix is an explicitly animatable width for that column — CSS cannot transition to `auto` — and that width is owned by ent#492, not by the voice-call code. Doing it from here means either a wrapper element in the row or holding the rail mounted through a call, both of which want a browser to verify rather than the node-env source scan this suite is limited to. Recorded as a Known Limitation in the feature flow, with a test that fails if the limitation is deleted from the record or if the rail starts animating. Tests: the class-counting case now requires BOTH reduced-motion classes and asserts the ordering relation, since asserting the class string rather than the behaviour is exactly why (1) shipped green. 113 files / 2534 tests green. Fixes #2640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
|
Addressed in 1. Reduced motion is instant. You had the mechanism exactly right, and I verified it against Tailwind's own output rather than reasoning about it: Both false claims are corrected: the The class-counting test now requires both classes and asserts the ordering relation, since "it asserted the class string, not the behaviour" is precisely why this shipped green. 2. The rail step — decision: accept here, tracked at #2676. The honest fix is an explicitly animatable width on the rail column so it can ramp So it is recorded rather than quietly left: a Known Limitation section in the feature flow stating the size honestly (up to the dragged
|
|
Re-review. No blockers, with one note that only matters for merge order. The reduced-motion half is verified rather than argued — Merge-order note: #2677 handles the rail step this PR deferred, and it does so by retiring The 8px resize-handle pop is still there and still accepted; it is documented in the flow doc on the follow-up. |
|
Deeper self re-review. No defect in the change. One correction to something I wrote in it. The Known Limitation section I added says the rail step is left "deliberately, not overlooked", and gives as the reason that fixing it needs "either a new wrapper element in the row or holding the rail mounted through a call". #2677 has since done it, and the first of those was the answer — a Portal-owned wrapper carrying an animatable width, which is the shape the sidebar column three columns to the left already had. That precedent existed while I was writing the paragraph and I did not look for it, which is why the estimate reads as more open-ended than it turned out to be. Nothing to change here — the limitation being recorded is what made the follow-up a decision rather than an omission, and #2677 deletes the section along with The reduced-motion half re-read clean and is the part I would keep: Merge before #2677. |
|
merge-train 2026-09-10: on this train. The earlier merge-train hold is fully discharged, and I re-verified it rather than taking the argument. The reduced-motion fix was checked against Tailwind's emitted CSS, not the source: Credit where it's due — this PR is the batch's counterexample on test quality. Six of the eight PRs I validated today lean on source-text assertions. Yours actually executes the shipped code: Findings left for you — none blocking, none fixed by me:
Merging before #2677, per your stated ordering. |
#2647 (the parent this branch was stacked on) squash-merged to dev an hour ago, so its content arrived here from two directions at once: as the branch's own commits and as dev's squashed form. All five conflicts are that, and the resolution is the same in each — keep the branch, which already carries #2647's work plus #2676's changes on top. Portal.vue, three hunks: #2676 RETIRES `voiceCanvasLeaving` (a rail entering from zero width is complementary to a canvas leaving toward zero grow, so the row's total is conserved and the flag has nothing left to sequence). dev still has the flag, its two transition handlers and the old `v-if` on `PortalRail`. The branch's side is the intended end state. The three spec files are the same shape one level along: each conflict is #2676's updated assertion against the pre-#2676 one dev still holds (`v-if="railHasColumn"` on the wrapper vs `v-if="railVisible && …"` on `PortalRail`). Resolved HUNK-WISE, not file-wise. `git checkout --ours` was the first attempt and was WRONG: it takes the whole file from HEAD and so would have discarded dev's ent#556 `PortalBrand` block from the signed-out shell — #2653's work, untouched by this branch and present only in dev. Caught by diffing the resolution against dev before committing. The merged file now carries both. Frontend suite green on the result: 118 files / 2613 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Two defects, one report.
1. The layout jumped
Portal.vuetoggled<main>betweenflex-1andsm:flex-[2_1_0%]onvoiceCall.activeand swappedPortalRailforPortalVoiceCanvasin the same frame. Nothing transitioned, so both columns landed at their new shares in one paint; End call jumped back.flex-growis a<number>and therefore animatable, so the share now transitions — 300 ms ease-out on<main>, and the canvas column ramps its own grow0 → 3over the same curve. The ramp is Vue enter/leave classes rather than a class toggle, because a newly inserted element has no value to transition from. Opacity rides the same transition, so the canvas's content is not re-wrapping in view while the column is still moving (AC 3).motion-reduce:transition-noneon every transitioning element.The shares stay shares. Reverting to
w-[40%]/w-[60%]would animate just as well and re-open #2581 — those summed to 100% + an 18rem sidebar and the shell clipped the canvas column off the right edge.Two consequences, both deliberate and both written into the flow doc:
v-if/v-else-ifchain is gone. A<Transition>wrapper breaks the adjacency a chain needs, so the exclusivity the chain guaranteed by construction is now a named computed both arms read (voiceCanvasHasColumn). Deriving one from the other is what stops them drifting into both claiming the column.voiceCanvasLeaving, from the transition's own hooks). Vue keeps a leaving element in the DOM for its transition; without the gate the rail would mount at full fixed width beside a canvas that is still shrinking — three columns in a row sized for two,<main>squeezed by flex for 300 ms, which is a worse jump than the one being fixed.2. The orb rendered squashed
VoiceOverlay.vue::resizeCanvassized the bitmap once, from thewatch(canvasEl)that fires on mount — noResizeObserver, no window listener, no per-frame check — while the canvas isabsolute inset-0 w-full h-full. Every later width change left CSS stretching a stale bitmap into an ellipse. The overlay also mounts in the same tick the call re-lays out the columns, so the single measurement could capture the pre-call width on its own, with nothing resizing afterwards.It now observes both, for different events:
ResizeObserverfor the box moving under a stable window (the column swap, a rail drag, a flex reflow) — which a window listener never sees;resizefor adevicePixelRatiochange — dragging to a different-density monitor resizes no box at all, so it fires no observer.The bitmap is sized at
css × dpr(capped at 2 — a 3x display would quadruple the fill cost of a full-column particle field for detail nobody can see at this blur radius) and the render loop draws in CSS pixels viactx.setTransform, so the 45px core and the particles' fixed radii keep meaning what they meant before DPR scaling existed.Three smaller properties, each of which is a bug if you get it wrong:
(0,0)and is seeded once instartLoop, so the orb does not restart when the column moves (the issue asks for this explicitly).canvas.widthclears the canvas and resets its context, so an observer firing on a sub-pixel reflow would otherwise blank the orb continuously.Acceptance criteria
prefers-reduced-motioninstant. Caveat, per the flow doc added here: the canvas column animates, but the rail column itself still steps (The Workspace rail column steps discretely when a voice call ends (#2640 follow-up) #2676) — on end and, symmetrically, on start.ResizeObserverwith DPR scaling, including during the width animation, a rail drag, a window resize and across thesmbreakpoint.Verification
portalVoiceLayoutMotion.spec.js— 17 passed. The resize contract is executed, not regexed:resizeCanvaslives in<script setup>and cannot be imported, so the test lifts its body out of the shipped component and runs it against a stub canvas whose box changes — DPR scaling, the cap, a missingdevicePixelRatio, the zero-box guard and the same-size guard. A copy of the function would have proven nothing about the shipped code.FOLLOWS a changed box — the reported bugand nothing else.portalVoiceMode.spec.jsandportalRail.spec.jsasserted the retiredv-if/v-else-ifchain. They now read the shared condition — the property they protect is exclusivity, not which construct expresses it.rawColorRatchet+loadingGateRatchet14 passed;vite buildclean, and the emitted CSS was checked directly for.\!grow-0{flex-grow:0!important},transition-property:flex-grow,opacityand theprefers-reduced-motionblock.Honest note on what is not verified here
There is no component-mount harness in this project (
package.jsoncarries no @vue/test-utils, jsdom or happy-dom; vitest runsenvironment: 'node'), and I have no browser to watch the animation in. What is proven is the resize behaviour, the class contract, and that Tailwind emits every class involved with the specificity the design depends on. The one thing that wants a human eye is the feel of the 300 ms curve.Fixes #2640
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP