Skip to content

fix(security): smooth the scan-end visual transition#351

Merged
graydawnc merged 1 commit into
mainfrom
fix/scan-complete-flicker
May 29, 2026
Merged

fix(security): smooth the scan-end visual transition#351
graydawnc merged 1 commit into
mainfrom
fix/scan-complete-flicker

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

What

Eliminates the one-frame "shake" SecurityPage produced when a manual rescan ended. The user reported it as a small but persistent visual judder that survived several iterations of the obvious fixes — the actual cause turned out to be four overlapping problems that all had to land together for the transition to feel like state morphing instead of a banner swap.

Why

After PR #350 (scan-worker enqueue burst batch) merged, manual verification on the dev archive surfaced a separate UX seam at the OTHER end of the burst — scan completion. Walked the codepath one fix at a time, with the user verifying each iteration, and converged on a four-layer fix:

  1. Two React commits at the busy → idle edge — the manual-rescan branch flipped displayBusy(false) + rescanInFlight(false) synchronously, then set scanResult after an await refresh() a few ms later. Two commits = one intermediate frame with neither banner mounted.
  2. Geometric column-width mismatchScanBanner's right column was 0 px (empty span); ScanResultBanner's right column was 20 px (dismiss button). Under grid-template-columns: auto 1fr auto the middle column gained / lost 20 px across the swap. Verified via Playwright getBoundingClientRect: pre-fix the middle column had 646 / 626 px in the two modes; post-fix both are 626 px (pixel-perfect).
  3. Banner appearance was an instant pop — no entry animation when the result banner mounted.
  4. Two separate React components at separate JSX positions — even after batching and geometry fixes, React saw "unmount ScanBanner, mount ScanResultBanner": two distinct subtrees swap-replacing each other. The pinging amber dot's animate-ping cut mid-animation; the amber background flipped to warm-surface in one paint frame; the 2 px amber progress strip vanished mid-interpolation. Cumulatively read as "the banner just shook".

How

1. Batch the busy → idle flip

setDisplayBusy(false) is now deferred into the same async block as setScanResult so React 18 auto-batches them into one commit. A try / catch falls back to riskRef.current so a refresh failure doesn't strand the user in a permanent busy state.

2. Width-matched columns

Added a <span aria-hidden className=\"inline-block w-5 h-5\" /> placeholder in the scanning state. The grid auto 1fr auto now resolves to the same column widths in both modes.

3. Fade-in entry

Both banner modes get animate-in fade-in duration-200 so the banner enters gradually rather than snapping in.

4. Unified ScanStatusBanner

Replaced ScanBanner + ScanResultBanner with a single ScanStatusBanner driven by a discriminated state: { kind: 'scanning' } | { kind: 'complete' } union. Because the JSX position and component type are the same across the state swap, React reconciles the outer div in place — no remount. transition-colors duration-300 morphs the background + border from amber to warm-surface smoothly. The icon, text, and right column all swap their inner JSX without touching the parent box. The progress strip is rendered in BOTH states with transition-[width,opacity] duration-300; on completion it slides to 100 % and fades to 0 in the same beat, giving the natural "completed and dissipates" feel rather than blinking off mid-animation.

How it connects

Verification

  • pnpm vitest run in packages/app — 338 passed (no test relied on the two banners coexisting).
  • pnpm exec tsc --noEmit in packages/app — clean.
  • Playwright getBoundingClientRect on the two banners' grid columns confirmed pixel-perfect alignment (16 / 626 / 20 in both modes).
  • Manual on a live dev archive at each of the four iterations the user verified the perceived improvement and the final feel.

Notes / non-goals

  • Behavior trade-off worth flagging: the old shape rendered both banners independently so a result banner could persist VISIBLY while a follow-up auto burst started ("ACK stays put while Spool rescans in the background"). The unified ternary prioritises scanning, so a follow-up auto burst temporarily replaces the ACK with the scanning state. scanResult is preserved in component state, so when that burst finishes the banner returns to its complete mode with the original counts intact — the ACK is paused, not lost. The morph between scanning ↔ complete is in-place CSS now, so the auto-burst interruption itself feels smooth.
  • No core / IPC / worker code touched. Renderer-only change.

