Skip to content

fix(workspace): animate the voice-call column swap; the orb follows its box (#2640) - #2647

Merged
vybe merged 3 commits into
devfrom
fix/2640-voice-call-layout-motion
Sep 10, 2026
Merged

vybe merged 3 commits into
devfrom
fix/2640-voice-call-layout-motion

Conversation

@dolho

@dolho dolho commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Two defects, one report.

1. 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; 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 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-none on 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:

  • The v-if / v-else-if chain 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.
  • The rail waits for the canvas to finish leaving (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::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. 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:

  • a ResizeObserver for the box moving under a stable window (the column swap, a rail drag, a flex reflow) — which a window listener never sees;
  • a window resize for a devicePixelRatio change — 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 via ctx.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:

  • Re-scale, never re-seed. The particle field lives in a fixed coordinate space around (0,0) and is seeded once in startLoop, so the orb does not restart when the column moves (the issue asks for this explicitly).
  • A zero-sized box is ignored rather than throwing the last good size away — a hidden or not-yet-laid-out canvas measures 0, and sizing a bitmap to 0 draws nothing.
  • A same-size measurement does not touch the bitmap. Assigning to canvas.width clears the canvas and resets its context, so an observer firing on a sub-pixel reflow would otherwise blank the orb continuously.

Acceptance criteria

  • Start and end animate as one continuous motion (300 ms ease-out), prefers-reduced-motion instant. 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.
  • The orb is round at every width — bitmap tracks its box via ResizeObserver with DPR scaling, including during the width animation, a rail drag, a window resize and across the sm breakpoint.
  • The canvas column's content does not visibly re-wrap mid-transition (opacity rides the width).
  • Ending the call returns the rail to its exact width and tab — its state is a setup ref of the view and is untouched by the swap; the gate above is what stops it mounting early.
  • A frontend unit test covers the resize path (bitmap follows a changed box) and the layout test asserts the transition classes / reduced-motion fallback.
  • Raw-colour and loading-gate ratchets do not grow (this change adds no colour classes).

Verification

  • New portalVoiceLayoutMotion.spec.js17 passed. The resize contract is executed, not regexed: resizeCanvas lives 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 missing devicePixelRatio, the zero-box guard and the same-size guard. A copy of the function would have proven nothing about the shipped code.
  • Mutation-checked: pinning the bitmap to its first measurement (the exact pre-fix behaviour) fails FOLLOWS a changed box — the reported bug and nothing else.
  • Two existing guards rewritten, not deleted: portalVoiceMode.spec.js and portalRail.spec.js asserted the retired v-if / v-else-if chain. They now read the shared condition — the property they protect is exclusivity, not which construct expresses it.
  • Full frontend suite 113 files / 2532 tests, all passing; rawColorRatchet + loadingGateRatchet 14 passed; vite build clean, and the emitted CSS was checked directly for .\!grow-0{flex-grow:0!important}, transition-property:flex-grow,opacity and the prefers-reduced-motion block.

Honest note on what is not verified here

There is no component-mount harness in this project (package.json carries no @vue/test-utils, jsdom or happy-dom; vitest runs environment: '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

…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
@dolho
dolho requested a review from vybe September 9, 2026 13:22
@vybe

vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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 ~300mssrc/frontend/src/views/Portal.vue:481, comment at :865

Tailwind's transition-none emits only transition-property: none; duration-300 keeps transition-duration: .3s outside the media query. Vue's getTransitionInfo reads transitionDuration only, so under prefers-reduced-motion it still computes a 300ms timeout and @after-leave resolves on the fallback timer. A reduced-motion user ending a call sees the canvas snap away, ~300ms of empty right column, then the rail pop in. That contradicts AC 1 and the comment at :865 ("fires immediately and this is never observably true"). The class-counting test passes because it asserts the class string, not the behaviour. motion-reduce:duration-0 on both active classes drives the timeout to 0.

2. "One continuous motion" is two-thirds met — the rail column still steps discretelysrc/frontend/src/components/portal/PortalRail.vue:246,250

I checked: the rail's <aside> classes carry no width transition in either state (the only transition in the file is on icon buttons), and it is a shrink-0 sibling of <main> in the same flex row. So on call end it mounts at full width in one frame while <main> instantaneously loses that width — 48px with the rail collapsed, 384px or the dragged --ws-rail when open. That is potentially a larger single-frame jump than the 211px snap this PR set out to remove, and nothing covers it.

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: docs/memory/feature-flows/workspace-voice-conversation.md:276 propagates the reduced-motion claim into the written record, so it needs correcting with (1). And the body says Related to #2640 — please make it Fixes #2640 so the issue promotes on merge.

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
@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in f74e87dc. Body now says Fixes #2640.

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: motion-reduce:duration-0 emits transition-duration: 0s inside the prefers-reduced-motion media query and after duration-300, so it wins the cascade and getTransitionInfo computes a 0ms timeout. Added to all three transitioning elements (<main> plus both canvas active classes).

Both false claims are corrected: the voiceCanvasLeaving comment no longer asserts after-leave fires immediately as if it were free — it says the zero duration is what makes that true — and workspace-voice-conversation.md gets the same correction, including why one class is not enough.

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 0 → w complementary to the canvas. CSS cannot transition to auto, so it needs either a wrapper element in the row or the rail held mounted through a call — and that column's width is owned by ent#492's --ws-rail work, not by the voice-call code. Both options are visible-layout decisions I would not want to take on the strength of a node-env source scan, which is all tests/unit can offer here.

So it is recorded rather than quietly left: a Known Limitation section in the feature flow stating the size honestly (up to the dragged --ws-rail, which on a wide rail is a bigger step than the 211px snap this PR removes), and a test that fails if that record is deleted or if the rail starts animating and nobody comes back to remove it.

113 files / 2534 tests green; raw-colour scan unchanged.

@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Re-review. No blockers, with one note that only matters for merge order.

The reduced-motion half is verified rather than argued — motion-reduce:duration-0 emits transition-duration: 0s inside the media query and after duration-300, so it wins the cascade and getTransitionInfo computes a 0ms timeout. Both false claims (the voiceCanvasLeaving comment and the feature-flow paragraph) are corrected, and the class-counting test now requires both classes plus their ordering.

Merge-order note: #2677 handles the rail step this PR deferred, and it does so by retiring voiceCanvasLeaving — a rail column entering from zero width is complementary to a canvas leaving towards zero grow, so the three-columns-in-a-row hazard that flag guards is gone by construction rather than held off. That means the flag, its two transition hooks, and the Known Limitation section this PR adds are all deleted one PR later. Nothing to change here — the limitation being recorded is what made the follow-up a decision rather than an omission — but if these two land close together it is worth merging this first and letting #2677 remove the scaffolding, rather than trying to fold them.

The 8px resize-handle pop is still there and still accepted; it is documented in the flow doc on the follow-up.

@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

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 voiceCanvasLeaving. Flagging it because the paragraph will be read by whoever reviews #2677 and it slightly overstates the difficulty of what they are about to approve.

The reduced-motion half re-read clean and is the part I would keep: motion-reduce:transition-none alone leaves transitionDuration at .3s, which is the exact property getTransitionInfo reads to size its fallback timer, so the class-counting test now requires both classes and their ordering — asserting the class string rather than the behaviour is what let the original ship green.

Merge before #2677.

@vybe

vybe commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-10: on this train. dev merged into fix/2640-voice-call-layout-motion and pushed (Portal.vue auto-merged; I ran your three specs on the merged tree — 146 passed). PR body edited to carry the rail-step caveat the flow doc states, since the AC line read unconditionally while the doc you added in the second commit calls it "two-thirds true".

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: motion-reduce:duration-0 lands at index 80011, after .duration-300 at 56324, so it genuinely wins the cascade.

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: resizeCanvasFromSource() slices the real resizeCanvas() body out of VoiceOverlay.vue (1022 chars, guarded by a toContain('getBoundingClientRect') so an empty slice fails loudly) and runs it through new Function against a stub canvas. I mutation-proved it — pinning the bitmap to its first measurement reproduced the original bug exactly (× FOLLOWS a changed box, 1 failed / 18 passed, nothing else moved). All three slice-based assertions were also checked for the #2646 vacuity defect; all three are non-vacuous.

Findings left for you — none blocking, none fixed by me:

  1. @leave-cancelled is unhandled, so voiceCanvasLeaving can strand true (Portal.vue:493-494). Vue calls onLeaveCancelled, not onAfterLeave, when an enter interrupts a leave. Restart a call inside the 300 ms leave window and the flag stays set. It self-heals and is latent — but the comment at :883 states the invariant as though it cannot happen. One line: @leave-cancelled="voiceCanvasLeaving = false". Defensible to accept if fix(workspace): the rail column is a width, so it moves with the canvas (#2676) #2677 deletes the flag anyway.
  2. The rail step is symmetric, but the doc and The Workspace rail column steps discretely when a voice call ends (#2640 follow-up) #2676 both say "when a voice call ends". On call start the rail unmounts in one frame while the canvas enters at zero width, so <main> jumps wider and animates back down — same magnitude.
  3. The 8 px ColumnResizeHandle pops un-gated (:456-457): thirdColumnResizable keys on voiceCall.active, so it mounts instantly on call end while the rail is still gated for 300 ms. You acknowledged this in a comment; it just isn't in the PR's Known Limitation section.
  4. The "every transitioning element" guard is spelling-scoped (portalVoiceLayoutMotion.spec.js:85,100) — the regex only sees arbitrary-value transition-[...]. An element written transition-all duration-300 is invisible to it, so the test's own comment over-claims.
  5. The leave path has no browser coveragee2e/canvas-gallery-voice.spec.js starts a call but never ends one, so the exact defect caught on the first pass is guarded only by class strings.
  6. AC 3 is met in spirit — the issue offered "reserve the width or fade in after it settles"; opacity rides the same 300 ms, so content is partially visible and re-wrapping for most of the transition.
  7. DPR scaling quadruples per-frame fill on a 2× display, and the 220 particle sprites are pre-rendered at 1× then upscaled — they pay the cost without gaining sharpness. Unmeasured, since there was no browser run.

Merging before #2677, per your stated ordering.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260910-1327 (#2686), 32 checks green across all five members together.

@vybe
vybe merged commit 11e52bc into dev Sep 10, 2026
27 of 28 checks passed
dolho added a commit that referenced this pull request Sep 10, 2026
#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants