Skip to content

perf(ui): take preview frames and the ambient tint off the per-frame path - #443

Merged
hyperb1iss merged 10 commits into
mainfrom
nova/ui-frame-cost
Oct 21, 2026
Merged

hyperb1iss merged 10 commits into
mainfrom
nova/ui-frame-cost

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

On a Mac, the desktop app's Effects page with a live 60 fps preview cost about 55% of a core across three WebKit processes, and WindowServer sat near 44%. A sample profile of the page showed whole-document style recalculation and layout every frame, three megabytes of memmove per frame, and garbage collector churn from JSFinalizationRegistry. With this PR the preview path moves frames without copying them through WASM, stops minting a closure per frame, the ambient tint no longer restyles the whole page, and a hidden page stops the preview stream on both ends. The preview itself is unchanged: same 60 fps, same full-size RGBA, same bytes on the wire.

💡 What changes

The ambient tint leaves the frame path. The dominant hue was already analysed at 2 Hz on about 200 sampled pixels, so the analysis was cheap; the cost was what each write triggered. Every write of --ambient-hue on :root restyled the whole document, the halo's two-second box-shadow transition restarted every 500 ms and therefore animated forever, and about fifteen transition-all buttons and spans animated scrollbar-color after each write. The hue is now published at most once a second and only when it moves 4° or more. The halo drifts on a registered, non-inherited --ambient-hue-drift over the same two-second curve, so each frame of the drift restyles one element. Elements that cannot scroll pin scrollbar-color: auto. Reduced motion still snaps. The tint is decorative chrome at single-digit alpha with no cadence contract of its own, unlike the preview, which is why its publish rate can drop.

No full-frame copies through WASM. A 640x480 RGBA frame exceeds the 1 MiB message limit and arrives as two chunks. Reassembly copied each frame three times through WASM memory; it now assembles the chunks in JS. The native reassembly used by the TUI and daemon tests is unchanged, and headless Firefox browser tests run every step through both and require identical bytes. The WebGL path already reused one texture.

No closure per frame. The requestAnimationFrame scheduler reuses one closure, and the chunk-expiry timer re-arms only when needed.

Hidden means idle. A hidden page kept a 6 fps preview stream (about 7 MB/s, with the daemon still encoding six frames a second). Hidden now unsubscribes, both ends go quiet, and the stream is back at about 54 fps within three seconds of the page becoming visible.

Preview resumes after a reconnect. This was an existing bug: after any socket close, including a Mac waking from sleep, the preview stayed dark. The subscribe went out while the new socket was still connecting, failed silently, and was remembered as sent. Preview effects now wait for the connection to be fully up. The page also stops sending 18 needless unsubscribe messages every six seconds, decode failures of reassembled frames are logged again, and the browser client's reassembly budget matches the native one.

🧪 Validation

Measured in headless Chromium 153 with software WebGL against a private daemon in a bwrap sandbox (own network, no hardware), Rainbow at 60 fps, the preview subscribed as full-size RGBA. The host was heavily loaded and software WebGL capped presented frames at 26 to 35 fps against 55 received, so the figures are per frame and medians of three interleaved rounds.

Metric Before After
Main-thread busy per presented frame 12.45 ms 10.39 ms
Restyled elements per presented frame 87 34
Paint events per presented frame 8.1 2.8
Style time per presented frame 0.99 ms 0.72 ms
Closure registrations 77.9/s 0.25/s
Copies into, inside, and out of WASM per frame 1.23 MB each 124 bytes in, none inside or out
Allocations per frame two 1.2 MB WASM buffers and one JS buffer one JS buffer

These numbers come from Chromium. WebKit on macOS, where the problem was reported, has not been measured yet, so the gains there are inferred from the same per-frame work disappearing.

Command Result
just ui-test 598 passed
just ui-build ok
hypercolor-ui library check and clippy with warnings as errors clean
cargo check --workspace --all-targets ok
leptos-ext native tests and clippy, daemon chunking and golden tests, TUI tests pass
Headless Firefox browser tests pass

