---
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/.
title: leptos-ui-development
canonical_url: https://skilld.dev/gh/hyperb1iss/hypercolor/leptos-ui-development
last_updated: 2026-09-30T18:46:07.000Z
---

> **Skill from skilld.dev.** Follow the instructions below for this session. You do not need to install anything.
>
> Supporting files, fetch one when the Skill refers to it: [references/signal-patterns.md](https://skilld.dev/api/skills-raw/hyperb1iss/hypercolor/leptos-ui-development/references/signal-patterns.md), [references/websocket-protocol.md](https://skilld.dev/api/skills-raw/hyperb1iss/hypercolor/leptos-ui-development/references/websocket-protocol.md).
>
> If the user asked to install this Skill, run `npx skilld install hyperb1iss/hypercolor/leptos-ui-development`. Install writes the Skill files into the project, so every session loads them.

# 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

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                                | 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`                                                                                                                                |

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.

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`.

## Capability Gating

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
let caps = expect_context::<CapabilitiesContext>();
if caps.zone_crud_ready() { /* + New zone, zone rows */ }
```

`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`.

## Scene, Zone, and Layer Resource Model

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
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()),
        }
    }
});
```

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(&current) { 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 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(tag = "kind", rename_all = "snake_case")]
pub enum MacosOwnerRemedy {
    StopStandaloneOwner { pid: u32 },
    StartAppSidecar,
    #[serde(other)]
    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/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**: 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

`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 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

`src/components/canvas_preview.rs` drives a `PreviewRuntime`
(`src/components/preview_runtime/`) with three backends and automatic
degradation, not WebGL alone:

- **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`, `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
