Web UI¶
The MoonLight web UI as shipped, the render layer over the MoonModule tree, and its implementation spec: status bar, cards, control rendering, styling, and the in-place update contract. The high-level architecture, hand-maintained files, MoonModule-driven rendering, the light-domain plug-in points, lives in MoonCore, the 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 the tag emoji legend.
What remains is the browser-side rendering behavior.
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 § patched in place).
Layout¶
┌─────────────────────────────────────────────────────────────┐
│ Status bar: ☰ · logo · brand · device name · sys stats │
│ · WS dot · reboot · theme toggle │
├──────────────┬──────────────────────────────────────────────┤
│ Side nav │ Preview ┃ Cards for the selected │
│ (root list) │ (beside, under ┃ root module │
│ │ or floating) ┃ ↳ child cards inline │
│ ───────── │ ┃ ↳ grandchild cards │
│ footer: │ split handle ┛ │
│ 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).
- The preview has three layouts, one canvas restyled in place by
preview3d.js::setupLayout: - Beside the cards on a screen 960 px wide or more. The split handle between them sets the card column's width; dragged so wide the preview drops under 240 px, the preview moves to the small layout, and back while the same drag returns.
- Under the cards, in a band whose height the split handle sets, which suits a phone.
- Floating, dragged by its bar and staying where it is dropped, kept as fractions of the screen so it holds its place through a rotation.
- The module panel resizes as a macOS window does (
panel.js): nothing is drawn, and the cursor changes on the top-level card's left border, the split handle, on its bottom border and on its bottom-left corner, which beside the preview also widens it. A card's areas are fixed or expandable: a control or a row keeps its natural height, and an expandable area, the device map or a text area, takes a share of the height the panel gains or gives up, in proportion to its own, down to 80 px; the device map then zooms out to fit. The height is kept per page and per browser; a double-click on an edge returns the page to its natural size. - A screen under 960 px uses the small layout, under or floating, which the preview bar's ⬓ / ⧉ switches. ⤢ takes a wide screen's preview out of the side and ⤡ puts it back. All of it is kept per browser.
- The preview bar's ⏺ records three seconds of the canvas as a GIF the browser saves, encoded by
gif.js(GIF89a with a median-cut palette), for a preset's gallery preview.
Status bar¶
Fixed top, 44 px, left-to-right:
- Hamburger (☰), toggles the side nav (
body.nav-open). See § Side navigation. - Momo, the MoonModules mascot, 28 px PNG from
/momo.png; the same image is the page favicon. - Brand wordmark, "MoonLight".
- 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 from 200 ms to a 5 s ceiling on close, and 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. - Help toggle (ⓘ), off by default: shows or hides every help line by flipping
body.help-hidden, dimmed while off. The first time it turns help on it asks before downloadinghelp.jsonfrom the docs site, and a yes is kept inmm_helpConsent; the choice is kept inmm_helpShown. - MoonWalk (🗺️): opens MoonWalk, the map of every process, in a resizable dialog. It opens on the reader's choices: Here, the processes touching the card in view, and Next, the next ones to do. The map and its script come from the docs site and its graph library from cdnjs, under the help's consent, which names both hosts. The device's whole state, from
GET /api/state, marks done what it shows done, and a?walked=list in the address, the installer's link after an install, records the processes the installer vouches for. With help on, a module's help lists the processes it takes part in, and a control's more names the step that sets it; a tap opens the map at that step. A step that points at a card has Show me: the map closes, and the card comes into view with its control outlined. A bar then holds the current step. It moves on when the device's state shows the step done, and a tap on it reopens the map. - 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). - The nav states its own order (
NAV_ORDERin app.js), which is deliberately NOT the order the roots run in.main.cpporders by dependency, Filesystem before anything that writes a file, System before the modules that read its identity, and that is load-bearing, so it cannot be reshuffled to suit a menu. It also reads as an implementation detail to a user: it puts System first and the lights last. The nav instead groups by what someone is looking for: Control, then the light pipeline in pipeline order (Layouts → Effects → Drivers), then the device (System, File Manager, Network, Services, Firmware). A root the list does not name still appears, after them, in scheduler order, adding a module never makes it invisible. - No root reorder. The order is fixed in that list; 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 page subscribes to what it shows: the selected root, plus by type the modules it reads outside that root (ALWAYS_SUBSCRIBED: System for the status bar and mode, FirmwareUpdate for the update badge, MoonCloud for the relay, PreviewDriver for the frame rate, MidiService for the MIDI bridge). The device answers with those modules in full and an outline of the rest, names and shape without controls, which is all the nav reads. Opening another root sends a new subscription, and its cards appear when the answer arrives; until then the previous cards stay. The control-surface target picker offers every module's controls, so assign mode fetches the whole tree from /api/state.
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] [✎ 🗑 ☰] [{ } api] │
│ [control rows, one per control] │
│ ┌─ child card ────────────────────────┐ │
│ │ … │ │
│ └─────────────────────────────────────┘ │
│ [+] [🗑] │
└─────────────────────────────────────────┘
- The parent's own controls render above its children;
+and🗑render 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. { }opens a popup holding the card as a state document, with copy, save as preset where a preset module exists, and diagnostics, which opensGET /api/modules/{name}in a new tab, that module's live JSON for issue reports (see Log an issue). On EVERY card, unlike✎/🗑(user-editable children only).- 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 the tag emoji legend. - Help under the title, from
help.json, which the browser fetches from the docs site and keeps inmm_help, so the firmware carries none of it. Each release carries its own copy, so the help names the controls the running firmware has; a release absent from the Pages mirror reads the site's copy. A cached copy shows only for the release it came from. The module's line and summary show; a tap opens its guide, picture, attribution and links to the docs, the source and the tests. The Docs link opens the module's card on the docs site, fromdocPathin/api/types. Each control with a line shows it under the field, and a ▸ before the line opens its longer help where it has one. Help is off until the ⓘ in the status bar turns it on. The first time it asks before downloading, since the file comes from the docs site rather than the device. While developing help,?help=localreads the filegen_helplast wrote, whichpreview_installerserves onlocalhost:8421, and?help=publishedreturns to the docs site. - Stats line:
🕒 <timing>then🧠 <static>[ + <dynamic>]. Timing is a tick time (µ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. The line shows from expert mode up, and holding it peeks at the rate on every card, the password field's gesture: a tick time inverts to a figure that reads as a frame rate and is not one, so the rate is a press away rather than a mode the cards sit in. - 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. +renders in a parent's footer when its type declares a non-emptyacceptsChildRoles, titled with those roles ("add effect / modifier"); it opens the type picker filtered to those roles.- 🗑 beside
+deletes every user-managed child of that parent, press-twice confirm like a card's own 🗑. It sends{"<parent>": {"$patch": "replace"}}throughPATCH /api/state, nested under the parent's ancestors, so code-wired children and the parent's own settings stay. It shows only while the parent holds something to delete.
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 |
textarea |
resizable multi-line box | sends debounced; the value IS the text, so it rides /api/control |
500 ms |
filepath |
file picker + inline editor (new/delete buttons) | picker sends on change; the file's CONTENTS save on blur, Ctrl/Cmd+S or the Save button, and a dot marks unsaved work | none |
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 + used / total, sized B, KB, MB or GB |
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 |
-
filepathstores a NAME, not a body. The value is a ~40-byte reference that travels through/api/controllike any text control; the file's contents move overGET/POST /api/file, because that is the only route allowed to exceed the request buffer (everything else returns 413). The module declares where its files live and which to offer (addFilePath(name, buf, size, pick), a {directory, extension, template} triple), so the UI lists a directory, filters it, and seeds a new file without knowing what kind of file it holds. Saving is all it takes: a written file asks the module tree to re-derive, so whatever was built from that file rebuilds itself, with no second request from the browser. Editing is the same code the File Manager's modal editor uses, mounted inline instead of in a<dialog>, so both share one set of guards (a binary or truncated file loads read-only rather than risking a lossy re-save). -
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 defense 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 (+) and replace (the ✎ button), a modal anchored to the button that opened it.
- 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: the 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.
- Color 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 recognize.
- 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 MoonCore, the web UI / PreviewDriver.
State updates, patched in place¶
When a WS state message arrives, a full state answering a subscription or a patch after it, 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 behavior¶
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 behavior the server doesn't dictate:
- URL
ws://<host>/ws(same port as HTTP). On open the page sends{"subscribe":[…]}; the server answers with a full state, then a patch of what changed in the subscribed modules each second. - After an add, delete, move or replace,
refetchState()sends{"resync":n}and resolves when the document ending in"resync":nhas rendered: the view alone, never the whole tree. Only the diagnostics download, the control-surface target picker and a config restore read/api/state, since each needs every module. - 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_selectedTabs the open tab per container ({ container: child })
mm_expanded modules whose "controls" disclosure is open ([name, …])
mm_textareaSizes user-dragged textarea heights ({ "module:control": px })
mm_cardsWidth dragged width of the docked card column (px)
mm_help the last help.json fetched and its address ({url, text})
mm_helpShown "1" | "0": help lines shown or hidden (default: "0")
mm_helpConsent "1" once the user agreed to download help (default: absent, nothing fetched)
mm_helpSource "local" while developing help (default: absent, the docs site)
The rest, reorder, control values and the like, lives 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: app.js and every module it imports become one minified app.js, style.css is minified, and each file carries an ETag, so a reload of an unchanged file is a 304. A desktop running from a checkout serves the files in src/ui as they are, for live editing.