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
just ui-dev # Dev server on :9430, proxies API to daemon on :9420
cd crates/hypercolor-ui && trunk build # One-shot buildTrunk 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 mods, 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.
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:
{ "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:
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.
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 throughws.protocols([HYPERCOLOR_WS_PROTOCOL]), and it is what the manifest advertises, but it is not an upgrade gate: the only rejection inws_handleris 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. Seereferences/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:
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:
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:
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:
#[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()insrc/style_utils.rsis 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 acategory_style()triplet. Never spray rawrgba(). - Semantic tokens only. No
oklch(...)or hex in component CSS or Leptosstyleattributes. - 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:
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/Rgbaframes. Texture reused across frames, reinit only if dimensions change, no pixel buffer copy - Canvas2d: last resort, gated behind
CANVAS2D_FALLBACK_THRESHOLD = 3consecutive 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 compositionreferences/websocket-protocol.md— Full binary frame parsing, channel subscription, reconnection state machine, backpressure handling