- 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, so always build and check it 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`, which 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 every module as `pub mod`,
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`, `scene_resolved`, `focused_zone` (the one target zone), `target_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`, `zones`, `scene_refresh`, `apply_effect` (lands in the target zone), 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 | `history` (`StudioHistory`), `selected_surface_id` (the open tab), `composition_open`, `rig_layout`, `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() { /* the + after the tabs, "A new zone" in Move to */ }
```
`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 primary 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`.
The target zone has one record: `ZonesContext::focused_zone`. Studio's open
LED tab and the preview's `CompactZoneTabs` write it; `target_zone()` resolves
it (a stale, Screen, or `None` value means the primary zone), and
`surface::apply_zone_id` turns it into the zone an apply names. There is no
`ApplyTarget` and no all-zones apply. Show a zone's name only through
`surface::zone_display_name`, which renders an unnamed, "Primary", or
"Default zone" primary as "Main".
The API modules in `src/api/` 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
Studio lives in `src/pages/studio/`, one zone tab at a time:
| Area | Modules |
| ---- | ------- |
| Page state | `mod` (`StudioContext`, selection, the target-zone effect) |
| Tab strip | `zone_strip`, `tab_model`, `zone_controls` (zone create, rename, color, pause, reorder) |
| Device pane | `device_pane`, `pane_model`, `device_card`, `offline_device_card`, `device_assignment`, `screen_list` |
| Stage and layers | `stage`, `composition_panel`, `face_composition` |
| Moves | `drag`, `drag_model`, `move_menu`, `moves` (one drag controller, the "Move to" twin, announcements) |
| History and placement | `history`, `undo_notice`, `placement` (rig seeding, first-arrival fit, Fit) |
Rules the code depends on:
- **Every membership and zone-structure write goes through `StudioHistory`** (`assign_device`, `edit_members`, `create_zone`, `create_zone_filled`, `patch_zone`, `delete_zone`, `undo_deletion`). It fences writes by scene revision, runs one at a time, queues the rest in order, and records the undo journal. Calling `api::zones::*` from a component skips undo and races the queue.
- **Layout edits commit live through a ledger.** `LayoutWriteHandle` records each finished edit in the history and turns it into per-field intents in the zone's ledger (`components/layout_builder/ledger.rs`; its module doc is the contract: confirmed state by revision plus ordered intents, writes fenced at the confirmed revision). The page-lifetime writer in `writer.rs` sends them, one write at a time, and outlives a Studio mount. There is no Save; never reintroduce a dirty-draft model or write fields that hold no intent.
- **The rules are Leptos-free.** `tab_model`, `pane_model`, `placement`, `drag_model`, `history`, `layout_builder/ledger`, and `layout_canvas/{picking,labels}` are tested from `tests/studio_*_tests.rs`. Change the rule and its test there, not inside a component.
- **Arrivals carry geometry.** An add from Unassigned sends `MemberPlacementHint`s computed from `StudioContext::rig_layout`; a move sends `MemberEdit`s whose `after` states already hold the placement. Never send an assignment that leaves the daemon's default cascade when the rig knows where the device sits.
## 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 face_resource = api::daemon_resource(move || {
let display_id = selected_display_id.get();
async move {
match display_id {
Some(display_id) => api::fetch_display_face(&display_id).await,
None => Ok(None),
}
}
});
```
Layer the hint on top when the data also changes mid-connection. The scene
View on GitHub