Skip to content

Frontend

The frontend is three files baked into the firmware binary as a single gzip-compressed header (src/frontend/frontend_bundle.h). No framework, no build step, no Node.js dependency. The browser gets one HTML page, one JS file, one CSS file — all from the device.

src/frontend/
  index.html          — static shell: DOM skeleton, no logic
  app.js              — all behaviour: WebSocket, REST, rendering
  style.css           — all presentation: layout, theming, control widgets
  frontend_bundle.h   — gzip of the three above, generated by scripts/gen_frontend_bundle.py

gen_frontend_bundle.py concatenates the three files into one gzip blob and writes a C array. The firmware includes the header; HttpServerModule serves it from RODATA with Content-Encoding: gzip and Cache-Control: no-cache. The browser decompresses transparently.


What the frontend renders

The frontend is a generic renderer of the module model described in system.md. It does not know about lights, effects, or any specific module type. Everything it displays comes from two JSON wire formats the backend sends:

Schema ({"t":"schema", "modules":[…]}): structure of the module tree. Each module entry carries id, type, category, core, memory sizes, and a controls array. Each control carries key, type (the UI kind), value, and optional min/max/default/options. The frontend rebuilds or patches the DOM from this.

State ([{id, controls:{key:value,…}, timing:{…}}, …]): current values of all controls plus per-module timing. The frontend patches existing DOM elements in-place — no rebuild.

Binary frame (ArrayBuffer, magic byte 0x02): a pixel blob from PreviewModule. PreviewModule reads RipplesEffect's DataBuffer<RGB> and streams it over WebSocket. The frontend renders it into the WebGL canvas. This is the only domain-specific thing in the frontend, and it is isolated to renderPixelFrame() / the GL functions at the top of app.js.


app.js — section by section

WebGL previewer (lines 1–205)

Renders binary pixel frames as a 3-D point cloud. The wire format is [0x02][w lo][w hi][h lo][h hi][d lo][d hi][R G B …] — one RGB triplet per pixel, row-major, depth-last. The renderer:

  • initialises a WebGL context on <canvas id="preview"> lazily on first binary frame
  • builds interleaved [x, y, z, r, g, b] vertex data, normalised to [−0.5, 0.5], skipping black pixels
  • uploads to a single DYNAMIC_DRAW buffer and draws with gl.POINTS
  • supports mouse-drag orbit, scroll-zoom, and touch-orbit
  • shrinks the canvas as the user scrolls down (scroll listener in the Init section)

Minimalism note: the WebGL code is self-contained and purely presentational. It has no knowledge of what the pixels represent — it accepts any w × h × d RGB blob. Adding a 2-D canvas fallback or a different effect type does not require touching this section.

WebSocket (lines 207–288)

connectWs() opens ws://<hostname>:81. Three message paths:

Message Condition Action
ArrayBuffer evt.data instanceof ArrayBuffer renderPixelFrame()
{t:"schema"} JSON with t field schemaStructureChanged_() → full render() or patchControlSchema_()
array (no t) default handleStateUpdate()
{t:"log"} JSON with t:"log" appendLogLine()

Auto-reconnect with exponential backoff (500 ms → 5 s cap). 25 s heartbeat ping to keep Safari from killing idle connections. Pauses rendering when the tab is hidden (visibilitychange) to avoid processing frames that will never be shown.

State update + drag guard (lines 323–435)

handleStateUpdate(state) iterates the state array and patches each control's DOM element by data-mid + data-key attribute lookup. The drag guard prevents the 1 Hz backend push from overwriting a slider the user is actively dragging:

  • dragTs[moduleId + ':' + key] stores the timestamp of the last pointerdown or input event on each control
  • updates are skipped if Date.now() − lastDrag ≤ 2000 ms or if the input is the document.activeElement
  • 2000 ms is deliberately wider than the 1 s push cadence to absorb the race at the boundary

schemaStructureChanged_() compares the incoming module id list against lastSchemaIds_. If only control values changed (geometry resize, schemaDirty from a slider), it calls patchControlSchema_() to update min/max/value in-place. If the module list itself changed, it calls render() for a full DOM rebuild.

REST API (lines 470–491)

Two REST calls at startup:

  • GET /api/typesknownTypes[] — the registered type registry, used by the type picker
  • GET /api/modules → initial render() — first paint before any WS schema arrives

After that, all live updates come via WebSocket. REST is used only for mutations (add, delete, replace, reorder, postControl, reboot, OTA upload).

Tree builder (lines 496–515)

buildTree(modules) converts the flat module array from the API into a parent-child tree using parent_id. Core affinity is propagated down to children so the badge always shows the root core the child runs inside.

The side nav shows one button per root module. Clicking selects it; renderSelected() renders only that root's card. Order is persisted via POST /api/modules/reorder — drag and drop on the nav buttons. Selection is persisted in localStorage (pmm_selectedRoot).

