Skip to content

fix(ui): stop recalculating styles between Wisp blinks - #330

Merged
gustmrg merged 2 commits into
refactor/remove-excess-motionfrom
fix/wisp-idle-blink
Nov 1, 2026
Merged

gustmrg merged 2 commits into
refactor/remove-excess-motionfrom
fix/wisp-idle-blink

Conversation

@gustmrg

@gustmrg gustmrg commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #329.

An untouched window never went idle: every blinking Wisp kept Chromium recalculating styles on every frame, even though the eyes only move for 4% of the time. Nothing runs between blinks now, and the blink looks the same. Menus and panels also become readable sooner, and moving between tooltips no longer replays their fade.

Blink

  • useWispBlink starts a 240ms animation with element.animate when a blink is due, and replaces the endless wisp-blink CSS animation.
  • It keeps the six-second period, the phase each name gets from hashSeed, and the same Wisps: the xl picture and the conversation list.
  • CSS reduced-motion rules don't reach animations started from script, so the hook checks prefers-reduced-motion itself. It also skips hidden windows.

Measured over 10 seconds in an idle window with seven Wisps:

Before After
Animations running between blinks 7 0
Style recalculations 601 176
Main thread time 0.50s 0.27s
Layouts 165 176

Layouts stay about the same because they come from the blink itself, which still moves the eyes. These are single runs under xvfb, so read them as an order of magnitude.

Other changes

  • --ease-out is now cubic-bezier(0.23, 1, 0.32, 1), and the --animate-* entrance tokens use it. This also changes every ease-out utility, such as the splash fade and the connection progress bar.
  • Dropdown, Popover, Select, Context menu, Tooltip, and the update toast ask for ease-out. They were falling back to the built-in ease.
  • TooltipContent skips its fade with data-instant:animate-none. Base UI sets data-instant when one tooltip follows another, when focus opens one, and when Escape dismisses one.
  • ToggleSwitch moves its thumb with translate instead of left, so toggling no longer triggers layout.
  • chat-subline-pulse scales an ::after disc from 1 to 3 and fades it, instead of repainting box-shadow on every frame. The size and fade match the old pulse.

Testing

  • npm run typecheck, npm run lint, npm run format:check, and npm run build pass. Lint reports the same 2 warnings as main.
  • npm test on Node 22: 2448 tests pass.
  • New tests in use-wisp-blink.test.tsx cover the timing per name, which Wisps blink, reduced motion, and stopping on unmount.
  • Built app with the fake agent, measured over DevTools:
    • The table above, plus 13 blinks in 10 seconds with scale keyframes over 240ms.
    • The first tooltip fades with the new curve. The next one has data-instant="delay" and animation-name: none.
    • The context menu and details panel report cubic-bezier(0.23, 1, 0.32, 1).
    • The switch thumb moves from 3px to 17px with translate.
    • The pulse animates only opacity and scale, on ::after, with no box-shadow.
  • The pulse was checked on an element mounted by hand, because the fake agent's working state is too brief to measure.

To check by hand before merging:

  • The blink and the working pulse look the same as before on a real display.
  • The stronger curve feels right on menus, the details panel, and the splash fade.

Not in this PR

Refs SWE-268

@gustmrg
gustmrg added this pull request to stack #331 October 31, 2026 20:05
@gustmrg gustmrg changed the title fix/wisp idle blink fix(ui): stop recalculating styles between Wisp blinks Oct 31, 2026
Each blinking Wisp ran an endless CSS animation that only moves for
4% of its six-second cycle. Chromium still recalculated styles on every
frame in between, so an untouched window with seven Wisps spent about
5% of its main thread on them and never went idle.

A timer now starts a short animation only when a blink is due, so
nothing runs between blinks and that window's cost drops to about 2.7%.
Each Wisp keeps the same rhythm and its place in it, so the blink looks
the same. Hidden windows and reduced motion skip it.
Surfaces that fade in used the weak built-in ease, while dialogs asked
for ease-out. A single strong --ease-out token now drives every
entrance, so menus and panels are readable sooner at the same duration.

Moving between tooltips no longer replays the fade, since the first
one already answered the wait. The switch thumb moves with translate
instead of left, and the working pulse scales a disc instead of
repainting a box shadow on every frame.
@gustmrg
gustmrg force-pushed the fix/wisp-idle-blink branch from 5422713 to 13b7eeb Compare November 1, 2026 03:13
@gustmrg
gustmrg merged commit 1071d40 into main Nov 1, 2026
2 checks passed
@gustmrg
gustmrg deleted the fix/wisp-idle-blink branch November 1, 2026 03:17
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