An independent verifier failed the first pass on one visual bug (the halo could swing the long way round the colour wheel on first load); 3657a0256 fixes it, and the verifier passed the result.

🎨 Visible differences

During a drift the halo rotates hue at constant lightness and chroma instead of crossfading; the old endless transition held it near 0.078 chroma, and it now stays at the intended 0.20. Engines without @property (Safari before 16.4) snap instead of drifting. Interactive previews close and reopen when the page hides and returns.

📌 Still expensive

  • The preview moves about 68 MB/s of RGBA; in Chromium the WebSocket intake dominates main-thread time. Requesting rgb for local connections would cut bytes by a quarter and fit a frame in one message, but it moves a conversion into the daemon and changes alpha handling.
  • The logo's blurred hue-rotate and two pulsing dots restyle every frame with or without a preview.
  • The sidebar palette rewrites its colours twice a second.
  • The engine target signal is set twice a second even when unchanged, which reruns the preview effects.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Preview streaming now reassembles large, chunked WebSocket frames directly in browser-managed buffers.
    • Ambient UI colors transition more smoothly and consistently across themes.
    • Preview subscriptions now respect both page and application-window visibility.
  • Bug Fixes

    • Hidden pages no longer consume preview resources or continue unnecessary encoding.
    • Preview expiry scheduling and reconnection behavior are more reliable.
    • Animation scheduling reduces unnecessary callback allocations.
  • Tests

    • Added coverage for browser preview reassembly, animation-frame scheduling, ambient hue behavior, and visibility-based streaming.

hyperb1iss and others added 10 commits October 19, 2026 23:28
The shell wrote the canvas's dominant hue to :root every time its
rounded value changed, which the 2 Hz analysis made up to twice a
second. Each write restyled the whole document, restarted the preview
halo's 2 s box-shadow transition before it could finish (so the halo
animated on every frame forever, even for a steady effect whose hue
only jittered by a degree), and started a scrollbar-color transition
on every transition-all button, because scrollbar-color is set on
every element and tracks the hue. On a 60 fps Effects page that was
344 restyled elements and 18 paint events per presented frame in a
headless Chromium trace; after this change it is 72 and 4, and the
remainder is the halo drift plus three decorative animations that run
with or without a preview.

The tint is decorative chrome at single-digit alpha with no declared
cadence contract, unlike the preview itself, so it now publishes at
most once a second and only for a move of 4 degrees or more
(AmbientHuePublisher). Published values are unwrapped so CSS turns the
short way around the wheel. The halo interpolates on a registered,
non-inherited --ambient-hue-drift over the same 2 s ease-silk curve,
so each frame of the drift restyles the cabinet alone and nothing
below it. The halo colors are rebuilt from new channel tokens because
the composed --ambient-* colors already substituted the root hue.
Buttons, links, spans and icons pin scrollbar-color to auto; none of
them can scroll, so nothing they show changes.

Reduced motion still snaps the halo: its transition: none !important
now covers the drift property.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two paths built a fresh wasm-bindgen closure per frame. The rAF
Scheduler created a Closure for every requestAnimationFrame and dropped
it after it fired, and the preview decoder re-armed its idle-expiry
timeout after every binary message. A chunked frame always leaves a
partial publication between its chunks, so that timer was rebuilt with
Closure::once_into_js once per frame, and those closures are only
released when the garbage collector finalizes them. Every closure is a
JS function plus a FinalizationRegistry entry, which is the
finalizeUnconditionally and visitChildren time in the WebKit profile.

The Scheduler now builds one closure up front and reuses it for every
frame it requests. The expiry sweep stays armed while its deadline is
no later than the one pending: a sweep that fires early expires
nothing and re-arms for the deadline then due, so idle partials are
still swept on time. On the 60 fps Effects page, finalization registry
registrations fall from 67.5/s (1.24 per received frame) to 0.12/s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hidden browser page kept its canvas, screen-canvas and web-viewport
subscriptions at a 6 fps cap, so the daemon kept encoding frames
nobody could see and the page kept receiving and decoding them. Only
a hidden Tauri window unsubscribed, and page visibility is what a
backgrounded, minimized or occluded WebKit window reports.

