Skip to main content

leptos-ui-development

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

Source facts

Repository
hyperb1iss/hypercolor
Last source activity
October 26, 2026 at 06:59
Detected SKILL.md language
English
Stars
32
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub