Skip to content

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 querySelector by data-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:

  1. Hamburger (☰) — toggles the side nav (body.nav-open). See § Side navigation.
  2. MoonLight logo — 28 px PNG from /moonlight-logo.png; the same image is the page favicon.
  3. Brand wordmark — "projectMM".
  4. Device name — from the System.deviceName control.
  5. System statsuptime · NN K free (uptime Xd Yh Zm Ws, free heap KB), read from the SystemModule controls in the WS state push; no separate polling endpoint.
  6. 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).
  7. Reboot button (⏻) — red border via data-crashed="true" when SystemModule.bootReason is PANIC / INT_WDT / TASK_WDT / BROWNOUT. Press-twice to confirm: the first click arms it (solid red), a second sends POST /api/reboot. Disarms after 3 s or on pointer-leave — no confirm() popup.
  8. Theme toggle (☀/🌙) — flips [data-theme] on <body>; preference in localStorage['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 in localStorage['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> MoonLight line.

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 child renders below them. Child cards live in a .card-children wrapper appended into the parent card's DOM node (not flat siblings); renderModuleTree recurses into the parent card, not into main.
  • 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 fires onEnabled(); the Scheduler skips disabled modules whose respectsEnabled() 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 docPath in /api/types (engine-provided, relative to docs/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 in localStorage['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 dragstart so the control's own gesture wins; a drag is accepted only when source and target share the same .card-children container (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 via userEditable: 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. A userEditable: false child (e.g. PreviewDriver) shows none of these — it's fixed-shape like a code-wired child.
  • + add child renders in a parent's footer when its type declares a non-empty acceptsChildRoles; 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: true on a descriptor → the UI skips rendering it (conditional controls like NetworkModule's static-IP fields under DHCP).
  • Password controls are obfuscated, not encrypted. A password control serializes in /api/state as {"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 and app.js, so it's trivially reversible — it stops the password being plainly readable in a raw curl /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 tags as 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 create or replace); 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 use font-variant-numeric: tabular-nums so 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 / 1 derives the height from the column width; max-height: 50vh caps it.
  • The last received frame is cached so camera gestures (orbit/pan/zoom) redraw instantly without a new frame; touch-action: none keeps 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 on pageshow (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.