should_stream_preview now requires a visible page as well as a
visible app window, and screen zones follow the same gate that
display previews already used. The hidden-tab FPS cap is gone with the
stream it capped. Becoming visible resubscribes through the same path
a reconnect takes, so the request starts from a cleared state and no
epoch is skipped.

Measured on the Effects page with a 60 fps effect: while hidden the
socket carried 11.6 messages and 7.1 MB per second and the daemon
published 6 canvas frames a second; now both are zero, and the stream
is back at about 54 fps within three seconds of becoming visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The canvas, screen-canvas and web-viewport effects rerun every time
the engine reports its target fps, about twice a second, and their
not-streaming branch sent an unsubscribe on every run whether or not
the stream had ever been requested. A page showing only the main
canvas sent screen-canvas and web-viewport unsubscribes in pairs, 18
redundant messages in six seconds, each parsed and answered by the
daemon.

The branch now acts only when a request is outstanding. A reconnect
clears the requested state together with the socket that held the
subscription, so nothing is left subscribed that the guard skips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A 640x480 RGBA canvas frame is larger than the 1 MiB message budget,
so the daemon sends it as two chunks. The UI copied each chunk out of
its WebSocket buffer into a WASM Vec, the reassembler copied that into
the publication buffer, and the decoder copied the result back out
into a new JS Uint8Array: three full-frame copies, two WASM
allocations and one JS allocation per frame, about 220 MB/s of memmove
at 60 fps before the texture upload, which is the copy WebKit's
WebContent profile showed.

The reassembler's state machine, budgets, ordering and tombstones are
now generic over where a publication's bytes accumulate
(PreviewPublicationBuffer), so they stay single-sourced. Vec<u8> keeps
the native path byte-identical for the TUI and daemon tests, and a new
JsPreviewPublicationBuffer keeps payloads in JS. Chunk envelopes decode
through a shared PreviewChunkHeader from their leading bytes, so only
those bytes enter WASM. The assembled buffer then goes through the
same view decoders and metadata check as a frame that arrived in one
message, and the presenter uploads from a view of it. JS allocations
use Reflect.construct so an oversized buffer reports failure instead
of throwing through WASM frames.

Per received frame on the Effects page: copies into WASM fall from
1.23 MB to 124 bytes, copies out of WASM from 1.23 MB to zero, the
in-WASM copy is gone, and one 1.23 MB JS-to-JS copy remains, the floor
for a frame split across messages. Script time per received frame fell
from 1.27 ms to 0.97 ms. Browser tests run every step through the
native and JS reassemblers and require identical outcomes and bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first publish normalized the measured hue into [0, 360) without
looking at what --ambient-hue already held, which is the token's 320
until something is published. A first hue below 140 then made the halo
drift up to 320 degrees the long way, through every hue in between,
over two seconds at app load, where the old Oklab box-shadow crossfade
went straight there. A remounted shell had the same problem against
the unwrapped value it left on the document element, and could spin
several laps.

AmbientHuePublisher now starts from a known value: the shell resumes
from the --ambient-hue left on the root, or the 320 fallback, and the
first publish unwraps against it like every later one. A first hue
inside the deadband of that value publishes nothing, since CSS is
already there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After any reconnect the canvas preview stayed dark until something
else changed its subscription. Opening a socket nudges the preview
effects while that socket is still connecting; the subscribe they send
then throws inside WebSocket.send, the error is dropped, and the
request is recorded as sent. Every later rerun, including the one the
hello's engine target triggers, matched that record and returned.
Force-closing the page's socket mid-stream left the client at 0 frames
a second, and the daemon published none, on origin/main and on this
branch alike. Waking a Mac reconnects the socket, so this hit the
desktop app's resume path.

The canvas, screen-canvas and web-viewport effects now wait for
ConnectionState::Connected, the same gate the display-preview and
device-metrics effects already use. Connecting and closing clear the
recorded requests, and the move to Connected reruns the effects against
the open socket. With the fix the stream is back at the daemon's rate
within the same window.