Reported: clicking Rescan all on SecurityPage produced a one-frame
"shake" at the moment scanning ended. Four cumulative causes, four
fixes that needed to land together to make the transition feel like
state morphing rather than a banner swap.

1. Two React commits at the busy → idle edge.
   The manual-rescan branch flipped `displayBusy(false)` + `rescanInFlight(false)`
   synchronously, then set `scanResult` after an `await refresh()` a
   few ms later. React committed twice: once with the scan banner
   already unmounted and the result banner not yet mounted (and the
   EmptyState / risk-list branch possibly flipped), then once with
   the result banner. The intermediate frame was empty.
   Fix: defer `setDisplayBusy(false)` into the same async block as
   `setScanResult` so React 18 auto-batches both into one commit. Add
   a `try / catch` fallback to `riskRef.current` so a refresh failure
   doesn't strand the user in a permanent busy state.

2. Geometric column-width mismatch between the two banners.
   ScanBanner's right grid column was an empty `<span aria-hidden />`
   (0 px); ScanResultBanner's right column was a `w-5 h-5` dismiss
   button (20 px). With `grid-template-columns: auto 1fr auto` the
   middle column gained 20 px of room on the swap, re-wrapping any
   text near the right edge. Verified via Playwright
   getBoundingClientRect: pre-fix the middle column had 646 px in
   one mode and 626 px in the other; post-fix both modes resolve to
   626 px (icon = 16, middle = 626, right = 20) — pixel-perfect.
   Fix: width-matched placeholder `<span aria-hidden className="w-5 h-5" />`
   in the scanning state.

3. Banner appearance was an instant pop.
   Both banners got `animate-in fade-in duration-200` so they enter
   gradually rather than snapping in.

4. The two banners were separate React components at separate JSX
   positions. Even after geometric + batching fixes, React still saw
   "unmount ScanBanner, mount ScanResultBanner" — two distinct DOM
   subtrees swap-replacing each other. The pinging amber dot's
   `animate-ping` cut mid-animation, the amber background flipped to
   warm-surface in one paint frame, and the 2 px amber progress
   strip vanished mid-interpolation. All cumulatively read as "the
   banner just shook".
   Fix: unify into a single `ScanStatusBanner` driven by a
   discriminated `state: { kind: 'scanning' | 'complete' }` union.
   The two old components live as conditional inner content of one
   outer div. Because the JSX position and component type are the
   same across the state swap, React reconciles the outer div in
   place — no remount. `transition-colors duration-300` morphs the
   background and border from amber to warm-surface smoothly. The
   icon, text, right column all swap their inner JSX without
   touching the parent box. The progress strip is rendered in BOTH
   states with a `transition-[width,opacity] duration-300`; on
   completion it slides to 100 % and fades to 0 in the same beat,
   giving the natural "completed and dissipates" feel rather than
   blinking off mid-animation.

Trade-off worth flagging: the old shape rendered both banners
independently so a result banner could persist visibly while a
follow-up auto burst started ("ACK stays put while Spool rescans the
background"). The unified ternary prioritises scanning, so a follow-
up auto burst temporarily replaces the ACK with the scanning state.
scanResult stays in component state, so when that burst finishes the
banner returns to its complete mode with the original counts intact
— the ACK is not lost, just paused. The morph between the two states
is in-place CSS now, so the auto-burst interruption itself feels
smooth.

App test suite green: 338 passed (no test relied on the two banners
coexisting). Typecheck clean.
@graydawnc graydawnc added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit ac0f896 May 29, 2026
6 checks passed
@graydawnc graydawnc deleted the fix/scan-complete-flicker branch May 29, 2026 09:10
@graydawnc graydawnc mentioned this pull request May 29, 2026
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.

1 participant