leptos-ui-development · diff
v1.0.0 to v1.0.0
350 added, 68 removed. Audit A to A.
---
name: leptos-ui-development
version: 1.0.0
description: >-
This skill should be used when working on the Hypercolor web UI in
crates/hypercolor-ui/. Triggers on "UI component", "Leptos signal", "WASM
build", "Trunk build", "WebSocket frame", "canvas preview", "effect card",
"control panel", "device page", "SilkCircuit token", "theme switching",
"sidebar", "layout builder", "Leptos context", "web-sys binding", "UI
state", "optimistic update", "WebGL texture", "toast notification",
"command palette", "color wheel", "device pairing", "leptoaster", or any
work in crates/hypercolor-ui/.
---
# Hypercolor UI Development
The UI is a **Leptos 0.8 CSR** app compiled to WASM via **Trunk**, excluded from the Cargo workspace. `cargo check --workspace` does NOT cover it — always build/check separately.
## Build Pipeline
```bash
just ui-dev # Dev server on :9430, proxies API to daemon on :9420
cd crates/hypercolor-ui && trunk build # One-shot build
```
Trunk pre-build hook runs Tailwind CSS compilation. Config in `Trunk.toml` — proxies `/api` to `127.0.0.1:9420`.
+ ## Verification
+
+ `cargo check -p hypercolor-ui --lib` is the check that covers the app.
+ Everything lives in the lib target: `src/lib.rs` declares 46 `pub mod`s, and
+ `src/main.rs` is a three-line shim that calls `hypercolor_ui::run()`. The bin
+ is the hollow half.
+
+ Do not run cargo in this crate while a Trunk dev server is serving. The two
+ fight over the target directory, and the usual symptom is a burst of phantom
+ "missing field" or "no such variant" errors from stale rmeta. Stop Trunk, or
+ wait for it.
+
+ That lib-first layout exists so integration tests under `tests/` reach every
+ module as `hypercolor_ui::...`. No test file uses a `#[path]` source include,
+ so write new tests against `hypercolor_ui::...` rather than reaching into
+ `../src/`. Leptos-free logic modules are exercised that way without a WASM
+ toolchain. Two `#[path]` attributes do remain in `src/layout_geometry.rs`, but
+ those are ordinary submodule layout, not test plumbing, and several module doc
+ comments still describe the older `#[path]`-include pattern that the lib-first
+ move retired. Trust `lib.rs` and `tests/`, not those comments. Visual verification goes through `agent-browser` against
+ `docs/DESIGN-SYSTEM.md`.
+
## Global Context Architecture
- Five context structs provided at app root, accessed via `expect_context::<T>()`:
+ Contexts are provided at the app root (`src/app.rs`) and by `crate::zones`,
+ and read with `expect_context::<T>()`. The ones you will actually reach for:
- | Context | Provides | Key Signals |
- | ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
- | `WsContext` | WebSocket state | `canvas_frame`, `connection_state`, `preview_fps`, `preview_target_fps`, `set_preview_cap`, `set_preview_consumers`, `metrics`, `backpressure_notice`, `active_effect`, `last_device_event`, `audio_level`, `audio_enabled`, `set_audio_enabled` |
- | `EffectsContext` | Effect library + active state | `effects_index` (Memo), `active_effect_id`, `active_effect_name`, `active_effect_category`, `active_controls`, `active_control_values`, `active_preset_id`, `favorite_ids` (each with read/write pair) |
- | `DevicesContext` | Device + layout resources | `devices_resource`, `layouts_resource` (both LocalResource with refetch) |
- | `ThemeContext` | Theme state | `is_dark` (Memo<bool>), `toggle` (Callback<()>) |
- | `PaletteContext` | Command palette trigger | `open` (Callback<()>) |
+ | Context | Provides | Notable members |
+ | ----------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `WsContext` | Everything streamed off the daemon socket | `canvas_frame`, `connection_generation`, the `last_*_event` hint family, `metrics`, `sensors`, `layer_health`, `output_paused`, `set_preview_consumers` |
+ | `ZonesContext` | The active scene's zones | `active_scene`, `zones`, `led_zones`, `multi_zone`, `focused_zone`, `refresh` |
+ | `ScenesContext` | Saved scenes and activation | `scenes`, `active`, `switching`, `activate`, `deactivate`, `refresh` |
+ | `EffectsContext` | Effect library plus active-effect state | `effects_index` (Memo, no setter), `zone_effects`, `focused_zone_effect`, `apply_target`, `scene_refresh`, and read/write pairs for the `active_*` signals |
+ | `CapabilitiesContext` | Daemon-advertised capability names | `has(name)`, `zone_crud_ready()` |
+ | `DevicesContext` | Device and layout resources | `devices_resource`, `layouts_resource` |
+ | `DisplaysContext` | Display resources | `displays_resource` |
+ | `ConfigContext` | Daemon config mirror | `config`, `refresh`, `audio_enabled` (Memo) |
+ | `ConfigSchemaContext` | The config key registry | `entries` |
+ | `FrameAnalysisContext` | Shared canvas analysis pass | `live_canvas` (dominant hue, palette) |
+ | `StudioContext` | Studio page state | `selected_surface_id`, `composition_open`, `selected_output_ids`, `hidden_outputs` |
+ | `PreviewTelemetryContext` | Preview delivery telemetry | `presenter`, `set_presenter` |
- ## WebSocket Binary Protocol
+ Sub-tree contexts are provided lower down and are only visible inside their
+ own subtree: `LayoutEditorContext`, `LayoutZoneDisplayContext`, and
+ `ZoneCanvasActions` in the layout builder, plus the extension slots
+ (`NavExtensionItems`, `SettingsExtensionSections`, `SidebarExtensionWidgets`,
+ `UiChromeFlags`), which are empty in the standalone OSS app.
- WsManager (`src/ws.rs`) handles the daemon connection:
+ There is no `ThemeContext` and no `PaletteContext`. Theme has no runtime
+ toggle at all (see Design System below), and the command palette is local
+ state inside `components/shell.rs`.
- - **Binary frames**: Header byte `0x03` = canvas frame data
- - **JSON messages**: Events, metrics, audio state
- - **Subscribe on connect**: `{ "type": "subscribe", "topics": [{ "topic": "events" }, { "topic": "metrics" }] }`
- - **Reconnection**: Exponential backoff 500ms → 15s
+ ## Capability Gating
- ### CanvasFrame Structure
+ Multi-zone Studio affordances gate on capability names the daemon advertises
+ through its authenticated `/api/v1/system` status. **There is no probe
+ fallback**: an absent advertisement means the affordance stays hidden, with no
+ error and no explanation anywhere in the UI. If a control you built never
+ appears, check the capability before you check your rendering.
```rust
- pub struct CanvasFrame {
- pub frame_number: u32,
- pub timestamp_ms: u32,
- pub width: u32,
- pub height: u32,
- format: CanvasPixelFormat, // Rgb | Rgba
- pixels: js_sys::Uint8Array, // Direct WASM heap view
- }
+ let caps = expect_context::<CapabilitiesContext>();
+ if caps.zone_crud_ready() { /* + New zone, zone rows */ }
```
- `rgba_at(pixel_index)` samples zero-copy. Used by shell for dominant hue extraction (circular mean with sin/cos for hue wraparound), sidebar for vibrant palette, and canvas_preview for WebGL texture.
+ `zone_crud_ready()` is the conjunction of `zone-crud`, `multi-zone-sampling`,
+ and `zone-device-assignment`, because a user who can create a zone but cannot
+ render it or move outputs into it has an unusable zone. The fourth name in use
+ is `scene-unassigned-behavior-write`.
- ## Critical Pattern: Event-Based Refetch
+ ## Scene, Zone, and Layer Resource Model
- **Never use timer-based polling for device lists** — it causes flickering. Instead, watch WebSocket device events:
+ Post-#216 this is most of what the UI does. One live scene tree hangs off
+ `/api/v1/scene`, and everything the user composes lives inside it.
+ ```
+ SceneDocument
+ └─ zones (LED zones and display Screens, in scene order)
+ └─ layers (ordered stack; the top layer is the tile caption)
+ └─ controls (patched per layer)
+ ```
+
+ `crate::zones` owns the shared resources and publishes `ZonesContext` and
+ `ScenesContext`. Never fetch `/api/v1/scene` yourself from a component: read
+ `active_scene` off the context and call `refresh` after a mutation.
+
+ The UI's presentation type for a zone is `zones::Surface` (`src/zones/surface.rs`):
+ `id`, `name`, `kind` (`SurfaceKind::Light` for LED zones, `Screen` for display
+ faces), `enabled`, `role` (`Primary` is the permanent Default zone, `Custom` is
+ deletable), `color`, `display_device_id`, `layer_ids`, `top_layer`.
+ `ZoneEffectState` (`src/zones.rs:141`) pairs a `Surface` with what it is showing:
+ `effect_id`, `effect_name`, `effect_category`, `control_values`, `preset_id`,
+ and the scene `revision` those values were read at.
+
+ `EffectsContext::zone_effects` is the multi-zone now-playing source of truth,
+ in scene order. The singular `active_*` signals mirror the primary zone only,
+ so anything zone-aware reads `zone_effects` or `focused_zone_effect`.
+
+ The API modules (`src/api/`, sixteen alongside `mod.rs`) map onto the tree:
+
+ | Module | Surface |
+ | ----------- | -------------------------------------------------------------------------- |
+ | `scenes.rs` | `/api/v1/scene`, `/api/v1/scene/deactivate`, `/api/v1/scenes` CRUD + activate |
+ | `zones.rs` | `/api/v1/scene/zones` create, update, delete, `/layout`, `/members` |
+ | `layers.rs` | `/api/v1/scene/zones/{zone}/layers` CRUD, `/order`, `/controls` |
+ | `effects.rs`| Effect catalog and `EffectLayerTarget { effect_id, zone_id, layer_id }` |
+ | `controls.rs`, `output.rs`, `assets.rs`, `displays.rs`, `drivers.rs`, `device_metrics.rs`, `devices.rs`, `layouts.rs`, `library.rs`, `config.rs`, `system.rs` | the rest, all over `client.rs` |
+
+ Mutations carry `expected_revision` for optimistic concurrency. Control writes
+ go through `api::patch_layer_controls(zone_id, layer_id, &controls)`, which
+ sends `PatchControlsRequest { values, clear_bindings }`. Control values
+ serialize as the canonical adjacently-tagged form:
+
+ ```json
+ { "values": { "speed": { "kind": "float", "value": 0.5 } }, "clear_bindings": [] }
+ ```
+
+ The deserializer denies unknown fields, so a bare `{"speed": 0.5}` or a
+ `{"speed": {"float": 0.5}}` shorthand is a hard parse failure on this route.
+ `patch_layer_controls` handles one retry itself: when the daemon rejects a
+ write because the control is bound, it resends the same patch with those keys
+ in `clear_bindings`, so removing the binding and applying the value land in one
+ atomic commit.
+
+ Studio lives in `src/pages/studio/` (10 modules: `mod`, `stage`, `zone_tree`,
+ `zone_controls`, `zone_add_device`, `device_card`, `device_assignment`,
+ `composition_panel`, `face_composition`, `scene_selector`).
+
+ ## Never Poll: Events, Hints, and Epochs
+
+ A timer-driven fetch loop anywhere in the UI is a fatal defect. Freshness comes
+ from two mechanisms, and non-trivial resources need both.
+
+ **1. Event hints.** The daemon relays every `HypercolorBus` event to `events`
+ subscribers, and `WsContext` exposes the latest of each kind as a signal:
+ `last_device_event`, `last_scene_event`, `last_effect_error`,
+ `last_control_surface_event`, `last_extension_event`,
+ `last_input_source_status_event`, `last_service_identity_event`. Read one
+ inside a fetcher or an `Effect` to make a resource live.
+
+ **2. Connection epochs.** Events are not replayed across a socket gap, so a
+ resource that only watches hints goes stale after a reconnect.
+ `WsContext::connection_generation` increments on every socket open. Fold it
+ into the fetcher and the resource refetches after every reconnect.
+
+ `api::daemon_resource(fetcher)` (`src/api/mod.rs:31`) does the epoch folding
+ for you, and is the default way to create a resource:
+
```rust
- Effect::new(move |_| {
- let Some(event) = ws_ctx.last_device_event.get() else { return; };
- let should_refetch = match event.event_type.as_str() {
- "device_connected" | "device_discovered" => !already_known,
- "device_disconnected" => is_known,
- _ => false,
- };
- if should_refetch { devices_resource.refetch(); }
+ let status_resource = api::daemon_resource(api::fetch_status);
+
+ // Reactive inputs are read in the sync part of the closure; the async block
+ // owns them. Reading a signal inside the future would not register it.
+ let layers_resource = api::daemon_resource(move || {
+ let zone_id = selected_surface_id.get();
+ async move {
+ match zone_id {
+ Some(zone_id) => api::list_layers(&zone_id).await,
+ None => Ok(empty_layer_stack()),
+ }
+ }
});
```
- Only refetch when state _actually changed_ — not on every event.
+ Layer the hint on top when the data also changes mid-connection. The scene
+ resources in `src/zones.rs` are the reference implementation, and they show the
+ discipline that matters: refetch only when state *actually* changed.
+ ```rust
+ Effect::new(move |previous: Option<Option<SceneEventHint>>| {
+ let current = last_scene_event.get();
+ if previous.as_ref() == Some(¤t) { return current; }
+ let Some(hint) = current.as_ref() else { return current; };
+
+ // Control patches arrive at slider-drag rate and change no structure.
+ let controls_only = hint.event_type == "zone_changed"
+ && hint.zone_change_kind == Some(ZoneChangeKind::ControlsPatched);
+ if !controls_only { active_scene_resource.refetch(); }
+ if matches!(hint.event_type.as_str(),
+ "active_scene_changed" | "scene_library_changed") {
+ scenes_resource.refetch();
+ }
+ current
+ });
+ ```
+
+ For devices, `should_refetch_devices_for_event(event_type, device_id, found_count, current_device_ids)`
+ in `src/device_event_logic.rs` is the tested predicate. Call it rather than
+ rewriting the match: it already handles `device_state_changed` and the
+ `device_discovery_completed` case where the list is empty but the scan found
+ something.
+
+ One-shot handshakes with server-driven pacing (device-authorization login) are
+ protocol, not polling.
+
+ ## WebSocket Layer
+
+ `src/ws/` is a directory: `connection.rs` (the socket and `WsManager`, at
+ `connection.rs:120`), `messages.rs` (JSON and binary dispatch), `preview.rs`
+ (preview subscriptions and FPS caps), `input.rs`, `interactive_preview.rs`.
+
+ - **Subprotocol**: always open the socket with `HYPERCOLOR_WS_PROTOCOL`
+ (`"hypercolor-v1"`). The daemon echoes it back through
+ `ws.protocols([HYPERCOLOR_WS_PROTOCOL])`, and it is what the manifest
+ advertises, but it is not an upgrade gate: the only rejection in
+ `ws_handler` is the Origin check. A verified session token rides as a
+ `?token=` query parameter.
+ - **Binary type**: `ArrayBuffer`, required for preview frames.
+ - **Initial subscribe**: three topics, one with a config.
+ `{"type":"subscribe","topics":[{"topic":"events"},{"topic":"metrics","config":{"fps":2.0}},{"topic":"sensors"}]}`
+ Preview topics are subscribed on demand, never at connect.
+ - **Binary frames**: preview transport v2 has several envelopes. Never parse
+ them by hand; decode through `PreviewBinaryDecoder`. See
+ `references/websocket-protocol.md`.
+ - **Reconnection**: exponential backoff 500ms to 15s.
+
+ ### CanvasFrame Structure
+
+ `CanvasFrame` is an alias for `hypercolor_leptos_ext::ws::PreviewFrameView`
+ (`src/ws/messages.rs:19-21`), not a UI-local struct:
+
+ ```rust
+ pub struct PreviewFrameView {
+ pub channel: PreviewFrameChannel,
+ pub frame_number: u32,
+ pub timestamp_ms: u32,
+ pub width: u32,
+ pub height: u32,
+ pub format: PreviewPixelFormat, // Rgb | Rgba | Jpeg
+ pub payload: js_sys::Uint8Array, // Direct WASM heap view
+ }
+ ```
+
+ **`Jpeg` is load-bearing.** Display previews arrive JPEG-encoded, and
+ `rgba_at(pixel_index)` returns `None` for that format because there are no raw
+ pixels to read. Any consumer doing pixel math has to handle it. For `Rgb` and
+ `Rgba`, `rgba_at` samples zero-copy off the heap view; the shared frame-analysis
+ pass uses it for dominant hue, and the preview runtimes upload the payload as a
+ texture.
+
## Critical Pattern: Optimistic Update with Rollback
Effect switching and favorites use capture/restore for error recovery:
```rust
let previous = capture_active_effect_state(&ctx); // snapshot
// ... apply effect via API ...
if api_call.is_err() {
restore_active_effect_state(&ctx, previous); // rollback
}
```
+ The snapshot carries `target: Option<EffectLayerTarget>` alongside id, name,
+ category, controls, control values, and preset id. Since #216 layer ids are
+ real server ids, so a snapshot that drops `target` restores the effect into the
+ wrong zone. Keep the field.
+
## Critical Pattern: Control Panel Memo
The control panel groups controls for rendering. **Memoize structure, not values:**
```rust
let grouped = Memo::new(move |_| {
let defs = control_definitions.get(); // reads definitions
// Group by definition structure — NOT by control_values
// Reading values here would teardown/rebuild entire widget tree on every slider move
});
```
## Serde Gotcha
- `#[serde(default)]` does NOT handle unknown enum variants. When the daemon adds a new variant the UI doesn't know about, deserialization fails. Use `#[serde(other)]` on a fallback variant:
+ `#[serde(default)]` does NOT handle unknown enum variants: when a producer adds
+ a variant the UI doesn't know about, deserialization fails outright. Use
+ `#[serde(other)]` on a fallback variant:
```rust
#[derive(Deserialize)]
- #[serde(rename_all = "snake_case")]
- enum EffectCategory {
- Ambient,
- Audio,
- Generative,
+ #[serde(tag = "kind", rename_all = "snake_case")]
+ pub enum MacosOwnerRemedy {
+ StopStandaloneOwner { pid: u32 },
+ StartAppSidecar,
#[serde(other)]
- Unknown, // catches future additions without breaking
+ Unknown,
}
```
+ The real uses are in `src/tauri_bridge.rs`, where the producer is the native
+ app shell rather than the daemon. **This does not apply to daemon types.**
+ Effect categories, control values, scene documents, and every other shared
+ contract come from `hypercolor-types`, compiled into both sides, so the UI
+ cannot skew from the daemon's definition. Never hand-mirror a daemon enum in
+ the UI just to add a fallback variant.
+
## Design System (Luminary)
**`docs/DESIGN-SYSTEM.md` is the canonical style guide.** Any UI design work (tokens, color, typography, surfaces, motion, glass, ambient reactivity, components) follows it. Consult it before styling a surface; its §14 ("Working in Luminary") is the rules checklist.
+ The design language is **Luminary**. SilkCircuit is the accent palette inside
+ it, not a synonym for it. The rules that get broken most often (§4):
+
+ - **Electric purple is the only accent in UI chrome.** Buttons, toggles, active
+ states, slider thumbs, focus partners: all purple. Reaching for cyan or coral
+ to decorate a button is a category or status signal leaking into chrome.
+ - **Category color is identity only.** `category_style()` in `src/style_utils.rs`
+ is the single source of truth for the category-to-accent map. Add a category
+ there and nowhere else.
+ - **Colored glows go through `--glow-rgb`**, set by an `.accent-*` class or
+ inline from a `category_style()` triplet. Never spray raw `rgba()`.
+ - **Semantic tokens only.** No `oklch(...)` or hex in component CSS or Leptos
+ `style` attributes.
+ - **Both themes, every time.** Verify `[data-theme="light"]` before a surface is
+ done.
+
Token files in `tokens/` (DESIGN-SYSTEM.md §3 has the full architecture):
- **`primitives.css`**: Tier 1, raw OKLCH values via `@theme` (void scale for dark surfaces, cloud scale for light, SilkCircuit palette). Auto-generates Tailwind utilities.
- **`semantic.css`**: Tier 2, intent-mapped tokens (`--surface-base`, `--text-primary`, `--border-focus`) that swap between `:root` (dark) and `[data-theme="light"]`.
- **Dynamic ambient glow**: Components set `--ambient-hue` from live canvas frame pixel data; CSS derives reactive glow tokens from it.
+ **Dynamic ambient glow**: `components/shell.rs` watches
+ `FrameAnalysisContext::live_canvas` and writes `--ambient-hue` onto the
+ **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.
- **Theme switching**: Stored in localStorage as `hc-theme`, restored _before first paint_ (inline `index.html` script) to prevent flash.
+ **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
+ toggle in Rust.
## API Client Pattern
- All API calls use `gloo_net::http::Request` with a standard envelope:
+ `src/api/client.rs` owns transport. It unwraps the canonical `ApiResponse<T>`
+ envelope exactly once, in `parse_envelope` (`client.rs:401`), and exposes typed
+ helpers that domain modules call. There is no `ApiEnvelope` type, and domain
+ modules do not build `gloo_net` requests by hand:
```rust
- let ApiEnvelope { data } = resp.json::<ApiEnvelope<T>>().await?;
+ let list: EffectListResponse = client::fetch_json("/api/v1/effects").await?;
+ let effects: Vec<EffectSummary> = list.items;
+ let layouts: Vec<LayoutSummary> = client::fetch_all_pages("/api/v1/layouts").await?;
+ client::patch_json_discard(&url, &request).await?;
```
+ `fetch_json` deserializes whatever the envelope's `data` actually is, so a list
+ route needs the `ListResponse<T>` alias, not a bare `Vec<T>`. `api/effects.rs`
+ fetches `EffectListResponse` (`= ListResponse<EffectSummary>`) and then takes
+ `.items`; `api/assets.rs` fetches `AssetListResponse`
+ (`= ListResponse<MediaAssetRecord>`) and returns the whole envelope to its
+ caller. `fetch_all_pages` is the other half of the
+ pair: it walks the `page` block and hands back a flat `Vec<T>`, which is how
+ `api/layouts.rs` and `api/devices.rs` read their collections.
+
+ The helper set is `fetch_json`, `fetch_json_optional`, `fetch_all_pages`,
+ `post_json`, `patch_json`, `put_json`, `delete_json`, the `_discard` and
+ `_empty` variants, and `send_json_versioned` for revision-checked mutations.
+ Errors come back as `ApiError`; `ApiResult<T>` is the alias.
+
Async operations run via `leptos::task::spawn_local()` — no threading in WASM.
- ## Canvas Preview (WebGL)
+ ## Canvas Preview
- `src/components/canvas_preview.rs` uploads canvas frames as WebGL textures:
+ `src/components/canvas_preview.rs` drives a `PreviewRuntime`
+ (`src/components/preview_runtime/`) with three backends and automatic
+ degradation, not WebGL alone:
- - Texture reused across frames; reinit only if dimensions change
- - No pixel buffer copy — uses `Uint8Array` view of WASM heap
- - Demand-driven streaming: components register/unregister via `set_preview_consumers` (WsContext). Canvas subscription is active only when consumer count > 0
- - Default FPS cap is 30 (`DEFAULT_PREVIEW_FPS_CAP`). Hidden tab auto-reduces to 6 (`HIDDEN_TAB_PREVIEW_FPS_CAP`) via `document.visibilitychange`
- - `set_preview_cap` (WsContext) lets pages override the client-side FPS ceiling; actual target = min(engine target, client cap, transport cap)
- - Listens to `backpressure_notice` to auto-reduce transport FPS cap
- - Does NOT use `Portal` — Portal is used by `control_panel.rs` (color picker popovers) and `component_picker.rs`, not canvas preview
+ - **Worker**: an OffscreenCanvas bitmap worker, used for JPEG frames and as the
+ first fallback when a WebGL context is unavailable
+ - **WebGl**: the default for `Rgb`/`Rgba` frames. Texture reused across frames,
+ reinit only if dimensions change, no pixel buffer copy
+ - **Canvas2d**: last resort, gated behind `CANVAS2D_FALLBACK_THRESHOLD = 3`
+ consecutive WebGL-unavailable attempts, so a transient context loss does not
+ permanently demote the surface
+ The component listens for `webglcontextlost` and `webglcontextrestored` and
+ reinitializes the runtime on restore.
+
+ Streaming is demand-driven: components register and unregister through
+ `set_preview_consumers` (WsContext), and the canvas subscription is active only
+ 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`):
+
+ | 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 |
+
+ These are product ceilings. Do not lower one to make a metric look better; fix
+ the cost instead. `set_preview_cap` (WsContext) lets a page lower its own
+ client-side ceiling, and the actual target is min(engine target, client cap,
+ transport cap).
+
+ Backpressure-driven auto-reduction lives in `src/ws/messages.rs:1009-1027`, not
+ in the preview component. Note that the daemon only runs backpressure reporters
+ for `frames`, `spectrum`, `metrics`, and `device_metrics`, so the UI's
+ `topic == "canvas"` reduction branch does not fire in practice.
+
+ Canvas preview does **not** use `Portal`. Portal is used by `modal.rs`,
+ `silk_select.rs`, `component_picker.rs`, `viewport_designer.rs`, and the
+ `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.
## leptos_icons Gotcha
`Icon`'s `style` prop is `MaybeProp<String>` — accepts `&str` or `String`, **not closures**. Use conditional rendering (`if`/`Show`) to vary icon styles reactively, not a closure-based style prop.
## Key File Locations
- | Purpose | Path |
- | ------------------- | ------------------------------------------------------------ |
- | App root + contexts | `src/app.rs` |
- | WebSocket manager | `src/ws.rs` |
- | API modules | `src/api/{effects,devices,layouts,library,config,system}.rs` |
- | Canvas preview | `src/components/canvas_preview.rs` |
- | Effect controls | `src/components/control_panel.rs` |
- | Layout builder | `src/components/layout_builder.rs` |
- | Style utilities | `src/style_utils.rs` |
- | Design tokens | `tokens/{primitives,semantic}.css` |
- | Design system guide | `docs/DESIGN-SYSTEM.md` (canonical Luminary style guide) |
- | Trunk config | `Trunk.toml` |
- | Tests (unit only) | `tests/` |
+ | Purpose | Path |
+ | ---------------------- | ---------------------------------------------------------------- |
+ | App root + contexts | `src/app.rs`, `src/app/` |
+ | Scene/zone/scene state | `src/zones.rs`, `src/zones/surface.rs` |
+ | Studio page | `src/pages/studio/` |
+ | WebSocket layer | `src/ws/{connection,messages,preview,input,interactive_preview}.rs` |
+ | API transport | `src/api/client.rs`, `src/api/mod.rs` (`daemon_resource`) |
+ | API modules | `src/api/` (sixteen domain modules) |
+ | Canvas preview | `src/components/canvas_preview.rs`, `src/components/preview_runtime/` |
+ | Effect controls | `src/components/control_panel/` |
+ | Layer stack UI | `src/components/layer_panel/` |
+ | Layout builder | `src/components/layout_builder.rs`, `src/components/layout_builder/` |
+ | Device refetch logic | `src/device_event_logic.rs` |
+ | Style utilities | `src/style_utils.rs` (`category_style`) |
+ | Design tokens | `tokens/{primitives,semantic}.css` |
+ | Design system guide | `docs/DESIGN-SYSTEM.md` (canonical Luminary style guide) |
+ | Trunk config | `Trunk.toml` |
+ | Tests (unit only) | `tests/` |
## Detailed References
- **`references/signal-patterns.md`** — Leptos 0.8 reactivity patterns specific to this codebase: StoredValue for closures, untracked access for snapshots, Resource + Memo composition
- **`references/websocket-protocol.md`** — Full binary frame parsing, channel subscription, reconnection state machine, backpressure handling