The unsubscribe guard's comment now gives its reason without
describing earlier behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Chunked publications now decode through the single-message view
decoders in the UI, and a frame that failed there was dropped without
a trace. The native reassembler used to report the same failure as a
rejected chunk publication, so the warning comes back, next to the
existing one for a metadata mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Stream state was measured with size_of the generic stream state, so
the JS-backed reassembler, whose buffer handle is smaller than a Vec,
counted 164 bytes a stream where the native one counts 172. The UI's
state and tombstone budgets therefore admitted about 5% more streams
than native clients and than the UI did before its storage changed.

The budget now measures state with the native layout for every
storage type, and the browser parity tests assert equal state and
tombstone bytes at every step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AmbientHuePublisher starts from 320 because that is what --ambient-hue
and the --ambient-hue-drift initial value hold before the first
publish. Nothing tied the three together, so moving one alone would
bring back the long-way sweep on first load. The test reads both CSS
files and holds them to AMBIENT_HUE_FALLBACK_DEG.

Also drops an em dash from a comment touched earlier on this branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e06f0620-e36d-4c4d-91bf-913e917cdae4
📥 Commits

Reviewing files that changed from the base of the PR and between ce477c8 and 422fab9.

📒 Files selected for processing: 17
  • .agents/skills/leptos-ui-development/SKILL.md
  • .agents/skills/leptos-ui-development/references/websocket-protocol.md
  • crates/hypercolor-leptos-ext/src/raf/mod.rs
  • crates/hypercolor-leptos-ext/src/ws/mod.rs
  • crates/hypercolor-leptos-ext/src/ws/preview.rs
  • crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs
  • crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs
  • crates/hypercolor-ui/input.css
  • crates/hypercolor-ui/src/ambient.rs
  • crates/hypercolor-ui/src/components/shell.rs
  • crates/hypercolor-ui/src/lib.rs
  • crates/hypercolor-ui/src/ws/connection.rs
  • crates/hypercolor-ui/src/ws/messages.rs
  • crates/hypercolor-ui/src/ws/preview.rs
  • crates/hypercolor-ui/tests/ambient_tests.rs
  • crates/hypercolor-ui/tokens/semantic.css
  • docs/DESIGN-SYSTEM.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

The changes add paced ambient hue publication and CSS interpolation, update preview chunk reassembly and visibility gating, and reuse a persistent animation-frame callback.

Changes: Ambient hue updates

Layer / File(s) Summary
Ambient hue publisher
crates/hypercolor-ui/src/ambient.rs
Adds hue publication with a 1-second interval, a 4° deadband, finite-value checks, and shortest-arc unwrapping.
Root hue publication
crates/hypercolor-ui/src/components/shell.rs, crates/hypercolor-ui/src/lib.rs
The shell initializes the publisher from the existing root hue and writes accepted hue values with one decimal place. The library exports the ambient module.
Ambient color rendering and validation
crates/hypercolor-ui/input.css, crates/hypercolor-ui/tokens/semantic.css, crates/hypercolor-ui/tests/ambient_tests.rs, docs/DESIGN-SYSTEM.md, .agents/skills/leptos-ui-development/SKILL.md
CSS derives ambient colors from channel tokens and transitions the registered hue drift property. Tests and guidance cover publishing behavior and matching CSS fallback values.

Preview delivery and visibility

Layer / File(s) Summary
Chunk parsing and generic reassembly
crates/hypercolor-leptos-ext/src/ws/preview.rs, crates/hypercolor-leptos-ext/src/ws/mod.rs
Separates chunk headers from payloads and generalizes reassembly storage through buffer traits. The WebSocket module exports the new preview types.
JavaScript-buffer decoding
crates/hypercolor-leptos-ext/src/ws/preview.rs, crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs, crates/hypercolor-ui/src/ws/messages.rs, .agents/skills/leptos-ui-development/references/websocket-protocol.md
The WASM decoder reassembles chunk payloads in JavaScript-owned buffers and decodes completed ArrayBuffers. Browser tests compare native and JavaScript reassembly results.
Preview subscription and expiry gating
crates/hypercolor-ui/src/ws/preview.rs, crates/hypercolor-ui/src/ws/connection.rs, .agents/skills/leptos-ui-development/SKILL.md
Preview subscriptions require a connected socket and visible page and window. The expiry timer retains an earlier deadline. FPS calculations no longer apply a hidden-page cap.

