Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .agents/skills/leptos-ui-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -407,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 |

Expand All @@ -431,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsPreviewPublicationBuffer>`: 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<u8>` 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).

Expand Down
46 changes: 23 additions & 23 deletions crates/hypercolor-leptos-ext/src/raf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ pub struct Scheduler {

struct SchedulerInner {
window: web_sys::Window,
callback: RefCell<Option<Closure<dyn FnMut(f64)>>>,
/// 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<dyn FnMut(f64)>,
pending_frame: Cell<Option<i32>>,
active: Cell<bool>,
user: RefCell<Box<dyn FnMut(FrameInfo)>>,
Expand Down Expand Up @@ -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<SchedulerInner>| {
let weak = Weak::clone(weak);
SchedulerInner {
window,
callback: Closure::<dyn FnMut(f64)>::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),
}
}),
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
}

Expand All @@ -120,27 +131,18 @@ fn request_next_frame(inner: &Rc<SchedulerInner>) {
return;
}

let weak = Rc::downgrade(inner);
let callback = Closure::<dyn FnMut(f64)>::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);
}
}

fn on_animation_frame(inner: &Rc<SchedulerInner>, time_ms: f64) {
inner.pending_frame.take();

if !inner.active.get() {
inner.callback.borrow_mut().take();
return;
}

Expand All @@ -164,8 +166,6 @@ fn on_animation_frame(inner: &Rc<SchedulerInner>, time_ms: f64) {
inner.mode.set(Mode::Idle);
}

inner.callback.borrow_mut().take();

if keep_running {
request_next_frame(inner);
}
Expand Down
23 changes: 13 additions & 10 deletions crates/hypercolor-leptos-ext/src/ws/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading
Loading