From 8c3993513f3ec3b113e59bc52c7ee430edf31f26 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 23:28:21 -0700 Subject: [PATCH 01/10] perf(ui): take the ambient tint off the frame path 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) --- .agents/skills/leptos-ui-development/SKILL.md | 5 +- crates/hypercolor-ui/input.css | 35 ++++++-- crates/hypercolor-ui/src/ambient.rs | 74 +++++++++++++++++ crates/hypercolor-ui/src/components/shell.rs | 19 +++-- crates/hypercolor-ui/src/lib.rs | 1 + crates/hypercolor-ui/tests/ambient_tests.rs | 82 +++++++++++++++++++ crates/hypercolor-ui/tokens/semantic.css | 30 +++++-- docs/DESIGN-SYSTEM.md | 21 +++-- 8 files changed, 238 insertions(+), 29 deletions(-) create mode 100644 crates/hypercolor-ui/src/ambient.rs create mode 100644 crates/hypercolor-ui/tests/ambient_tests.rs diff --git a/.agents/skills/leptos-ui-development/SKILL.md b/.agents/skills/leptos-ui-development/SKILL.md index 8aff89781..c3526b607 100644 --- a/.agents/skills/leptos-ui-development/SKILL.md +++ b/.agents/skills/leptos-ui-development/SKILL.md @@ -348,7 +348,10 @@ Token files in `tokens/` (DESIGN-SYSTEM.md §3 has the full architecture): **document element**, not the shell div. Custom-property `var()` substitution resolves where the property is *declared*, and every `--ambient-*` token is declared in a `:root` block of `semantic.css`, so a hue set lower in the tree -would never reach them. +would never reach them. That write restyles the whole document, so +`AmbientHuePublisher` (`src/ambient.rs`) paces it to at most once a second +with a 4° deadband, and `.preview-glow` drifts between published values on +the non-inherited `--ambient-hue-drift` property. **Theme**: stored in localStorage as `hc-theme` and applied by an inline `index.html` script _before first paint_ to prevent flash. There is no runtime diff --git a/crates/hypercolor-ui/input.css b/crates/hypercolor-ui/input.css index 82564b483..bb362c62c 100644 --- a/crates/hypercolor-ui/input.css +++ b/crates/hypercolor-ui/input.css @@ -90,6 +90,14 @@ button:not(:disabled), scrollbar-color: var(--scrollbar-thumb) transparent; } +/* Buttons, links, inline text and icons never scroll, yet they still carry + `scrollbar-color`, which tracks the ambient hue. Every hue publish would + then start a scrollbar-color transition on each one wearing + `transition-all`, animating chrome that has no scrollbar to tint. */ +button, a, span, svg { + scrollbar-color: auto; +} + *::-webkit-scrollbar { width: 5px; height: 5px; @@ -1058,21 +1066,36 @@ input[type="range"].slider-silk::-moz-range-track { /* Preview panel glow — ambient-reactive edge glow around the live canvas. Includes the `.edge-glow` hairline so stacking both classes adds the - ambient halo without dropping the neutral edge ring. */ + ambient halo without dropping the neutral edge ring. + + The halo drifts toward each published hue through `--ambient-hue-drift`, + a registered number that does not inherit. Interpolating it on this one + element keeps every frame of the drift here: nothing below inherits it, + so the cabinet's subtree never restyles while the hue settles, and + `--ambient-hue` itself only changes once per publish. The halo colors are + rebuilt from the ambient channel tokens because `--ambient-glow` and + `--ambient-border` already substituted the undrifted root hue. */ +@property --ambient-hue-drift { + syntax: ""; + inherits: false; + initial-value: 320; +} + .preview-glow { - box-shadow: 0 0 40px var(--ambient-glow), - 0 0 2px var(--ambient-border), + --ambient-hue-drift: var(--ambient-hue); + box-shadow: 0 0 40px oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue-drift) / var(--ambient-glow-alpha)), + 0 0 2px oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue-drift) / var(--ambient-border-alpha)), 0 0 1px var(--border-subtle), inset 0 1px 0 rgba(255, 255, 255, 0.03); - transition: box-shadow 2s var(--ease-silk); + transition: --ambient-hue-drift 2s var(--ease-silk); } /* The preview cabinet runs both its accent-border crossfade and the ambient - glow; the transitions must ride one declaration or the later rule's + drift; the transitions must ride one declaration or the later rule's `transition` would silently drop the border crossfade. */ .cabinet-accent-transition.preview-glow { transition: border-top-color 0.45s var(--ease-silk), - box-shadow 2s var(--ease-silk); + --ambient-hue-drift 2s var(--ease-silk); } /* Command palette / modal glow — cinematic ambient */ diff --git a/crates/hypercolor-ui/src/ambient.rs b/crates/hypercolor-ui/src/ambient.rs new file mode 100644 index 000000000..084de0db2 --- /dev/null +++ b/crates/hypercolor-ui/src/ambient.rs @@ -0,0 +1,74 @@ +//! When the chrome's ambient hue moves. +//! +//! The shell publishes the live canvas's dominant hue as `--ambient-hue` +//! on the document element, where every `--ambient-*` token resolves it. +//! A write there restyles the whole document, so it happens on a slow, +//! fixed cadence and only for a change the eye could see. CSS interpolates +//! between published values (`--ambient-hue-drift` in +//! `tokens/semantic.css`), so the tint still drifts instead of stepping. + +/// Least time between two published hues. +/// +/// The tint is decorative chrome at single-digit alpha with no cadence of +/// its own to honor, unlike the preview, and the halo spends two seconds +/// drifting to each new value. One publish a second keeps that drift +/// continuous while capping document-wide restyles at one a second. +pub const AMBIENT_PUBLISH_INTERVAL_MS: f64 = 1_000.0; + +/// Smallest hue change, in degrees, worth a publish. +/// +/// The dominant hue of a steady effect wanders by a few degrees from one +/// sample to the next. Below this the tint cannot visibly change, and +/// publishing would restart the drift for nothing. +pub const AMBIENT_HUE_DEADBAND_DEG: f64 = 4.0; + +/// Paces `--ambient-hue` writes and keeps the published value continuous. +/// +/// Published values are unwrapped: a move from 350° to 10° publishes 370, +/// not 10, so CSS interpolating the number turns 20° the short way rather +/// than 340° the long way. OKLCH reads any angle modulo 360. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct AmbientHuePublisher { + published: Option, + published_at_ms: f64, +} + +impl AmbientHuePublisher { + /// Offer a freshly measured hue in degrees. Returns the value to write + /// as `--ambient-hue`, or `None` when the tint should stay put. + pub fn offer(&mut self, hue_deg: f64, now_ms: f64) -> Option { + if !hue_deg.is_finite() || !now_ms.is_finite() { + return None; + } + let Some(published) = self.published else { + let first = hue_deg.rem_euclid(360.0); + self.published = Some(first); + self.published_at_ms = now_ms; + return Some(first); + }; + if now_ms - self.published_at_ms < AMBIENT_PUBLISH_INTERVAL_MS { + return None; + } + let delta = shortest_hue_delta(published, hue_deg); + if delta.abs() < AMBIENT_HUE_DEADBAND_DEG { + return None; + } + let next = published + delta; + self.published = Some(next); + self.published_at_ms = now_ms; + Some(next) + } + + /// The last value handed out, unwrapped. + #[must_use] + pub const fn published(&self) -> Option { + self.published + } +} + +/// Signed turn from `from` to `to` along the shorter arc, in `(-180, 180]`. +#[must_use] +pub fn shortest_hue_delta(from: f64, to: f64) -> f64 { + let delta = (to - from).rem_euclid(360.0); + if delta > 180.0 { delta - 360.0 } else { delta } +} diff --git a/crates/hypercolor-ui/src/components/shell.rs b/crates/hypercolor-ui/src/components/shell.rs index daca84e6c..7c151a881 100644 --- a/crates/hypercolor-ui/src/components/shell.rs +++ b/crates/hypercolor-ui/src/components/shell.rs @@ -2,6 +2,7 @@ use hypercolor_leptos_ext::events::Input; use hypercolor_leptos_ext::events::document as browser_document; +use hypercolor_leptos_ext::prelude::now_ms; use leptos::prelude::*; use leptos_icons::Icon; use leptos_router::hooks::use_navigate; @@ -9,6 +10,7 @@ use wasm_bindgen::JsCast; use hypercolor_types::scene::SceneMutationMode; +use crate::ambient::AmbientHuePublisher; use crate::app::{EffectsContext, FrameAnalysisContext}; use crate::components::mobile_nav::MobileNav; use crate::components::page_search_bar::PAGE_SEARCH_INPUT_ID; @@ -51,19 +53,23 @@ pub fn Shell(children: Children) -> impl IntoView { // custom-property `var()` substitution happens where a property is // *declared*, and every `--ambient-*` token plus the scrollbar tints are // declared in `:root` blocks of tokens/semantic.css, so a hue set any - // lower in the tree would never reach them. + // lower in the tree would never reach them. A `:root` write restyles the + // whole document, so `AmbientHuePublisher` paces it; CSS drifts the + // halo between published values. let frame_analysis = use_context::(); - let last_ambient_hue = StoredValue::new(None::); + let ambient_publisher = StoredValue::new(AmbientHuePublisher::default()); if let Some(frame_analysis) = frame_analysis { Effect::new(move |_| { let Some(analysis) = frame_analysis.live_canvas.get() else { return; }; - let hue = analysis.dominant_hue.round() as i16; - if last_ambient_hue.get_value() == Some(hue) { + let Some(hue) = ambient_publisher + .try_update_value(|publisher| publisher.offer(analysis.dominant_hue, now_ms())) + .flatten() + else { return; - } + }; if let Some(doc) = browser_document() && let Some(root) = doc.document_element() @@ -71,8 +77,7 @@ pub fn Shell(children: Children) -> impl IntoView { { let _ = html_el .style() - .set_property("--ambient-hue", &hue.to_string()); - last_ambient_hue.set_value(Some(hue)); + .set_property("--ambient-hue", &format!("{hue:.1}")); } }); } diff --git a/crates/hypercolor-ui/src/lib.rs b/crates/hypercolor-ui/src/lib.rs index 3decf1020..ab9d5c96a 100644 --- a/crates/hypercolor-ui/src/lib.rs +++ b/crates/hypercolor-ui/src/lib.rs @@ -8,6 +8,7 @@ //! shim over [`run`]. That keeps every module reachable from integration //! tests as `hypercolor_ui::...` without `#[path]` source includes. +pub mod ambient; pub mod api; pub mod app; pub mod apply_target; diff --git a/crates/hypercolor-ui/tests/ambient_tests.rs b/crates/hypercolor-ui/tests/ambient_tests.rs new file mode 100644 index 000000000..8636a4c89 --- /dev/null +++ b/crates/hypercolor-ui/tests/ambient_tests.rs @@ -0,0 +1,82 @@ +use hypercolor_ui::ambient::{ + AMBIENT_HUE_DEADBAND_DEG, AMBIENT_PUBLISH_INTERVAL_MS, AmbientHuePublisher, shortest_hue_delta, +}; + +const LATER: f64 = AMBIENT_PUBLISH_INTERVAL_MS; + +#[test] +fn the_first_hue_publishes_at_once_normalized_into_the_wheel() { + let mut publisher = AmbientHuePublisher::default(); + assert_eq!(publisher.offer(-30.0, 0.0), Some(330.0)); + assert_eq!(publisher.published(), Some(330.0)); +} + +#[test] +fn a_change_inside_the_interval_waits_for_the_next_slot() { + let mut publisher = AmbientHuePublisher::default(); + publisher.offer(100.0, 0.0); + assert_eq!(publisher.offer(160.0, LATER - 1.0), None); + assert_eq!(publisher.offer(160.0, LATER), Some(160.0)); +} + +#[test] +fn a_change_below_the_deadband_never_publishes() { + let mut publisher = AmbientHuePublisher::default(); + publisher.offer(100.0, 0.0); + let jitter = AMBIENT_HUE_DEADBAND_DEG * 0.9; + assert_eq!(publisher.offer(100.0 + jitter, LATER), None); + assert_eq!(publisher.offer(100.0 - jitter, LATER * 2.0), None); + assert_eq!(publisher.published(), Some(100.0)); +} + +#[test] +fn a_rejected_offer_does_not_restart_the_interval() { + let mut publisher = AmbientHuePublisher::default(); + publisher.offer(100.0, 0.0); + assert_eq!(publisher.offer(101.0, LATER), None); + assert_eq!(publisher.offer(150.0, LATER + 1.0), Some(150.0)); +} + +#[test] +fn crossing_zero_publishes_an_unwrapped_value_the_short_way() { + let mut publisher = AmbientHuePublisher::default(); + publisher.offer(350.0, 0.0); + assert_eq!(publisher.offer(10.0, LATER), Some(370.0)); + assert_eq!(publisher.offer(340.0, LATER * 2.0), Some(340.0)); +} + +#[test] +fn a_full_lap_keeps_climbing_instead_of_snapping_back() { + let mut publisher = AmbientHuePublisher::default(); + let mut now = 0.0; + let mut last = publisher.offer(0.0, now).expect("first publish"); + for step in 1..=12 { + now += LATER; + let hue = f64::from(step * 30 % 360); + let next = publisher + .offer(hue, now) + .expect("every 30 degree step publishes"); + assert!( + (next - last - 30.0).abs() < 1e-9, + "step {step}: {last} -> {next}" + ); + last = next; + } + assert!((last - 360.0).abs() < 1e-9); +} + +#[test] +fn non_finite_input_is_ignored() { + let mut publisher = AmbientHuePublisher::default(); + assert_eq!(publisher.offer(f64::NAN, 0.0), None); + assert_eq!(publisher.offer(120.0, f64::INFINITY), None); + assert_eq!(publisher.published(), None); +} + +#[test] +fn shortest_delta_takes_the_shorter_arc() { + assert!((shortest_hue_delta(350.0, 10.0) - 20.0).abs() < 1e-9); + assert!((shortest_hue_delta(10.0, 350.0) + 20.0).abs() < 1e-9); + assert!((shortest_hue_delta(370.0, 20.0) - 10.0).abs() < 1e-9); + assert!((shortest_hue_delta(0.0, 180.0) - 180.0).abs() < 1e-9); +} diff --git a/crates/hypercolor-ui/tokens/semantic.css b/crates/hypercolor-ui/tokens/semantic.css index 9ec807a01..6f1ea22be 100644 --- a/crates/hypercolor-ui/tokens/semantic.css +++ b/crates/hypercolor-ui/tokens/semantic.css @@ -42,11 +42,19 @@ --accent-muted: oklch(0.65 0.30 320 / 0.12); --accent-subtle: oklch(0.65 0.30 320 / 0.06); - /* Ambient (dynamic — --ambient-hue set from Leptos) */ + /* Ambient (dynamic — --ambient-hue set from Leptos). The colors are + built from channel tokens so a consumer that drifts its own copy of + the hue (.preview-glow) can rebuild them; a token declared here has + already substituted the root hue. Light mode overrides the channels. */ --ambient-hue: 320; - --ambient-glow: oklch(0.65 0.20 var(--ambient-hue) / 0.08); - --ambient-border: oklch(0.65 0.20 var(--ambient-hue) / 0.15); - --ambient-tint: oklch(0.65 0.20 var(--ambient-hue) / 0.04); + --ambient-l: 0.65; + --ambient-c: 0.20; + --ambient-glow-alpha: 0.08; + --ambient-border-alpha: 0.15; + --ambient-tint-alpha: 0.04; + --ambient-glow: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-glow-alpha)); + --ambient-border: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-border-alpha)); + --ambient-tint: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-tint-alpha)); /* Status */ --status-success: oklch(0.85 0.22 155); @@ -119,10 +127,16 @@ --accent-muted: oklch(0.58 0.28 320 / 0.10); --accent-subtle: oklch(0.58 0.28 320 / 0.05); - /* Ambient (lower intensity on light) */ - --ambient-glow: oklch(0.58 0.15 var(--ambient-hue) / 0.06); - --ambient-border: oklch(0.58 0.15 var(--ambient-hue) / 0.12); - --ambient-tint: oklch(0.58 0.15 var(--ambient-hue) / 0.03); + /* Ambient (lower intensity on light). Redeclaring the colors here + resolves them against these channels on this element. */ + --ambient-l: 0.58; + --ambient-c: 0.15; + --ambient-glow-alpha: 0.06; + --ambient-border-alpha: 0.12; + --ambient-tint-alpha: 0.03; + --ambient-glow: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-glow-alpha)); + --ambient-border: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-border-alpha)); + --ambient-tint: oklch(var(--ambient-l) var(--ambient-c) var(--ambient-hue) / var(--ambient-tint-alpha)); /* Status (darkened for contrast on light surfaces) */ --status-success: oklch(0.55 0.18 155); diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 667b43ca4..f0ed27633 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -504,15 +504,22 @@ root, and every `--ambient-*` token derives from that hue in OKLCH. | `--ambient-border` | 12 to 15% | Scrollbar thumb, active card border | | `--ambient-tint` | 3 to 4% | "Now Playing" background, noise tint | -`.preview-glow` crossfades over 2s on the `--ease-silk` curve, so hue shifts -drift rather than snap. +`.preview-glow` drifts to each new hue over 2s on the `--ease-silk` curve, so +hue shifts drift rather than snap. The drift runs on `--ambient-hue-drift`, a +registered `` that does not inherit, so each frame of it restyles the +halo element alone and never its subtree. **Discipline.** Hue extraction runs on a low-priority cadence, not the render -loop and not every frame. The CSS property update is one cheap `setProperty` -call; OKLCH interpolation happens in CSS. When no effect is active, -`--ambient-hue` falls back to `320` (purple), matching the primary accent. -Ambient reactivity is a progressive enhancement: the UI is correct and complete -without it. +loop and not every frame. A `:root` write restyles the whole document, so the +hue publishes at most once a second and only when it moves at least 4° +(`AmbientHuePublisher` in `src/ambient.rs`); the published value is unwrapped +so CSS always turns the short way around the wheel. Interpolation happens in +CSS. A surface that wants the drift rebuilds its color from the +`--ambient-l`, `--ambient-c` and `--ambient-*-alpha` channel tokens against +`--ambient-hue-drift`, because the composed `--ambient-*` colors have already +substituted the root hue. When no effect is active, `--ambient-hue` falls back +to `320` (purple), matching the primary accent. Ambient reactivity is a +progressive enhancement: the UI is correct and complete without it. --- From 8689a3f330ffcdf10a85279e7bcbf813449ade84 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 23:38:53 -0700 Subject: [PATCH 02/10] perf(ui): stop minting a JS closure for every preview frame 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) --- crates/hypercolor-leptos-ext/src/raf/mod.rs | 46 ++++++++--------- .../tests/browser_raf_tests.rs | 27 ++++++++++ crates/hypercolor-ui/src/ws/connection.rs | 50 +++++++++++++------ 3 files changed, 84 insertions(+), 39 deletions(-) diff --git a/crates/hypercolor-leptos-ext/src/raf/mod.rs b/crates/hypercolor-leptos-ext/src/raf/mod.rs index 151da49ba..89e51f7f9 100644 --- a/crates/hypercolor-leptos-ext/src/raf/mod.rs +++ b/crates/hypercolor-leptos-ext/src/raf/mod.rs @@ -15,7 +15,11 @@ pub struct Scheduler { struct SchedulerInner { window: web_sys::Window, - callback: RefCell>>, + /// One closure serves every frame this scheduler requests. A fresh + /// `Closure` per frame would allocate a JS function and register it with + /// wasm-bindgen's finalization registry sixty times a second, churn the + /// garbage collector pays for on every animated surface. + callback: Closure, pending_frame: Cell>, active: Cell, user: RefCell>, @@ -46,14 +50,21 @@ impl Scheduler { { let window = web_sys::window().expect("browser window is available for animation frames"); Self { - inner: Rc::new(SchedulerInner { - window, - callback: RefCell::new(None), - pending_frame: Cell::new(None), - active: Cell::new(false), - user: RefCell::new(Box::new(callback)), - last_time_ms: Cell::new(None), - mode: Cell::new(Mode::Idle), + inner: Rc::new_cyclic(|weak: &Weak| { + let weak = Weak::clone(weak); + SchedulerInner { + window, + callback: Closure::::new(move |time_ms| { + if let Some(inner) = Weak::upgrade(&weak) { + on_animation_frame(&inner, time_ms); + } + }), + pending_frame: Cell::new(None), + active: Cell::new(false), + user: RefCell::new(Box::new(callback)), + last_time_ms: Cell::new(None), + mode: Cell::new(Mode::Idle), + } }), } } @@ -96,7 +107,6 @@ impl SchedulerInner { if let Some(request_id) = self.pending_frame.take() { let _ = self.window.cancel_animation_frame(request_id); } - self.callback.borrow_mut().take(); } fn monotonic_ms(&self, time_ms: f64) -> f64 { @@ -108,10 +118,11 @@ impl SchedulerInner { impl Drop for SchedulerInner { fn drop(&mut self) { + // Cancel before the closure drops with the struct, so the browser + // never calls into a freed callback. if let Some(request_id) = self.pending_frame.take() { let _ = self.window.cancel_animation_frame(request_id); } - self.callback.borrow_mut().take(); } } @@ -120,19 +131,11 @@ fn request_next_frame(inner: &Rc) { return; } - let weak = Rc::downgrade(inner); - let callback = Closure::::new(move |time_ms| { - if let Some(inner) = Weak::upgrade(&weak) { - on_animation_frame(&inner, time_ms); - } - }); - if let Ok(request_id) = inner .window - .request_animation_frame(callback.as_ref().unchecked_ref()) + .request_animation_frame(inner.callback.as_ref().unchecked_ref()) { inner.pending_frame.set(Some(request_id)); - inner.callback.borrow_mut().replace(callback); } } @@ -140,7 +143,6 @@ fn on_animation_frame(inner: &Rc, time_ms: f64) { inner.pending_frame.take(); if !inner.active.get() { - inner.callback.borrow_mut().take(); return; } @@ -164,8 +166,6 @@ fn on_animation_frame(inner: &Rc, time_ms: f64) { inner.mode.set(Mode::Idle); } - inner.callback.borrow_mut().take(); - if keep_running { request_next_frame(inner); } diff --git a/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs b/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs index e02da1a66..2b6ea52fc 100644 --- a/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs +++ b/crates/hypercolor-leptos-ext/tests/browser_raf_tests.rs @@ -25,6 +25,33 @@ async fn scheduler_runs_once_for_single_schedule() { assert!(!scheduler.is_pending()); } +#[wasm_bindgen_test(async)] +async fn continuous_schedule_fires_every_frame_until_paused() { + let calls = Rc::new(Cell::new(0)); + let calls_handle = Rc::clone(&calls); + let scheduler = Scheduler::new(move |_| { + calls_handle.set(calls_handle.get() + 1); + }); + + scheduler.schedule_continuous(); + for _ in 0..4 { + wait_for_animation_frame().await; + } + let while_running = calls.get(); + assert!(while_running >= 3, "only {while_running} frames ran"); + + scheduler.pause(); + assert!(!scheduler.is_pending()); + wait_for_animation_frame().await; + wait_for_animation_frame().await; + assert_eq!(calls.get(), while_running); + + scheduler.schedule(); + wait_for_animation_frame().await; + wait_for_animation_frame().await; + assert_eq!(calls.get(), while_running + 1); +} + async fn wait_for_animation_frame() { let promise = js_sys::Promise::new(&mut |resolve, _reject| { let callback = wasm_bindgen::closure::Closure::once(move || { diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index a28161057..d0bcd7557 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -64,32 +64,50 @@ fn preview_now_ms() -> u64 { .unwrap_or(u64::MAX) } -fn clear_preview_decoder( - decoder: &Rc>, - timeout: &Rc>>, -) { - timeout.borrow_mut().take(); +/// The timer that sweeps idle partial preview publications, and when it fires. +struct PreviewExpiryTimer { + _handle: BrowserTimeoutHandle, + deadline_ms: u64, +} + +type PreviewExpirySlot = Rc>>; + +fn clear_preview_decoder(decoder: &Rc>, timer: &PreviewExpirySlot) { + timer.borrow_mut().take(); decoder.borrow_mut().clear(); } -fn schedule_preview_expiry( - decoder: &Rc>, - timeout: &Rc>>, -) { - timeout.borrow_mut().take(); +/// Keep a sweep armed no later than the earliest idle deadline. +/// +/// Every chunked frame leaves a partial publication between its chunks, so +/// re-arming per message would mint a timer, and the JS closure behind it, +/// for every frame. A sweep that fires before anything is idle expires +/// nothing and re-arms for the deadline then pending, so an armed timer +/// only has to be replaced when a deadline lands before it, and an idle +/// partial is still swept on time. +fn schedule_preview_expiry(decoder: &Rc>, timer: &PreviewExpirySlot) { let Some(deadline_ms) = decoder.borrow().next_expiry_ms() else { return; }; + if timer + .borrow() + .as_ref() + .is_some_and(|armed| armed.deadline_ms <= deadline_ms) + { + return; + } let delay = Duration::from_millis(deadline_ms.saturating_sub(preview_now_ms())); let decoder_for_timeout = Rc::clone(decoder); - let timeout_for_callback = Rc::clone(timeout); - let timeout_for_schedule = Rc::clone(timeout); + let timer_for_callback = Rc::clone(timer); let handle = browser_set_timeout(delay, move || { - timeout_for_callback.borrow_mut().take(); + timer_for_callback.borrow_mut().take(); decoder_for_timeout.borrow_mut().expire_at(preview_now_ms()); - schedule_preview_expiry(&decoder_for_timeout, &timeout_for_schedule); + schedule_preview_expiry(&decoder_for_timeout, &timer_for_callback); + }); + *timer.borrow_mut() = Some(PreviewExpiryTimer { + _handle: handle, + deadline_ms, }); - *timeout.borrow_mut() = Some(handle); } fn quantize_preview_fps(value: f64) -> f32 { @@ -403,7 +421,7 @@ impl WsManager { let generation = socket_generation.get_value().wrapping_add(1); socket_generation.set_value(generation); let preview_decoder = Rc::new(RefCell::new(PreviewBinaryDecoder::default())); - let preview_expiry_timeout = Rc::new(RefCell::new(None::)); + let preview_expiry_timeout: PreviewExpirySlot = Rc::new(RefCell::new(None)); let event_preview_decoder = Rc::clone(&preview_decoder); let event_preview_expiry_timeout = Rc::clone(&preview_expiry_timeout); let process_event: Rc = Rc::new(move |event| { From 69382dabbe2cd31c21cb793f19029ebbbdac5ed1 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 23:48:26 -0700 Subject: [PATCH 03/10] perf(ui): unsubscribe previews while the page is hidden 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) --- .agents/skills/leptos-ui-development/SKILL.md | 17 +++++-- crates/hypercolor-ui/src/ws/connection.rs | 20 ++++---- crates/hypercolor-ui/src/ws/preview.rs | 50 ++++++++----------- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/.agents/skills/leptos-ui-development/SKILL.md b/.agents/skills/leptos-ui-development/SKILL.md index c3526b607..cf8d75335 100644 --- a/.agents/skills/leptos-ui-development/SKILL.md +++ b/.agents/skills/leptos-ui-development/SKILL.md @@ -410,12 +410,11 @@ while the consumer count is above zero. Parallel counters exist for the other streams (`set_screen_preview_consumers`, `set_screen_zones_consumers`, `set_web_viewport_preview_consumers`, `set_device_metrics_consumers`). -FPS caps (`src/ws/preview.rs:11-14`): +FPS caps (`src/ws/preview.rs`): | Constant | Value | Applies to | | ------------------------------- | ----- | ---------------------------------- | | `DEFAULT_PREVIEW_FPS_CAP` | 60 | The main canvas preview | -| `HIDDEN_TAB_PREVIEW_FPS_CAP` | 6 | Any preview while the tab is hidden | | `SCREEN_PREVIEW_FPS_CAP` | 15 | The screen-source preview | | `WEB_VIEWPORT_PREVIEW_FPS_CAP` | 15 | The web-viewport preview | @@ -434,9 +433,17 @@ Canvas preview does **not** use `Portal`. Portal is used by `modal.rs`, `control_panel/{color,enum_select,sensor}.rs` popovers, which have to escape overflow-hidden cards. -## Visibility-Aware FPS - -Tab hidden → reduce preview FPS to 6 via `document.visibilitychange` listener. Resets smoothed FPS counters on reconnect to avoid glitch display. +## Hidden Means Idle + +A hidden page shows no frame, so `should_stream_preview` subscribes to none: +when `document.visibilitychange` reports hidden, or the Tauri window reports +hidden (`hypercolor-window-visibility`), the canvas, screen-canvas, +web-viewport, screen-zones and display-preview subscriptions are dropped and +the daemon stops encoding for this client. Becoming visible resubscribes from +scratch through the same path a reconnect takes, so no epoch is lost. An +interactive preview closes with the canvas stream (its extent comes from the +authoritative frame) and reopens when frames return. Smoothed FPS counters +reset on reconnect to avoid a glitchy display. ## leptos_icons Gotcha diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index d0bcd7557..923a99e12 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -724,7 +724,7 @@ impl WsManager { let width_cap = preview_width_cap.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { if let Some(ws) = ws_handle.get_value() { clear_preview_subscription( requested_preview, @@ -746,7 +746,6 @@ impl WsManager { engine_target, client_cap, width_cap, - is_visible, ); } }); @@ -759,7 +758,7 @@ impl WsManager { let consumer_count = screen_preview_consumers.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { if let Some(ws) = ws_handle.get_value() { clear_screen_preview_subscription( requested_screen_preview, @@ -775,19 +774,18 @@ impl WsManager { ws.as_ref(), requested_screen_preview, engine_target, - is_visible, ); } }); // Zone frames are tiny and carry no per-client config, so the // subscription is a simple on/off keyed to consumers, connection - // state, and app window visibility. + // state, and page and app window visibility. Effect::new(move |_| { let connected = connection_state.get() == ConnectionState::Connected; let consumer_count = screen_zones_consumers.get(); - let window_visible = app_window_visible.get(); - let wants_stream = connected && window_visible && consumer_count > 0; + let visible = page_visible.get() && app_window_visible.get(); + let wants_stream = connected && visible && consumer_count > 0; if wants_stream { if !screen_zones_requested.get_value() @@ -813,7 +811,7 @@ impl WsManager { let consumer_count = web_viewport_preview_consumers.get(); let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); - if !should_stream_preview(window_visible, engine_target, consumer_count) { + if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { if let Some(ws) = ws_handle.get_value() { clear_web_viewport_preview_subscription( requested_web_viewport_preview, @@ -829,7 +827,6 @@ impl WsManager { ws.as_ref(), requested_web_viewport_preview, engine_target, - is_visible, ); } }); @@ -968,8 +965,9 @@ impl WsManager { visibility_change_callback.set_value(Some(on_visibility_change)); } - // Tauri native-window visibility listener. Browser tab visibility only - // caps preview FPS; a hidden app window should unsubscribe entirely. + // Tauri native-window visibility listener. Page visibility misses some + // native minimize and hide states, so the window's own signal gates + // previews alongside it. if let Some(window) = browser_window() { tauri_visibility_change_callback.update_value(|handle| { if let Some(mut handle) = handle.take() { diff --git a/crates/hypercolor-ui/src/ws/preview.rs b/crates/hypercolor-ui/src/ws/preview.rs index ff387bb2d..616490f22 100644 --- a/crates/hypercolor-ui/src/ws/preview.rs +++ b/crates/hypercolor-ui/src/ws/preview.rs @@ -13,7 +13,6 @@ use hypercolor_types::spatial::SpatialLayout; use super::transport::{WebSocketConnection, send_json}; pub const DEFAULT_PREVIEW_FPS_CAP: u32 = 60; -pub(super) const HIDDEN_TAB_PREVIEW_FPS_CAP: u32 = 6; pub(super) const SCREEN_PREVIEW_FPS_CAP: u32 = 15; pub(super) const WEB_VIEWPORT_PREVIEW_FPS_CAP: u32 = 15; const REMOTE_PREVIEW_WIDTH_MEDIUM: u32 = 640; @@ -66,25 +65,22 @@ impl PreviewSubscriptionRequest { } } -pub(super) fn desired_preview_fps( - engine_target_fps: u32, - client_cap: u32, - page_visible: bool, -) -> u32 { - let capped_target = engine_target_fps.clamp(1, 60).min(client_cap.clamp(1, 60)); - if page_visible { - capped_target - } else { - capped_target.min(HIDDEN_TAB_PREVIEW_FPS_CAP) - } +pub(super) fn desired_preview_fps(engine_target_fps: u32, client_cap: u32) -> u32 { + engine_target_fps.clamp(1, 60).min(client_cap.clamp(1, 60)) } +/// Whether a preview stream should be subscribed at all. +/// +/// A hidden page shows no frame, so it subscribes to none: the daemon stops +/// encoding for this client and the page stops decoding. Returning to view +/// resubscribes from scratch, the same path a reconnect takes. pub(super) const fn should_stream_preview( + page_visible: bool, app_window_visible: bool, engine_target_fps: u32, consumer_count: u32, ) -> bool { - app_window_visible && engine_target_fps > 0 && consumer_count > 0 + page_visible && app_window_visible && engine_target_fps > 0 && consumer_count > 0 } fn preview_canvas_format_for_host(hostname: &str) -> &'static str { @@ -110,10 +106,9 @@ pub(super) fn request_preview_subscription( engine_target_fps: u32, client_cap: u32, width_cap: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::canvas( - desired_preview_fps(engine_target_fps, client_cap, page_visible), + desired_preview_fps(engine_target_fps, client_cap), width_cap, ); if requested_preview_request.get_value() == Some(request) { @@ -142,10 +137,9 @@ pub(super) fn request_screen_preview_subscription( ws: &dyn WebSocketConnection, requested_preview_request: StoredValue>, engine_target_fps: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::canvas( - desired_preview_fps(engine_target_fps, SCREEN_PREVIEW_FPS_CAP, page_visible), + desired_preview_fps(engine_target_fps, SCREEN_PREVIEW_FPS_CAP), 0, ); if requested_preview_request.get_value() == Some(request) { @@ -173,12 +167,10 @@ pub(super) fn request_web_viewport_preview_subscription( ws: &dyn WebSocketConnection, requested_preview_request: StoredValue>, engine_target_fps: u32, - page_visible: bool, ) { let request = PreviewSubscriptionRequest::web_viewport(desired_preview_fps( engine_target_fps, WEB_VIEWPORT_PREVIEW_FPS_CAP, - page_visible, )); if requested_preview_request.get_value() == Some(request) { return; @@ -626,18 +618,20 @@ mod tests { } #[test] - fn hidden_page_caps_preview_fps() { - assert_eq!(desired_preview_fps(60, 60, false), 6); - assert_eq!(desired_preview_fps(20, 15, false), 6); - assert_eq!(desired_preview_fps(5, 60, false), 5); + fn preview_fps_is_the_lower_of_engine_target_and_client_cap() { + assert_eq!(desired_preview_fps(60, 60), 60); + assert_eq!(desired_preview_fps(60, 15), 15); + assert_eq!(desired_preview_fps(20, 60), 20); + assert_eq!(desired_preview_fps(120, 0), 1); } #[test] - fn hidden_tauri_window_disables_preview_streaming() { - assert!(should_stream_preview(true, 60, 1)); - assert!(!should_stream_preview(false, 60, 1)); - assert!(!should_stream_preview(true, 0, 1)); - assert!(!should_stream_preview(true, 60, 0)); + fn a_hidden_page_or_window_disables_preview_streaming() { + assert!(should_stream_preview(true, true, 60, 1)); + assert!(!should_stream_preview(false, true, 60, 1)); + assert!(!should_stream_preview(true, false, 60, 1)); + assert!(!should_stream_preview(true, true, 0, 1)); + assert!(!should_stream_preview(true, true, 60, 0)); } #[test] From 7986662715be185f824d1aa4c72bf677274c4c47 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 23:56:51 -0700 Subject: [PATCH 04/10] fix(ui): only unsubscribe preview streams that were requested 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) --- crates/hypercolor-ui/src/ws/connection.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index 923a99e12..9a7188caa 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -725,7 +725,12 @@ impl WsManager { let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + // Only a requested stream has anything to unsubscribe. The + // effect reruns on every engine target report, so an + // unguarded branch resent the unsubscribe twice a second. + if requested_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_preview_subscription( requested_preview, &set_preview_target_fps, @@ -759,7 +764,9 @@ impl WsManager { let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + if requested_screen_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_screen_preview_subscription( requested_screen_preview, &set_screen_canvas_frame, @@ -812,7 +819,9 @@ impl WsManager { let is_visible = page_visible.get(); let window_visible = app_window_visible.get(); if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { - if let Some(ws) = ws_handle.get_value() { + if requested_web_viewport_preview.get_value().is_some() + && let Some(ws) = ws_handle.get_value() + { clear_web_viewport_preview_subscription( requested_web_viewport_preview, &set_web_viewport_canvas_frame, From 80057eebecaa8d9b75caeb353d2193535f1f383e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 00:27:22 -0700 Subject: [PATCH 05/10] perf(ui): reassemble chunked preview frames without WASM copies 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 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) --- .../references/websocket-protocol.md | 9 + crates/hypercolor-leptos-ext/src/ws/mod.rs | 23 +- .../hypercolor-leptos-ext/src/ws/preview.rs | 537 ++++++++++++++---- .../tests/browser_preview_reassembly_tests.rs | 277 +++++++++ crates/hypercolor-ui/src/ws/messages.rs | 111 +--- 5 files changed, 756 insertions(+), 201 deletions(-) create mode 100644 crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs diff --git a/.agents/skills/leptos-ui-development/references/websocket-protocol.md b/.agents/skills/leptos-ui-development/references/websocket-protocol.md index 5f6d0b5f1..2799d49cf 100644 --- a/.agents/skills/leptos-ui-development/references/websocket-protocol.md +++ b/.agents/skills/leptos-ui-development/references/websocket-protocol.md @@ -42,6 +42,15 @@ code. Decode them through `PreviewBinaryDecoder`, which uses the canonical `hypercolor-leptos-ext::ws` codecs and negotiates the daemon's advertised transport capability. +A frame larger than one message (a 640x480 RGBA canvas is) arrives as +`0x0f` chunks. The decoder reassembles them with +`PreviewChunkReassembler`: only each chunk's +envelope is copied into WASM, the payloads are copied once into a JS +`Uint8Array`, and the assembled buffer goes through the same +`*View::decode_array_buffer` decoders as a frame that arrived whole, so the +preview uploads straight from a view of it. Never route frame payloads +through `Vec` in the UI; each crossing is a full-frame copy. + The generated byte-level contract lives in [`docs/content/api/websocket-binary-frames.md`](../../../../docs/content/api/websocket-binary-frames.md). diff --git a/crates/hypercolor-leptos-ext/src/ws/mod.rs b/crates/hypercolor-leptos-ext/src/ws/mod.rs index c1b9aadbc..8748f4f47 100644 --- a/crates/hypercolor-leptos-ext/src/ws/mod.rs +++ b/crates/hypercolor-leptos-ext/src/ws/mod.rs @@ -35,17 +35,20 @@ pub use preview::{ PREVIEW_CANCEL_FIXED_HEADER_LEN, PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CANCEL_SCHEMA, PREVIEW_CHUNK_FIXED_HEADER_LEN, PREVIEW_CHUNK_FRAME_TAG, PREVIEW_CHUNK_SCHEMA, PREVIEW_FRAME_HEADER_LEN, PREVIEW_MIN_MESSAGE_BYTES, PreviewCancelFrame, PreviewChunkError, - PreviewChunkFrame, PreviewChunkReassembler, PreviewFrame, PreviewFrameChannel, - PreviewFrameDecodeError, PreviewPixelFormat, PreviewPublicationMetadata, - PreviewReassemblyLimits, PreviewStreamId, PreviewTransportLimits, - ReassembledPreviewPublication, SCREEN_ZONES_FRAME_HEADER_LEN, SCREEN_ZONES_FRAME_TAG, - ScreenZonesFrame, WIDE_DISPLAY_PREVIEW_FRAME_PREFIX_LEN, WIDE_DISPLAY_PREVIEW_FRAME_TAG, - WIDE_INTERACTIVE_PREVIEW_FRAME_PREFIX_LEN, WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, - WIDE_PREVIEW_FRAME_HEADER_LEN, WIDE_PREVIEW_FRAME_TAG, WIDE_SCREEN_ZONES_FRAME_HEADER_LEN, - WIDE_SCREEN_ZONES_FRAME_TAG, WIDE_ZONE_PREVIEW_FRAME_HEADER_LEN, WIDE_ZONE_PREVIEW_FRAME_TAG, - ZONE_PREVIEW_FRAME_HEADER_LEN, ZONE_PREVIEW_FRAME_TAG, ZonePreviewFrame, - split_preview_publication, split_preview_publication_with_limits, + PreviewChunkFrame, PreviewChunkHeader, PreviewChunkPayload, PreviewChunkReassembler, + PreviewFrame, PreviewFrameChannel, PreviewFrameDecodeError, PreviewPixelFormat, + PreviewPublicationBuffer, PreviewPublicationMetadata, PreviewReassemblyLimits, PreviewStreamId, + PreviewTransportLimits, ReassembledPreviewPublication, SCREEN_ZONES_FRAME_HEADER_LEN, + SCREEN_ZONES_FRAME_TAG, ScreenZonesFrame, WIDE_DISPLAY_PREVIEW_FRAME_PREFIX_LEN, + WIDE_DISPLAY_PREVIEW_FRAME_TAG, WIDE_INTERACTIVE_PREVIEW_FRAME_PREFIX_LEN, + WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, WIDE_PREVIEW_FRAME_HEADER_LEN, WIDE_PREVIEW_FRAME_TAG, + WIDE_SCREEN_ZONES_FRAME_HEADER_LEN, WIDE_SCREEN_ZONES_FRAME_TAG, + WIDE_ZONE_PREVIEW_FRAME_HEADER_LEN, WIDE_ZONE_PREVIEW_FRAME_TAG, ZONE_PREVIEW_FRAME_HEADER_LEN, + ZONE_PREVIEW_FRAME_TAG, ZonePreviewFrame, split_preview_publication, + split_preview_publication_with_limits, }; +#[cfg(feature = "ws-client-wasm")] +pub use preview::{JsPreviewPublicationBuffer, ReassembledPreviewBuffer}; pub use spectrum::{ SPECTRUM_FRAME_HEADER_LEN, SPECTRUM_FRAME_TAG, SpectrumFrame, SpectrumFrameDecodeError, }; diff --git a/crates/hypercolor-leptos-ext/src/ws/preview.rs b/crates/hypercolor-leptos-ext/src/ws/preview.rs index 9deefdc88..194e51f49 100644 --- a/crates/hypercolor-leptos-ext/src/ws/preview.rs +++ b/crates/hypercolor-leptos-ext/src/ws/preview.rs @@ -886,10 +886,57 @@ impl PreviewChunkFrame { } pub fn decode_bytes(input: &Bytes) -> Result { - if input.len() < PREVIEW_CHUNK_FIXED_HEADER_LEN { + let (header, payload_offset) = PreviewChunkHeader::decode_prefix(input, input.len())?; + Ok(Self { + metadata: header.metadata, + total_encoded_bytes: header.total_encoded_bytes, + chunk_offset: header.chunk_offset, + chunk_index: header.chunk_index, + chunk_count: header.chunk_count, + payload: input.slice(payload_offset..), + }) + } + + fn validate_layout(&self) -> Result<(), PreviewChunkError> { + validate_chunk_layout( + self.total_encoded_bytes, + self.chunk_offset, + self.chunk_index, + self.chunk_count, + self.payload.len(), + ) + } +} + +/// A chunk envelope without its payload: where the payload belongs in its +/// publication. Decoding needs only the envelope's leading bytes, so a +/// client can place a payload it never copies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewChunkHeader { + pub metadata: PreviewPublicationMetadata, + pub total_encoded_bytes: u64, + pub chunk_offset: u64, + pub chunk_index: u32, + pub chunk_count: u32, +} + +impl PreviewChunkHeader { + /// Decode the envelope of a chunk message `message_len` bytes long. + /// + /// `input` starts at the message's first byte and holds at least the + /// envelope (the fixed header plus the stream identity); it may hold the + /// whole message. The payload length follows from `message_len` and is + /// checked against the layout. Returns the header and the payload offset. + pub fn decode_prefix( + input: &[u8], + message_len: usize, + ) -> Result<(Self, usize), PreviewChunkError> { + if message_len < PREVIEW_CHUNK_FIXED_HEADER_LEN + || input.len() < PREVIEW_CHUNK_FIXED_HEADER_LEN + { return Err(PreviewChunkError::TooShort { expected: PREVIEW_CHUNK_FIXED_HEADER_LEN, - actual: input.len(), + actual: message_len.min(input.len()), }); } if input[0] != PREVIEW_CHUNK_FRAME_TAG { @@ -904,10 +951,10 @@ impl PreviewChunkFrame { let payload_offset = PREVIEW_CHUNK_FIXED_HEADER_LEN .checked_add(identity_len) .ok_or(PreviewChunkError::LengthOverflow)?; - if input.len() < payload_offset { + if message_len < payload_offset || input.len() < payload_offset { return Err(PreviewChunkError::TooShort { expected: payload_offset, - actual: input.len(), + actual: message_len.min(input.len()), }); } let stream = PreviewStreamId::decode( @@ -915,7 +962,7 @@ impl PreviewChunkFrame { input[3], &input[PREVIEW_CHUNK_FIXED_HEADER_LEN..payload_offset], )?; - let frame = Self { + let header = Self { metadata: PreviewPublicationMetadata { stream, publication_id: u64::from_le_bytes( @@ -937,43 +984,52 @@ impl PreviewChunkFrame { chunk_offset: u64::from_le_bytes(input[39..47].try_into().expect("slice has 8 bytes")), chunk_index: u32::from_le_bytes(input[47..51].try_into().expect("slice has 4 bytes")), chunk_count: u32::from_le_bytes(input[51..55].try_into().expect("slice has 4 bytes")), - payload: input.slice(payload_offset..), }; - frame.validate_layout()?; - Ok(frame) + validate_chunk_layout( + header.total_encoded_bytes, + header.chunk_offset, + header.chunk_index, + header.chunk_count, + message_len - payload_offset, + )?; + Ok((header, payload_offset)) } +} - fn validate_layout(&self) -> Result<(), PreviewChunkError> { - if self.total_encoded_bytes == 0 || self.chunk_count == 0 { - return Err(PreviewChunkError::InvalidLayout); - } - if u64::from(self.chunk_count) > self.total_encoded_bytes { - return Err(PreviewChunkError::ImpossibleChunkCount { - chunks: self.chunk_count, - total_bytes: self.total_encoded_bytes, - }); - } - if self.chunk_index >= self.chunk_count || self.payload.is_empty() { - return Err(PreviewChunkError::InvalidLayout); - } - let payload_len = - u64::try_from(self.payload.len()).map_err(|_| PreviewChunkError::LengthOverflow)?; - let end = self - .chunk_offset - .checked_add(payload_len) - .ok_or(PreviewChunkError::LengthOverflow)?; - if end > self.total_encoded_bytes { - return Err(PreviewChunkError::InvalidLayout); - } - if self.chunk_index + 1 == self.chunk_count { - if end != self.total_encoded_bytes { - return Err(PreviewChunkError::InvalidLayout); - } - } else if end >= self.total_encoded_bytes { +fn validate_chunk_layout( + total_encoded_bytes: u64, + chunk_offset: u64, + chunk_index: u32, + chunk_count: u32, + payload_len: usize, +) -> Result<(), PreviewChunkError> { + if total_encoded_bytes == 0 || chunk_count == 0 { + return Err(PreviewChunkError::InvalidLayout); + } + if u64::from(chunk_count) > total_encoded_bytes { + return Err(PreviewChunkError::ImpossibleChunkCount { + chunks: chunk_count, + total_bytes: total_encoded_bytes, + }); + } + if chunk_index >= chunk_count || payload_len == 0 { + return Err(PreviewChunkError::InvalidLayout); + } + let payload_len = u64::try_from(payload_len).map_err(|_| PreviewChunkError::LengthOverflow)?; + let end = chunk_offset + .checked_add(payload_len) + .ok_or(PreviewChunkError::LengthOverflow)?; + if end > total_encoded_bytes { + return Err(PreviewChunkError::InvalidLayout); + } + if chunk_index + 1 == chunk_count { + if end != total_encoded_bytes { return Err(PreviewChunkError::InvalidLayout); } - Ok(()) + } else if end >= total_encoded_bytes { + return Err(PreviewChunkError::InvalidLayout); } + Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1212,28 +1268,104 @@ pub struct ReassembledPreviewPublication { pub encoded: Bytes, } +/// One chunk's payload bytes, wherever they live. +pub trait PreviewChunkPayload { + fn byte_len(&self) -> usize; + + /// Copy the payload's leading bytes into `out`, as many as fit or as + /// the payload holds. Returns the count copied. + fn copy_prefix_into(&self, out: &mut [u8]) -> usize; +} + +/// Storage a reassembler fills with one publication's encoded bytes. +/// +/// The state machine, budgets and tombstones are the same for every +/// storage; only where the bytes accumulate differs. `Vec` serves +/// native clients, and the browser keeps payloads in JS so a chunked frame +/// never crosses into WASM memory. +pub trait PreviewPublicationBuffer: Sized { + type Payload: PreviewChunkPayload; + + /// Empty storage with room for `capacity` bytes, or `None` when that + /// much cannot be allocated. + fn try_with_capacity(capacity: usize) -> Option; + + fn byte_len(&self) -> usize; + + /// Make room for `total` bytes in all. `false` when allocation fails. + fn try_reserve_total(&mut self, total: usize) -> bool; + + /// Append a payload. `false` when growing the storage fails. + fn try_append(&mut self, payload: &Self::Payload) -> bool; + + /// Copy the stored leading bytes into `out`. Returns the count copied. + fn copy_prefix_into(&self, out: &mut [u8]) -> usize; +} + +impl PreviewChunkPayload for Bytes { + fn byte_len(&self) -> usize { + self.len() + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len()); + out[..count].copy_from_slice(&self[..count]); + count + } +} + +impl PreviewPublicationBuffer for Vec { + type Payload = Bytes; + + fn try_with_capacity(capacity: usize) -> Option { + let mut bytes = Vec::new(); + bytes.try_reserve_exact(capacity).ok()?; + Some(bytes) + } + + fn byte_len(&self) -> usize { + self.len() + } + + fn try_reserve_total(&mut self, total: usize) -> bool { + self.try_reserve_exact(total.saturating_sub(self.len())) + .is_ok() + } + + fn try_append(&mut self, payload: &Bytes) -> bool { + self.extend_from_slice(payload); + true + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len()); + out[..count].copy_from_slice(&self[..count]); + count + } +} + #[derive(Debug)] -struct PartialPreviewPublication { +struct PartialPreviewPublication { metadata: PreviewPublicationMetadata, total_encoded_bytes: usize, chunk_count: u32, next_chunk_index: u32, screen_zones_header_validated: bool, - bytes: Vec, + bytes: B, } #[derive(Debug)] -struct PreviewStreamState { +struct PreviewStreamState { high_water_publication_id: u64, - partial: Option, + partial: Option>, last_activity_ms: u64, tombstone_generation: u64, } #[derive(Debug)] -pub struct PreviewChunkReassembler { +pub struct PreviewChunkReassembler> { limits: PreviewReassemblyLimits, - streams: HashMap, PreviewStreamState>, + streams: HashMap, PreviewStreamState>, active_streams: usize, tombstones: usize, reserved_bytes: usize, @@ -1248,19 +1380,7 @@ pub struct PreviewChunkReassembler { impl PreviewChunkReassembler { #[must_use] pub fn new(limits: PreviewReassemblyLimits) -> Self { - Self { - limits, - streams: HashMap::new(), - active_streams: 0, - tombstones: 0, - reserved_bytes: 0, - state_bytes: 0, - tombstone_bytes: 0, - logical_now_ms: 0, - next_tombstone_generation: 1, - superseded_publications: 0, - expired_publications: 0, - } + Self::with_limits(limits) } pub fn push( @@ -1276,15 +1396,61 @@ impl PreviewChunkReassembler { encoded_chunk: &Bytes, now_ms: u64, ) -> Result, PreviewChunkError> { + self.begin_push(encoded_chunk.len(), now_ms)?; + let (header, payload_offset) = + PreviewChunkHeader::decode_prefix(encoded_chunk, encoded_chunk.len())?; + let payload = encoded_chunk.slice(payload_offset..); + let Some((metadata, bytes)) = self.push_parts(&header, &payload, now_ms)? else { + return Ok(None); + }; + let encoded = Bytes::from(bytes); + validate_completed_publication(&metadata, &encoded, self.limits)?; + Ok(Some(ReassembledPreviewPublication { metadata, encoded })) + } +} + +impl PreviewChunkReassembler { + /// A reassembler that accumulates publications in `B`. + #[must_use] + pub fn with_limits(limits: PreviewReassemblyLimits) -> Self { + Self { + limits, + streams: HashMap::new(), + active_streams: 0, + tombstones: 0, + reserved_bytes: 0, + state_bytes: 0, + tombstone_bytes: 0, + logical_now_ms: 0, + next_tombstone_generation: 1, + superseded_publications: 0, + expired_publications: 0, + } + } + + /// Advance the clock, sweep idle partials, and check a chunk message's + /// length against the budget, before anything decodes it. + fn begin_push(&mut self, message_len: usize, now_ms: u64) -> Result<(), PreviewChunkError> { self.logical_now_ms = self.logical_now_ms.max(now_ms); self.expire_at(now_ms); - if encoded_chunk.len() > self.limits.max_message_bytes { + if message_len > self.limits.max_message_bytes { return Err(PreviewChunkError::MessageBudgetExceeded { - requested: encoded_chunk.len(), + requested: message_len, limit: self.limits.max_message_bytes, }); } - let chunk = PreviewChunkFrame::decode_bytes(encoded_chunk)?; + Ok(()) + } + + /// Place one decoded chunk. Returns the publication's metadata and + /// storage once its last chunk lands; validating the assembled bytes is + /// the caller's, since only it knows how to read its storage. + fn push_parts( + &mut self, + chunk: &PreviewChunkHeader, + payload: &B::Payload, + now_ms: u64, + ) -> Result, PreviewChunkError> { let max_chunk_count = self .limits .effective_max_chunk_count(chunk.metadata.stream.identity_bytes()); @@ -1320,14 +1486,15 @@ impl PreviewChunkReassembler { .get(&stream) .is_none_or(|state| publication_id > state.high_water_publication_id); if starts_new_publication { - self.admit_partial(&chunk, total_encoded_bytes, now_ms)?; + self.admit_partial(chunk, payload, total_encoded_bytes, now_ms)?; } - self.append_chunk(&stream, &chunk, total_encoded_bytes, now_ms) + self.append_chunk(&stream, chunk, payload, total_encoded_bytes, now_ms) } fn admit_partial( &mut self, - chunk: &PreviewChunkFrame, + chunk: &PreviewChunkHeader, + payload: &B::Payload, total_encoded_bytes: usize, now_ms: u64, ) -> Result<(), PreviewChunkError> { @@ -1335,13 +1502,13 @@ impl PreviewChunkReassembler { return Err(PreviewChunkError::MissingPublicationStart); } let screen_zones_header_validated = - validate_reassembly_admission(chunk, total_encoded_bytes, self.limits)?; + validate_reassembly_admission(chunk, payload, total_encoded_bytes, self.limits)?; let stream = &chunk.metadata.stream; let existing_is_active = self .streams .get(stream) .is_some_and(|state| state.partial.is_some()); - let stream_state_bytes = preview_stream_state_bytes(stream)?; + let stream_state_bytes = preview_stream_state_bytes::(stream)?; let existing_is_tombstone = self .streams .get(stream) @@ -1391,8 +1558,7 @@ impl PreviewChunkReassembler { } drop(replaced); - let mut bytes = Vec::new(); - if bytes.try_reserve_exact(initial_reservation).is_err() { + let Some(bytes) = B::try_with_capacity(initial_reservation) else { if existing_is_active { self.active_streams = self.active_streams.saturating_sub(1); self.mark_tombstone(stream, true); @@ -1400,7 +1566,7 @@ impl PreviewChunkReassembler { return Err(PreviewChunkError::AllocationFailed { bytes: initial_reservation, }); - } + }; let partial = PartialPreviewPublication { metadata: chunk.metadata.clone(), total_encoded_bytes, @@ -1445,10 +1611,11 @@ impl PreviewChunkReassembler { fn append_chunk( &mut self, stream: &PreviewStreamId, - chunk: &PreviewChunkFrame, + chunk: &PreviewChunkHeader, + payload: &B::Payload, total_encoded_bytes: usize, now_ms: u64, - ) -> Result, PreviewChunkError> { + ) -> Result, PreviewChunkError> { let state = self .streams .get_mut(stream) @@ -1466,43 +1633,46 @@ impl PreviewChunkReassembler { if chunk.chunk_index < partial.next_chunk_index { return Err(PreviewChunkError::DuplicateChunk); } - let expected_offset = - u64::try_from(partial.bytes.len()).map_err(|_| PreviewChunkError::LengthOverflow)?; + let expected_offset = u64::try_from(partial.bytes.byte_len()) + .map_err(|_| PreviewChunkError::LengthOverflow)?; if chunk.chunk_index != partial.next_chunk_index || chunk.chunk_offset != expected_offset { return Err(PreviewChunkError::NonContiguousChunk); } if !partial.screen_zones_header_validated { let prefix_len = partial .bytes - .len() - .checked_add(chunk.payload.len()) + .byte_len() + .checked_add(payload.byte_len()) .ok_or(PreviewChunkError::LengthOverflow)? .min(EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN); let mut prefix = [0_u8; EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN]; - let retained_prefix_len = partial.bytes.len().min(prefix_len); - prefix[..retained_prefix_len].copy_from_slice(&partial.bytes[..retained_prefix_len]); - let incoming_prefix_len = prefix_len.saturating_sub(retained_prefix_len); - prefix[retained_prefix_len..prefix_len] - .copy_from_slice(&chunk.payload[..incoming_prefix_len]); + let retained_prefix_len = partial.bytes.byte_len().min(prefix_len); + partial + .bytes + .copy_prefix_into(&mut prefix[..retained_prefix_len]); + payload.copy_prefix_into(&mut prefix[retained_prefix_len..prefix_len]); if validate_screen_zones_header_prefix( &partial.metadata, partial.total_encoded_bytes, &prefix[..prefix_len], self.limits, )? { - let additional = partial - .total_encoded_bytes - .checked_sub(partial.bytes.len()) - .ok_or(PreviewChunkError::LengthOverflow)?; - partial.bytes.try_reserve_exact(additional).map_err(|_| { - PreviewChunkError::AllocationFailed { + if partial.bytes.byte_len() > partial.total_encoded_bytes { + return Err(PreviewChunkError::LengthOverflow); + } + if !partial.bytes.try_reserve_total(partial.total_encoded_bytes) { + return Err(PreviewChunkError::AllocationFailed { bytes: partial.total_encoded_bytes, - } - })?; + }); + } partial.screen_zones_header_validated = true; } } - partial.bytes.extend_from_slice(&chunk.payload); + if !partial.bytes.try_append(payload) { + return Err(PreviewChunkError::AllocationFailed { + bytes: partial.total_encoded_bytes, + }); + } partial.next_chunk_index = partial .next_chunk_index .checked_add(1) @@ -1512,7 +1682,7 @@ impl PreviewChunkReassembler { if partial.next_chunk_index != partial.chunk_count { return Ok(None); } - if partial.bytes.len() != partial.total_encoded_bytes { + if partial.bytes.byte_len() != partial.total_encoded_bytes { return Err(PreviewChunkError::NonContiguousChunk); } let completed = state @@ -1525,12 +1695,7 @@ impl PreviewChunkReassembler { .ok_or(PreviewChunkError::LengthOverflow)?; self.active_streams = self.active_streams.saturating_sub(1); self.mark_tombstone(stream, true); - let encoded = Bytes::from(completed.bytes); - validate_completed_publication(&completed.metadata, &encoded, self.limits)?; - Ok(Some(ReassembledPreviewPublication { - metadata: completed.metadata, - encoded, - })) + Ok(Some((completed.metadata, completed.bytes))) } pub fn expire_at(&mut self, now_ms: u64) -> usize { @@ -1547,7 +1712,7 @@ impl PreviewChunkReassembler { if let Some(partial) = state.partial.take() { expired_bytes = expired_bytes.saturating_add(partial.total_encoded_bytes); expired_state_bytes = expired_state_bytes - .saturating_add(preview_stream_state_bytes(stream).unwrap_or(usize::MAX)); + .saturating_add(preview_stream_state_bytes::(stream).unwrap_or(usize::MAX)); expired_publications = expired_publications.saturating_add(1); } state.tombstone_generation = self.next_tombstone_generation; @@ -1665,7 +1830,7 @@ impl PreviewChunkReassembler { } fn mark_tombstone(&mut self, stream: &PreviewStreamId, was_active: bool) { - let stream_state_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); + let stream_state_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); let is_new_tombstone = self .streams .get(stream) @@ -1716,7 +1881,7 @@ impl PreviewChunkReassembler { && state.tombstone_generation == oldest_generation; removed |= evict; if evict { - removed_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); + removed_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); } !evict }); @@ -1753,7 +1918,7 @@ impl PreviewChunkReassembler { && state.tombstone_generation == oldest_generation; removed |= evict; if evict { - removed_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); + removed_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); } !evict }); @@ -1766,16 +1931,17 @@ impl PreviewChunkReassembler { } } -fn preview_stream_state_bytes(stream: &PreviewStreamId) -> Result { - std::mem::size_of::<(Arc, PreviewStreamState)>() +fn preview_stream_state_bytes(stream: &PreviewStreamId) -> Result { + std::mem::size_of::<(Arc, PreviewStreamState)>() .checked_add(std::mem::size_of::<(usize, usize)>()) .and_then(|bytes| bytes.checked_add(std::mem::size_of::())) .and_then(|bytes| bytes.checked_add(stream.identity_bytes())) .ok_or(PreviewChunkError::LengthOverflow) } -fn validate_reassembly_admission( - chunk: &PreviewChunkFrame, +fn validate_reassembly_admission( + chunk: &PreviewChunkHeader, + payload: &P, total_encoded_bytes: usize, limits: PreviewReassemblyLimits, ) -> Result { @@ -1799,10 +1965,12 @@ fn validate_reassembly_admission( limit: limits.max_decoded_publication_bytes, }); } + let mut prefix = [0_u8; EXTENDED_SCREEN_ZONES_FRAME_HEADER_LEN]; + let prefix_len = payload.copy_prefix_into(&mut prefix); return validate_screen_zones_header_prefix( metadata, total_encoded_bytes, - &chunk.payload, + &prefix[..prefix_len], limits, ); } @@ -2480,6 +2648,171 @@ impl IdentityFrameHeader { } } +#[cfg(feature = "ws-client-wasm")] +impl PreviewChunkPayload for js_sys::Uint8Array { + fn byte_len(&self) -> usize { + self.length() as usize + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.length() as usize); + self.subarray(0, count as u32).copy_to(&mut out[..count]); + count + } +} + +/// Browser storage for chunked publications: a JS `Uint8Array` that chunk +/// payloads are copied into straight from their WebSocket buffers. +/// +/// A publication assembled here costs one JS-to-JS copy and one allocation, +/// the same as a frame small enough to arrive in a single message, instead +/// of a copy into WASM memory, a second into the reassembly buffer, and a +/// third back out to JS. +#[cfg(feature = "ws-client-wasm")] +#[derive(Debug)] +pub struct JsPreviewPublicationBuffer { + bytes: js_sys::Uint8Array, + len: usize, +} + +#[cfg(feature = "ws-client-wasm")] +impl JsPreviewPublicationBuffer { + /// Allocate through `Reflect.construct` so the `RangeError` an + /// oversized buffer throws comes back as `None`. A plain constructor + /// call would throw straight through the WASM frames above it. + fn try_allocate(capacity: usize) -> Option { + use wasm_bindgen::JsCast; + + let length = u32::try_from(capacity).ok()?; + let constructor = js_sys::Reflect::get( + &js_sys::global(), + &wasm_bindgen::JsValue::from_str("Uint8Array"), + ) + .ok()? + .dyn_into::() + .ok()?; + js_sys::Reflect::construct( + &constructor, + &js_sys::Array::of1(&wasm_bindgen::JsValue::from(length)), + ) + .ok()? + .dyn_into::() + .ok() + } + + fn grow_to(&mut self, capacity: usize) -> bool { + let Some(grown) = Self::try_allocate(capacity) else { + return false; + }; + grown.set(&self.bytes.subarray(0, self.len as u32), 0); + self.bytes = grown; + true + } + + /// The assembled publication in an `ArrayBuffer` of its own, ready for + /// the same decoders a single-message frame goes through. + #[must_use] + pub fn into_array_buffer(self) -> js_sys::ArrayBuffer { + if self.bytes.length() as usize == self.len { + self.bytes.buffer() + } else { + self.bytes.slice(0, self.len as u32).buffer() + } + } +} + +#[cfg(feature = "ws-client-wasm")] +impl PreviewPublicationBuffer for JsPreviewPublicationBuffer { + type Payload = js_sys::Uint8Array; + + fn try_with_capacity(capacity: usize) -> Option { + Some(Self { + bytes: Self::try_allocate(capacity)?, + len: 0, + }) + } + + fn byte_len(&self) -> usize { + self.len + } + + fn try_reserve_total(&mut self, total: usize) -> bool { + self.bytes.length() as usize >= total || self.grow_to(total) + } + + fn try_append(&mut self, payload: &js_sys::Uint8Array) -> bool { + let Some(end) = self.len.checked_add(payload.length() as usize) else { + return false; + }; + let capacity = self.bytes.length() as usize; + if end > capacity && !self.grow_to(end.max(capacity.saturating_mul(2))) { + return false; + } + let Ok(offset) = u32::try_from(self.len) else { + return false; + }; + self.bytes.set(payload, offset); + self.len = end; + true + } + + fn copy_prefix_into(&self, out: &mut [u8]) -> usize { + let count = out.len().min(self.len); + self.bytes + .subarray(0, count as u32) + .copy_to(&mut out[..count]); + count + } +} + +/// A publication reassembled in JS: its metadata, and its encoded bytes +/// exactly as a single-message frame would carry them. +#[cfg(feature = "ws-client-wasm")] +#[derive(Debug, Clone)] +pub struct ReassembledPreviewBuffer { + pub metadata: PreviewPublicationMetadata, + pub encoded: js_sys::ArrayBuffer, +} + +#[cfg(feature = "ws-client-wasm")] +impl PreviewChunkReassembler { + /// Place one chunk message straight from its WebSocket buffer. Only the + /// envelope is copied into WASM memory; the payload stays in JS. + /// + /// Unlike [`PreviewChunkReassembler::push_at`], a completed publication + /// is not decoded here. The caller decodes [`ReassembledPreviewBuffer::encoded`] + /// with the `*View::decode_array_buffer` decoders and checks the result + /// against the metadata, as it would a frame that arrived whole. + pub fn push_array_buffer_at( + &mut self, + message: &js_sys::ArrayBuffer, + now_ms: u64, + ) -> Result, PreviewChunkError> { + let data = js_sys::Uint8Array::new(message); + let message_len = data.length() as usize; + self.begin_push(message_len, now_ms)?; + let fixed_len = message_len.min(PREVIEW_CHUNK_FIXED_HEADER_LEN); + let mut envelope = data.subarray(0, fixed_len as u32).to_vec(); + if envelope.len() == PREVIEW_CHUNK_FIXED_HEADER_LEN { + let identity_len = usize::from(u16::from_le_bytes([envelope[5], envelope[6]])); + let envelope_len = PREVIEW_CHUNK_FIXED_HEADER_LEN + .saturating_add(identity_len) + .min(message_len); + if envelope_len > envelope.len() { + envelope = data.subarray(0, envelope_len as u32).to_vec(); + } + } + let (header, payload_offset) = PreviewChunkHeader::decode_prefix(&envelope, message_len)?; + let payload = data.subarray(payload_offset as u32, message_len as u32); + Ok(self + .push_parts(&header, &payload, now_ms)? + .map(|(metadata, bytes)| ReassembledPreviewBuffer { + metadata, + encoded: bytes.into_array_buffer(), + })) + } +} + #[cfg(feature = "ws-client-wasm")] #[derive(Debug, Clone)] pub struct PreviewFrameView { diff --git a/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs new file mode 100644 index 000000000..359eecdfc --- /dev/null +++ b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs @@ -0,0 +1,277 @@ +#![cfg(all(target_arch = "wasm32", feature = "ws-client-wasm"))] + +//! The JS-backed reassembler shares the native state machine, so every +//! step here runs through both and they must agree on the outcome and on +//! the bytes a completed publication carries. + +use bytes::Bytes; +use hypercolor_leptos_ext::ws::{ + DEFAULT_PREVIEW_MAX_MESSAGE_BYTES, JsPreviewPublicationBuffer, PreviewCancelFrame, + PreviewChunkError, PreviewChunkReassembler, PreviewFrame, PreviewFrameChannel, + PreviewFrameView, PreviewPixelFormat, PreviewPublicationMetadata, PreviewReassemblyLimits, + PreviewStreamId, ReassembledPreviewBuffer, ReassembledPreviewPublication, ScreenZonesFrame, + ZonePreviewFrame, split_preview_publication, +}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +wasm_bindgen_test_configure!(run_in_browser); + +type NativeOutcome = Result, PreviewChunkError>; +type JsOutcome = Result, PreviewChunkError>; + +struct Pair { + native: PreviewChunkReassembler, + js: PreviewChunkReassembler, +} + +impl Pair { + fn new(limits: PreviewReassemblyLimits) -> Self { + Self { + native: PreviewChunkReassembler::new(limits), + js: PreviewChunkReassembler::with_limits(limits), + } + } + + /// Push one chunk through both and return the completed bytes, after + /// checking both sides agree. + fn push(&mut self, chunk: &Bytes, now_ms: u64) -> Result, PreviewChunkError> { + let native = self.native.push_at(chunk, now_ms); + let js = self.js.push_array_buffer_at(&array_buffer(chunk), now_ms); + assert_outcomes_agree(&native, &js); + assert_eq!(self.native.partial_count(), self.js.partial_count()); + assert_eq!(self.native.reserved_bytes(), self.js.reserved_bytes()); + assert_eq!(self.native.tombstone_count(), self.js.tombstone_count()); + assert_eq!( + self.native.superseded_publications(), + self.js.superseded_publications() + ); + assert_eq!( + self.native.expired_publications(), + self.js.expired_publications() + ); + native.map(|completed| completed.map(|publication| publication.encoded)) + } + + fn cancel(&mut self, cancellation: &PreviewCancelFrame) { + assert_eq!( + self.native.cancel_publication(cancellation), + self.js.cancel_publication(cancellation) + ); + assert_eq!(self.native.partial_count(), self.js.partial_count()); + assert_eq!(self.native.reserved_bytes(), self.js.reserved_bytes()); + } +} + +fn array_buffer(bytes: &[u8]) -> js_sys::ArrayBuffer { + js_sys::Uint8Array::from(bytes).buffer() +} + +fn js_bytes(buffer: &js_sys::ArrayBuffer) -> Vec { + js_sys::Uint8Array::new(buffer).to_vec() +} + +fn assert_outcomes_agree(native: &NativeOutcome, js: &JsOutcome) { + match (native, js) { + (Ok(None), Ok(None)) => {} + (Ok(Some(native)), Ok(Some(js))) => { + assert_eq!(native.metadata, js.metadata); + assert_eq!(native.encoded.as_ref(), js_bytes(&js.encoded).as_slice()); + } + (Err(native), Err(js)) => assert_eq!(native, js), + (native, js) => panic!("reassemblers disagree: native {native:?}, js {js:?}"), + } +} + +fn canvas_frame(frame_number: u32, width: u32, height: u32) -> (Bytes, PreviewPublicationMetadata) { + let pixel_bytes = usize::try_from(width * height * 4).expect("fixture fits"); + let payload: Vec = (0..pixel_bytes) + .map(|index| u8::try_from(index % 251).expect("below 251")) + .collect(); + let frame = PreviewFrame { + channel: PreviewFrameChannel::Canvas, + frame_number, + timestamp_ms: frame_number * 16, + width, + height, + format: PreviewPixelFormat::Rgba, + payload: Bytes::from(payload), + }; + let metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::Passive(frame.channel), + publication_id: u64::from(frame_number), + frame_number, + timestamp_ms: frame.timestamp_ms, + width, + height, + format: frame.format, + }; + (frame.encode(), metadata) +} + +#[wasm_bindgen_test] +fn a_chunked_canvas_frame_reassembles_in_js_byte_for_byte() { + let (encoded, metadata) = canvas_frame(7, 640, 480); + let chunks = split_preview_publication(&encoded, &metadata, DEFAULT_PREVIEW_MAX_MESSAGE_BYTES) + .expect("chunks split"); + assert!( + chunks.len() > 1, + "a 640x480 RGBA frame needs several messages" + ); + + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + let mut completed = None; + for (index, chunk) in chunks.iter().enumerate() { + let outcome = pair.push(chunk, 1).expect("valid chunk"); + assert_eq!(outcome.is_some(), index + 1 == chunks.len()); + completed = outcome.or(completed); + } + assert_eq!(completed.as_ref(), Some(&encoded)); + + let mut js = PreviewChunkReassembler::::with_limits( + PreviewReassemblyLimits::default(), + ); + let mut assembled = None; + for chunk in &chunks { + assembled = js + .push_array_buffer_at(&array_buffer(chunk), 1) + .expect("valid chunk") + .or(assembled); + } + let assembled = assembled.expect("publication completes"); + let view = PreviewFrameView::decode_array_buffer(&assembled.encoded).expect("view decodes"); + assert_eq!((view.width, view.height), (640, 480)); + assert_eq!(view.format, PreviewPixelFormat::Rgba); + assert_eq!(view.payload.length(), 640 * 480 * 4); + assert_eq!(view.rgba_at(1), Some([4, 5, 6, 7])); +} + +#[wasm_bindgen_test] +fn one_byte_chunks_reassemble_in_js_for_screen_zones_and_zone_streams() { + let screen_zones = ScreenZonesFrame { + frame_number: 2, + timestamp_ms: 22, + source_width: 3840, + source_height: 2160, + grid_cols: 1, + grid_rows: 1, + letterbox: [0; 4], + payload: Bytes::from_static(&[4, 5, 6]), + }; + let screen_metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::ScreenZones, + publication_id: 2, + frame_number: 2, + timestamp_ms: 22, + width: 3840, + height: 2160, + format: PreviewPixelFormat::Rgb, + }; + let zone = ZonePreviewFrame { + scene_id: [0x11; 16], + zone_id: [0x22; 16], + frame_number: 3, + timestamp_ms: 33, + width: 1, + height: 1, + format: PreviewPixelFormat::Rgb, + payload: Bytes::from_static(&[7, 8, 9]), + }; + let zone_metadata = PreviewPublicationMetadata { + stream: PreviewStreamId::Zone { + scene_id: zone.scene_id, + zone_id: zone.zone_id, + }, + publication_id: 3, + frame_number: 3, + timestamp_ms: 33, + width: 1, + height: 1, + format: PreviewPixelFormat::Rgb, + }; + for (encoded, metadata, identity_len) in [ + (screen_zones.encode(), screen_metadata, 0), + (zone.encode(), zone_metadata, 32), + ] { + let max_message_bytes = + hypercolor_leptos_ext::ws::PREVIEW_CHUNK_FIXED_HEADER_LEN + identity_len + 1; + let chunks = split_preview_publication(&encoded, &metadata, max_message_bytes) + .expect("one-byte chunks"); + assert_eq!(chunks.len(), encoded.len()); + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + let mut completed = None; + for chunk in &chunks { + completed = pair.push(chunk, 1).expect("valid chunk").or(completed); + } + assert_eq!(completed, Some(encoded)); + } +} + +#[wasm_bindgen_test] +fn js_and_native_reassemblers_agree_on_misordered_superseded_and_cancelled_chunks() { + let (first, first_metadata) = canvas_frame(1, 64, 48); + let (second, second_metadata) = canvas_frame(2, 64, 48); + let (third, third_metadata) = canvas_frame(3, 64, 48); + let first_chunks = split_preview_publication(&first, &first_metadata, 4096).expect("split"); + let second_chunks = split_preview_publication(&second, &second_metadata, 4096).expect("split"); + let third_chunks = split_preview_publication(&third, &third_metadata, 4096).expect("split"); + assert!(first_chunks.len() >= 3); + + let mut pair = Pair::new(PreviewReassemblyLimits::default()); + // A publication cannot start mid-way. + assert!(pair.push(&first_chunks[1], 10).is_err()); + assert_eq!(pair.push(&first_chunks[0], 11), Ok(None)); + // A repeated chunk and a skipped one are both rejected. + assert!(pair.push(&first_chunks[0], 12).is_err()); + assert!(pair.push(&first_chunks[2], 13).is_err()); + // A newer publication supersedes the partial one, and the older is stale. + assert_eq!(pair.push(&second_chunks[0], 14), Ok(None)); + assert!(pair.push(&first_chunks[0], 15).is_err()); + let mut completed = None; + for chunk in &second_chunks[1..] { + completed = pair + .push(chunk, 16) + .expect("second continues") + .or(completed); + } + assert_eq!(completed, Some(second)); + // A cancelled publication drops its partial state on both sides. + assert_eq!(pair.push(&third_chunks[0], 17), Ok(None)); + pair.cancel(&PreviewCancelFrame { + stream: third_metadata.stream.clone(), + publication_id: third_metadata.publication_id, + }); + assert!(pair.push(&third_chunks[1], 18).is_err()); +} + +#[wasm_bindgen_test] +fn js_and_native_reassemblers_expire_an_idle_partial_together() { + let (encoded, metadata) = canvas_frame(4, 64, 48); + let chunks = split_preview_publication(&encoded, &metadata, 4096).expect("split"); + let limits = PreviewReassemblyLimits::default(); + let mut pair = Pair::new(limits); + assert_eq!(pair.push(&chunks[0], 100), Ok(None)); + let later = 100 + limits.max_idle_ms; + assert_eq!(pair.native.expire_at(later), pair.js.expire_at(later)); + assert_eq!(pair.native.partial_count(), 0); + assert_eq!(pair.js.partial_count(), 0); + assert!(pair.push(&chunks[1], later + 1).is_err()); +} + +#[wasm_bindgen_test] +fn an_oversized_message_is_rejected_before_it_is_decoded() { + let limits = PreviewReassemblyLimits { + max_message_bytes: 64, + ..PreviewReassemblyLimits::default() + }; + let mut pair = Pair::new(limits); + let garbage = Bytes::from(vec![0x0F; 65]); + assert!(matches!( + pair.push(&garbage, 1), + Err(PreviewChunkError::MessageBudgetExceeded { .. }) + )); + let short = Bytes::from_static(&[0x0F, 1, 0]); + assert!(matches!( + pair.push(&short, 2), + Err(PreviewChunkError::TooShort { .. }) + )); +} diff --git a/crates/hypercolor-ui/src/ws/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index 3eb353149..e1a112266 100644 --- a/crates/hypercolor-ui/src/ws/messages.rs +++ b/crates/hypercolor-ui/src/ws/messages.rs @@ -7,14 +7,13 @@ use hypercolor_leptos_ext::prelude::now_ms; pub(super) use hypercolor_leptos_ext::ws::PreviewFrameChannel; pub use hypercolor_leptos_ext::ws::ScreenZonesFrame; use hypercolor_leptos_ext::ws::{ - DISPLAY_PREVIEW_FRAME_TAG, DisplayPreviewFrame, DisplayPreviewFrameView, - EXTENDED_SCREEN_ZONES_FRAME_TAG, INTERACTIVE_PREVIEW_FRAME_TAG, InteractivePreviewFrame, - InteractivePreviewFrameView, PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CHUNK_FRAME_TAG, - PreviewCancelFrame, PreviewChunkReassembler, PreviewFrame, PreviewPublicationMetadata, - PreviewReassemblyLimits, PreviewStreamId, ReassembledPreviewPublication, + DISPLAY_PREVIEW_FRAME_TAG, DisplayPreviewFrameView, EXTENDED_SCREEN_ZONES_FRAME_TAG, + INTERACTIVE_PREVIEW_FRAME_TAG, InteractivePreviewFrameView, JsPreviewPublicationBuffer, + PREVIEW_CANCEL_FRAME_TAG, PREVIEW_CHUNK_FRAME_TAG, PreviewCancelFrame, PreviewChunkReassembler, + PreviewPublicationMetadata, PreviewReassemblyLimits, PreviewStreamId, ReassembledPreviewBuffer, SCREEN_ZONES_FRAME_TAG, WIDE_DISPLAY_PREVIEW_FRAME_TAG, WIDE_INTERACTIVE_PREVIEW_FRAME_TAG, WIDE_SCREEN_ZONES_FRAME_TAG, WIDE_ZONE_PREVIEW_FRAME_TAG, ZONE_PREVIEW_FRAME_TAG, - ZonePreviewFrame, ZonePreviewFrameView, + ZonePreviewFrameView, }; pub use hypercolor_leptos_ext::ws::{ PreviewFrameView as CanvasFrame, PreviewPixelFormat as CanvasPixelFormat, @@ -601,14 +600,17 @@ pub(super) enum PreviewBinaryMessage { ScreenZones(ScreenZonesFrame), } +/// Decodes binary preview messages. Chunked publications reassemble in JS +/// (`JsPreviewPublicationBuffer`), so a frame too large for one message +/// still reaches the presenter without passing through WASM memory. pub(super) struct PreviewBinaryDecoder { - chunks: PreviewChunkReassembler, + chunks: PreviewChunkReassembler, } impl Default for PreviewBinaryDecoder { fn default() -> Self { Self { - chunks: PreviewChunkReassembler::new(PreviewReassemblyLimits::default()), + chunks: PreviewChunkReassembler::with_limits(PreviewReassemblyLimits::default()), } } } @@ -622,9 +624,8 @@ impl PreviewBinaryDecoder { let buffer = frame.into_array_buffer(); let bytes = js_sys::Uint8Array::new(&buffer); if bytes.length() > 0 && bytes.get_index(0) == PREVIEW_CHUNK_FRAME_TAG { - let encoded = Bytes::from(bytes.to_vec()); - return match self.chunks.push_at(&encoded, now_ms) { - Ok(Some(publication)) => decode_reassembled_preview(&publication), + return match self.chunks.push_array_buffer_at(&buffer, now_ms) { + Ok(Some(publication)) => decode_reassembled_preview(publication), Ok(None) => None, Err(error) => { log::warn!("rejected preview chunk publication: {error}"); @@ -679,86 +680,18 @@ fn decode_direct_preview(buffer: js_sys::ArrayBuffer) -> Option Option { - let metadata = &publication.metadata; - let decoded = match &metadata.stream { - PreviewStreamId::Passive(expected_channel) => { - let frame = PreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.channel != *expected_channel { - return None; - } - PreviewBinaryMessage::Frame( - frame.channel, - CanvasFrame { - channel: frame.channel, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::Zone { scene_id, zone_id } => { - let frame = ZonePreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.scene_id != *scene_id || frame.zone_id != *zone_id { - return None; - } - PreviewBinaryMessage::Zone(ZonePreviewFrameView { - scene_id: frame.scene_id, - zone_id: frame.zone_id, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }) - } - PreviewStreamId::Interactive(expected_preview_id) => { - let frame = InteractivePreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.preview_id != *expected_preview_id { - return None; - } - PreviewBinaryMessage::Interactive( - frame.preview_id, - CanvasFrame { - channel: PreviewFrameChannel::Canvas, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::Display(expected_device_id) => { - let frame = DisplayPreviewFrame::decode_bytes(&publication.encoded).ok()?; - if frame.device_id != *expected_device_id { - return None; - } - PreviewBinaryMessage::Display( - frame.device_id, - CanvasFrame { - channel: PreviewFrameChannel::Canvas, - frame_number: frame.frame_number, - timestamp_ms: frame.timestamp_ms, - width: frame.width, - height: frame.height, - format: frame.format, - payload: js_sys::Uint8Array::from(frame.payload.as_ref()), - }, - ) - } - PreviewStreamId::ScreenZones => { - PreviewBinaryMessage::ScreenZones(ScreenZonesFrame::decode(&publication.encoded).ok()?) - } - }; - preview_metadata_matches(&decoded, metadata).then_some(decoded) + let ReassembledPreviewBuffer { metadata, encoded } = publication; + let decoded = decode_direct_preview(encoded)?; + if !preview_metadata_matches(&decoded, &metadata) { + log::warn!("reassembled preview does not match its chunk metadata"); + return None; + } + Some(decoded) } fn preview_metadata_matches( From 3657a0256256be0167a1d7d3603907c0ed756f8c Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 01:03:39 -0700 Subject: [PATCH 06/10] fix(ui): start the ambient drift from the hue CSS already holds 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) --- crates/hypercolor-ui/src/ambient.rs | 51 ++++++++++----- crates/hypercolor-ui/src/components/shell.rs | 17 ++++- crates/hypercolor-ui/tests/ambient_tests.rs | 67 +++++++++++++------- crates/hypercolor-ui/tokens/semantic.css | 2 +- 4 files changed, 95 insertions(+), 42 deletions(-) diff --git a/crates/hypercolor-ui/src/ambient.rs b/crates/hypercolor-ui/src/ambient.rs index 084de0db2..7681ced38 100644 --- a/crates/hypercolor-ui/src/ambient.rs +++ b/crates/hypercolor-ui/src/ambient.rs @@ -4,8 +4,8 @@ //! on the document element, where every `--ambient-*` token resolves it. //! A write there restyles the whole document, so it happens on a slow, //! fixed cadence and only for a change the eye could see. CSS interpolates -//! between published values (`--ambient-hue-drift` in -//! `tokens/semantic.css`), so the tint still drifts instead of stepping. +//! between published values (`--ambient-hue-drift` in `input.css`), so the +//! tint still drifts instead of stepping. /// Least time between two published hues. /// @@ -22,46 +22,63 @@ pub const AMBIENT_PUBLISH_INTERVAL_MS: f64 = 1_000.0; /// publishing would restart the drift for nothing. pub const AMBIENT_HUE_DEADBAND_DEG: f64 = 4.0; +/// The hue `tokens/semantic.css` declares before anything is published. +pub const AMBIENT_HUE_FALLBACK_DEG: f64 = 320.0; + /// Paces `--ambient-hue` writes and keeps the published value continuous. /// /// Published values are unwrapped: a move from 350° to 10° publishes 370, /// not 10, so CSS interpolating the number turns 20° the short way rather -/// than 340° the long way. OKLCH reads any angle modulo 360. -#[derive(Debug, Clone, Copy, Default, PartialEq)] +/// than 340° the long way. OKLCH reads any angle modulo 360. The first +/// publish unwraps against the value CSS already holds for the same reason. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct AmbientHuePublisher { - published: Option, + published: f64, published_at_ms: f64, } +impl Default for AmbientHuePublisher { + fn default() -> Self { + Self::starting_at(AMBIENT_HUE_FALLBACK_DEG) + } +} + impl AmbientHuePublisher { + /// A publisher continuing from `hue_deg`, the value `--ambient-hue` + /// holds now. Its first move is not held back by the interval. + #[must_use] + pub fn starting_at(hue_deg: f64) -> Self { + Self { + published: if hue_deg.is_finite() { + hue_deg + } else { + AMBIENT_HUE_FALLBACK_DEG + }, + published_at_ms: f64::NEG_INFINITY, + } + } + /// Offer a freshly measured hue in degrees. Returns the value to write /// as `--ambient-hue`, or `None` when the tint should stay put. pub fn offer(&mut self, hue_deg: f64, now_ms: f64) -> Option { if !hue_deg.is_finite() || !now_ms.is_finite() { return None; } - let Some(published) = self.published else { - let first = hue_deg.rem_euclid(360.0); - self.published = Some(first); - self.published_at_ms = now_ms; - return Some(first); - }; if now_ms - self.published_at_ms < AMBIENT_PUBLISH_INTERVAL_MS { return None; } - let delta = shortest_hue_delta(published, hue_deg); + let delta = shortest_hue_delta(self.published, hue_deg); if delta.abs() < AMBIENT_HUE_DEADBAND_DEG { return None; } - let next = published + delta; - self.published = Some(next); + self.published += delta; self.published_at_ms = now_ms; - Some(next) + Some(self.published) } - /// The last value handed out, unwrapped. + /// The value `--ambient-hue` holds, unwrapped. #[must_use] - pub const fn published(&self) -> Option { + pub const fn published(&self) -> f64 { self.published } } diff --git a/crates/hypercolor-ui/src/components/shell.rs b/crates/hypercolor-ui/src/components/shell.rs index 7c151a881..8782a6aa0 100644 --- a/crates/hypercolor-ui/src/components/shell.rs +++ b/crates/hypercolor-ui/src/components/shell.rs @@ -33,6 +33,18 @@ fn targets_editable(ev: &leptos::ev::KeyboardEvent) -> bool { tag == "input" || tag == "textarea" || tag == "select" || el.is_content_editable() } +/// The hue a previous shell left on the document element, so a remounted +/// shell keeps unwrapping from it instead of from the CSS fallback. +fn published_ambient_hue() -> Option { + let root = browser_document()?.document_element()?; + let value = root + .dyn_ref::()? + .style() + .get_property_value("--ambient-hue") + .ok()?; + value.trim().parse().ok() +} + /// Focus the current page's search bar (the `/` kbd hint in `PageSearchBar`). fn focus_page_search() { if let Some(doc) = browser_document() @@ -57,7 +69,10 @@ pub fn Shell(children: Children) -> impl IntoView { // whole document, so `AmbientHuePublisher` paces it; CSS drifts the // halo between published values. let frame_analysis = use_context::(); - let ambient_publisher = StoredValue::new(AmbientHuePublisher::default()); + let ambient_publisher = StoredValue::new(published_ambient_hue().map_or_else( + AmbientHuePublisher::default, + AmbientHuePublisher::starting_at, + )); if let Some(frame_analysis) = frame_analysis { Effect::new(move |_| { diff --git a/crates/hypercolor-ui/tests/ambient_tests.rs b/crates/hypercolor-ui/tests/ambient_tests.rs index 8636a4c89..427bab872 100644 --- a/crates/hypercolor-ui/tests/ambient_tests.rs +++ b/crates/hypercolor-ui/tests/ambient_tests.rs @@ -1,57 +1,77 @@ use hypercolor_ui::ambient::{ - AMBIENT_HUE_DEADBAND_DEG, AMBIENT_PUBLISH_INTERVAL_MS, AmbientHuePublisher, shortest_hue_delta, + AMBIENT_HUE_DEADBAND_DEG, AMBIENT_HUE_FALLBACK_DEG, AMBIENT_PUBLISH_INTERVAL_MS, + AmbientHuePublisher, shortest_hue_delta, }; const LATER: f64 = AMBIENT_PUBLISH_INTERVAL_MS; #[test] -fn the_first_hue_publishes_at_once_normalized_into_the_wheel() { +fn the_first_hue_publishes_at_once_unwrapped_from_the_css_fallback() { let mut publisher = AmbientHuePublisher::default(); - assert_eq!(publisher.offer(-30.0, 0.0), Some(330.0)); - assert_eq!(publisher.published(), Some(330.0)); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); + // 320 to 10 is 50 degrees forward, not 310 back through green. + assert_eq!(publisher.offer(10.0, 0.0), Some(370.0)); } #[test] -fn a_change_inside_the_interval_waits_for_the_next_slot() { +fn a_first_hue_beside_the_fallback_leaves_the_css_alone() { let mut publisher = AmbientHuePublisher::default(); - publisher.offer(100.0, 0.0); + let near = AMBIENT_HUE_FALLBACK_DEG + AMBIENT_HUE_DEADBAND_DEG * 0.5; + assert_eq!(publisher.offer(near, 0.0), None); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); +} + +#[test] +fn a_publisher_resuming_from_an_unwrapped_value_keeps_turning_the_short_way() { + // A remounted shell picks up where `--ambient-hue` was left, laps and all. + let mut publisher = AmbientHuePublisher::starting_at(1_090.0); + assert_eq!(publisher.offer(20.0, 0.0), Some(1_100.0)); +} + +#[test] +fn a_non_finite_starting_value_falls_back() { + let publisher = AmbientHuePublisher::starting_at(f64::NAN); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); +} + +#[test] +fn a_change_inside_the_interval_waits_for_the_next_slot() { + let mut publisher = AmbientHuePublisher::starting_at(100.0); + assert_eq!(publisher.offer(120.0, 0.0), Some(120.0)); assert_eq!(publisher.offer(160.0, LATER - 1.0), None); assert_eq!(publisher.offer(160.0, LATER), Some(160.0)); } #[test] fn a_change_below_the_deadband_never_publishes() { - let mut publisher = AmbientHuePublisher::default(); - publisher.offer(100.0, 0.0); + let mut publisher = AmbientHuePublisher::starting_at(100.0); let jitter = AMBIENT_HUE_DEADBAND_DEG * 0.9; - assert_eq!(publisher.offer(100.0 + jitter, LATER), None); - assert_eq!(publisher.offer(100.0 - jitter, LATER * 2.0), None); - assert_eq!(publisher.published(), Some(100.0)); + assert_eq!(publisher.offer(100.0 + jitter, 0.0), None); + assert_eq!(publisher.offer(100.0 - jitter, LATER), None); + assert_eq!(publisher.published(), 100.0); } #[test] fn a_rejected_offer_does_not_restart_the_interval() { - let mut publisher = AmbientHuePublisher::default(); - publisher.offer(100.0, 0.0); - assert_eq!(publisher.offer(101.0, LATER), None); - assert_eq!(publisher.offer(150.0, LATER + 1.0), Some(150.0)); + let mut publisher = AmbientHuePublisher::starting_at(100.0); + assert_eq!(publisher.offer(140.0, 0.0), Some(140.0)); + assert_eq!(publisher.offer(141.0, LATER), None); + assert_eq!(publisher.offer(190.0, LATER + 1.0), Some(190.0)); } #[test] fn crossing_zero_publishes_an_unwrapped_value_the_short_way() { - let mut publisher = AmbientHuePublisher::default(); - publisher.offer(350.0, 0.0); - assert_eq!(publisher.offer(10.0, LATER), Some(370.0)); - assert_eq!(publisher.offer(340.0, LATER * 2.0), Some(340.0)); + let mut publisher = AmbientHuePublisher::starting_at(350.0); + assert_eq!(publisher.offer(10.0, 0.0), Some(370.0)); + assert_eq!(publisher.offer(340.0, LATER), Some(340.0)); } #[test] fn a_full_lap_keeps_climbing_instead_of_snapping_back() { - let mut publisher = AmbientHuePublisher::default(); + let mut publisher = AmbientHuePublisher::starting_at(0.0); let mut now = 0.0; - let mut last = publisher.offer(0.0, now).expect("first publish"); + let mut last = publisher.published(); for step in 1..=12 { - now += LATER; let hue = f64::from(step * 30 % 360); let next = publisher .offer(hue, now) @@ -61,6 +81,7 @@ fn a_full_lap_keeps_climbing_instead_of_snapping_back() { "step {step}: {last} -> {next}" ); last = next; + now += LATER; } assert!((last - 360.0).abs() < 1e-9); } @@ -70,7 +91,7 @@ fn non_finite_input_is_ignored() { let mut publisher = AmbientHuePublisher::default(); assert_eq!(publisher.offer(f64::NAN, 0.0), None); assert_eq!(publisher.offer(120.0, f64::INFINITY), None); - assert_eq!(publisher.published(), None); + assert_eq!(publisher.published(), AMBIENT_HUE_FALLBACK_DEG); } #[test] diff --git a/crates/hypercolor-ui/tokens/semantic.css b/crates/hypercolor-ui/tokens/semantic.css index 6f1ea22be..81184309f 100644 --- a/crates/hypercolor-ui/tokens/semantic.css +++ b/crates/hypercolor-ui/tokens/semantic.css @@ -42,7 +42,7 @@ --accent-muted: oklch(0.65 0.30 320 / 0.12); --accent-subtle: oklch(0.65 0.30 320 / 0.06); - /* Ambient (dynamic — --ambient-hue set from Leptos). The colors are + /* Ambient (dynamic: --ambient-hue is set from Leptos). The colors are built from channel tokens so a consumer that drifts its own copy of the hue (.preview-glow) can rebuild them; a token declared here has already substituted the root hue. Light mode overrides the channels. */ From 9dc7dd32179a11a551ca6e4f0619f165a6a318a0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 01:08:00 -0700 Subject: [PATCH 07/10] fix(ui): resubscribe preview streams after a socket reconnect 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) --- crates/hypercolor-ui/src/ws/connection.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index 9a7188caa..1374bb924 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -713,9 +713,16 @@ impl WsManager { connect.set_value(Some(connect_fn)); - // Preview subscription effect — reacts to FPS cap / visibility changes + // Preview subscription effect — reacts to FPS cap / visibility changes. + // + // Every preview effect waits for `Connected`. A socket that is still + // opening rejects sends, and a request recorded as sent but never + // delivered would match every later rerun and never be retried, + // leaving the stream dark after a reconnect. The connect and close + // paths clear the recorded requests, and the move to `Connected` + // reruns these effects against the open socket. Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); @@ -726,8 +733,8 @@ impl WsManager { let window_visible = app_window_visible.get(); if !should_stream_preview(is_visible, window_visible, engine_target, consumer_count) { // Only a requested stream has anything to unsubscribe. The - // effect reruns on every engine target report, so an - // unguarded branch resent the unsubscribe twice a second. + // effect reruns on every engine target report, about twice + // a second, and each rerun would otherwise send another. if requested_preview.get_value().is_some() && let Some(ws) = ws_handle.get_value() { @@ -756,7 +763,7 @@ impl WsManager { }); Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); @@ -811,7 +818,7 @@ impl WsManager { }); Effect::new(move |_| { - if !frame_topics { + if !frame_topics || connection_state.get() != ConnectionState::Connected { return; } let engine_target = engine_preview_target.get(); From 38fbc9ca5dd703e36a18ff37197168e93646ebcd Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 01:08:00 -0700 Subject: [PATCH 08/10] fix(ui): log reassembled previews that fail to decode 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) --- crates/hypercolor-ui/src/ws/messages.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-ui/src/ws/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index e1a112266..20b3d5c34 100644 --- a/crates/hypercolor-ui/src/ws/messages.rs +++ b/crates/hypercolor-ui/src/ws/messages.rs @@ -686,7 +686,10 @@ fn decode_reassembled_preview( publication: ReassembledPreviewBuffer, ) -> Option { let ReassembledPreviewBuffer { metadata, encoded } = publication; - let decoded = decode_direct_preview(encoded)?; + let Some(decoded) = decode_direct_preview(encoded) else { + log::warn!("rejected reassembled preview publication: frame does not decode"); + return None; + }; if !preview_metadata_matches(&decoded, &metadata) { log::warn!("reassembled preview does not match its chunk metadata"); return None; From e185636d2f8217b6d1c0b14667d9b4ce6c747b3e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 01:08:29 -0700 Subject: [PATCH 09/10] fix(ws): budget reassembly state the same for every storage 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) --- crates/hypercolor-leptos-ext/src/ws/preview.rs | 17 ++++++++++------- .../tests/browser_preview_reassembly_tests.rs | 2 ++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/hypercolor-leptos-ext/src/ws/preview.rs b/crates/hypercolor-leptos-ext/src/ws/preview.rs index 194e51f49..095b88271 100644 --- a/crates/hypercolor-leptos-ext/src/ws/preview.rs +++ b/crates/hypercolor-leptos-ext/src/ws/preview.rs @@ -1508,7 +1508,7 @@ impl PreviewChunkReassembler { .streams .get(stream) .is_some_and(|state| state.partial.is_some()); - let stream_state_bytes = preview_stream_state_bytes::(stream)?; + let stream_state_bytes = preview_stream_state_bytes(stream)?; let existing_is_tombstone = self .streams .get(stream) @@ -1712,7 +1712,7 @@ impl PreviewChunkReassembler { if let Some(partial) = state.partial.take() { expired_bytes = expired_bytes.saturating_add(partial.total_encoded_bytes); expired_state_bytes = expired_state_bytes - .saturating_add(preview_stream_state_bytes::(stream).unwrap_or(usize::MAX)); + .saturating_add(preview_stream_state_bytes(stream).unwrap_or(usize::MAX)); expired_publications = expired_publications.saturating_add(1); } state.tombstone_generation = self.next_tombstone_generation; @@ -1830,7 +1830,7 @@ impl PreviewChunkReassembler { } fn mark_tombstone(&mut self, stream: &PreviewStreamId, was_active: bool) { - let stream_state_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); + let stream_state_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); let is_new_tombstone = self .streams .get(stream) @@ -1881,7 +1881,7 @@ impl PreviewChunkReassembler { && state.tombstone_generation == oldest_generation; removed |= evict; if evict { - removed_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); + removed_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); } !evict }); @@ -1918,7 +1918,7 @@ impl PreviewChunkReassembler { && state.tombstone_generation == oldest_generation; removed |= evict; if evict { - removed_bytes = preview_stream_state_bytes::(stream).unwrap_or(usize::MAX); + removed_bytes = preview_stream_state_bytes(stream).unwrap_or(usize::MAX); } !evict }); @@ -1931,8 +1931,11 @@ impl PreviewChunkReassembler { } } -fn preview_stream_state_bytes(stream: &PreviewStreamId) -> Result { - std::mem::size_of::<(Arc, PreviewStreamState)>() +/// Budgeted size of one stream's state. Measured with the native storage +/// for every buffer type, so a browser client admits exactly the streams +/// a native one does. +fn preview_stream_state_bytes(stream: &PreviewStreamId) -> Result { + std::mem::size_of::<(Arc, PreviewStreamState>)>() .checked_add(std::mem::size_of::<(usize, usize)>()) .and_then(|bytes| bytes.checked_add(std::mem::size_of::())) .and_then(|bytes| bytes.checked_add(stream.identity_bytes())) diff --git a/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs index 359eecdfc..e897f8f71 100644 --- a/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs +++ b/crates/hypercolor-leptos-ext/tests/browser_preview_reassembly_tests.rs @@ -41,6 +41,8 @@ impl Pair { assert_eq!(self.native.partial_count(), self.js.partial_count()); assert_eq!(self.native.reserved_bytes(), self.js.reserved_bytes()); assert_eq!(self.native.tombstone_count(), self.js.tombstone_count()); + assert_eq!(self.native.state_bytes(), self.js.state_bytes()); + assert_eq!(self.native.tombstone_bytes(), self.js.tombstone_bytes()); assert_eq!( self.native.superseded_publications(), self.js.superseded_publications() From 422fab9abf551838f6d61afaa9532871880d4e91 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 20 Oct 2026 01:23:02 -0700 Subject: [PATCH 10/10] test(ui): pin the ambient fallback to the CSS it unwraps against 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) --- crates/hypercolor-ui/src/ws/connection.rs | 2 +- crates/hypercolor-ui/tests/ambient_tests.rs | 27 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index 1374bb924..2b069208c 100644 --- a/crates/hypercolor-ui/src/ws/connection.rs +++ b/crates/hypercolor-ui/src/ws/connection.rs @@ -713,7 +713,7 @@ impl WsManager { connect.set_value(Some(connect_fn)); - // Preview subscription effect — reacts to FPS cap / visibility changes. + // Preview subscription effect: reacts to FPS cap and visibility changes. // // Every preview effect waits for `Connected`. A socket that is still // opening rejects sends, and a request recorded as sent but never diff --git a/crates/hypercolor-ui/tests/ambient_tests.rs b/crates/hypercolor-ui/tests/ambient_tests.rs index 427bab872..6088e3e4f 100644 --- a/crates/hypercolor-ui/tests/ambient_tests.rs +++ b/crates/hypercolor-ui/tests/ambient_tests.rs @@ -101,3 +101,30 @@ fn shortest_delta_takes_the_shorter_arc() { assert!((shortest_hue_delta(370.0, 20.0) - 10.0).abs() < 1e-9); assert!((shortest_hue_delta(0.0, 180.0) - 180.0).abs() < 1e-9); } + +/// The fallback is the hue CSS holds before the first publish. If the token +/// or the drift property's initial value moved without it, the first publish +/// would unwrap against the wrong angle and could sweep the long way. +#[test] +fn the_fallback_matches_the_css_it_unwraps_against() { + let read = |path: &str| { + std::fs::read_to_string(format!("{}/{path}", env!("CARGO_MANIFEST_DIR"))) + .unwrap_or_else(|error| panic!("read {path}: {error}")) + }; + let declared = |css: &str, marker: &str| -> f64 { + let start = css.find(marker).expect("marker present") + marker.len(); + let value: String = css[start..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + value.parse().expect("numeric value") + }; + let tokens = read("tokens/semantic.css"); + let input = read("input.css"); + let drift = &input[input + .find("@property --ambient-hue-drift") + .expect("drift property registered")..]; + assert!((declared(&tokens, "--ambient-hue:") - AMBIENT_HUE_FALLBACK_DEG).abs() < f64::EPSILON); + assert!((declared(drift, "initial-value:") - AMBIENT_HUE_FALLBACK_DEG).abs() < f64::EPSILON); +}