Animation-frame scheduling

Layer / File(s) Summary
Persistent animation-frame callback
crates/hypercolor-leptos-ext/src/raf/mod.rs, crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs
The scheduler reuses one callback for frame requests. Browser tests cover continuous scheduling, pausing, and resuming with a one-shot schedule.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant WebSocket
  participant PreviewBinaryDecoder
  participant PreviewChunkReassembler
  participant JsPreviewPublicationBuffer
  participant PreviewView
  WebSocket->>PreviewBinaryDecoder: deliver chunk ArrayBuffer
  PreviewBinaryDecoder->>PreviewChunkReassembler: push_array_buffer_at
  PreviewChunkReassembler->>JsPreviewPublicationBuffer: append payload bytes
  JsPreviewPublicationBuffer-->>PreviewChunkReassembler: completed ArrayBuffer
  PreviewChunkReassembler-->>PreviewBinaryDecoder: publication metadata and buffer
  PreviewBinaryDecoder->>PreviewView: decode completed buffer
Loading
Merge Risk: ⚪ Minimal · up to 422fa

This change reduces per-frame UI work. Ambient tint updates are paced, preview chunks are reassembled outside WASM memory, previews pause while the page or window is hidden, and the animation-frame callback is reused. No concrete defect was found in the ambient, preview delivery, or scheduling paths. The only open question is WebKit rendering of the halo transition, which has not been measured. The change looks ready to merge.

Security architecture risk: 🔵 Low · up to 422fa

Preview validation remains enforced before frames are displayed, and no broader access was established. One conditional recovery weakness remains: a rejected unsubscribe can leave background preview work running until the session is reconciled or disconnected.

Retained concerns

  • Low · reliability · inferred: The new requested-subscription guard makes preview shutdown one-shot even when transmission fails. Local subscription state is cleared before unsubscribe delivery, send errors are discarded, and later hidden-state updates no longer retry. If a transport rejects the send while the session remains live, daemon preview demand can outlive local ownership until another subscription transition or connection teardown. This weakens resource-failure containment; it does not establish unauthorized access.
Security review details

Security Blast Radius

  • observed — Reassembly state is instantiated per socket generation. Default logical limits include one MiB per chunk message, approximately 512 MiB per encoded publication, and twice that publication allowance per connection. These are logical reservations, not guarantees about peak JavaScript heap usage during growth or final buffer slicing.

Security Findings and Attack Paths

  • inferred — The identified unsubscribe failure can preserve work for an already admitted preview session; it does not establish a new recipient, credential privilege or authorization bypass. Broader exploitation would require additional evidence about live transport failures or resource exhaustion.

Trust Boundaries and Controls

  • observed — The inspected transport continues to require daemon-relative connection paths and constructs credential-bearing WebSocket URLs through the existing route. Preview effects now wait for initial subscription admission before sending their requests; connection-generation checks continue to reject stale callbacks.

Resilience and Maintainability Implications

  • observed — Completion, expiry, cancellation and clear use the shared reassembly state machine for native and JS storage. Expiry releases idle partial reservations and retains bounded tombstone bookkeeping; clear drops stream state and resets reservation counters.

Hardening Proposals

  • proposed — Preserve pending unsubscribe intent until transmission is accepted, or reset the connection on rejected delivery, so local ownership cannot silently diverge from daemon demand. This would address the conditional recovery weakness without restoring continuous redundant unsubscribe traffic.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 44.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 98 functions across 12 files. (5 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: moving preview-frame handling and ambient tint updates off the UI per-frame path.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 44.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 98 functions across 12 files. (5 skipped: 5 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@hyperb1iss
hyperb1iss merged commit 37d938a into main Oct 21, 2026
43 checks passed
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