Web UI¶
The projectMM web UI as shipped — the render layer over the MoonModule tree. This page is the UI's
own implementation spec (status bar, cards, control rendering, styling, the no-rebuild update
contract). The high-level architecture — hand-maintained files, MoonModule-driven rendering, the
light-domain plug-in points — lives in architecture.md § Web UI;
the backend contract it consumes (every /api/* endpoint, the /ws frame shape, the control
descriptors) is owned by HttpServerModule; the emoji legend the
cards and picker render is architecture.md § Tag emoji legend.
This page covers only what those don't: the browser-side rendering behaviour.
Interaction principles¶
- Two timescales for inputs. Local UI feedback (a slider's value label, a toggle's state) updates within ~20 ms of the input event; network sends are debounced (150 ms slider, 500 ms text), so a slider feels instant even when the server is busy.
- No write-back races. A
dragTs[moduleId:key]cooldown of 1 s stops an incoming WS push from overwriting a control the user is actively manipulating. - No DOM rebuilds on state updates. The initial render builds the tree; state updates patch values
in place via
querySelectorbydata-mid/data-key(see § no-rebuild contract).
Layout¶
┌─────────────────────────────────────────────────────────────┐
│ Status bar: ☰ · logo · brand · device name · sys stats │
│ · WS dot · reconnect · reboot · theme toggle │
├──────────────┬──────────────────────────────────────────────┤
│ Side nav │ Sticky preview canvas (shrinks on scroll) │
│ (root list) ├──────────────────────────────────────────────┤
│ │ Cards for the selected root module │
│ │ ↳ child cards inline │
│ ───────── │ ↳ grandchild cards inline │
│ footer: │ │
│ social © │ │
└──────────────┴──────────────────────────────────────────────┘
- Fixed status bar at top (44 px) — hamburger, MoonLight logo, then the rest.
- Side nav: a left column listing root modules; the selected root's card subtree fills the main area (one root visible at a time).
- Module-card column max-width 500 px, centered (single-column, easier to read on phones). The preview canvas is not capped — it spans the full content width.
- Preview canvas is sticky just below the status bar and shrinks 0 % → 50 % as the user scrolls 0 → 300 px.
Status bar¶
Fixed top, 44 px, left-to-right:
- Hamburger (☰) — toggles the side nav (
body.nav-open). See § Side navigation. - MoonLight logo — 28 px PNG from
/moonlight-logo.png; the same image is the page favicon. - Brand wordmark — "projectMM".
- Device name — from the
System.deviceNamecontrol. - System stats —
uptime · NN K free(uptimeXd Yh Zm Ws, free heap KB), read from the SystemModule controls in the WS state push; no separate polling endpoint. - WebSocket dot — green = connected, gray = disconnected. The socket auto-reconnects with exponential backoff (500 ms → 5 s) on close; no manual reconnect button (a page reload covers the rare half-dead socket).
- Reboot button (⏻) — red border via
data-crashed="true"whenSystemModule.bootReasonis PANIC / INT_WDT / TASK_WDT / BROWNOUT. Press-twice to confirm: the first click arms it (solid red), a second sendsPOST /api/reboot. Disarms after 3 s or on pointer-leave — noconfirm()popup. - Theme toggle (☀/🌙) — flips
[data-theme]on<body>; preference inlocalStorage['mm_theme'].
Side navigation¶
A left column listing the root modules, one entry per top-level MoonModule.
- One root visible at a time. Clicking a nav entry sets the selected root;
renderCards()renders only that root's card subtree. The selection persists inlocalStorage['mm_selectedRoot']; the active entry is highlighted. - Hamburger toggle. ☰ toggles
body.nav-open. On wide screens (≥ 820 px) the nav is a static column the hamburger collapses/expands; on narrow screens (< 820 px) it's a slide-in drawer over a dimming overlay (click the overlay or press Esc to close). - No root reorder. Root order is fixed in
main.cpp; the nav does not drag-reorder. - Footer pinned to the bottom of the nav: social links (GitHub, Discord, Reddit, YouTube — inline
SVG) and a
© <year> MoonLightline.
The WS state push carries the full module tree; only the selected root is rendered, and
updateValues() patches just the visible cards (non-rendered roots have no DOM nodes, so their data
is silently ignored). Per-root server-side filtering was evaluated and deferred — the 1 Hz push is
cheap and the JSON is built through a streaming sink, so tree size is not a buffer-limit concern.
Module card¶
Each MoonModule renders as a card, with child cards nested inside their parent card's box — the parent's border encloses its children, so the tree shape is visible structurally, not just by indentation. Nesting depth shows as progressively lighter backgrounds and a left-margin indent.
┌─ card ──────────────────────────────────┐
│ [name] [emoji] [timing · 🧠 mem] [enabled toggle] [✎ × ☰] [? help] │
│ [control rows — one per control] │
│ ┌─ child card ────────────────────────┐ │
│ │ … │ │
│ └─────────────────────────────────────┘ │
│ [+ add child] │
└─────────────────────────────────────────┘
- The parent's own controls render above its children;
+ add childrenders below them. Child cards live in a.card-childrenwrapper appended into the parent card's DOM node (not flat siblings);renderModuleTreerecurses into the parent card, not intomain. - Enabled toggle in the right-hand action cluster mirrors
MoonModule::enabled()— a styled<button>(transparent + muted border, 26×26); state shown by the glyph alone (accent ✓ on, muted ○ off;data-checked+aria-pressed). Toggling firesonEnabled(); the Scheduler skips disabled modules whoserespectsEnabled()returns true (default). - Emoji tags next to the name show the same set the type picker uses: role + dimensional (both
UI-derived) + the curated
tags()from/api/types. Identical identity across card and picker; the assignments are architecture.md § Tag emoji legend. - Help link (?) at the far right of the title row opens the module's doc page in a new tab. The
path comes from
docPathin/api/types(engine-provided, relative todocs/moonmodules/); omitted when the type declares none. - Stats line —
🕒 <timing>then🧠 <static>[ + <dynamic>]. Timing is fps or µs/ms per the global toggle (µs under 1 ms, ms above), omitted when the module has no measured loop time. Memory is the C++ object size (classSize); the+ <dynamic>part (dynamicBytes, heap) shows only when the module allocated heap. Clicking cycles the timing figure fps ↔ ms (persists inlocalStorage['mm_timing_mode'], applies to all cards). - Reorder is drag-and-drop (HTML5 DnD) on the whole card, desktop and mobile. A drag starting on
an interactive control is canceled in
dragstartso the control's own gesture wins; a drag is accepted only when source and target share the same.card-childrencontainer (true siblings). - Replace (✎) / Delete (×) / drag-reorder apply to user-managed children — a module whose role a
container accepts (
acceptsChildRoles) and that hasn't opted out viauserEditable: false. Replace swaps for another type at the same position (picker filtered to the module's role; replacement starts at its own defaults). Delete is press-twice confirm. AuserEditable: falsechild (e.g. PreviewDriver) shows none of these — it's fixed-shape like a code-wired child. + add childrenders in a parent's footer when its type declares a non-emptyacceptsChildRoles; it opens the type picker filtered to those roles.
Control types¶
Auto-rendered by controls[].type — adding a MoonModule with these types needs no UI change. (The
descriptors themselves, and their storage shape, are Control /
coding-standards § store values in their native shape; this table is
how each renders.)
| Type | Element | Interaction | Debounce |
|---|---|---|---|
slider (uint8/uint16) |
range + numeric display | drag → label instant; value sent debounced; reset-to-default ↺ | 150 ms |
toggle (bool) |
switch (pill + thumb; hidden <input type=checkbox> is the source of truth) |
sends on change | none |
select |
dropdown | sends immediately; server may rebuild controls (dynamic defineControls) |
none |
text |
text input | sends debounced | 500 ms |
password |
password input | masked; hold-to-peek reveals the stored value | 500 ms |
display (read-only) |
static text | WS push updates in place | n/a |
display-int (read-only int + unit) |
formatted text (-58 dBm) |
unit suffix set device-side at addReadOnlyInt time, carried in the descriptor's aux slot |
n/a |
time (read-only seconds) |
formatted text (1d 4h 27m 13s) |
WS push updates | n/a |
progress |
bar + numeric X / max |
WS push updates | n/a |
ipv4 |
text input (dotted-quad) | server validates (parseDottedQuad), 400 on malformed; stored as 4 octets device-side |
n/a |
button |
clickable button | sends value = 1 on click | none |
- Reset-to-default (↺) appears next to controls whose default is known (captured from a fresh
probe instance per type, emitted in
/api/types); dim when value == default, clicking sends the default. - Hidden controls.
hidden: trueon a descriptor → the UI skips rendering it (conditional controls like NetworkModule's static-IP fields under DHCP). - Password controls are obfuscated, not encrypted. A
passwordcontrol serializes in/api/stateas{"type":"password","value":"<encoded>"}where<encoded>is the password XOR'd with a fixed key then base64-encoded; the UI decodes it (decodePassword()) so the input holds the real value (masked, hold-to-peek). A first line of defence only: the XOR key is a shared constant in both the firmware andapp.js, so it's trivially reversible — it stops the password being plainly readable in a rawcurl /api/state, not a determined reader.
Type picker¶
One picker serves add (+ add child) and replace (the ✎ button), rendered inline in the card
(not a modal).
- Role filter. Add mode filters to the parent's
acceptsChildRoles(declared per-type in/api/types— the UI hardcodes no container→role map); replace mode filters to the target's own role. - Emoji tag chips. A row of toggle chips above the list, one per distinct emoji across the filtered
types (sources + meanings: architecture.md § Tag emoji legend).
The UI treats
tagsas opaque — splits it into grapheme clusters, one chip each. Toggling narrows with AND logic (a type shows only if it carries every active chip). - Search box — substring match on type name; combines with chips (both must match).
- Keyboard nav — type to filter, ↓ to enter list, ↑↓ to move, Enter to confirm, Esc to cancel.
- Confirm / Cancel at the bottom (confirm reads
createorreplace); double-click a row to confirm immediately.
Styling¶
- Dark theme (default): background
#1a1a2e, foreground#e0e0e0, accent#a78bfa(purple). - Light theme:
[data-theme="light"]on<body>swaps a curated set of overrides. - System UI font stack (
system-ui, sans-serif, not monospace); numbers usefont-variant-numeric: tabular-numsso digits don't dance. - Module nesting by progressively lighter card backgrounds (depth 0 / 1 / 2).
- Responsive breakpoint at 820 px.
- Colour semantics consistent across the app: green (
#22c55e/#6ee7b7) = connected/ok/pass; red (#f87171) = error/fail/crashed/delete; purple (#a78bfa/#c4b5fd) = accent/brand/active/value; gray (#6b7280/#9ca3af/#4b5563) = secondary/muted.
Domain preview channel¶
The UI dedicates a binary slot on its WebSocket — separate from the JSON state updates — for a
domain-specific preview frame; the engine pushes one per render and the UI hands it to a domain
renderer. Generic shape: [type-byte] [domain header] [payload] — the first byte selects the
renderer, everything after is the domain's choice; the UI ignores types it doesn't recognise.
- The canvas is sticky just below the status bar, scroll-shrinks 0→50 % over 300 px.
width: 100%+aspect-ratio: 1 / 1derives the height from the column width;max-height: 50vhcaps it.- The last received frame is cached so camera gestures (orbit/pan/zoom) redraw instantly without a new
frame;
touch-action: nonekeeps gestures off native scroll/zoom. - WebGL clear color
(0, 0, 0, 0)— a transparent canvas blends into either theme with no per-frame color work.
The light-domain renderer (WebGL point cloud, the 0x03/0x02 frame format, orbit camera,
downsampling) is architecture.md § Web UI /
PreviewDriver.
State updates — the no-rebuild contract¶
When a WS state snapshot arrives, for each module in the payload:
- If controls.deviceName exists, update the status-bar device name and document.title.
- For each controls[key]: look up the matching <input>/<span>/<progress>/<select> by
[data-mid][data-key]; skip if Date.now() - dragTs[mid:key] < 1000 (user interacting);
otherwise patch the value in place (sliders update input + value-display; toggles set checked;
selects set value). Then update the reset-to-default "at default?" state and the timing display.
The DOM is never rebuilt during a state update. A full re-render happens only on (a) initial load
and (b) a mutation that changes the tree shape (/api/control for a Select that triggers
rebuildControls; /api/modules add/delete/move/replace).
Object identity across pushes. Every WS push replaces state with a fresh JSON tree, so a
card-render closure holding a mod reference goes stale within ~1 s. Any "which index am I now?"
lookup must use findIndex(c => c.name === mod.name), not indexOf(mod).
Communication client behaviour¶
The endpoints, the /ws frame shape, and the streaming state sink are owned by
HttpServerModule; /api/control is {module, control, value}. The
browser-side socket behaviour the server doesn't dictate:
- URL
ws://<host>/ws(same port as HTTP); the server pushes a full-state snapshot ~1/s. - Client sends
"ping"every 25 s as keepalive (Safari kills idle sockets otherwise). - Auto-reconnect on close with exponential backoff (500 ms → 5 s ceiling).
- Pause on
document.visibilityState === 'hidden', resume onpageshow(Safari bfcache survival).
localStorage keys¶
mm_selectedRoot id of the currently-selected root module (string)
mm_theme "dark" | "light" (default: "dark")
mm_timing_mode "fps" | "ms" (default: "fps")
No other client state persists; reorder, control values, etc. all live on the device.
Source¶
Hand-maintained files: index.html · app.js · style.css · preview3d.js · install-picker.js. Embedded into firmware at build time via embed_ui.cmake.