Render + card builder (lines 602–784)

render(modules)buildTree()updateNav() + renderSelected()buildCard().

buildCard(mod) builds one module card: - header: setup-ok dot, name, replace button, id, category badge, core badge, timing (fps/ms toggle) - controls: one row per control, delegated to buildControl() - children: recursively nested cards with drag-to-reorder - actions: + add child, ✕ delete

The TYPE_TO_DOC map (lines 620–635) is the only place in the frontend that names specific module types — it maps type names to documentation URLs. This is presentational metadata, not logic. Everything else in buildCard is driven by the schema fields (category, core, controls) and is completely generic.

Type picker (lines 786–986)

Context-aware module type picker: emoji chip filter + text search. Filters by allowedChildCategories from the parent's type metadata when adding a child. Replace mode restricts to the same category. Double-click or Enter to confirm. All state is local to the closure; no global side effects.

Control builder (lines 1041–1213)

buildControl(moduleId, ctrl) maps ctrl.type to a DOM widget:

ctrl.type Widget Notes
slider <input type="range"> pointerdown + input → dragTs; 150 ms debounce → postControl
display <span> read-only; updated by handleStateUpdate
time <span> formatted as d/h/m/s by fmtTime
progress <progress> + text read-only bar with value/max display
button <button> sends value=1 on click
text <input type="text"> 500 ms debounce → postControl
password <input type="password"> hold-to-peek button; never shows current value
toggle <input type="checkbox"> immediate postControl on change
select <select> options from schema; immediate postControl on change

Every interactive widget stamps data-mid and data-key attributes so handleStateUpdate and patchControlSchema_ can locate it with a single querySelector.

OTA / firmware update (lines 1287–1544)

buildOtaPanel() is injected into any card whose mod.type === 'FirmwareUpdateModule'. Two tabs: file upload (POST /api/firmware with XHR for upload-progress events) and GitHub releases (fetches api.github.com/repos/ewowi/projectMM/releases, finds the projectMM-<env>.bin asset, sends the URL to the device for on-device download via POST /api/firmware/url). The OTA panel is the only place that checks mod.type for behaviour, not presentation.

System status + health (lines 1546–1595)

  • loadSystemStatus(): GET /api/system every 2 s → status bar (uptime, heap, temperature)
  • checkForUpdate(): compares firmware_version from /api/system against GitHub releases; shows update badge if a newer non-prerelease exists with a matching projectMM-<env>.bin asset
  • loadHealth(): GET /api/test every 30 s → health panel (pass/fail count + per-case table)

Init (lines 1599–1712)

DOMContentLoaded wires all the above: initGL(), loadTypes(), loadModules(), connectWs(), loadHealth(), then event listeners for theme toggle, nav hamburger, reboot, reconnect, scroll-shrink, visibility change, and Safari pageshow (bfcache resume).


style.css — structure

Seven logical sections (matching the HTML skeleton):

  1. CSS custom properties + reset--bg, --fg, --accent, --border for light/dark theming via [data-theme] attribute on <body>
  2. Status bar — fixed top strip: brand, device name, WS dot, action buttons
  3. Side nav — slide-in drawer on narrow viewports, always-visible sidebar on wide; drag-handle for reorder; footer with community links
  4. Preview canvas — full-width WebGL canvas, square aspect ratio, shrinks on scroll
  5. Module cards.module-card, .module-header, .control-row, .children nesting; core-badge colour by core number; category emoji badge
  6. Control widgetsinput[type=range], .progress-bar, .select-input, .text-input, .toggle-input, .reset-btn, .action-btn; reset button turns accent-coloured when value differs from default
  7. Panels — log, health, OTA; <details> for collapsible sections

No classes encode domain semantics. .module-card is generic; it renders the same for a system module or an effect module. The only domain-aware CSS is the CATEGORY_EMOJI map in app.js (app.js:668) which maps category strings to emoji — and that is in JS, not CSS.


Architectural fit

The frontend fits system.md's four-piece model as the consumer of the control/schema surface:

Browser
  ├── schema/state WS ──→ renders MoonModule tree generically
  ├── binary WS ────────→ WebGL point cloud (only domain-specific section)
  └── REST ─────────────→ mutations (add/delete/reorder/control/reboot/OTA)

What is generic: navigation, card layout, all control widgets, type picker, timing display, health panel, log panel. None of these know about lights.

What is domain-specific: renderPixelFrame() (expects RGB pixel blobs) and TYPE_TO_DOC (maps module type names to doc URLs). Both are isolated — replacing the pixel protocol or the doc site requires changing one function each.

Minimalism invariant: no framework, no build step, no npm. Adding a new control type means one else if branch in buildControl(). Adding a new module type requires nothing — the schema drives everything. The frontend grows only when the wire protocol grows.