diff --git a/.gitignore b/.gitignore
index 95ca093d..52f1002b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -145,3 +145,15 @@ __pycache__/
# Root-anchored + dir-scoped, like /build/ above: a bare `.snapshots` would also swallow any
# nested path of that name anywhere in the tree.
/.snapshots/
+
+# Video-production OUTPUT: the music a cut is scored against, the raw takes, the
+# finished videos, and the intermediates between them. Nothing here is redistributable
+# (licensed music, large binaries), and a repository is not a media library: what ships
+# to a viewer lives on the video platform.
+#
+# The whole folder, because /media/ is output ONLY. The sources live elsewhere by
+# design: the run files under test/uiscenarios/, the published doc clips under
+# docs/assets/uiscenarios/, the tooling under moondeck/uiscenario/. An earlier version
+# listed media types one by one, to protect run files that used to live here; they no
+# longer do, so the rule can say what it means.
+/media/
diff --git a/CLAUDE.md b/CLAUDE.md
index 1e63352b..86f14fbe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -205,6 +205,8 @@ flowchart LR
Each name is a script under `moondeck/`, run through `uv run`; the command and what it does are in [MoonDeck.md](moondeck/MoonDeck.md), one section per script. 🐢 marks a check costing tens of seconds or more.
+**`test_host --ui` is never a gate.** The UI scenario runs drive a real browser against a running device and are what the documentation clips are recorded from, so they cost minutes and skip wholesale without a desktop and a Playwright browser. They run on request only, never as part of pre-commit, pre-merge or pre-release, and the `src/ui` trigger above means `--js` alone.
+
Three checks earn their place for a reason worth knowing. **Repo health** is the only place the creeping numbers are visible: flash and DRAM per target, binary size, the tick matrix, line counts, complexity warnings. Its diff belongs in the commit and its deltas in the commit message. It runs when the code changes rather than on every commit, because its timings drift with the host: on a docs-only diff it records a regression that nothing in the diff caused. **The no-backend build** catches a helper left unused outside its guard, fatal under GCC while clang stays silent. **ESP32 firmware fresh** compares the binary against every source in a tenth of a second and catches the edit that was never compiled; compile for real after an sdkconfig or toolchain change. The [provisioning path](moondeck/MoonDeck.md#improv_smoke_test) is the five files MoonDeck names.
diff --git a/README.md b/README.md
index 46e7112c..cbc83733 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,7 @@ Per-grid and per-device tables, free-heap figures, and why WiFi costs what it do
**ESP32**: open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge ([MoonInstaller](#mooninstaller)). It walks you through device, firmware, flashing and network setup.
-**Desktop**: download your build from the [releases page](https://github.com/MoonModules/projectMM/releases), then open `http://localhost:8080/`. Step by step with screenshots: [Installing projectMM on a desktop](docs/tutorials/installing-to-desktop.md).
+**Desktop**: download your build from the [releases page](https://github.com/MoonModules/projectMM/releases), then open `http://localhost:8080/`. Step by step with screenshots: [Installing projectMM on a desktop](docs/how-to/installing-to-desktop.md).
- **macOS arm64**: `.dmg`, drag to Applications. Ad-hoc signed, so right-click and Open the first time.
- **Windows x64**: `-setup.exe` installs for your user without an admin prompt. Unsigned, so SmartScreen asks once.
diff --git a/docs/assets/uiscenarios/add-a-layer.webm b/docs/assets/uiscenarios/add-a-layer.webm
new file mode 100644
index 00000000..37908af7
Binary files /dev/null and b/docs/assets/uiscenarios/add-a-layer.webm differ
diff --git a/docs/assets/uiscenarios/add-a-modifier.webm b/docs/assets/uiscenarios/add-a-modifier.webm
new file mode 100644
index 00000000..a696ada3
Binary files /dev/null and b/docs/assets/uiscenarios/add-a-modifier.webm differ
diff --git a/docs/assets/uiscenarios/add-an-effect.webm b/docs/assets/uiscenarios/add-an-effect.webm
new file mode 100644
index 00000000..30f4c69d
Binary files /dev/null and b/docs/assets/uiscenarios/add-an-effect.webm differ
diff --git a/docs/assets/uiscenarios/change-layout.webm b/docs/assets/uiscenarios/change-layout.webm
new file mode 100644
index 00000000..61da6b13
Binary files /dev/null and b/docs/assets/uiscenarios/change-layout.webm differ
diff --git a/docs/assets/uiscenarios/install-firmware.webm b/docs/assets/uiscenarios/install-firmware.webm
new file mode 100644
index 00000000..19f9188c
Binary files /dev/null and b/docs/assets/uiscenarios/install-firmware.webm differ
diff --git a/docs/assets/uiscenarios/react-to-sound.webm b/docs/assets/uiscenarios/react-to-sound.webm
new file mode 100644
index 00000000..ebe7f59c
Binary files /dev/null and b/docs/assets/uiscenarios/react-to-sound.webm differ
diff --git a/docs/assets/uiscenarios/show-the-preview.webm b/docs/assets/uiscenarios/show-the-preview.webm
new file mode 100644
index 00000000..ad566ec5
Binary files /dev/null and b/docs/assets/uiscenarios/show-the-preview.webm differ
diff --git a/docs/assets/uiscenarios/swap-an-effect.webm b/docs/assets/uiscenarios/swap-an-effect.webm
new file mode 100644
index 00000000..c7bd3849
Binary files /dev/null and b/docs/assets/uiscenarios/swap-an-effect.webm differ
diff --git a/docs/assets/uiscenarios/write-an-effect.webm b/docs/assets/uiscenarios/write-an-effect.webm
new file mode 100644
index 00000000..c86bee81
Binary files /dev/null and b/docs/assets/uiscenarios/write-an-effect.webm differ
diff --git a/docs/explanation/architecture/moonbase.md b/docs/explanation/architecture/moonbase.md
index 59375874..97da6b80 100644
--- a/docs/explanation/architecture/moonbase.md
+++ b/docs/explanation/architecture/moonbase.md
@@ -4,6 +4,28 @@ A small maintenance image in the factory slot that installs updates into one lar
What it replaces comes first, then the update cycle, then how MoonBase itself is updated and how the two images are told apart.
+```mermaid
+flowchart LR
+ subgraph flash["one flash, two images"]
+ direction TB
+ base["factory slot MoonBase, ~750 KB small and rarely changing"]
+ app["app slot projectMM one copy, not two"]
+ fs["filesystem the space the second app slot used to hold"]
+ end
+
+ base -->|"installs the app while running from factory"| app
+ app -->|"installs MoonBase while running from ota_0"| base
+
+ classDef recovery fill:#4d3d1f,stroke:#c9a95f,color:#fff
+ classDef running fill:#1f4d3d,stroke:#5fb89a,color:#fff
+ classDef gained fill:#2d3561,stroke:#7b88c9,color:#fff
+ class base recovery
+ class app running
+ class fs gained
+```
+
+Neither image can rewrite the partition it is executing from, so each installs the other. That is the whole scheme: the arrows are the only two write paths, and the app is the only thing that can repair a broken recovery image. The two directions fail differently, and both fail safe. An app update points the bootloader at MoonBase first, so a power cut anywhere in it lands in MoonBase, which a user retries from over the network. A MoonBase update writes and verifies the factory slot without touching otadata, so a cut there leaves the still-valid app in `ota_0` to boot and try again.
+
## What it replaces
Dual-OTA spends half the app area on a second copy of the firmware that is idle except during an
diff --git a/docs/explanation/architecture/mooncloud.md b/docs/explanation/architecture/mooncloud.md
index 42b9b626..3c8ea882 100644
--- a/docs/explanation/architecture/mooncloud.md
+++ b/docs/explanation/architecture/mooncloud.md
@@ -25,7 +25,7 @@ flowchart LR
class db check
```
-The device talks to one origin and nothing else. A report is one POST; the aggregates come back from the same Worker that stored them.
+The device talks to one origin and nothing else.
## The decisions that shape it
diff --git a/docs/explanation/architecture/mooncore.md b/docs/explanation/architecture/mooncore.md
index 754b5085..1ada3a18 100644
--- a/docs/explanation/architecture/mooncore.md
+++ b/docs/explanation/architecture/mooncore.md
@@ -4,6 +4,44 @@ The domain-neutral runtime: the platform abstraction, the services that bridge t
The runtime is described from the outside in: what a service is, how several devices behave as one, how a device is named, and last the platform layer every one of them calls through.
+```mermaid
+flowchart TB
+ q{"does the module consume the light output buffer?"}
+ drv["driver lives in the light domain"]
+ svc["service lives here, in core"]
+
+ q -->|"yes"| drv
+ q -->|"no"| svc
+
+ subgraph core["MoonCore · knows nothing about lights"]
+ direction TB
+ services["Services user-added: gyro, mic, relay"]
+ system["System wired by code: identity, network"]
+ multi["multi-device discovery, and a shared clock"]
+ end
+
+ plat["platform abstraction time · memory · allocExec sockets · scheduling"]
+ hw["ESP32 · Teensy · desktop"]
+
+ svc --> services
+ services --> plat
+ system --> plat
+ multi --> plat
+ drv -.->|"reaches hardware the same way"| plat
+ plat --> hw
+
+ classDef ask fill:#2d3561,stroke:#7b88c9,color:#fff
+ classDef light fill:#4d3d1f,stroke:#c9a95f,color:#fff
+ classDef neutral fill:#1f4d3d,stroke:#5fb89a,color:#fff
+ classDef seam fill:#3d2d61,stroke:#a07bc9,color:#fff
+ class q ask
+ class drv light
+ class svc,services,system,multi neutral
+ class plat,hw seam
+```
+
+One question sorts every module, and it is about the data relationship rather than the connector: a DMX sender speaks over a UART and is still a driver, because it sends the rendered buffer. Everything below the seam is reached only through the platform layer, which is why the same tree runs on a board and on a laptop.
+
## Services
A **service** is a MoonModule (role `ModuleRole::Service`) that bridges to the outside world (hardware or network) *independently of the light pipeline*. Examples: a gyro/IMU over I²C, a microphone over I²S, a relay or GPIO toggled out, a status push to Home Assistant. Services are **domain-neutral and live in core**; the platform transport they use (I²C, UART, GPIO) is itself a domain-neutral platform primitive.
diff --git a/docs/explanation/architecture/moondeck.md b/docs/explanation/architecture/moondeck.md
index 7286f86e..4d12803b 100644
--- a/docs/explanation/architecture/moondeck.md
+++ b/docs/explanation/architecture/moondeck.md
@@ -6,8 +6,6 @@ The shape comes first, then why the scripts are ours, then where the state lives
## One script per task, two front ends
-
-
```mermaid
flowchart LR
task["a task build · flash · test · check"]
@@ -29,6 +27,8 @@ flowchart LR
Every action the console offers is a thin wrapper around a script, so `uv run moondeck/build/build_desktop.py` and the button run the same code. That is the whole design: one implementation, two ways in, and no path where a human and an agent measure something differently.
+
+
**The script is the contract.** It picks the right per-host build directory, applies the flags the gate expects, and tees its output where the report reads it. Reaching past it to `cmake` or `idf.py` produces a number measured differently, or a stale binary the script would have rebuilt.
## Why our own scripts
diff --git a/docs/explanation/architecture/mooninstaller.md b/docs/explanation/architecture/mooninstaller.md
index 2e7383a0..6560d71f 100644
--- a/docs/explanation/architecture/mooninstaller.md
+++ b/docs/explanation/architecture/mooninstaller.md
@@ -2,10 +2,41 @@
Three words name three different things, and a device is configured by all of them: the firmware it runs, the deviceModel it is, and the board it sits on. Getting a device onto the network is the installer's job; knowing what it is afterwards is this vocabulary.
-
-
The three words come first, then where a default legitimately comes from.
+```mermaid
+flowchart TB
+ mcu["MCU classic · S3 · P4"]
+ fw["firmware the compiled binary"]
+ model["deviceModel the assembled product Olimex ESP32-Gateway Rev G"]
+ board["board the bare PCB, literally"]
+ name["deviceName which individual unit this is"]
+
+ mcu --> fw
+ fw -->|"seeds a per-chip default"| model
+ board --> model
+ model --> name
+
+ fixes["fixes: which radios, which ethernet driver, PSRAM"]
+ owns["fixes: the real pin map, buttons, mic, TX power"]
+
+ fw -.-> fixes
+ model -.-> owns
+
+ classDef silicon fill:#4d3d1f,stroke:#c9a95f,color:#fff
+ classDef product fill:#1f4d3d,stroke:#5fb89a,color:#fff
+ classDef unit fill:#2d3561,stroke:#7b88c9,color:#fff
+ classDef note fill:#3d2d61,stroke:#a07bc9,color:#fff
+ class mcu,fw silicon
+ class model,board product
+ class name unit
+ class fixes,owns note
+```
+
+A default belongs at the level that fixes it, which is the whole rule. The firmware seeds what the silicon decides; the deviceModel overrides it with what the product wired. So the ethernet pins appear at both levels without contradiction: the firmware offers a fallback, the catalog entry states the truth. A control nobody fixed is omitted, and stays unset for the user to wire.
+
+
+
## The three words
Three distinct things, kept distinct in the vocabulary:
diff --git a/docs/explanation/architecture/moonlight.md b/docs/explanation/architecture/moonlight.md
index 026ebd06..e8bece07 100644
--- a/docs/explanation/architecture/moonlight.md
+++ b/docs/explanation/architecture/moonlight.md
@@ -6,8 +6,6 @@ The pipeline comes first, then each stage in the order light flows through it, a
## The pipeline
-
-
Modules in the light pipeline can be added, replaced, or removed dynamically at runtime.
```mermaid
@@ -54,6 +52,8 @@ flowchart TB
class dma,udp,sock gate
```
+
+
**Data flow.** The pipeline instantiates both core data-exchange shapes (see [data exchange between modules](moonmodule.md#data-exchange-between-modules)):
- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, more than at boot: `Drivers::prepare()` re-resolves the active `Layer` (`Effects::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild. That is why consumers re-read it each frame and tolerate a null, per the [robustness rule](moonmodule.md#robustness): a Layer add, delete or replace re-binds or clears it live, with no dangling reference.
diff --git a/docs/explanation/architecture/moonlive.md b/docs/explanation/architecture/moonlive.md
index 2c838036..1ba5f190 100644
--- a/docs/explanation/architecture/moonlive.md
+++ b/docs/explanation/architecture/moonlive.md
@@ -4,6 +4,43 @@ Scripts compiled to native machine code on the device. An effect written as text
The engine comes first, then how a script reaches the device, and last the one place the lifecycle does not yet fit.
+```mermaid
+flowchart TB
+ src["a script, as text written in the browser"]
+
+ subgraph front["front-end · core, platform-independent"]
+ direction TB
+ lex["lex and parse every argument is an expression"]
+ ir["typed IR three-address ops over virtual registers"]
+ lex --> ir
+ end
+
+ subgraph seam["the two seams"]
+ direction TB
+ host["host builtin table name to Call or Inline the LED words live only here"]
+ back["per-ISA backend Xtensa · RISC-V · host a new CPU is a new file"]
+ end
+
+ native["native machine code in an allocExec block"]
+ tick["the render loop calls it through a function pointer"]
+
+ src --> lex
+ ir --> back
+ host -.->|"names and opcodes"| back
+ back --> native --> tick
+
+ classDef text fill:#2d3561,stroke:#7b88c9,color:#fff
+ classDef core fill:#1f4d3d,stroke:#5fb89a,color:#fff
+ classDef edge fill:#4d3d1f,stroke:#c9a95f,color:#fff
+ classDef out fill:#3d2d61,stroke:#a07bc9,color:#fff
+ class src text
+ class lex,ir core
+ class host,back edge
+ class native,tick out
+```
+
+The IR is the seam, and it is what keeps the three tiers from knowing about each other: it names operations, never an instruction set and never a domain. The front-end never branches on CPU, the core never learns an LED word, and the compiled result is called like any other function. Adding a CPU is a backend file; adding a domain function is a table entry.
+
## A native-codegen compiler
MoonLive lets you author an effect (later: a layout, modifier, driver, or core rule) as **text** and run it on a running device, with no recompile-and-flash cycle. Its standout property is *how* it runs the script. It is a **native-codegen compiler** rather than a bytecode interpreter: source text is lexed, parsed, lowered to a typed IR, and assembled to real machine code. The render loop calls that through a plain function pointer, so a scripted effect runs at near-hand-written speed in the hot path. This is the core construct; a scripted effect (`MoonLiveEffect`) is the thin binding that gives it the MoonModule lifecycle.
diff --git a/docs/explanation/architecture/moonmodule.md b/docs/explanation/architecture/moonmodule.md
index 8e929c33..9f1e4b16 100644
--- a/docs/explanation/architecture/moonmodule.md
+++ b/docs/explanation/architecture/moonmodule.md
@@ -4,6 +4,59 @@ The one building block. Every effect, modifier, layout, driver and service is a
The lifecycle comes first, then what a module exposes and persists, then how modules reach each other, and last the two rules every module is held to: robustness and the hot path.
+```mermaid
+flowchart TB
+ subgraph boot["boot · Scheduler::setup, five phases in order"]
+ direction TB
+ p1["1 · defineControls bind each name to a member variable"]
+ p2["2 · load persisted values land in those variables"]
+ p3["3 · setup the module's own init, values already in place"]
+ p4["4 · applyState build the derived state, or release it"]
+ p5["5 · reapply values for controls only a prepare could declare"]
+ p1 --> p2 --> p3 --> p4 --> p5
+ end
+
+ subgraph run["run · every frame, until something changes"]
+ direction TB
+ t1["tick per frame, hot path"]
+ t2["tick20ms periodic work"]
+ t3["tick1s housekeeping"]
+ end
+
+ subgraph change["a control changes · three tiers, cheapest first"]
+ direction TB
+ c1["onControlChanged this module only, in place"]
+ c2{"affectsPrepare?"}
+ c3["prepareTree every module rebuilds its derived state"]
+ c1 --> c2
+ c2 -->|"yes: the shape changed"| c3
+ c2 -->|"no: a value moved"| c1
+ end
+
+ subgraph gone["release · reverse order, two passes"]
+ direction TB
+ r1["release every module, siblings still alive"]
+ r2["deleteTree then the memory goes"]
+ r1 --> r2
+ end
+
+ boot --> run
+ run --> change
+ change -->|"applyState routes each node"| run
+ run --> gone
+
+ classDef phase fill:#1f4d3d,stroke:#5fb89a,color:#fff
+ classDef hot fill:#4d3d1f,stroke:#c9a95f,color:#fff
+ classDef cold fill:#2d3561,stroke:#7b88c9,color:#fff
+ classDef teardown fill:#3d2d61,stroke:#a07bc9,color:#fff
+ class p1,p2,p3,p4,p5 phase
+ class t1,t2,t3 hot
+ class c1,c2,c3 cold
+ class r1,r2 teardown
+```
+
+Four states, and the arrows are the only way between them. Boot runs its five phases once, in that order, because each depends on the one before. A control cannot take a persisted value before it is bound, and a buffer cannot size itself before the value that sizes it has arrived. After that the module ticks until something changes, and a change re-enters the build phase rather than taking a path of its own. Teardown reverses the order so a module's `release` still sees live siblings.
+
## What counts as a module
The core building block is a **[MoonModule](../../moonmodules/core/moxygen/MoonModule.md)**. Everything is a MoonModule, more than effects, modifiers, layouts, and drivers, but also system infrastructure (HTTP server, WebSocket server, file server, WiFi, mDNS, OTA updates) and [services](mooncore.md#services) (sensors and actuators bridging to hardware/network). The core itself is minimal: MoonModule base, buffer management, a [Scheduler](../../moonmodules/core/moxygen/Scheduler.md).
@@ -67,7 +120,7 @@ Controls are the bridge between the [web UI](../../moonmodules/core/ui.md) and t
Control values and each module's `enabled` flag are persisted to flash so settings survive a reboot. The mechanism lives in [FilesystemModule](../../moonmodules/core/moxygen/FilesystemModule.md):
- **Storage**: one flat JSON file per top-level module under `/.config/.json`. Children are encoded positionally with `.` key prefixes, a deliberately flat file shape loaded by the cheap first-match key helpers in `core/JsonUtil.h`. A control whose *value* is structured, such as a List control's array of objects, round-trips that array with the recursive reader in the same header through its own restore hook. The file's top level stays flat, and the structure lives inside one control's value.
-- **Lifecycle**: `Scheduler::setup()` runs four phases. First `defineControls` binds every module's full control set. Then the FilesystemModule load hook overlays persisted values onto the bound variables, and `rebuildControls` re-evaluates conditional `hidden` flags against the loaded state. Third, each module's own `setup()` runs with persisted values already in its member variables. Last, `prepare` sizes the buffers. Modules themselves know nothing about persistence; they bind their variables.
+- **Lifecycle**: the five phases the diagram above draws, read from persistence's side. `defineControls` binds every module's control set. The load hook then overlays persisted values onto those variables, and `rebuildControls` re-evaluates conditional `hidden` flags against the loaded state. Each module's own `setup()` runs with the values already in place, and `applyState` sizes the buffers. The fifth phase exists for persistence alone: a control that appears only once `prepare` has run, such as one a MoonLive script declares, had nothing to land on during the load, so saved values are re-applied once at the end. Modules themselves know nothing about persistence; they bind their variables.
- **Save trigger**: HttpServerModule marks the target module dirty on every successful control mutation. FilesystemModule debounces 2 s in `tick1s()`, walks the tree, writes any subtree containing a dirty descendant via atomic write-and-rename.
- **Conditional controls**: every conditional control is always bound; the module sets a `hidden` flag (`controls_.setHidden(i, …)`) to tell the UI not to render it. The load path can therefore find persisted values regardless of the live conditional state.
- **Code-wired children survive a stale file**: some children aren't created by the user; `main.cpp`'s boot wiring attaches them (`ImprovProvisioningModule` under `NetworkModule`; `NetworkSendDriver`, `PreviewDriver` under their parents). Each such child calls `markWiredByCode()` after `addChild()`, a one-bit flag meaning *"I belong here because the code put me here, not because a saved file or a user asked for me."* The problem it solves is a trim on load. Persistence reconciles the live tree against the saved JSON, so a child that exists in code but is absent from an older file, written before that child was added, would be dropped. The flag tells the apply step to keep it. Children added through the HTTP API or recreated from JSON stay unmarked; those follow the file's tree shape exactly, so UI deletes still take effect.
diff --git a/docs/explanation/why-we-write-our-own.md b/docs/explanation/why-we-write-our-own.md
index c02219df..03abdbee 100644
--- a/docs/explanation/why-we-write-our-own.md
+++ b/docs/explanation/why-we-write-our-own.md
@@ -4,7 +4,7 @@ title: Why we write our own code
# Why we write our own code
-projectMM pulls in no third-party libraries: no FastLED, no ESPAsyncWebServer, no ArduinoJson. A library that is genuinely needed lives behind the platform boundary in `src/platform/`, never in core or the light domain. The *what*, with the replacement for each, is in [building.md § Third-party libraries](../how-to/building.md#third-party-libraries). The *why* follows.
+projectMM pulls in no third-party libraries: no FastLED, no ESPAsyncWebServer, no ArduinoJson. A library that is genuinely needed lives behind the platform boundary in `src/platform/`, never in core or the light domain. The *what*, with the replacement for each, is [below](#third-party-libraries). The *why* comes first.
## A dependency is a hole in the test coverage
@@ -57,3 +57,25 @@ Writing your own implementation of a known idea can land in either column, and w
Credit is the fifth row, and it needs care for a mechanical reason: rewriting removes the easiest form of attribution there is. Take a dependency and the author's name appears in the manifest automatically, as a side effect of the build. Write it yourself and that disappears, even when the idea, the approach or the algorithm came straight from someone else's work. So it has to be deliberate: named in the README's Credits, named in each module's Prior art notes, named in the [friend-repo digests](../friend-repos/index.md), in the place where it can be checked against the source.
If something here came from your work and is not credited where it should be, [open an issue](../how-to/logging-an-issue.md) or find us on [Discord](https://discord.gg/TC8NSUSCdV). We would much rather hear it directly.
+
+## ESP-IDF directly, rather than Arduino
+
+The ESP32 target uses ESP-IDF directly for three reasons:
+
+- **Direct hardware control.** RMT peripheral for LED protocols, FreeRTOS task pinning with explicit stack sizes, `heap_caps_malloc` with SPIRAM/8BIT caps, `esp_timer` microsecond timing. Arduino wraps these with abstractions that add overhead and hide control.
+- **Native CMake.** ESP-IDF's build system *is* CMake (`idf.py` wraps it). No impedance mismatch. Arduino-on-ESP-IDF adds a compatibility layer that complicates the build.
+- **Version stability.** ESP-IDF APIs are stable. Arduino-esp32 version churn caused recurring breakage in MoonLight.
+
+Arduino can be added as an ESP-IDF component later if a specific Arduino library is needed; this is officially supported by Espressif and doesn't require restructuring.
+
+## Third-party libraries
+
+The platform abstraction layer replaces what libraries typically provide. Today no third-party libraries are pulled in:
+
+| Library | Why not | What replaces it |
+|---|---|---|
+| [FastLED](https://github.com/FastLED/FastLED) | Arduino-dependent. LED protocol drivers (RMT, SPI) are available natively in ESP-IDF; FastLED's color math is small enough to reimplement. | Own color math in core. Own LED drivers per platform in `src/platform/`. |
+| [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) | Arduino-dependent. Past memory-leak issues. Ties us to Arduino. | Own HTTP server via ESP-IDF's `esp_http_server` (ESP32) or BSD sockets (desktop). Reconsider if Arduino-as-component is added. |
+| [ArduinoJson](https://github.com/bblanchon/ArduinoJson) | Works on ESP-IDF, but heavy: dynamic allocation, large footprint. | Own `JsonSink`, which serves both the API and persistence: each module's state is written as a flat `/.config/.json` and read back through `loadSubtree`. |
+
+When a library is genuinely needed (e.g. FastLED for specific hardware support), it lives inside `src/platform/` and is not referenced from core or light-domain code.
diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md
index c5f33e04..324ae774 100644
--- a/docs/gettingstarted.md
+++ b/docs/gettingstarted.md
@@ -28,6 +28,8 @@ Chrome or Edge, then plug your ESP32 into a USB port.

+
+
### 2. Pick the USB port
Click **USB Port → Pick a port…**. Your browser shows a small list of connected
diff --git a/docs/how-to/backup-and-restore.md b/docs/how-to/backup-and-restore.md
new file mode 100644
index 00000000..996222d4
--- /dev/null
+++ b/docs/how-to/backup-and-restore.md
@@ -0,0 +1,56 @@
+# Back up and restore a device
+
+Every setting a device holds, saved to one file on your computer, and put back on the same device or a different one.
+
+Worth doing before a firmware update, before rewiring, and once a rig you care about is working the way you want.
+
+## Take a backup
+
+Open the **File Manager** panel and press **Backup (⤓)**.
+
+The browser downloads one `.json` bundle named for the device and the date. It walks the whole filesystem, so it holds the configuration of every module, your saved [presets](presets.md), and your MoonLive scripts.
+
+**Keep the file private. It contains the WiFi password.**
+
+Every file is byte-verified against the directory listing as it is read, with three outcomes. A file shorter than listed aborts the backup, because silent truncation is worse than no backup at all. A file longer than listed is binary rather than text, so it is skipped and named. A file that cannot be read is skipped and named too.
+
+One thing is left out on purpose: `/.hls`, the streaming encoder's scratch output, which is rewritten every second and would fill the report with skipped binaries.
+
+## Put it back
+
+Press **Restore (⟲)**, choose the bundle, and confirm twice. The second press is deliberate: restoring overwrites the device's files.
+
+Then read the report. It lists what needed an eye:
+
+- **Renamed and mapped**: a file, module type or control that changed name since the backup was taken, and what it became.
+- **Values to review**: something the mapping could not decide on its own, usually because it depends on the chip or the wiring.
+- **No longer present**: a module type or control this firmware does not have.
+
+Most of a restore applies live, module by module, as each file lands. Two things do not, and the dialog names both:
+
+- **Network settings** apply at boot, because bringing an interface up is not a re-runnable operation. The dialog offers the restart, after which the device joins the network the backup names. Its file is written **last** for that reason: joining another network mid-restore would cut off the writes still to come.
+- **The web server's port**, which binds at boot.
+
+## Why the browser does the work
+
+The device never runs migration code. Its loader is robust by design: an absent key keeps the control's default, a stale value clamps to the new bounds, and an unknown key is ignored.
+
+So the rename maps live in the browser, in [migrate.js](https://github.com/MoonModules/projectMM/blob/main/src/ui/migrate.js), and apply to the bundle *before* it is uploaded. Each firmware release embeds the map as of that release, which is what lets any older backup restore correctly onto it with no version stamps anywhere.
+
+The breaks a map cannot express are written down instead, in [MIGRATING](../reference/MIGRATING.md).
+
+## Restoring onto a blank device
+
+A backup restores onto a freshly erased device, which is what makes an erase safe to do.
+
+Join the device's `MM-XXXX` access point, open `http://4.3.2.1`, restore there, and take the offered restart. The bundle carries the WiFi credentials, so the device comes back on your network by itself.
+
+## From firmware older than the Backup button
+
+The [installer page](https://moonmodules.org/projectMM/install/) offers the same backup as a bookmarklet, so a device too old to have the button can still be captured before you update it.
+
+## Moving a setup to another device
+
+A restore is how you clone a rig. Take a backup from the working device, restore onto the new one, and read the report.
+
+One thing to check afterwards: anything that names a pin. The configuration carries the pins the old board used, and if the new device is wired differently, the lights will be wrong in a way that looks like a broken effect. The [device name](../explanation/architecture/mooncore.md#device-name-one-identity-every-network-name-derives-from-it) comes across too, so give the clone its own.
diff --git a/docs/how-to/building.md b/docs/how-to/building.md
index 6d90e32c..f49d87e8 100644
--- a/docs/how-to/building.md
+++ b/docs/how-to/building.md
@@ -23,32 +23,6 @@ MoonDeck has three tabs:
Script definitions and configuration live in `moondeck/moondeck_config.json` (committed). Script documentation lives in `moondeck/MoonDeck.md`, one section per script. Runtime state (selected devices, ports) persists in `moondeck/moondeck.json` (gitignored).
-## Tooling overview
-
-CMake is the sole build system. The source tree is shared across every platform, but build entry points are separate because ESP-IDF wraps CMake with its own conventions (`idf_component_register()` instead of `add_library()`).
-
-```text
-CMakeLists.txt ← standard CMake: desktop / RPi + tests
-src/
- main.cpp ← shared pipeline wiring (mm_main), platform-neutral
- platform/
- desktop/
- main_desktop.cpp ← desktop entry point: int main() + SIGINT
- platform_config.h ← desktop platform constants
- esp32/
- platform_config.h ← ESP32 platform constants (reads sdkconfig)
-esp32/
- CMakeLists.txt ← ESP-IDF project root (thin wrapper)
- main/
- CMakeLists.txt ← idf_component_register() pointing at src/
- main.cpp ← ESP32 entry point: app_main() + Ethernet init
- sdkconfig.defaults ← board-specific defaults
-```
-
-The shared `src/main.cpp` defines `mm_main(keepRunning, gridW, gridH)` — the full pipeline wiring. Each platform provides a thin entry point that does platform-specific init (SIGINT on desktop, Ethernet on ESP32) then calls `mm_main()`.
-
-The project is structured as a small set of CMake libraries: a core library (platform-independent), a platform library (selected at configure time), an application target (links both, provides the entry point). Further decomposition (effects, networking, drivers as separate libraries) happens when the codebase is large enough to justify it.
-
## Desktop / Raspberry Pi
Desktop and RPi both build with the root `CMakeLists.txt`. RPi can cross-compile against the same tree or build natively on the device — same source.
@@ -395,29 +369,6 @@ older layout adopts a new one only through a full serial flash (see the note und
[Firmware variants](#firmware-variants)). On the 4 MB classic that migration also moves the
filesystem, so the device comes back unprovisioned.
-### Why not Arduino
-
-The ESP32 target uses ESP-IDF directly for three reasons:
-
-- **Direct hardware control.** RMT peripheral for LED protocols, FreeRTOS task pinning with explicit stack sizes, `heap_caps_malloc` with SPIRAM/8BIT caps, `esp_timer` microsecond timing. Arduino wraps these with abstractions that add overhead and hide control.
-- **Native CMake.** ESP-IDF's build system *is* CMake (`idf.py` wraps it). No impedance mismatch. Arduino-on-ESP-IDF adds a compatibility layer that complicates the build.
-- **Version stability.** ESP-IDF APIs are stable. Arduino-esp32 version churn caused recurring breakage in MoonLight.
-
-Arduino can be added as an ESP-IDF component later if a specific Arduino library is needed; this is officially supported by Espressif and doesn't require restructuring.
-
-### Third-party libraries
-
-The platform abstraction layer replaces what libraries typically provide. Today no third-party libraries are pulled in:
-
-| Library | Why not | What replaces it |
-|---|---|---|
-| [FastLED](https://github.com/FastLED/FastLED) | Arduino-dependent. LED protocol drivers (RMT, SPI) are available natively in ESP-IDF; FastLED's color math is small enough to reimplement. | Own color math in core. Own LED drivers per platform in `src/platform/`. |
-| [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) | Arduino-dependent. Past memory-leak issues. Ties us to Arduino. | Own HTTP server via ESP-IDF's `esp_http_server` (ESP32) or BSD sockets (desktop). Reconsider if Arduino-as-component is added. |
-| [ArduinoJson](https://github.com/bblanchon/ArduinoJson) | Works on ESP-IDF, but heavy: dynamic allocation, large footprint. | Own fixed-size control storage. JSON only for API serialisation, not internal state. |
-
-When a library is genuinely needed (e.g. FastLED for specific hardware support), it lives inside `src/platform/` and is not referenced from core or light-domain code.
-
-Why the trade is worth making, and what it costs: [Why we write our own code](../explanation/why-we-write-our-own.md).
## Teensy
diff --git a/docs/tutorials/control-surface.md b/docs/how-to/control-surface.md
similarity index 100%
rename from docs/tutorials/control-surface.md
rename to docs/how-to/control-surface.md
diff --git a/docs/tutorials/installing-on-linux.md b/docs/how-to/installing-on-linux.md
similarity index 99%
rename from docs/tutorials/installing-on-linux.md
rename to docs/how-to/installing-on-linux.md
index fac175d8..e2ef89dc 100644
--- a/docs/tutorials/installing-on-linux.md
+++ b/docs/how-to/installing-on-linux.md
@@ -244,5 +244,5 @@ The realistic limits are architecture and memory, not the kind of device. It nee
## Where to go next
- [Install & first light](../gettingstarted.md): the same program on an ESP32.
-- [How projectMM works](how-projectmm-works.md): layouts, layers, effects and drivers.
+- [How projectMM works](../tutorials/how-projectmm-works.md): layouts, layers, effects and drivers.
- [building.md](../how-to/building.md): building, testing and packaging in depth.
diff --git a/docs/tutorials/installing-to-desktop.md b/docs/how-to/installing-to-desktop.md
similarity index 97%
rename from docs/tutorials/installing-to-desktop.md
rename to docs/how-to/installing-to-desktop.md
index d0b136e1..a2a53e4b 100644
--- a/docs/tutorials/installing-to-desktop.md
+++ b/docs/how-to/installing-to-desktop.md
@@ -27,7 +27,7 @@ Your browser will most likely flag it straight away: *"isn't commonly downloaded
## 2. Tell the browser to keep it
-Microsoft Defender SmartScreen judges a download by its **reputation**, built from how many people have downloaded that exact file from a publisher it recognises. projectMM is not code-signed, and every build produces a brand-new file, so its reputation is always zero. The warning is about the certificate, not about the contents.
+Microsoft Defender SmartScreen judges a download by its **reputation**, built from how many people have downloaded that exact file from a publisher it recognizes. projectMM is not code-signed, and every build produces a brand-new file, so its reputation is always zero. The warning is about the certificate, not about the contents.
In the Downloads panel, click the **`⋯`** next to the file, then open the **Delete** dropdown and choose **Keep anyway**:
@@ -75,7 +75,7 @@ The console window **is** the application. It shows the log, and closing it stop
On a first install you get a default grid and a running effect, enough to confirm everything works. The screenshot above is not a first install: that machine already had projectMM configured with a Game of Life layer, and the setup left it exactly as it was. That is §6.
-From here, [How projectMM works](how-projectmm-works.md) explains the Layouts, Effects and Drivers down the left-hand side.
+From here, [How projectMM works](../tutorials/how-projectmm-works.md) explains the Layouts, Effects and Drivers down the left-hand side.
Two options worth knowing: `--no-browser` stops it opening a browser (for a headless machine), and `--port ` serves somewhere other than 8080.
@@ -138,6 +138,6 @@ Settings live in the same per-user folder whichever route you take, so the three
## Where to go next
-- **[How projectMM works](how-projectmm-works.md)**: the interface, and the Layouts / Effects / Drivers model.
+- **[How projectMM works](../tutorials/how-projectmm-works.md)**: the interface, and the Layouts / Effects / Drivers model.
- **[Driving LED panels with a receiving card](panel-cards.md)**: turn this desktop into the sending card for an LED wall.
- **[Install & first light](../gettingstarted.md)**: flashing an ESP32, if you want the same thing on a device.
diff --git a/docs/tutorials/panel-cards.md b/docs/how-to/panel-cards.md
similarity index 99%
rename from docs/tutorials/panel-cards.md
rename to docs/how-to/panel-cards.md
index c1f32d98..8a64e148 100644
--- a/docs/tutorials/panel-cards.md
+++ b/docs/how-to/panel-cards.md
@@ -2,7 +2,7 @@
You bought a panel receiving card, most likely a **ColorLight** one, which is the family projectMM supports today. This page takes you from a box of parts to a lit wall, on an ESP32 or from a desktop.
-> New here? Start with **[Install & first light](../gettingstarted.md)**, then **[How projectMM works](how-projectmm-works.md)**. This page assumes you can find a card and change a control.
+> New here? Start with **[Install & first light](../gettingstarted.md)**, then **[How projectMM works](../tutorials/how-projectmm-works.md)**. What follows assumes you can find a card and change a control.
---
diff --git a/docs/how-to/presets.md b/docs/how-to/presets.md
new file mode 100644
index 00000000..93ed2d1e
--- /dev/null
+++ b/docs/how-to/presets.md
@@ -0,0 +1,66 @@
+# Save and recall presets
+
+A preset is a saved state you can bring back with one click: a look you liked, a geometry you wired, a hardware setup you got right.
+
+They live on the Control card as a grid of 64 pads. Click one to apply it.
+
+> **Two different things are called a preset.** The pads on the Control card are what people usually mean, and what follows is about those. The **light preset** under Drivers is a fixture profile, naming which channel carries red or pan: see [LightPresets](../moonmodules/light/supporting.md#lightpresets).
+
+## Save one
+
+Right-click a pad, or press and hold it on a touchscreen. The pad editor opens.
+
+Type a name, choose what to capture, and press **save current state here**.
+
+To overwrite, do the same on a pad that already holds one: the editor offers **save current state over it**, a rename, and **delete preset**.
+
+## What a preset captures
+
+Exactly one of four subtrees, chosen as a radio button rather than a set of checkboxes:
+
+| Capture | What it holds | What it is |
+|---|---|---|
+| **Effects** | the layer, its effects and modifiers | a look |
+| **Layouts** | where the lights are | a geometry |
+| **Drivers** | pins, brightness, outputs | a hardware setup |
+| **Services** | sensors and bridges | a service configuration |
+
+One subtree, never a combination, and that is the whole model. It is what makes a look **portable**: an Effects preset applies on any board, because it carries no pin map. Add Drivers to it and it becomes a device snapshot tied to one rig's wiring, which is a different and much less shareable thing.
+
+The combinations used to be expressible, and they were the hard part to explain and the hard part to display.
+
+## Applying one
+
+Click the pad. The look, geometry or setup replaces what was there.
+
+**It is a restore, not an overlay.** A preset carrying more modules than the device has adds them; one describing fewer removes what it omits. That is what makes a pad reliable: what you saved is what comes back, rather than what you saved merged with whatever drifted since.
+
+**One active preset per role**, so a layout preset and a look stay lit together. Applying a new look replaces only the look.
+
+## Arranging the pads
+
+Drag a pad to move it. A pad is a **position, not a list entry**: slot 14 stays slot 14 whether or not anything sits in it, and deleting slot 3 does not shuffle slot 4 into its place.
+
+The order persists, stamped into each preset's own file, so a preset folder copied to another device brings its layout along.
+
+## Where they live
+
+One file per preset, at `/.config/presets/.json`. A name may use printable characters but no `/`, `\` or `.`, up to 31 characters.
+
+They ride along in a [backup](backup-and-restore.md), which is how a rig's looks move to another device.
+
+Adding or removing preset files by hand in the File Manager has one catch. The pad grid rebuilds its list when the module next rescans: at startup, or after a save, rename or delete on the card. A file dropped in does not appear the instant it lands.
+
+## In Home Assistant
+
+Only **Effects** presets travel to Home Assistant, where they appear as the light entity's effect list over MQTT, or in the native preset dropdown through the WLED integration.
+
+Layouts and Drivers presets are deliberately excluded: they rewire pins and geometry, and should not be reachable from something that believes it is choosing a color scheme. Setting that up is in [Home automation](home-automation.md).
+
+## When a preset refuses to apply
+
+The card says why rather than applying half of it:
+
+- **It names a subtree this firmware does not have.** Refused whole.
+- **It carries several roles**, having been written by an older build. Listed but not applied, with `re-save it` as the fix, so you can see it and decide rather than watch it vanish.
+- **The file is malformed.** The live tree is untouched.
diff --git a/docs/how-to/troubleshooting.md b/docs/how-to/troubleshooting.md
new file mode 100644
index 00000000..73fbe007
--- /dev/null
+++ b/docs/how-to/troubleshooting.md
@@ -0,0 +1,64 @@
+# Troubleshooting
+
+Start from the symptom. Each entry says what to check first and where the detailed page is, because the fastest fix is usually eliminating the innocent half rather than guessing at the guilty one.
+
+## The lights are dark
+
+**Check the preview first.** If the 3D preview shows the effect running, the pipeline is fine and the problem is downstream: the driver, the wiring, or the power. If the preview is also dark, it is upstream: the layout, the effect, or the brightness.
+
+Then, in order:
+
+- **Brightness** on the Drivers card. Zero is dark and looks identical to broken.
+- **Is a driver added at all?** A fresh device previews without one. Real lights need a driver naming the pin or the destination.
+- **Does the light count match?** A layout of 256 and a driver of 16 lights the first sixteen and nothing else.
+- **Module status.** A card with a problem says so on itself. A red line names the failure.
+
+A scripted effect that fails to compile renders dark deliberately and shows the parse error on its card: see [when the compile fails](../tutorials/first-script.md#when-the-compile-fails).
+
+## Stray pixels, or wrong colors on lights that should be off
+
+Almost always electrical rather than firmware, on a 3.3 V board driving 5 V WS2812 directly.
+
+The diagnosis path, which eliminates the firmware before anyone reaches for a soldering iron, is [LED signal integrity](led-signal-integrity.md).
+
+## Colors are wrong everywhere
+
+Red where you expect green usually means the channel order does not match the strip. Set it on the driver: the [light preset](../moonmodules/light/supporting.md#lightpresets) names which channel carries which color.
+
+Whole-panel color shifts on a receiving card are a different thing, covered in [panel cards](panel-cards.md).
+
+## Every other row is backwards
+
+The strip zig-zags and the layout does not know. Turn on **serpentine** on the Grid layout.
+
+## The device is missing from the network
+
+- **It never joined.** An unprovisioned device opens its own access point named `MM-XXXX`. Join it and open `http://4.3.2.1` to set the credentials.
+- **It joined but you cannot find it.** Try the IP from your router's client list before the `.local` name: mDNS fails on plenty of networks that route fine. A device with no mDNS responder is reachable by address all along.
+- **It was working and stopped.** Check the device is powered and the access point is not sitting between two networks. A repeater on one radio halves its throughput and drops the client side under load.
+
+## The interface is slow, or the connection indicator flickers
+
+The preview streams a full frame per update, and a large grid over a marginal link is the usual cause. Turn the preview off and see whether the interface recovers.
+
+The device drops preview frames rather than blocking the render loop, so this costs smoothness rather than correctness.
+
+## An update failed
+
+The device stays in [MoonBase](updating-firmware.md#when-the-device-boots-into-moonbase) rather than pretending to have worked, and its page offers you a retry. You cannot brick a device this way: MoonBase is never overwritten by an app update.
+
+## The device rebooted on its own
+
+Check the power first. A board browning out under load looks exactly like a software crash, and a supply that cannot hold current while lights draw is the more common cause.
+
+If the power is solid, the crash log is on the device: the System card shows the last reset reason, and [logging an issue](logging-an-issue.md) says what to collect.
+
+## Settings vanished after a reboot
+
+Config persists a couple of seconds after the last change, so a power cut within that window loses the last edit and nothing else.
+
+If more than that vanished, the filesystem may have been erased by an install with **Erase chip first** ticked. A [backup](backup-and-restore.md) restores it.
+
+## When none of this helps
+
+[Log an issue](logging-an-issue.md). You do not need to diagnose it; the page says what to include so somebody else can.
diff --git a/docs/how-to/updating-firmware.md b/docs/how-to/updating-firmware.md
new file mode 100644
index 00000000..72da9e31
--- /dev/null
+++ b/docs/how-to/updating-firmware.md
@@ -0,0 +1,54 @@
+# Update the firmware
+
+Install a newer projectMM on a device that is already running one. Over the network, from the device's own interface, with no cable.
+
+Your settings survive: a firmware update replaces the program, not the configuration. The one thing it costs is a reboot, which is what makes it different from every other change in projectMM.
+
+## The normal route
+
+Open the device's **Firmware** card under System.
+
+It shows the version it runs, the build it came from, and which partition it lives in. Pick the image and start the install. The card reports progress as it downloads and writes, then the device reboots into the new firmware on its own.
+
+Where to get an image:
+
+- **`latest`** is the rolling build from `main`: every merged change, published continuously. What to use if you want the newest fixes and can live with the occasional rough edge.
+- **A tagged release** is a version somebody decided was worth naming. Slower moving, and what to use on a rig that has to keep working.
+
+Both are on the [releases page](https://github.com/MoonModules/projectMM/releases), one file per firmware variant. The filename names the variant, and the variant must match your device: an `esp32s3-n16r8` image on a classic ESP32 refuses to boot.
+
+The card checks for both channels and tells you when one is newer, and a stable release always wins. A device already on a `-dev` build is the only one offered the moving channel, so a stable device is never nudged toward an unreleased build.
+
+## On a device that carries MoonBase
+
+Some variants carry [MoonBase](../explanation/architecture/moonbase.md), a small recovery image in the factory slot. The 4 MB classic, `esp32-16mb` and the S3-Zero use it today.
+
+The card looks slightly different there. It gains an **image** selector, choosing whether the version shown and the install performed apply to the app or to MoonBase itself, and a **Restart in MoonBase** button.
+
+The install runs the same way from your side, behind one "updating firmware" overlay. Underneath, the device stages the URL, reboots into MoonBase, lets MoonBase write the app slot, and reboots back. A board cannot rewrite the partition it is executing from, so the two images install each other.
+
+What that buys you is the failure case. A power cut in the middle leaves the device in MoonBase rather than holding half an app, and MoonBase is a working page you can retry from over the network. A failed install stays there visibly instead of pretending to have worked.
+
+## Updating MoonBase itself
+
+The same card installs a newer MoonBase, writing the factory slot while the app runs. If the card marks the carried version outdated, that is the fix, and it needs no cable.
+
+This is the one write with a window where the device holds no recovery image. The app keeps running throughout, so the answer to a failure is to try again.
+
+## When the device boots into MoonBase
+
+It means the app did not start, or an update was interrupted. MoonBase serves its own page and offers three ways out:
+
+- **Boot the app**, which changes nothing and is worth trying first: it boots only an image that validates.
+- **From a file**, installing a `firmware-....bin` you already downloaded.
+- **From a URL**, fetching and installing in one step. Keep it under 255 characters: it crosses into MoonBase through a fixed-size slot, and a longer one is refused rather than truncated.
+
+If the device is not on your network, MoonBase opens its own access point and answers at **4.3.2.1** once you join it.
+
+## Before a risky update
+
+Take a backup. The Firmware card does not touch your configuration, but a variant change or an erase does, and [restoring](backup-and-restore.md) takes a minute where reconfiguring a rig takes an evening.
+
+## Serial, when the network cannot help
+
+A device that will not boot far enough to serve a page needs the [web installer](https://moonmodules.org/projectMM/install/) and a USB cable. That is the same path as a first install, and it is covered in [Install & first light](../gettingstarted.md).
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index 4e4ed152..9b5d726e 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -18,6 +18,8 @@ A Service (added by the user, not auto-wired): the audio source that feeds the F
+
+
- `mode` — Local audio / Receive network / Simulate: analyze the on-board mic/line-in, consume a peer's audio off the network (WLED-compatible), or feed a synthesized signal. Receive network appears only on a network build; the controls below are its detail, shown per mode.
- `micMode`: (Local, I²S targets) `I2S` for a three-wire part (the INMP441 and most MEMS mics, and line-in ADCs), `PDM` for a two-wire one (a clock and a data line, as on the QuinLED Dig-Next-2's onboard microphone). PDM uses `wsPin` as its clock and `sdPin` as its data, and hides the two clock pins it does not have.
- `sckPin` / `wsPin` / `sdPin`: (Local, I²S targets) the I²S GPIOs (bit clock / word-select / data; unset until entered).
@@ -69,7 +71,7 @@ their own on load, so every widget would show its layout file's defaults until t
The shipped session has a `sync from device` button for exactly this.
**Setting one up**, from installing the app to using it from a phone, is its own page:
-[Driving projectMM from a phone or tablet](../../tutorials/control-surface.md). It needs no
+[Driving projectMM from a phone or tablet](../../how-to/control-surface.md). It needs no
checkout and no tooling, just the app and the session file from the latest release.
Detail: [technical](moxygen/OscModule.md)
diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md
index a3b98553..b073c5e0 100644
--- a/docs/moonmodules/light/MoonLiveEffect.md
+++ b/docs/moonmodules/light/MoonLiveEffect.md
@@ -19,7 +19,7 @@ class RandomPixelEffect {
Inside a function the grammar is a sequence of **statements** — a function call, or a `for` loop over them — with **expression arguments**, so any argument may be a literal or a nested call. The class declaration is required: one top-level form rather than two means one set of rules to learn and one parse path to maintain.
-**A script's role is its extension**: `.mle` an effect, `.mll` a [layout](MoonLiveLayout.md), `.mlm` a [modifier](MoonLiveModifier.md). That is what a card filters its picker on, so an effect card offers effects. The engine is role-blind and runs whichever moment the binding asks for; the extension decides what is OFFERED, not what runs.
+**A script's role is its extension**: `.mle` an effect, `.mll` a [layout](#a-layout-written-as-a-script), `.mlm` a [modifier](#a-modifier-written-as-a-script). That is what a card filters its picker on, so an effect card offers effects. The engine is role-blind and runs whichever moment the binding asks for; the extension decides what is OFFERED, not what runs.
**The shipped scripts are the reference**: [`moonlive/`](https://github.com/MoonModules/projectMM/tree/main/moonlive) in the repository holds every script the library ships, one file per effect, layout and modifier. Read them to see what the language looks like in practice: they are the same text the card edits.
@@ -125,13 +125,13 @@ Some names are **reserved**: the engine defines them, the script only reads them
|---|---|
| `t` | elapsed milliseconds — the clock an animation is written against |
| `width`, `height`, `depth` | the **logical grid**, `0..255` |
-| `xPos`, `yPos`, `zPos` | the light being transformed, `0..255` (a [modifier](MoonLiveModifier.md) is the one handed these; elsewhere they read 0) |
+| `xPos`, `yPos`, `zPos` | the light being transformed, `0..255` (a [modifier](#a-modifier-written-as-a-script) is the one handed these; elsewhere they read 0) |
Every one but `t` is a byte, because it lives in the controls arena. A grid extent past 255 reports 255 rather than wrapping to a small number, and a modifier handed a coordinate outside `0..255` passes it through untransformed instead of folding a wrong position — so a script never silently sees a value that means something else.
The coordinate is `xPos`/`yPos`/`zPos` rather than `x`/`y`/`z` so that **`x` and `y` stay free as loop counters in every script**, which is what an author reaches for and what the shipped `grid.mll` uses. Reserving them globally would break the most ordinary code there is; a per-role reservation was the alternative and was worse, because a name then meant one thing in one role and was refused in another — which is how `disasm.py`, compiling against the widest vocabulary, came to refuse the shipped default layout.
-`width`/`height`/`depth` are the Layer's own dimensions, derived from the layouts and the modifier chain. An effect is *told* its canvas rather than declaring it: a size restated as a control is a second answer that can disagree with the first, and a script that sets `width` to 16 on an 8×8 panel draws off the edge. A [layout](MoonLiveLayout.md) is upstream of that grid — it is what the dimensions are derived *from* — so it names its own controls instead (`cols`, `rows`) and reads the grid only if it has a use for it.
+`width`/`height`/`depth` are the Layer's own dimensions, derived from the layouts and the modifier chain. An effect is *told* its canvas rather than declaring it: a size restated as a control is a second answer that can disagree with the first, and a script that sets `width` to 16 on an 8×8 panel draws off the edge. A [layout](#a-layout-written-as-a-script) is upstream of that grid (it is what the dimensions are derived *from*), so it names its own controls instead (`cols`, `rows`) and reads the grid only if it has a use for it.
Reserving is what makes the guarantee hold: without it a declaration would silently shadow the value the engine handed in, and the script would disagree with its layer with no error anywhere.
@@ -151,9 +151,9 @@ Registered by the light domain, not built into the compiler (the core owns only
| call | does |
|---|---|
| `setRGB(index, r, g, b)` | write one light |
-| `setXYZ(x, y, z)` | write one position (a [modifier](MoonLiveModifier.md)) |
+| `setXYZ(x, y, z)` | write one position (a [modifier](#a-modifier-written-as-a-script)) |
| `fill(r, g, b)` | write every light |
-| `addLight(x, y, z)` | place the next light (a [layout](MoonLiveLayout.md)) |
+| `addLight(x, y, z)` | place the next light (a [layout](#a-layout-written-as-a-script)) |
| `line(x1, y1, x2, y2, r, g, b)` | a straight segment on the grid, via the shared `draw::line` |
| `random16(n)` | a value in `[0, n)` |
| `mod(a, b)` | `a % b` — the wrap a cyclic animation needs |
@@ -287,6 +287,114 @@ ordinary script reads its size and nothing else.
MoonLive's native-codegen approach — compile a small C-like language straight to machine code and call it as a function, so a live-authored effect runs at near hand-written speed — was pioneered by **Yves Bazin (hpwit)** in **[ESPLiveScript](https://github.com/hpwit/ESPLiveScript)**: a from-scratch tokenizer, parser, and Xtensa code generator that drives a 12,288-LED panel at ~85 fps where interpreted languages (Lua, Gravity) managed 3–10. That result is what makes "go native, not interpreted" the right call, and ESPLiveScript is the reference MoonLive is built against — studied closely, credited, and written fresh against projectMM's architecture, never copied, per [*Industry standards, our own code*](../../../CLAUDE.md#principles). The live-scripting idea in this ecosystem also descends from **ARTI-FX / ARTI** (the interpreted-effects runtime in WLED MoonModules), which proved the load-a-script-and-run-it-live loop end to end. The host-binding surface (`setRGB`/`setRGBXY`/`setRGBXYZ`) is modelled on the **MoonLight** [effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/).
+## A layout written as a script
+
+Where the lights physically are, authored as text instead of compiled in as a C++ class. A [layout](layouts.md) is the one part of the pipeline that differs for every physical build: a ring, a spiral staircase, a car grille, a costume sewn last night. Each one has meant writing a class, rebuilding and reflashing. A script means the person who hung the lights can describe where they went, on the device, and see it immediately.
+
+
+
+The script places every light itself, with a loop. That is the difference from a scripted modifier: the Layer calls a modifier once per light, so its script transforms a single coordinate, where a layout has no such per-light call to ride on.
+
+```c
+class GridLayout {
+ byte cols = 16;
+ byte rows = 16;
+
+ void defineControls() {
+ addControl("cols", cols, 1, 64);
+ addControl("rows", rows, 1, 64);
+ }
+
+ void placeLights() {
+ for (int y = 0; y < rows; y = y + 1) {
+ for (int x = 0; x < cols; x = x + 1) {
+ addLight(x, y, 0);
+ }
+ }
+ }
+}
+```
+
+`addLight(x, y, z)` places the next light along the strand. There is no index, because the order the script calls it in *is* the strand order.
+
+The `cols` and `rows` lines are the script's own controls, not something the module hands it. A layout is never told how big it is: the pipeline works out the bounding box from the coordinates the layouts actually place, so a size passed in from outside would be a second answer that could disagree with the first. They are named `cols`/`rows` rather than `width`/`height` because those are [system variables](#system-variables-what-the-engine-hands-a-script) naming the logical grid a Layer hands an effect, and a layout is upstream of that grid.
+
+A few shapes that are one line here and a new class otherwise:
+
+```c
+// a strand that runs right to left
+for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }
+
+// a diagonal
+for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }
+
+// a circle: lights and grid cells are not the same number
+// (`count` and `radius` are members, surfaced by addControl in defineControls)
+for (int i = 0; i < count; i = i + 1) {
+ addLight(scale(cos(i * turn(count)), radius * 2 + 1),
+ scale(sin(i * turn(count)), radius * 2 + 1), 0);
+}
+```
+
+`t` is the one system variable a layout is given, and it is always **0**: the script runs twice per rebuild and must agree with itself, so it is handed a fixed clock rather than a live one. Asking for `width`/`height`/`depth` is a compile error rather than a silent zero, since those are what a layout is *defining*.
+
+### How the count is known
+
+A layout has to answer **how many lights** before it produces a single coordinate, because the Layer sizes its buffer from that number and only then asks where each light is. A script cannot be asked "how many?" without running it.
+
+So it runs twice. On the first pass `addLight` counts; on the second it emits each position. Same script, same arithmetic, so a deterministic script cannot disagree with itself, which is exactly what the compiled layouts do (`SphereLayout` walks its shell twice for the same reason).
+
+**Nothing is stored between the passes.** Staging 16,384 coordinates would cost 48 KB, which a classic ESP32 driving that many lights does not have spare. Running the script again is cheaper than remembering what it said, and it means a scripted layout costs the same as a compiled one.
+
+A script that calls `random16` breaks the determinism the two passes need. The passes disagree on the COUNT only when the random value decides a loop bound or how many times `addLight` runs; a random COORDINATE keeps the count right and places the lights somewhere else on the second pass, so the fixture is the size it claims but not the shape.
+
+A serpentine, every other row reversed, is what `if` makes expressible, and it is the common panel wiring:
+
+```c
+byte odd = 0;
+for (int y = 0; y < rows; y = y + 1) {
+ for (int x = 0; x < cols; x = x + 1) {
+ if (odd == 0) { addLight(x, y, 0); }
+ else { addLight(cols - 1 - x, y, 0); }
+ }
+ if (odd == 0) { odd = 1; } else { odd = 0; }
+}
+```
+
+Editing any control rebuilds the pipeline, because every one can change where the lights are. A script that fails to compile leaves a fixture with no lights, shows the parse error on the module, and the device keeps running. Detail: [technical](moxygen/MoonLiveLayout.md).
+
+## A modifier written as a script
+
+The coordinate transform that decides where each light sits in the pattern. A [modifier](modifiers.md) reshapes how a Layer's output maps onto the physical lights: mirror it, shift it, swap its axes. Each hand-written one is a class, a rebuild and a reflash; a scripted one is a line of text, applied as you type.
+
+
+
+The script transforms **one coordinate**. It needs no loop over the lights, because the Layer already does that: it calls the script once per physical light while building its mapping.
+
+```c
+class MirrorModifier {
+ void modifyLogical() { setXYZ(width - 1 - xPos, yPos, zPos); } // mirror along x
+}
+```
+
+The body is one expression per axis. Other shapes, in the same place:
+
+```c
+setXYZ(yPos, xPos, zPos); // swap the axes
+setXYZ(xPos + 4, yPos, zPos); // shift by four
+setXYZ((width - 1 - xPos) * 2, yPos, zPos); // mirror, then stretch
+```
+
+`setXYZ(x, y, z)` writes the transformed position, mirroring `setRGB(index, r, g, b)`. The index is the destination slot: today the script is handed a single coordinate, so it is always `0`.
+
+`width` matters more than it looks. A mirror written against a fixed `255` sends every light of a 16-wide grid far outside the grid, the Layer discards each one as out of bounds, and the fixture goes black. No error appears anywhere, because the script itself ran perfectly.
+
+**A computed coordinate is full width.** `setXYZ` hands its three values to the binding as a call rather than storing them as bytes, so `setXYZ(767 - xPos, ...)` on a 768-wide wall arrives as 767 rather than clamping to 255. It was an inline three-byte store once, and that is exactly the bug it caused. A negative position handed TO a script is passed through untransformed rather than wrapped.
+
+**A script cannot resize the logical box.** A modifier has two hooks: one reshapes the box once per rebuild, one folds each coordinate. A script drives only the second, so transforms that keep the box the same size work, and ones that halve it (the way the built-in [Mirror](modifiers.md#mirror) does) need the compiled modifier.
+
+A script that fails to compile shows the parse error on the module and the mapping falls back to passing coordinates straight through, so the transform disappears until the script parses again and the device keeps rendering throughout. Detail: [technical](moxygen/MoonLiveModifier.md).
+
## Tests
[unit_moonlive_fill](../../../test/unit/core/unit_moonlive_fill.cpp) runs the engine path in-process on the desktop host backend (`compile`/`run`, the animated routine, zero-lights, recompile, `free`, the `allocExec`/`writeExec`/`freeExec` round-trip, the buffer-shape guards). [unit_moonlive_ir](../../../test/unit/core/unit_moonlive_ir.cpp) pins the **behavioral golden** — a compiled `fill` and the hand-encoded reference render an identical buffer — plus setRGB's single-pixel write and the runtime bounds guard. [unit_moonlive_compiler](../../../test/unit/core/unit_moonlive_compiler.cpp) pins the expression grammar (`random16` in any/every argument slot, uint16 bounds), the parser diagnostics (no crash on malformed input), live recompile, and the **domain-neutral** property: with an empty builtin table the core knows *no* functions, and a host can register an arbitrary name against the same machinery.
diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md
deleted file mode 100644
index ecf4b2c1..00000000
--- a/docs/moonmodules/light/MoonLiveLayout.md
+++ /dev/null
@@ -1,114 +0,0 @@
-# MoonLiveLayout
-
-A **layout written as a live script**: where the lights physically are, authored as text on a running device instead of compiled in as a C++ class. Same [MoonLive](MoonLiveEffect.md) engine as a scripted effect or [modifier](MoonLiveModifier.md), pointed at the third job.
-
-A [layout](layouts.md) is the one part of the pipeline that differs for every physical build — a ring, a spiral staircase, a car grille, a costume sewn last night. Each one has meant writing a C++ class, rebuilding and reflashing. A script means the person who hung the lights can describe where they went, on the device, and see it immediately.
-
-
-
-## Writing one
-
-The script places every light itself, with a loop. That is the difference from a scripted modifier: the Layer calls a modifier once per light, so its script transforms a single coordinate — a layout has no such per-light call to ride on.
-
-```c
-class GridLayout {
- byte cols = 16;
- byte rows = 16;
-
- void defineControls() {
- addControl("cols", cols, 1, 64);
- addControl("rows", rows, 1, 64);
- }
-
- void placeLights() {
- for (int y = 0; y < rows; y = y + 1) {
- for (int x = 0; x < cols; x = x + 1) {
- addLight(x, y, 0);
- }
- }
- }
-}
-```
-
-That is the default: a plain grid, one light per cell. The function is named `placeLights` because that is the moment a layout is asked about: the module calls it when the fixture is being built, and a script that does not define it places nothing. An effect's moment is `tick`, a modifier's is `modifyLogical`, and a class may define any of them. `addLight(x, y, z)` places the next light along the strand — no index, because the order the script calls it in *is* the strand order.
-
-The `cols` and `rows` lines are the script's own controls, not something the module hands it. A layout is never told how big it is: the pipeline works out the bounding box from the coordinates the layouts actually place, so a size passed in from outside would be a second answer that could disagree with the first.
-
-They are named `cols`/`rows` because `width`, `height` and `depth` are [system variables](MoonLiveEffect.md#system-variables-what-the-engine-hands-a-script) — the logical grid the Layer hands an effect or a modifier. A layout is upstream of that grid, so it names its own controls.
-
-A few shapes that are one line here and a new class otherwise:
-
-```c
-// a strand that runs right to left
-for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }
-
-// a diagonal
-for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }
-
-// two rows, stacked
-for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }
-
-// a circle: lights and grid cells are not the same number
-// (`count` and `radius` are members, surfaced by addControl in defineControls)
-for (int i = 0; i < count; i = i + 1) {
- addLight(scale(cos(i * turn(count)), radius * 2 + 1),
- scale(sin(i * turn(count)), radius * 2 + 1), 0);
-}
-```
-
-### What a script can read
-
-A script reads whatever it declares. `byte cols = 16;` is a member the script owns; naming it in `defineControls()` with `addControl("cols", cols, 1, 64)` also makes it a real slider in the UI, and the loop reads it, which is how a panel gets resized without editing code. A member whose value a byte cannot hold is declared `int` and surfaced by the same call — the widget follows the type, so the two cannot disagree. A member no such call names stays private to the script.
-
-`t` is the one [system variable](MoonLiveEffect.md#system-variables-what-the-engine-hands-a-script) a layout is given, and it is always **0** here: the script runs twice per rebuild (once to count, once to place) and must agree with itself, so it is handed a fixed clock rather than a live one — a moving `t` would let the two passes disagree on how many lights there are. `width`/`height`/`depth` name the grid a layout is *defining*, so asking for one is a compile error rather than a silent zero; `x` and `y` are free to use as loop counters.
-
-### Seeing inside a script
-
-`print(v)` logs a value and returns it, so it wraps any part of an expression: `addLight(print(x), y, 0)`.
-It is for debugging and comes back out again: [what print costs](https://github.com/MoonModules/projectMM/blob/main/moonlive/README.md#debugging-print).
-
-## How the count is known
-
-A layout has to answer **how many lights** before it produces a single coordinate — the Layer sizes its buffer from that number and only then asks where each light is. A script cannot be asked "how many?" without running it.
-
-So it runs twice. On the first pass `addLight` counts; on the second it emits each position to whoever asked. Same script, same arithmetic, so as long as the script is deterministic the two answers cannot drift apart — which is exactly what the compiled layouts do (`SphereLayout` walks its shell twice for the same reason). A script that calls `random16` breaks that condition. The two passes disagree on the COUNT only when the random value decides a loop bound or how many times `addLight` runs; a random COORDINATE keeps the count right and simply places the lights somewhere else on the second pass, so the fixture is the size it claims but not the shape. See [Limits](#limits).
-
-**Nothing is stored between the passes.** Staging 16,384 coordinates would cost 48 KB, which a classic ESP32 driving that many lights does not have spare. Running the script again is cheaper than remembering what it said, and it means a scripted layout costs the same as a compiled one: the JIT'd program, and nothing that grows with the light count.
-
-## Limits
-
-**The grammar is arithmetic, calls, `for` and `if`**: `+`, `-`, `*`, parentheses, nested loops, and the six comparisons (`<`, `<=`, `>`, `>=`, `==`, `!=`). Division and `%` are not in the language, so where a script would divide it calls `mod(a, b)` or `turn(n)` instead.
-
-A serpentine (every other row reversed) is what `if` makes expressible, and it is the common panel wiring:
-
-```c
-byte odd = 0;
-for (int y = 0; y < rows; y = y + 1) {
- for (int x = 0; x < cols; x = x + 1) {
- if (odd == 0) { addLight(x, y, 0); }
- else { addLight(cols - 1 - x, y, 0); }
- }
- if (odd == 0) { odd = 1; } else { odd = 0; }
-}
-```
-
-**A script runs twice per rebuild**, once to count and once to place, so it has to be deterministic. With `random16` in a loop bound or around an `addLight` call, the two passes disagree on the count; with `random16` in a coordinate, the count holds and only the positions move.
-
-## What the card tells you
-
-`status` is the size of the compiled program; the memory figure is what the module costs the device
-(its own `sizeof`, plus the exec block and control arena); `tickTimeUs` is the real per-tick cost.
-Past half full, the status also names the tightest limit the script is approaching. Detail:
-[MoonLive](MoonLiveEffect.md#what-the-card-tells-you-size-memory-and-how-close-to-a-wall).
-
-## Controls
-
-| control | what it does |
-|---|---|
-| `script` | the script's file name, picked from the [library](MoonLiveEffect.md) or your own; naming it (or re-naming it after an edit) recompiles and re-places the lights live |
-
-Plus one control per `addControl` in the script's `defineControls()`.
-
-Editing any of them rebuilds the pipeline, because every one can change where the lights are. A script that fails to compile leaves a fixture with no lights, shows the parse error on the module, and the device keeps running.
-
-Detail: [technical](moxygen/MoonLiveLayout.md)
diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md
deleted file mode 100644
index 2aa8d0e3..00000000
--- a/docs/moonmodules/light/MoonLiveModifier.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# MoonLiveModifier
-
-A **modifier written as a live script**: the coordinate transform that decides where each light sits in the pattern, authored as text on a running device instead of compiled in as a C++ class. Same [MoonLive](MoonLiveEffect.md) engine as a scripted effect, pointed at a different job.
-
-A [modifier](modifiers.md) reshapes how a Layer's output maps onto the physical lights: mirror it, shift it, swap its axes. Each hand-written one is a class, a rebuild and a reflash. A scripted one is a line of text, applied as you type.
-
-
-
-## Writing one
-
-The script transforms **one coordinate**. It needs no loop over the lights, because the Layer already does that: it calls the script once per physical light while it builds its mapping. (A `for` is available if the arithmetic wants one, though it is not how the script reaches the next light.)
-
-```c
-class MirrorModifier {
- void modifyLogical() { setXYZ(width - 1 - xPos, yPos, zPos); } // mirror along x
-}
-```
-
-The function is named `modifyLogical` because that is the moment a modifier is asked about: the Layer calls it once per light while building its mapping, and a script that does not define it passes every light through unchanged. An effect's moment is `tick`, a layout's is `placeLights`.
-
-The body is one expression per axis. Other shapes, in the same place:
-
-```c
-setXYZ(yPos, xPos, zPos); // swap the axes
-setXYZ(xPos + 4, yPos, zPos); // shift by four
-setXYZ((width - 1 - xPos) * 2, yPos, zPos); // mirror, then stretch
-```
-
-`setXYZ(x, y, z)` writes the transformed position, mirroring `setRGB(index, r, g, b)`. The index is the destination slot: today the script is handed a single coordinate, so it is always `0`.
-
-### What a script can read
-
-`x`, `y`, `z` (the light being folded) and `width`, `height`, `depth` (the box it lives in) are [system variables](MoonLiveEffect.md#system-variables-what-the-engine-hands-a-script): the engine writes them per call, and a script cannot declare a name that shadows one.
-
-`width` matters more than it looks. A mirror written against a fixed `255` sends every light of a 16-wide grid far outside the grid, the Layer discards each one as out of bounds, and the fixture goes black. No error appears anywhere, because the script itself ran perfectly.
-
-### Seeing inside a script
-
-`print(v)` logs a value and returns it, so it wraps any part of an expression: `setXYZ(print(width - 1 - xPos), yPos, zPos)`.
-It is for debugging and comes back out again: [what print costs](https://github.com/MoonModules/projectMM/blob/main/moonlive/README.md#debugging-print).
-
-## Limits
-
-**A coordinate is a byte, so an axis spans 0..255.** A position handed TO a script outside that range is passed through untransformed rather than wrapped. A position a script COMPUTES past 255 keeps its low byte, so `(width - 1 - x) * 2` on a grid wider than 128 lands somewhere unintended, so keep a computed result inside the box. A script's own MEMBERS may be `int`, so intermediate arithmetic can exceed 255 even where the coordinate handed back cannot.
-
-**A script cannot resize the logical box.** A modifier has two hooks: one reshapes the box once per rebuild, one folds each coordinate. A script drives only the second, so transforms that keep the box the same size work, and ones that halve it (the way the built-in [Mirror](modifiers.md#mirror) does) need the compiled modifier.
-
-**The grammar is arithmetic over calls**: `+`, `-`, `*`, parentheses, the usual precedence, `for`, and `if` with the six comparisons. Division and `%` are not operators; `mod(a, b)` and `turn(n)` are the calls that cover them.
-
-## What the card tells you
-
-`status` is the size of the compiled program; the memory figure is what the module costs the device
-(its own `sizeof`, plus the exec block and control arena); `tickTimeUs` is the real per-tick cost.
-Past half full, the status also names the tightest limit the script is approaching. Detail:
-[MoonLive](MoonLiveEffect.md#what-the-card-tells-you-size-memory-and-how-close-to-a-wall).
-
-## Controls
-
-| control | what it does |
-|---|---|
-| `script` | the script's file name, picked from the [library](MoonLiveEffect.md) or your own; naming it (or re-naming it after an edit) recompiles and re-maps live |
-
-Plus one control per `addControl` in the script's `defineControls()`: `addControl("amount", amount, 0, 64)`
-becomes a slider, and moving it rebuilds the mapping as editing the script does.
-
-Editing the script asks the Layer to rebuild its mapping, so a change is visible immediately. A script that fails to compile shows the parse error on the module and the mapping falls back to passing coordinates straight through, so the transform disappears until the script parses again, and the device keeps rendering throughout.
-
-Detail: [technical](moxygen/MoonLiveModifier.md)
diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md
index 1a5355a9..9abb6bf4 100644
--- a/docs/moonmodules/light/drivers.md
+++ b/docs/moonmodules/light/drivers.md
@@ -1,10 +1,10 @@
# Drivers
-A driver sends lights somewhere. It reads its slice of the [Drivers](moxygen/Drivers.md) container's shared buffer, applies its own [output correction](moxygen/DriverBase.md), and outputs — over a wire (WS2812), the network (Art-Net / E1.31 / DDP), to a smart-light hub (Hue), or to the web UI (Preview).
+A driver sends lights somewhere. It reads its slice of the [Drivers](moxygen/Drivers.md) container's shared buffer, applies its own [output correction](moxygen/DriverBase.md), and outputs: over a wire (WS2812), to a HUB75 panel on the board's own pins, over the network (Art-Net / E1.31 / DDP), to a smart-light hub (Hue), or to the web UI (Preview).
Several drivers can share one buffer, each driving its own slice. Every driver starts with the same [shared controls](#shared-driver-controls), then adds its own. Drivers are added per board through the catalog ([`deviceModels.json`](../../../mooninstaller/deviceModels.json)); `PreviewDriver` is the one boot-wired driver.
-**Jump to:** [shared controls](#shared-driver-controls) · [LED](#led-drivers) · [Network](#network-drivers) · [Smart light](#smart-light-drivers) · [Preview](#preview-drivers)
+**Jump to:** [shared controls](#shared-driver-controls) · [LED](#led-drivers) · [HUB75](#hub75) · [Network](#network-drivers) · [Smart light](#smart-light-drivers) · [Preview](#preview-drivers)
## Shared driver controls
@@ -66,6 +66,36 @@ Tests: [RMT](../../reference/tests/unit-tests.md#rmtleddriver) · [shared + peri
Detail: [RMT](moxygen/RmtLedDriver.md) · [Parallel](moxygen/ParallelLedDriver.md) · peripherals: [i80](moxygen/MultiPinLedDriver.md) · [MoonI80](moxygen/MoonLedDriver.md) · [Parlio](moxygen/ParlioLedDriver.md)
+
+
+### HUB75 🟦 · panels on your own pins
+
+Drives **HUB75 LED panels directly from the board's GPIO**, with no receiving card in between. The sibling of [Panel Card](#panelcard), which drives the same panels the other way: through a ColorLight receiving card over Ethernet. Which one you want is a size question. A receiving card earns its place above roughly 16,384 pixels; below that it is a Windows tool to configure and a dedicated Ethernet link to run, for a panel the board could have driven itself.
+
+- `board`: which board's wiring to use. Picking one fills in the fourteen pins below, so a fresh driver arrives wired rather than blank.
+- `r1 g1 b1` / `r2 g2 b2`: the six color lines. A HUB75 panel lights two rows at once, an upper and a lower, which is what the two sets are.
+- `a b c d e`: the row address. `d` appears on 1/16 panels, `e` on 1/32; a 1/8 panel leaves both unwired.
+- `clk lat oe`: shift clock, latch, and output enable.
+- `peripheral`: which silicon block drives the panel, `LCD_CAM` or `Parlio`, offered only where the chip has both and the geometry fits. Yours to pick rather than the driver's: a P4 has one of each, so a board already driving WS2812 strips from one needs the panel on the other.
+- `scanRate`: 1/8, 1/16 or 1/32, **read off the panel rather than calculated**. Two panels of identical dimensions can scan differently, so this is the one fact about your panel its size does not tell the driver.
+- `bitDepth` (2 to 4): color precision against refresh and memory, and the trade is yours. Read the cost below before raising it.
+- `refresh`: the **measured** rate, not a prediction. If a panel flickers, this is the number to report.
+- **No geometry controls**: the panel size comes from the [Layout](layouts.md), as it does for every driver. A `PanelLayout` describes one panel and a `PanelsLayout` tiles several; this driver reads the finished picture.
+
+**The board select is where the pins come from.** It defaults to MoonHub75 and fills all fourteen lines in on first use. Pick a published board map (MoonHub75, MatrixPortal S3, Waveshare RGB Matrix) and the pin rows are **hidden**: those lines are soldered, so there is nothing to act on. The generic per-chip sets and **Custom** **show** them, because those exist to be adjusted. Hidden rows stay bound, so the values still persist and still drive the panel. A soldered line is never guessed from nothing, which is why a board map supplies them: pick from the per-chip free sets in [GPIO usage](../../reference/hardware/gpio-usage.md) when you wire your own.
+
+**Which silicon.** A chip with an LCD_CAM or Parlio block. The classic ESP32 has neither, and is excluded on pins before memory is even a question: it has 13 usable output GPIOs and a 1/16 port needs all 13, leaving nothing for a strand, a button or a microphone.
+
+**What depth costs.** Every bit plane is a full scan of the panel, so depth costs refresh and memory linearly: 8-bit is eight passes where 4-bit is four. Each slot on the wire is 2 bytes, because the address, latch and output-enable lines sit above bit 7 of a 16-bit bus word. One 64x64 panel at 1/32 scan is 16,640 bytes a frame at 4-bit, 24,960 at 6-bit and 33,280 at 8-bit; a 256x256 wall at 8-bit is 524,800, which needs the PSRAM the LCD_CAM path reaches and is far past what Parlio carries in one transfer.
+
+**Depth does not yet change the ramp, which is why it stops at 4.** The planes are emitted once each rather than weighted for 2^p time, so bit 3 lights for the same time as bit 0 and a gradient is coarser than the depth suggests. Every extra plane still costs a full scan pass and its share of the frame, so a fifth plane and beyond would buy nothing the eye can find. The weighting is [backlogged](https://github.com/MoonModules/projectMM/blob/main/docs/work/future/backlog-light.md), and the cap lifts with it.
+
+**This driver is new and has not run on a wall we own.** It is built from the panel's documented behavior and its encoder is pinned by [host tests](../../reference/tests/unit-tests.md#hub75driver), which is not the same as hardware verification. Reports welcome, and `refresh` plus your geometry is what makes one useful.
+
+Prior art: the HUB75 lineage generally ([mrcodetastic/ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA), [hzeller/rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix), [ESPHome's hub75 component](https://github.com/esphome-libs/esp-hub75)). The scan and bit-plane structure belongs to the panel rather than to any library; those implementations were studied, not copied.
+
+Detail: [technical](moxygen/Hub75Driver.md)
+
## Network drivers
@@ -123,9 +153,9 @@ Streams the buffer to **LED panel cards** as raw Ethernet frames, compatible wit
The board renders and sends: effects, layers and MoonLive run on the device, so one board replaces a host PC driving the same panels. Add a Network Receive effect to take Art-Net in as well.
- `format`: the card's wire format (ColorLight 5A-75).
-- `firmware`: the card's firmware generation, `v12 and older` (default) or `v13 and newer`. v13 and newer act on the *second* copy of the brightness and sync frames, so both are sent twice; v12 and older act on the first, and take a second sync as another latch. Set to `v13 and newer` on a downgraded card, the wall updates once every few seconds. Reading and changing a card's version: [the tutorial](../../tutorials/panel-cards.md#7-card-firmware-and-the-flicker).
+- `firmware`: the card's firmware generation, `v12 and older` (default) or `v13 and newer`. v13 and newer act on the *second* copy of the brightness and sync frames, so both are sent twice; v12 and older act on the first, and take a second sync as another latch. Set to `v13 and newer` on a downgraded card, the wall updates once every few seconds. Reading and changing a card's version: [the tutorial](../../how-to/panel-cards.md#7-card-firmware-and-the-flicker).
- **No geometry controls**: the wall comes from the [Layout](layouts.md). A `PanelsLayout` already states how many panels there are, their size, wiring order and snaking; this driver reads the finished picture and cuts it into card rows. A row wider than 497 pixels goes out as several packets.
-- `interface`: which NIC to send from on desktop/Raspberry Pi, a dropdown of the DETECTED adapters (friendly names on Windows via Npcap, kernel names on Linux/macOS), re-listed on every control change so a hot-plugged NIC appears. The choice is remembered by adapter NAME, never by index, so it survives reboots and Npcap reinstalls. `none (capture only)` records frames without sending. **Not shown on ESP32**, which has one MAC. Raw sending is privileged: root or `CAP_NET_RAW` on Linux, BPF access on macOS, and [Npcap](https://npcap.com/) or WinPcap on Windows; without it the driver records frames instead and says so. Step-by-step per OS: [Driving LED panels with a receiving card](../../tutorials/panel-cards.md).
+- `interface`: which NIC to send from on desktop/Raspberry Pi, a dropdown of the DETECTED adapters (friendly names on Windows via Npcap, kernel names on Linux/macOS), re-listed on every control change so a hot-plugged NIC appears. The choice is remembered by adapter NAME, never by index, so it survives reboots and Npcap reinstalls. `none (capture only)` records frames without sending. **Not shown on ESP32**, which has one MAC. Raw sending is privileged: root or `CAP_NET_RAW` on Linux, BPF access on macOS, and [Npcap](https://npcap.com/) or WinPcap on Windows; without it the driver records frames instead and says so. Step-by-step per OS: [Driving LED panels with a receiving card](../../how-to/panel-cards.md).
- `fps`: frame-rate limit (default 40, 1 to 120).
**These cards need a 1 Gbit link.** Not for bandwidth — a 256×256 panel at 40 fps is only ~65 Mbit/s — but for wire time: the cards have no buffering and latch on the sync frame, so a whole frame must arrive inside the inter-frame window. At 100 Mbit the same bytes take ten times as long, which breaks that timing and shows up as tearing or wrong rows rather than as an error. The driver reads the negotiated speed and warns, but still sends: a small panel may be fine, and a measurement beats a refusal.
diff --git a/docs/moonmodules/light/index.md b/docs/moonmodules/light/index.md
new file mode 100644
index 00000000..34de5138
--- /dev/null
+++ b/docs/moonmodules/light/index.md
@@ -0,0 +1,22 @@
+# Lights
+
+Everything a light show is built from, and the pages here follow the order light flows through: a **layout** says where the lights physically are, **effects** stacked in layers write color into them, **modifiers** reshape how that pattern lands, and a **driver** sends the result out to the strip, the panel or the network.
+
+Each page is a catalog: one block per module, with its preview, what it does, and what every control means together. How the pipeline fits together is [MoonLight](../../explanation/architecture/moonlight.md); this is what you pick from while building.
+
+| Page | What you reach for it |
+|---|---|
+| [Effects](effects.md) | The animation itself: fire, noise, a spectrum, a game of life |
+| [Layouts](layouts.md) | Where the lights are: a grid, a ring, a spiral, a shape you wired yourself |
+| [Modifiers](modifiers.md) | Reshaping the result: mirror, rotate, swap axes |
+| [Drivers](drivers.md) | Getting it out: LED strips, Art-Net, DMX, a panel card, the browser preview |
+| [MoonLive](MoonLiveEffect.md) | Writing any of the above as a script on a running device, no reflash |
+| [Writing scripts](writing-scripts.md) | The script language itself, and the library that ships with it |
+| [Power functions](power-functions.md) | The shared drawing, field and motion routines every effect composes from |
+| [Supporting](supporting.md) | The pieces the four above are built on |
+
+A light show stacks them: one layout, one or more layers of effects with modifiers on top, and the drivers that output the whole composite. Every one of them is a [MoonModule](../../explanation/architecture/moonmodule.md), so they are added, replaced and reordered live from the same interface, and a new one is a new file rather than a change to the framework.
+
+## Source
+
+The pieces the whole pipeline is assembled from: [Layer](moxygen/Layer.md) holds the effects and composites them, [Layouts](moxygen/Layouts.md) and [Effects](moxygen/Effects.md) and [Drivers](moxygen/Drivers.md) are the three containers a user adds children to, and [Buffer](moxygen/Buffer.md) is the pixel memory they all read and write.
diff --git a/docs/reference/MIGRATING.md b/docs/reference/MIGRATING.md
index 4c3cc5c4..7315f357 100644
--- a/docs/reference/MIGRATING.md
+++ b/docs/reference/MIGRATING.md
@@ -46,6 +46,33 @@ The published pages were flat at the site root and the folders that did exist ca
`index.html` and `gettingstarted.html` stay at the root, and `moonmodules/` is unchanged. No redirects are published, so an old bookmark 404s rather than forwarding.
+### The three MoonLive pages became one
+
+**Action: nothing on a device. Update a bookmark to a documentation page.**
+Affects readers, not devices. The engine is the same whichever job a script does, so three pages repeated it and differed only in which moment the host calls. The roles are now sections on the one page.
+
+| Was | Now |
+|---|---|
+| `moonmodules/light/MoonLiveLayout.html` | `moonmodules/light/MoonLiveEffect.html#a-layout-written-as-a-script` |
+| `moonmodules/light/MoonLiveModifier.html` | `moonmodules/light/MoonLiveEffect.html#a-modifier-written-as-a-script` |
+
+The generated technical pages under `moonmodules/light/moxygen/` keep one page per class and are unchanged.
+
+### Four task pages moved from Tutorials to How-to
+
+**Action: nothing on a device. Update a bookmark to a documentation page.**
+Affects readers, not devices. A tutorial is a lesson for someone learning; putting projectMM on a machine is a task somebody already has. The four moved to the folder naming what they are, and the build page shed the two halves that were never how-to.
+
+| Was | Now |
+|---|---|
+| `tutorials/installing-to-desktop.html` | `how-to/installing-to-desktop.html` |
+| `tutorials/installing-on-linux.html` | `how-to/installing-on-linux.html` |
+| `tutorials/panel-cards.html` | `how-to/panel-cards.html` |
+| `tutorials/control-surface.html` | `how-to/control-surface.html` |
+| `how-to/building.html#tooling-overview` | `reference/build-system.html` |
+| `how-to/building.html#why-not-arduino` | `explanation/why-we-write-our-own.html#esp-idf-directly-rather-than-arduino` |
+| `how-to/building.html#third-party-libraries` | `explanation/why-we-write-our-own.html#third-party-libraries` |
+
### Audio: `floor` is now the silence threshold in both level modes
diff --git a/docs/reference/build-system.md b/docs/reference/build-system.md
new file mode 100644
index 00000000..9abc8752
--- /dev/null
+++ b/docs/reference/build-system.md
@@ -0,0 +1,33 @@
+# Build system
+
+How the source tree maps onto CMake, and where each platform's entry point lives. The steps to build are [Building, running, flashing](../how-to/building.md); this is the layout to look up.
+
+CMake is the sole build system. The source tree is shared across every platform, but build entry points are separate because ESP-IDF wraps CMake with its own conventions (`idf_component_register()` instead of `add_library()`).
+
+```text
+CMakeLists.txt ← standard CMake: desktop / RPi + tests
+src/
+ main.cpp ← shared pipeline wiring (mm_main), platform-neutral
+ platform/
+ desktop/
+ main_desktop.cpp ← desktop entry point: int main() + SIGINT
+ platform_config.h ← desktop platform constants
+ esp32/
+ platform_config.h ← ESP32 platform constants (reads sdkconfig)
+esp32/
+ CMakeLists.txt ← ESP-IDF project root (thin wrapper)
+ main/
+ CMakeLists.txt ← idf_component_register() pointing at src/
+ main.cpp ← ESP32 entry point: app_main() + Ethernet init
+ sdkconfig.defaults ← board-specific defaults
+```
+
+The shared `src/main.cpp` defines `mm_main(keepRunning, gridW, gridH)`, the full pipeline wiring. Each platform provides a thin entry point that does platform-specific init (SIGINT on desktop, Ethernet on ESP32) then calls `mm_main()`.
+
+The project is structured as a small set of CMake libraries: a core library (platform-independent), a platform library (selected at configure time), an application target (links both, provides the entry point). Further decomposition (effects, networking, drivers as separate libraries) happens when the codebase is large enough to justify it.
+
+Which script runs which build: [MoonDeck.md](../../moondeck/MoonDeck.md). Why the tooling is ours rather than PlatformIO: [Why we write our own code](../explanation/why-we-write-our-own.md).
+
+## Source
+
+[CMakeLists.txt](https://github.com/MoonModules/projectMM/blob/main/CMakeLists.txt) is the desktop and test build; [esp32/CMakeLists.txt](https://github.com/MoonModules/projectMM/blob/main/esp32/CMakeLists.txt) is the ESP-IDF project root.
diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json
index aa6574f6..5f2f80a9 100644
--- a/docs/reference/metrics/repo-health.json
+++ b/docs/reference/metrics/repo-health.json
@@ -1,10 +1,10 @@
{
- "commit": "b52e8b57",
+ "commit": "c8c1d9ed",
"flash": {
- "esp32s3-n16r8": 2103024,
- "desktop": 1949704,
- "esp32": 2078080,
- "esp32p4rev1-eth": 1997440,
+ "esp32s3-n16r8": 2128880,
+ "desktop": 1953352,
+ "esp32": 2080640,
+ "esp32p4rev1-eth": 2027824,
"esp32p4rev1-eth-wifi": 2284640,
"esp32s3-n8r8": 2087168,
"esp32s31": 2348592,
@@ -17,12 +17,12 @@
"esp32-pico": 2107168
},
"measured": {
- "esp32p4rev1-eth": "2026-09-09",
+ "esp32p4rev1-eth": "2026-09-15",
"esp32s31": "2026-09-06",
- "esp32": "2026-09-12",
+ "esp32": "2026-09-15",
"esp32-pico": "2026-09-09",
- "esp32s3-n16r8": "2026-09-09",
- "desktop": "2026-09-11",
+ "esp32s3-n16r8": "2026-09-15",
+ "desktop": "2026-09-15",
"esp32s3-n8r8": "2026-09-08",
"esp32s3-zero": "2026-09-08",
"esp32-16mb": "2026-09-09",
@@ -31,20 +31,20 @@
},
"perf": {
"desktop": {
- "tick_us": 132,
- "fps": 7575,
+ "tick_us": 128,
+ "fps": 7812,
"scenario_p50": {
"Layer_base_pipeline": {
- "p50": 69,
- "p95": 139,
+ "p50": 70,
+ "p95": 87,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"Layer_memory_1to1": {
"p50": 5,
- "p95": 7,
+ "p95": 24,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
}
}
},
@@ -55,10 +55,10 @@
"scenario_matrix": {
"MoonModule_control_change": {
"desktop-macos": {
- "p50": 126,
- "p95": 246,
+ "p50": 125,
+ "p95": 196,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32-eth-wifi": {
"p50": 89895,
@@ -171,10 +171,10 @@
},
"Audio_mutation": {
"desktop-macos": {
- "p50": 24,
+ "p50": 22,
"p95": 61,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 40,
@@ -197,18 +197,18 @@
},
"Aurora_fps": {
"desktop-macos": {
- "p50": 1574,
+ "p50": 1526,
"p95": 2013,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
}
},
"Driver_mutation": {
"desktop-macos": {
"p50": 20,
- "p95": 27,
+ "p95": 29,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 42,
@@ -231,10 +231,10 @@
},
"Effects_composition": {
"desktop-macos": {
- "p50": 147,
- "p95": 169,
+ "p50": 145,
+ "p95": 309,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 549,
@@ -245,18 +245,18 @@
},
"Fields_polar_lut": {
"desktop-macos": {
- "p50": 1286,
- "p95": 1892,
+ "p50": 1319,
+ "p95": 2014,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
}
},
"Fluid_solver": {
"desktop-macos": {
- "p50": 222,
- "p95": 265,
+ "p50": 221,
+ "p95": 326,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
}
},
"GridBlacks_blackpixel": {
@@ -264,7 +264,7 @@
"p50": 2,
"p95": 2,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32s3-n16r8": {
"p50": 267,
@@ -288,9 +288,9 @@
"GridLayout_resize": {
"desktop-macos": {
"p50": 121,
- "p95": 143,
+ "p95": 142,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32-eth-wifi": {
"p50": 82231,
@@ -331,10 +331,10 @@
},
"Layer_base_pipeline": {
"desktop-macos": {
- "p50": 69,
- "p95": 139,
+ "p50": 70,
+ "p95": 87,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 118,
@@ -346,9 +346,9 @@
"Layer_memory_1to1": {
"desktop-macos": {
"p50": 5,
- "p95": 7,
+ "p95": 24,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 1,
@@ -360,9 +360,9 @@
"Layouts_mutation": {
"desktop-macos": {
"p50": 94,
- "p95": 121,
+ "p95": 114,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 111,
@@ -412,9 +412,9 @@
"MoonLiveEffect_livescript": {
"desktop-macos": {
"p50": 5,
- "p95": 15,
+ "p95": 12,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32s3-n16r8": {
"p50": 8255,
@@ -462,9 +462,9 @@
},
"desktop-macos": {
"p50": 5,
- "p95": 7,
+ "p95": 9,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 1,
@@ -478,7 +478,7 @@
"p50": 3,
"p95": 4,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 3,
@@ -489,10 +489,10 @@
},
"MultiplyModifier_pipeline": {
"desktop-macos": {
- "p50": 121,
- "p95": 146,
+ "p50": 120,
+ "p95": 158,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 225,
@@ -504,17 +504,17 @@
"Trails_ladder": {
"desktop-macos": {
"p50": 362,
- "p95": 433,
+ "p95": 523,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
}
},
"modifier_chain": {
"desktop-macos": {
- "p50": 44,
- "p95": 48,
+ "p50": 43,
+ "p95": 46,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 69,
@@ -531,10 +531,10 @@
},
"modifier_swap": {
"desktop-macos": {
- "p50": 22,
+ "p50": 23,
"p95": 24,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32-eth": {
"p50": 1010,
@@ -569,10 +569,10 @@
},
"perf_full": {
"desktop-macos": {
- "p50": 259,
- "p95": 301,
+ "p50": 257,
+ "p95": 298,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32s3-n16r8": {
"p50": 16915,
@@ -602,9 +602,9 @@
"perf_light": {
"desktop-macos": {
"p50": 16,
- "p95": 20,
+ "p95": 19,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32s3-n16r8": {
"p50": 2485,
@@ -645,10 +645,10 @@
"last": "2026-07-25"
},
"desktop-macos": {
- "p50": 260,
- "p95": 813,
+ "p50": 252,
+ "p95": 286,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"desktop-windows": {
"p50": 649,
@@ -672,9 +672,9 @@
},
"desktop-macos": {
"p50": 4,
- "p95": 5,
+ "p95": 4,
"n": 32,
- "last": "2026-09-11"
+ "last": "2026-09-15"
},
"esp32p4rev1-eth": {
"p50": 217,
@@ -698,54 +698,54 @@
}
},
"loc": {
- "core": 26988,
- "light": 35669,
- "platform": 18966,
- "ui": 11179,
- "test": 58735,
- "moondeck": 23143
+ "core": 27154,
+ "light": 36351,
+ "platform": 19661,
+ "ui": 11196,
+ "test": 59431,
+ "moondeck": 24995
},
"comments": {
"core": {
- "lines": 10781,
- "ratio": 0.432
+ "lines": 10870,
+ "ratio": 0.433
},
"light": {
- "lines": 13558,
- "ratio": 0.417
+ "lines": 13839,
+ "ratio": 0.418
},
"platform": {
- "lines": 6708,
- "ratio": 0.387
+ "lines": 6892,
+ "ratio": 0.384
},
"ui": {
- "lines": 3326,
+ "lines": 3333,
"ratio": 0.314
},
"test": {
- "lines": 11066,
+ "lines": 11201,
"ratio": 0.216
},
"moondeck": {
- "lines": 3736,
+ "lines": 4016,
"ratio": 0.184
}
},
"tests": {
- "cases": 2038,
+ "cases": 2057,
"scenarios": 27
},
"docs": {
- "md_files": 121,
- "md_lines": 27147,
- "plans_files": 30,
- "backlog_lines": 2861,
- "lessons_lines": 0,
- "claude_md_lines": 260
+ "md_files": 136,
+ "md_lines": 28152,
+ "plans_files": 31,
+ "backlog_lines": 2886,
+ "lessons_lines": 518,
+ "claude_md_lines": 275
},
"complexity": {
- "functions": 3557,
- "over_threshold": 252,
+ "functions": 3616,
+ "over_threshold": 262,
"worst_ccn": 128
}
}
diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md
index afea5b3b..77c86750 100644
--- a/docs/reference/metrics/repo-health.md
+++ b/docs/reference/metrics/repo-health.md
@@ -1,26 +1,26 @@
# Repo health
-Measured at `b52e8b57`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
+Measured at `c8c1d9ed`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
-Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
+Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
## Firmware size
| Target | Flash | Capacity | Used | Built |
|---|---:|---:|---:|:--:|
-| desktop | 1,904 KB | - | - | carried 1d |
-| esp32 | 2,029 KB | 2,496 KB | 81% | yes |
-| esp32-16mb | 2,012 KB | 4,096 KB | 49% | carried 3d |
-| esp32-eth | 1,642 KB | 2,496 KB | 66% | carried 1d |
-| esp32-pico | 2,058 KB | 3,072 KB | 67% | carried 3d |
+| desktop | 1,908 KB (+3 KB) ⚠ | - | - | yes |
+| esp32 | 2,032 KB | 2,496 KB | 81% | yes |
+| esp32-16mb | 2,012 KB | 4,096 KB | 49% | carried 6d |
+| esp32-eth | 1,642 KB | 2,496 KB | 66% | carried 4d |
+| esp32-pico | 2,058 KB | 3,072 KB | 67% | carried 6d |
| esp32-wrover | 1,801 KB | - | - | carried (age?) |
-| esp32p4rev1-eth | 1,951 KB | 4,096 KB | 48% | carried 3d |
-| esp32p4rev1-eth-wifi | 2,231 KB | 4,096 KB | 54% | carried 4d |
+| esp32p4rev1-eth | 1,980 KB (+30 KB) ⚠ | 4,096 KB | 48% | yes |
+| esp32p4rev1-eth-wifi | 2,231 KB | 4,096 KB | 54% | **STALE 7d** |
| esp32p4rev3-eth | 1,605 KB | - | - | carried (age?) |
-| esp32s3-n16r8 | 2,054 KB | 4,096 KB | 50% | carried 3d |
-| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | carried 4d |
-| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | carried 4d |
-| esp32s31 | 2,294 KB | 4,096 KB | 56% | carried 6d |
+| esp32s3-n16r8 | 2,079 KB (+25 KB) ⚠ | 4,096 KB | 51% | yes |
+| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 7d** |
+| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 7d** |
+| esp32s31 | 2,294 KB | 4,096 KB | 56% | **STALE 9d** |
| qemu | 1,351 KB | - | - | carried (age?) |
`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table.
@@ -29,39 +29,39 @@ Current state only; the trend is this file's git history (`git log -p docs/metri
| Target | Tick | FPS |
|---|---:|---:|
-| desktop | 132 µs (+4 µs) ⚠ | 7,575 (−237) ⚠ |
+| desktop | 128 µs | 7,812 |
| esp32 | 8,354 µs | 119 |
### Scenario tick by target (p50 of each sample window)
| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown |
|---|---|---|---|---|---|---|---|---|---|
-| Audio_mutation | 24 | 40 ? | 13,152 | 47 ? | - | - | - | - | - |
-| Aurora_fps | 1,574 | - | - | - | - | - | - | - | - |
+| Audio_mutation | 22 | 40 ? | 13,152 | 47 ? | - | - | - | - | - |
+| Aurora_fps | 1,526 | - | - | - | - | - | - | - | - |
| Driver_mutation | 20 | 42 ? | 12,812 | 39 ? | - | - | - | - | - |
-| Effects_composition | 147 | 549 ? | - | - | - | - | - | - | - |
-| Fields_polar_lut | 1,286 | - | - | - | - | - | - | - | - |
-| Fluid_solver | 222 | - | - | - | - | - | - | - | - |
+| Effects_composition | 145 | 549 ? | - | - | - | - | - | - | - |
+| Fields_polar_lut | 1,319 (+21) ⚠ | - | - | - | - | - | - | - | - |
+| Fluid_solver | 221 | - | - | - | - | - | - | - | - |
| GridBlacks_blackpixel | 2 | 8 ? | 269 ? | 267 ? | - | - | - | - | - |
-| GridLayout_resize | 121 | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - |
-| Layer_base_pipeline | 69 | 118 ? | - | - | - | - | - | - | - |
+| GridLayout_resize | 121 (−1) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - |
+| Layer_base_pipeline | 70 | 118 ? | - | - | - | - | - | - | - |
| Layer_memory_1to1 | 5 | 1 ? | - | - | - | - | - | - | - |
| Layouts_mutation | 94 | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - |
| MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - |
| MoonLiveEffect_livescript | 5 | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - |
| MoonLive_pipeline | 5 | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? |
-| MoonModule_control_change | 126 | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - |
+| MoonModule_control_change | 125 (+1) ⚠ | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - |
| MqttModule_haDiscovery_toggle | 3 ? | - | 36 ? | 36 ? | - | - | - | - | - |
| MultiplyModifier_memory_lut | 3 | 3 ? | - | - | - | - | - | - | - |
-| MultiplyModifier_pipeline | 121 | 225 ? | - | - | - | - | - | - | - |
+| MultiplyModifier_pipeline | 120 | 225 ? | - | - | - | - | - | - | - |
| NetworkModule_eth_reconfigure | - | - | 1,169 ? | 97,843 ? | - | - | - | - | - |
| NetworkModule_mdns_toggle | 13 ? | - | 36 ? | 36 ? | 21 ? | - | 109,767 ? | 93,963 ? | - |
-| Trails_ladder | 362 | - | - | - | - | - | - | - | - |
-| modifier_chain | 44 | 69 ? | 13,337 | - | - | - | - | - | - |
-| modifier_swap | 22 | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - |
-| perf_full | 259 | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - |
+| Trails_ladder | 362 (+3) ⚠ | - | - | - | - | - | - | - | - |
+| modifier_chain | 43 | 69 ? | 13,337 | - | - | - | - | - | - |
+| modifier_swap | 23 (+1) ⚠ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - |
+| perf_full | 257 | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - |
| perf_light | 16 | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - |
-| peripheral_grid_sweep | 260 | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - |
+| peripheral_grid_sweep | 252 | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - |
| peripheral_switch | 4 | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - |
Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario.
@@ -72,8 +72,8 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first
| Scenario | p50 | p95 | n |
|---|---:|---:|---:|
-| Layer_base_pipeline | 69 µs | 139 µs | 32 |
-| Layer_memory_1to1 | 5 µs | 7 µs | 32 |
+| Layer_base_pipeline | 70 µs | 87 µs | 32 |
+| Layer_memory_1to1 | 5 µs | 24 µs | 32 |
These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers.
@@ -81,36 +81,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan
| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
-| core | 26,988 | 10,781 | 43.2 % |
-| light | 35,669 | 13,558 | 41.7 % |
-| platform | 18,966 | 6,708 | 38.7 % |
-| ui | 11,179 | 3,326 | 31.4 % |
-| test | 58,735 | 11,066 | 21.6 % |
-| moondeck | 23,143 | 3,736 | 18.4 % |
+| core | 27,154 | 10,870 | 43.3 % |
+| light | 36,351 (+659) ⚠ | 13,839 | 41.8 % (+0.1 %) ⚠ |
+| platform | 19,661 (+551) ⚠ | 6,892 | 38.4 % (−0.3 %) ✓ |
+| ui | 11,196 | 3,333 | 31.4 % |
+| test | 59,431 (+251) ⚠ | 11,201 | 21.6 % |
+| moondeck | 24,995 (+40) ⚠ | 4,016 | 18.4 % (+0.1 %) ⚠ |
## Tests
| Kind | Count |
|---|---:|
-| unit cases | 2,038 |
+| unit cases | 2,057 (+8) ✓ |
| scenarios | 27 |
## Complexity
| Metric | Value |
|---|---:|
-| functions | 3,557 |
-| over threshold | 252 |
+| functions | 3,616 (+53) ✓ |
+| over threshold | 262 (+5) ⚠ |
| worst CCN | 128 |
## Documentation
| Metric | Value |
|---|---:|
-| markdown files | 121 (−1) ✓ |
-| markdown lines | 27,147 (−11) ✓ |
-| plan files | 30 |
-| backlog lines | 2,861 |
-| lessons lines | 0 (−518) ✓ |
-| CLAUDE.md lines | 260 (−7) ✓ |
+| markdown files | 136 (+1) ⚠ |
+| markdown lines | 28,152 (+208) ⚠ |
+| plan files | 31 (+1) ⚠ |
+| backlog lines | 2,886 |
+| lessons lines | 518 |
+| CLAUDE.md lines | 275 |
diff --git a/docs/reference/testing.md b/docs/reference/testing.md
index 2b368145..72abbb21 100644
--- a/docs/reference/testing.md
+++ b/docs/reference/testing.md
@@ -225,7 +225,7 @@ Picking the right mode:
A `mutate` scenario that needs platform-bound modules (Network mDNS, WiFi, OTA) the in-process runner can't honestly stand up should add `"live_only": true`.
-**Bespoke convention.** The `mode` + `fixture` + `reset` trinity is projectMM-specific — no off-the-shelf BDD or scenario framework was borrowed wholesale. It exists because the same JSON has to serve both an in-process runner that owns the scheduler and a live runner that doesn't (main.cpp does). The closest analogs from widely-recognised testing patterns: `fixture` ≈ xUnit fixtures (setup-once, replayed per scenario); `reset` ≈ SQL `BEGIN`/`ROLLBACK` (idempotent state restoration); `mode` ≈ pytest's parametrised execution modes (one test runs in different worlds). If a future contributor finds an off-the-shelf scenario framework that captures this construct/mutate asymmetry, that's worth migrating to.
+**Bespoke convention.** The `mode` + `fixture` + `reset` trinity is projectMM-specific: no off-the-shelf BDD or scenario framework was borrowed wholesale. It exists because the same JSON has to serve both an in-process runner that owns the scheduler and a live runner that does not (main.cpp does). The closest analogs from widely recognized testing patterns: `fixture` ≈ xUnit fixtures (setup-once, replayed per scenario); `reset` ≈ SQL `BEGIN`/`ROLLBACK` (idempotent state restoration); `mode` ≈ pytest's parameterized execution modes (one test runs in different worlds). A future contributor who finds an off-the-shelf framework capturing this construct/mutate asymmetry is worth migrating to.
### Reset block: idempotent scenarios
@@ -478,6 +478,21 @@ Memory tracking works on ESP32: `freeHeap` and `freeInternalHeap` report real va
One live-tier test lives outside the scenario JSON schema because it spans **multiple devices**: `uv run moondeck/scenario/run_network_live.py` runs a lights-over-UDP matrix (ArtNet, E1.31 and DDP) over every online board in moondeck.json — each board is once the sender, all others listen, and reception is asserted by reading each device's `/ws` preview stream (see [MoonDeck.md § run_network_live](../moondeck/MoonDeck.md#run_network_live)). A device matrix needs loops and per-round state the declarative scenario JSON can't express, so it follows the `improv_smoke_test.py` script shape instead.
+## UI scenarios
+
+UI scenarios drive the web interface itself: a run file lists what a person does (open a card, add a module through the picker, drag a slider) and each step checks itself by reading the device back over REST.
+
+```bash
+uv run moondeck/test/test_host.py --ui # the whole lane
+uv run moondeck/uiscenario/uivideo.py --run test/uiscenarios/clips/add-a-layer.json
+```
+
+The runs live in `test/uiscenarios/clips/`, the engine in `moondeck/uiscenario/`. Data under `test/`, runner under `moondeck/`: the same split the pipeline scenarios use. Tests are parameterized over the directory, so a new run file is a new test with nothing to wire up. Format and actions: [RUNS.md](../../moondeck/uiscenario/RUNS.md).
+
+**REST is read-only here.** Every state change goes through the affordance a person uses, because a step that POSTs its way to the outcome proves nothing about the interface. The reads are what `expect` compares against. That lets one file be both a test and a documentation video: the same run recorded produces the clips under `docs/assets/uiscenarios/`. A failing test means the UI no longer does what a published video shows.
+
+**Opt-in, because it needs something running.** A bare `test_host.py` leaves this lane out, and it is never a gate: it runs on request only. `--ui` skips rather than fails when nothing answers, the same way the JS lane skips without node. A run that drives another surface names its own `host` (the installer's preview server). One that needs particular hardware names a `requires` capability resolved against the bench registry.
+
## Hardware Verification
All live scenarios pass on both desktop and ESP32 with `min_pct: 80` relative bounds. Per-module timing, memory allocation, and sizeof measurements for each platform are in [performance.md](performance.md).
diff --git a/docs/tutorials/first-light-show.md b/docs/tutorials/first-light-show.md
new file mode 100644
index 00000000..5718dfdb
--- /dev/null
+++ b/docs/tutorials/first-light-show.md
@@ -0,0 +1,77 @@
+# Build your first light show
+
+You have lights running and you know [what the cards are](how-projectmm-works.md). Now you build something deliberately, instead of accepting what the defaults gave you: a shape you chose, an effect on top of it, a second effect blended into the first, and the whole thing going out to real lights.
+
+Everything here happens in the device's own web interface, live. Nothing is compiled, nothing is saved and applied, nothing reboots. You change a number and the lights change while you are still holding the mouse.
+
+You need a device with projectMM on it and some lights attached, real or previewed. A bare board with no strip works the whole way through: the 3D preview is what you will be watching anyway.
+
+## 1. Say where the lights are
+
+Open **Layouts**. A fresh device has a **Grid**, which is the shape most rigs start as: a width, a height and a depth of evenly spaced points.
+
+Set **width** to 16 and **height** to 16. The preview reshapes as you type, and the light count under the card follows.
+
+
+
+
+That number is the whole point of a layout. Every effect downstream asks the layout how many lights there are and where each one sits, so this one card decides what the rest of the pipeline is painting on. Nothing else in the tree stores a size, which is why changing it here never leaves something stale behind.
+
+If your strip zig-zags back and forth along the rows, turn on **serpentine**. Watch a running effect while you toggle it: a wrong setting shows up as every other row drawn backwards, which is unmistakable once seen.
+
+## 2. Paint something on it
+
+Open **Effects**. Under the Layer, press **+ add module** and pick an effect. **Bouncing Balls** is a good first choice: it has motion you can read at a glance, so the controls show their effect immediately.
+
+Now change **numBalls** while it runs. Then **grav**. The lights respond as the slider moves, because an effect is not a rendered animation the device plays back: it is a function being run once per frame, reading its controls each time.
+
+
+
+
+Try a second effect. Press **+ add module** again and add **Ripples** beside the first.
+
+Both now run into the same Layer, in order, each writing over what the one before it left. That is useful when the second effect draws sparsely (sparks over a wash), and it is not blending: two effects in one Layer share one buffer.
+
+Blending happens between **layers**. Press **+ add module** on the Effects card to add a second Layer, give it its own effect, and the Layer card carries a **blendMode** and an **opacity**. The drivers composite the layers bottom to top, so lowering the top layer's opacity tints what is underneath instead of replacing it.
+
+
+
+This is the same model an image editor uses, and it is worth a minute of play. Layers compose; you are not picking one effect from a list.
+
+## 3. Reshape it, leaving the effect alone
+
+Under the Layer, add a **modifier**: **Mirror**.
+
+The effect did not change. The modifier sits between the effect and the lights and folds the coordinates on the way through, so a pattern that ran across the whole grid now runs across half and reflects.
+
+
+
+
+That separation is why a modifier is worth having at all. Mirror, rotate and multiply are things you want on *any* effect, and writing them into each effect would be the same code many times over.
+
+## 4. Send it somewhere real
+
+Open **Drivers**. Set **brightness** first, and set it low: 20 is plenty on a bench, and a full-brightness panel at arm's length is genuinely unpleasant.
+
+Then add the driver for your hardware:
+
+- **LED strip on a pin**: add an **RmtLedDriver** (or a **ParallelLedDriver** for many strands at once), set **pins** to the GPIO your data line is soldered to, and set the light count to match the layout.
+- **Over the network**: add a **NetworkSendDriver** for Art-Net, E1.31/sACN or DDP, and give it the destination address.
+- **Nothing attached**: the **Preview** driver is already there. That is what has been feeding the 3D view all along.
+
+The driver is the only part of the tree that knows about wires. Everything upstream of it produced colors for positions, which is why the same show runs out a GPIO pin, across the network to a panel card, and into your browser at the same time.
+
+## 5. Keep it
+
+Everything you did is already persisted. Controls save themselves a couple of seconds after the last change. Once that save lands a power cut costs you nothing; pull the plug mid-turn of a knob and you lose the last second or two of fiddling.
+
+Reboot the device if you want to prove it. The tree comes back as you left it.
+
+## What you built
+
+A layout, a layer with two effects and a modifier, and a driver. That is the whole pipeline, and it is the same pipeline whether it is driving twelve lights on a desk or twelve thousand on a wall.
+
+Two directions from here, and they go to different places:
+
+- **[Making beautiful effects](generative-effects.md)** is about the effects themselves: why noise looks organic and how a field turns into something worth watching.
+- **[Build your own MoonModules](build-your-own-moonmodules.md)** is about writing one in C++ when the catalog does not have what you want.
diff --git a/docs/tutorials/first-script.md b/docs/tutorials/first-script.md
new file mode 100644
index 00000000..036d94b2
--- /dev/null
+++ b/docs/tutorials/first-script.md
@@ -0,0 +1,112 @@
+# Write your first script
+
+An effect you write yourself, typed into the browser, running as native machine code on the device seconds later. No toolchain, no rebuild, no reflash, and no reboot.
+
+This is [MoonLive](../moonmodules/light/MoonLiveEffect.md). The reference for the language is [Writing scripts](../moonmodules/light/writing-scripts.md); what follows is the shortest path to seeing your own code drive real lights.
+
+You need a device with lights or the 3D preview, and [a light show already running](first-light-show.md) so there is something to replace.
+
+## 1. Add a scripted effect
+
+Open **Effects**, press **+ add module** under the Layer, and add **MoonLive**.
+
+It renders nothing and says `no script — set the script name`. That is deliberate: a fresh module compiling a default would mean every new one lights up the same, and you would be editing someone else's code before writing your own.
+
+## 2. Write four lines
+
+In the **script** box, type a name ending in `.mle`: `mine.mle`. The card opens an editor.
+
+Type this:
+
+```c
+class MyEffect {
+ void tick() {
+ fill(0, 0, 40);
+ }
+}
+```
+
+Click away from the editor, or press Ctrl/Cmd+S. The lights turn dim blue.
+
+
+
+
+That is the whole loop. `tick()` runs once per frame, `fill(r, g, b)` writes every light, and the numbers are 0 to 255.
+
+## 3. Make it move
+
+Change the body to read:
+
+```c
+class MyEffect {
+ void tick() {
+ fill(beatsin(30, 0, 100), 0, 40);
+ }
+}
+```
+
+Save. The blue now pulses, because `beatsin(bpm, low, high)` returns a value sweeping between the bounds at the tempo you named. Every effect that breathes is doing some version of this.
+
+## 4. Give yourself a knob
+
+A number typed into a script is a decision you have to re-edit. A control is one you can turn while watching:
+
+```c
+class MyEffect {
+ byte speed = 30;
+
+ void defineControls() {
+ addControl("speed", speed, 1, 120);
+ }
+
+ void tick() {
+ fill(beatsin(speed, 0, 100), 0, 40);
+ }
+}
+```
+
+Save, and a **speed** slider appears on the card. Drag it: the pulse follows.
+
+The slider is a real control, the same kind a compiled effect declares. It persists across a reboot and it is reachable over the API, because nothing about it is special-cased for scripts.
+
+## 5. Paint per light
+
+`fill` writes every light the same. To make a pattern, write them one at a time:
+
+```c
+class MyEffect {
+ byte speed = 30;
+
+ void defineControls() {
+ addControl("speed", speed, 1, 120);
+ }
+
+ void tick() {
+ for (int i = 0; i < lightCount; i = i + 1) {
+ setRGB(i, i * 4 + beatsin(speed, 0, 255), 80, 120);
+ }
+ }
+}
+```
+
+Save. Color now varies along the strand and drifts over time.
+
+`setRGB(index, r, g, b)` is the per-light write, `lightCount` is how many the layout placed, and the `for` is ordinary. A value past 255 wraps, which is what makes `i * 4` sweep through hues rather than saturating.
+
+## When the compile fails
+
+Break it on purpose: delete a closing brace and save.
+
+The lights go dark, the card shows the parse error, and the device keeps running. A bad script costs you a message, not a reboot, which is the point of editing code on a device that might be mounted three meters up a wall.
+
+Fix the brace, save, and it comes back.
+
+## What you have
+
+A working effect, written in a browser, running compiled on the device. It survives a reboot, it has a control, and it is a file you can copy to another device.
+
+Where to go next:
+
+- **[Writing scripts](../moonmodules/light/writing-scripts.md)** is the language: every function, the types, and the shipped library to read.
+- **[MoonLive](../moonmodules/light/MoonLiveEffect.md)** covers the same engine writing layouts and modifiers, not only effects.
+- **[Making beautiful effects](generative-effects.md)** is the ideas half: what to write once you can write anything.
diff --git a/docs/tutorials/how-projectmm-works.md b/docs/tutorials/how-projectmm-works.md
index 1ff1331b..d25fff57 100644
--- a/docs/tutorials/how-projectmm-works.md
+++ b/docs/tutorials/how-projectmm-works.md
@@ -11,6 +11,8 @@ and becomes a place you know your way around.

+
+
---
## 1. Everything is a card
@@ -148,6 +150,8 @@ ever knowing it.
Move a slider and the next frame uses it. Change the WiFi credentials, add an
effect, resize a grid — all of it takes effect immediately, on a running device.
+
+
**Files are the one exception, and only because typing is different.** A script
you are editing is saved when you click away, press Ctrl/Cmd+S, or press Save —
a half-typed line should not be compiled onto your fixture mid-word. The moment
diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md
index eede0979..a2f587db 100644
--- a/docs/work/future/backlog-core.md
+++ b/docs/work/future/backlog-core.md
@@ -2,6 +2,26 @@
Forward-looking to-build items for the **core / infrastructure** domain (`src/core/`, `src/platform/`, build, CI, network, persistence, UI). The light-domain counterpart is [backlog-light.md](backlog-light.md); items that genuinely span both are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](index.md). Completed items are removed.
+### The update overlay's Cancel button does nothing on a plain OTA (2026-09-14)
+
+`showUpdateOverlay` shows a Cancel button on every firmware install and wires it to
+`POST /api/firmware/cancel` (`src/ui/app.js:1479`). Only MoonBase implements that route
+(`moonbase/main/moonbase_main.cpp:532`); the application's dispatch table has none, so the request
+404s and the `.catch(() => {})` swallows it. The button therefore works during the MoonBase phase of
+an install and is inert for a plain app OTA on a device without MoonBase.
+
+A file upload still cancels there, but by a different mechanism: `uploadCtl.abort()` drops the
+connection on the line above. So the two install sources behave differently behind one button, and a
+URL install on a non-MoonBase device is the case where pressing Cancel does nothing at all.
+
+Two ways out, and the choice is the question. Implement the route in the application, which means an
+abort flag the OTA task polls between chunks, the shape MoonBase already uses. Or hide the button
+where it cannot act, which is honest but leaves a long download uninterruptible. The first is the
+better system and the larger change; the second is a few lines. Found while writing
+[Updating firmware](../../how-to/updating-firmware.md), which deliberately does not mention Cancel:
+documenting a button that half-works is worse than leaving it undocumented until it is one thing or
+the other.
+
### The audio-sync test waits on the wall clock (2026-09-06)
`test/unit/core/unit_AudioService_sync.cpp` drives the quiet-packet case with `platform::delayMs(1)`
diff --git a/docs/work/future/backlog-light.md b/docs/work/future/backlog-light.md
index a296e91f..fc4308d3 100644
--- a/docs/work/future/backlog-light.md
+++ b/docs/work/future/backlog-light.md
@@ -986,3 +986,65 @@ small a stack overflows under a rare path. So measure one at a time on a board t
serving the UI, not idle. The payoff is real: at 88 KB free a classic board is one large allocation
away from trouble, which is what drove both driver decisions on 2026-09-09.
+
+## HUB75, native (2026-09-16)
+
+The [Hub75Driver](../../../src/light/drivers/Hub75Driver.h) drives a panel from the board's own pins, as against [PanelCardDriver](../../../src/light/drivers/PanelCardDriver.h) which sends ColorLight frames to a receiving card. It ships unverified: no pixel has come out of it on real hardware, and the entries below are what a comparison against WLED-MM's path found missing. WLED-MM does not implement HUB75 itself; it wraps [mrcodetastic/ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) v3.0.12 in about 600 lines (`wled00/bus_manager.cpp:677`), so the items here are measured against a library with years of hardware debugging behind it.
+
+### Run it on a panel, which nothing has done (the blocker for everything else)
+
+Every other item here is a judgment made from reading. This one is the measurement. What a first session settles, roughly in the order the failures appear: whether anything lights at all; whether the bit order on the bus matches what the ribbon expects; whether the latch pulse lands correctly relative to the address change, which shows as the previous row ghosting into the current one; whether the blanking width is enough; and whether the clock phase suits the panel.
+
+The defect class this catches is exactly the one host tests cannot. F1 of the pre-merge review (the encoder truncating each slot to 8 bits, so the address, latch and output-enable lines never reached the wire) made the committed driver incapable of lighting a panel, and every host test passed throughout because the test remapped those lines into the low byte. A panel answers that question in seconds.
+
+Also unverified for the same reason: the ISR restart path and the PSRAM cache handling in [platform_esp32_hub75.cpp](../../../src/platform/esp32/platform_esp32_hub75.cpp), since the desktop build never compiles that file.
+
+### Panel quirk controls: latch blanking, clock phase, shift-register driver
+
+A HUB75 "panel" is a family, and the differences are not discoverable from its dimensions. The library exposes three knobs we have none of, each for a failure mode with a distinct look:
+
+- `latch_blanking` (1 to 3, default 2): widens the dark window around the latch. Too narrow and the previous row ghosts into the current one.
+- `clkphase`: which clock edge the panel samples on. Wrong, and the image shears by a pixel or shows the wrong column.
+- The shift-register driver: FM6124 and ICN2038S need a different init sequence. WLED-MM picks FM6124 for "outdoor" panels and its comment says the symptom of the wrong choice is a dark panel or pastel colors.
+
+Build trigger: a panel that misbehaves in one of these ways. Adding all three speculatively is three controls nobody can act on; adding the one whose symptom appears is a control with a reason.
+
+### Panel brightness through the output-enable window
+
+Brightness works: `Correction::apply` writes every channel through `briLut`, so the slider dims a HUB75 panel exactly as it dims a strip. The question is HOW it dims, and on this driver it costs color resolution.
+
+Scaling pixel values squeezes the available levels into the bottom of the range. At 4-bit depth that is 16 levels per channel at full brightness and roughly 8 at half, so the banding the cap already causes gets worse the further the slider comes down, which is the opposite of what a user expects from a dimmer.
+
+The library modulates the OE window per plane instead (`brtCtrlOEv2`), shortening how long each plane is lit rather than changing its value. The panel keeps its full depth at any brightness. WLED-MM therefore passes brightness straight through to `display->setBrightness(_bri)` rather than folding it into the pixel data.
+
+This shares its whole mechanism with the plane weighting below, so the two are one piece of work rather than two. Worth noting what it would cost elsewhere: `Correction` is one shared pipeline across every driver, so HUB75 taking brightness out of `briLut` means either a per-driver opt-out or a second path, and that design question is the real work rather than the OE arithmetic.
+
+### Repaint only what changed
+
+`tick()` corrects and encodes the entire frame every time, whatever moved. The library tracks a dirty bit per pixel in `setPixelColor` and repaints only those in `show()`. For a full-frame effect the two are comparable; for a sparse overlay, a clock, or a mostly-static scene, the difference is the whole encode.
+
+Worth measuring before building: our encode is a tight loop over a packed buffer, and a dirty-bit test per pixel is not free either. The win is real only where the frame is mostly unchanged.
+
+### Depth that degrades instead of failing
+
+`bitDepth` is a user control with a hard ceiling, so asking for more than the wall can hold fails init with a memory error. WLED-MM reduces depth automatically by pixel count (8 bits under `MAX_PIXELS_8BIT`, then 6, 4, 3), so a large wall gets a dimmer picture rather than no picture.
+
+That fits the allocate-and-degrade rule every other output seam here follows. It interacts with the weighting item below, since the library's `lsbMsbTransitionBit` is the same trade expressed as refresh rather than as memory, so build them together rather than adding two overlapping automatic reductions.
+
+### Four-scan panels whose physical layout is not their logical one
+
+Some panels scan in a pattern where the physical row order does not match the logical image, and driving them without remapping produces a scrambled picture that looks like a wiring fault. The library handles this with `VirtualMatrixPanel` and `setPhysicalPanelScanRate` (FOUR_SCAN_32PX_HIGH, FOUR_SCAN_64PX_HIGH), and WLED-MM exposes them as bus types 105 to 108.
+
+Our `Hub75Geometry::rowsPerScan()` handles a panel driving more than two rows per address step, which is the common multi-pair case, but not an arbitrary remap. Build trigger: someone owns such a panel. The remap belongs in the Layout rather than the driver, since it describes where lights physically are, which is what a Layout is for.
+
+### Bit depth is not yet weighted
+
+`Hub75Slots.h` emits every bit plane once, so bit 7 lights for the same time as bit 0 and a value renders as a flat ramp: 0x7F and 0x80 are indistinguishable. Binary coded modulation is what the format expects, and the header documents it as the design. `bitDepth` is capped at 4 meanwhile, because an unweighted plane above that costs a scan pass and a share of the frame buffer for a difference the eye cannot find.
+
+**The approach: repeat DMA descriptors, not data.** Plane `p` is stored once and the descriptor chain points at it 2^p times, so the weighting costs descriptors rather than frame memory. mrcodetastic's ESP32-HUB75-MatrixPanel-DMA does exactly this (`configureDMA`, the `for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++)` loop), and it carries a refinement worth taking with it: `lsbMsbTransitionBit` is a dial, not a constant. Planes below it show once each; planes above get full weighting. The library raises it until a configured minimum refresh is met, trading exactness at the low end for rate. That makes depth and refresh one control instead of two fighting ones.
+
+**What it costs here: a new LCD_CAM backend.** The blocker is not the weighting loop, it is that `esp_lcd_panel_io_tx_color` owns its descriptors and gives no way to aim several at one buffer. The library programs the silicon directly instead (`gdma_new_channel`, `gdma_connect(GDMA_TRIG_PERIPH_LCD)`, hand-linked `dma_descriptor_t.next`). So this is the same step this project already took for WS2812, from `platform_esp32_i80.cpp` (675 lines, esp_lcd) to `platform_esp32_moon_i80.cpp` (1,952 lines, own GDMA), with the same hazards that took bench bisects to find there: re-mounting the chain per frame, terminator placement, producer/consumer headroom.
+
+**Parlio cannot express it.** Its only repeat primitive is `loop_transmission`, which repeats the whole buffer forever with no per-plane control. Weighted depth would be an LCD_CAM capability, and the `peripheral` select has to say so rather than offering a choice that silently changes the picture.
+
+Worth doing after the driver has run on a real panel, not before: a flat ramp is visible in seconds on hardware, and so are the ghosting and latch-timing faults that only hardware finds. Brightness belongs in the same piece of work, since the OE window is where both live: today `Correction` scales pixel values before encoding, which spends color resolution exactly where there is least of it.
diff --git a/docs/work/present/Plan-20260915 - Native HUB75 output.md b/docs/work/present/Plan-20260915 - Native HUB75 output.md
new file mode 100644
index 00000000..50c3694f
--- /dev/null
+++ b/docs/work/present/Plan-20260915 - Native HUB75 output.md
@@ -0,0 +1,174 @@
+# Plan — Native HUB75 output: drive panels directly, without a receiving card
+
+Answers [issue #102](https://github.com/MoonModules/projectMM/issues/102). Phase 1 only; Phase 2 (PWM panels) is deliberately out of scope and § "What this plan does not do" says why.
+
+## The gap
+
+projectMM drives HUB75 panels one way: [PanelCardDriver](../../../src/light/drivers/PanelCardDriver.h) emits ColorLight frames over raw Ethernet to a 5A-75B/E receiving card, which does the HUB75 driving. That path is excellent above roughly 16,384 pixels and it is what the [panel-cards guide](../../how-to/panel-cards.md) documents.
+
+Below that size it is the wrong shape. The user buys a receiving card, configures it with LEDvision (a Windows tool, not ours), and runs a dedicated Ethernet link, all to light one 64×64 panel that a $10 board could drive from its own pins. WLED does this natively; projectMM does not. That is the gap, and it is the whole of Phase 1.
+
+## What decides the design: two hardware ceilings
+
+A HUB75 panel is **scanned, not addressed**. One frame is `rows/2` scan lines (RGB1 and RGB2 drive both half-panels at once), each line clocked out as `width` parallel words, and brightness comes from repeating the whole thing once per **bit plane**. So the DMA buffer is:
+
+```
+frame bytes = (height / 2) × width × bitDepth
+```
+
+Measured against the peripherals projectMM already has:
+
+Measured from the encoder's own formula (`Hub75Geometry::frameBytes`, pinned by [unit_Hub75Slots.cpp](../../../test/unit/light/unit_Hub75Slots.cpp)), at 1/32 scan:
+
+| Panels | Geometry | 6-bit | 8-bit | Parlio (65,535 B cap) | i80/LCD_CAM (PSRAM) |
+|---|---|---:|---:|:--|:--:|
+| 1 | 64×64 | 12,480 B | 16,640 B | ✅ both | ✅ |
+| 4 | 128×128 | 49,344 B | 65,792 B | ✅ 6-bit, ❌ 8-bit | ✅ |
+| 16 | 256×256 | 196,800 B | 262,400 B | ❌ | ✅ |
+
+**Four panels at 8-bit misses the Parlio cap by 257 bytes.** That is worth stating precisely rather than as "borderline": a user with four panels gets full depth on an S3 and 6-bit on a Parlio-only chip, and the driver picks the backend that can carry what they asked for.
+
+**This is the fact the issue does not account for.** #102 proposes the P4-Nano as lead candidate because Espressif's reference uses PARLIO, but [platform_esp32_parlio.cpp:238](../../../src/platform/esp32/platform_esp32_parlio.cpp) records the hardware cap: `kParlioMaxTransferBytes = 0x7FFFF / 8` = 65,535 bytes, width-invariant. PARLIO carries one panel comfortably, reaches its limit at four, and cannot do sixteen at any useful depth.
+
+The route that scales is the one the [shift-register analysis](../future/shift-register-driver-analysis.md) already established for a structurally identical problem: **i80/LCD_CAM, whose DMA reaches PSRAM**, proven on this exact path to 16,384 lights. `hasLcdCam` is true on S3, P4 and S31.
+
+### GPIO requirements
+
+HUB75 is pin-hungry in a way the memory table hides. One port needs:
+
+| Group | Lines | Count |
+|---|---|---:|
+| Colour | `r1 g1 b1 r2 g2 b2` | 6 |
+| Row address | `a b c` (1/8 scan), `+d` (1/16), `+e` (1/32) | 3-5 |
+| Control | `clk lat oe` | 3 |
+| | **Total** | **12-14** |
+
+Against the per-chip free sets in [gpio-usage.md](../../reference/hardware/gpio-usage.md), which exclude flash/PSRAM, input-only, USB, UART0 and strapping pins:
+
+| Chip | Usable output GPIOs | 1/16 scan (13 pins) | 1/32 scan (14 pins) |
+|---|---:|:--:|:--:|
+| **ESP32-S3** (N16R8) | 16 | ✅ 3 spare | ✅ 2 spare |
+| **ESP32-P4** (P4-NANO) | 20 | ✅ 7 spare | ✅ 6 spare |
+| **ESP32-S31** | board-specific | take from the [coreboard reference](../../reference/hardware/esp32-s31-coreboard.md) | |
+| **ESP32 classic** | 13 | ❌ | ❌ |
+
+**This settles the target list, and it removes a chip the issue implies.** The classic ESP32 has 13 usable output GPIOs and would need every one for a 1/16-scan port, leaving nothing for the LED strand, a button or a mic; a 1/32 panel does not fit at all. gpio-usage.md already records the same squeeze for 16-lane i80 ("a 16-lane set must borrow strap pins, and GPIO 12 is the flash-voltage strap: driving it at reset can brick the boot"). So HUB75 is an **S3, P4 and S31 feature**, and the classic is out on pins before memory is even considered.
+
+Two consequences for the plan:
+
+- **The board catalog entries in step 5 are load-bearing, not polish.** An S3 has exactly 3 spare pins at 1/16 scan, so a user picking 13 by hand will land on octal-PSRAM (33-37), USB (19-20), UART0 (43-44) or a strap (0, 3, 45, 46). [PinsModule](../../moonmodules/core/system.md) flags a conflict after the fact; a catalog entry prevents it.
+- **Defaults stay unset.** Even with the pins available, a default would guess the user's wiring. Same rule the LED drivers follow, and the P4's own history is the argument: its first LED-driver default landed on strapping pins ([lessons.md](../past/lessons.md)).
+
+### Refresh
+
+The second ceiling is **refresh rate**, and it is the one users will feel. Every bit plane costs a full scan pass, so depth trades directly against flicker:
+
+```
+line time = width x clock period + latch/OE overhead
+frame time = (height / 2) x bitDepth x line time
+refresh = 1 / frame time
+```
+
+Predicted at a 20 MHz shift clock (the rate the i80 path already runs, per the [shift-register analysis](../future/shift-register-driver-analysis.md)), ignoring latch overhead, so these are ceilings rather than promises:
+
+| Geometry | 4-bit | 6-bit | 8-bit |
+|---|---:|---:|---:|
+| 64×64 (1 panel) | 2441 Hz | 1628 Hz | 1221 Hz |
+| 128×128 (4) | 610 Hz | 407 Hz | 305 Hz |
+| 256×256 (16) | 153 Hz | 102 Hz | 76 Hz |
+
+**The arithmetic says refresh is not the wall it looked like.** Even 16 panels at 8-bit clears 76 Hz before overhead, and flicker becomes visible somewhere below about 60 Hz. What will actually bite first is latch/OE overhead per line and the render cost of filling a 256 KB buffer every frame, neither of which this calculation includes. So the honest position is: **memory is the hard ceiling (§ above), refresh is a soft one**, and the numbers here are what a tester should compare their reading against.
+
+`bitDepth` is a user control rather than a driver choice, because the tradeoff is theirs: the table is what makes it an informed one. And the driver **reports its achieved refresh in its status line**, so a discrepancy between this table and a real panel is visible to whoever is holding it.
+
+## Design
+
+### The driver
+
+`Hub75Driver : DriverBase`, registered in [main.cpp](../../../src/main.cpp) beside the others, gated on `platform::hasLcdCam || platform::parlioLanes` so it is offered only where it can run — the same inert-on-wrong-chip rule [ParlioLedDriver](../../../src/light/drivers/ParlioLedDriver.h) follows. That gate happens to exclude the classic ESP32, which § GPIO requirements shows could not host a port anyway.
+
+It is **not** a `LedPeripheral` backend. That seam is shaped around the parallel WS2812 orchestrator (`supportsPinExpander`, `lanesAvailable`, slot encoding), and HUB75 shares none of it: different wire protocol, different buffer shape, different control set. Reusing it would mean a base class whose every method one subclass ignores, which is the abstraction CLAUDE.md's minimalism rule exists to prevent. What they legitimately share is the **platform DMA seam**, and that is where the sharing belongs.
+
+Controls, all defaulting to unset because a soldered pin must never be guessed ([lessons.md](../past/lessons.md)):
+
+| Control | Why |
+|---|---|
+| `r1 g1 b1 r2 g2 b2` | The six colour lines |
+| `a b c d e` | Row-address lines; `e` only on 1/32-scan panels |
+| `clk lat oe` | Clock, latch, output-enable |
+| `bitDepth` (2..8) | The refresh tradeoff, named and the user's to make |
+| `scanRate` | 1/8, 1/16, 1/32 — panel-dependent, not derivable from size |
+| `sPWM` | The [WLED-MM sPWM commit](https://github.com/MoonModules/WLED-MM/commit/778ae558) case |
+| `peripheral` | Which silicon block drives the panel, where the chip has more than one |
+
+**Geometry is not a driver control.** [PanelLayout](../../../src/light/layouts/PanelLayout.h) and [PanelsLayout](../../../src/light/layouts/PanelsLayout.h) already model a serpentine panel and an M×N tiling of them, with configurable axis order and per-axis direction. A layout emits coordinates and the driver owns pins: that separation is the architecture, and HUB75 must not re-litigate it. The issue asks for "UI features to set panel dimensions and arrangement", and the honest answer is that projectMM already has them.
+
+### The platform seam
+
+Four functions, mirroring the shape the Parlio and i80 seams already use:
+
+```cpp
+bool hub75Init(Hub75Handle& h, const Hub75Pins& pins, uint16_t width,
+ uint16_t height, uint8_t scanRate, uint8_t bitDepth);
+uint8_t* hub75Buffer(const Hub75Handle& h, uint8_t buffer);
+bool hub75Transmit(Hub75Handle& h, uint8_t buffer);
+void hub75Deinit(Hub75Handle& h);
+```
+
+Two backends behind it, and **the user picks which**, through a `peripheral` select exactly like the one [ParallelLedDriver](../../../src/light/drivers/ParallelLedDriver.h) already carries. Each backend self-registers at static init, and `main.cpp` links it only where its SOC macro says the silicon exists, so the dropdown's options are per-chip rather than a fixed list: LCD_CAM on an S3 or S31, LCD_CAM and PARLIO on a P4, nothing on a classic.
+
+**An earlier draft of this plan had the platform choose, on memory alone. That was wrong, and the reason is contention rather than capacity.** A P4 has both peripherals and only one of each. A user driving WS2812 strips from PARLIO needs HUB75 on LCD_CAM; another user wants the reverse. Both are correct, the difference is what else is plugged into that board, and the platform cannot know it. The sibling claim guard stops two drivers colliding on one block, but it cannot guess which driver should win — that is the user's call, and a select is how the repo already asks it.
+
+The default is whichever backend fits the geometry, so a fresh driver works without a decision. What the plan does NOT do is silently downgrade: four panels at 8-bit is 65,792 bytes against PARLIO's 65,535-byte cap, and the driver says so rather than quietly dropping to 6-bit.
+
+A desktop stub returning false from everything keeps `mm_tests` and every non-HUB75 target compiling untouched.
+
+### The encoder
+
+The one genuinely new piece: rendered RGB → bit-plane-major scan buffer. Per bit plane, per scan row, pack six colour bits per pixel-column into the parallel word alongside the row address and the latch/OE timing.
+
+This is the same class of work as [ParallelSlots.h](../../../src/light/drivers/ParallelSlots.h) (serialise a frame through a parallel bus under a DMA ceiling) and is testable the same way: a host unit test that walks a known pattern through the encoder and asserts the exact bytes, including a walking-one per colour line. The '595 investigation's hardest lesson was that an encoder proven byte-for-byte on the host still left a wall-visible artifact, so the test pins the encoder and hardware verification stays the PO's.
+
+## End states considered
+
+**A. i80/LCD_CAM only.** Simplest, scales to 16 panels, covers S3/P4/S31. Loses the C6/H2 class entirely and ignores the issue's own P4 reference.
+
+**B. PARLIO only.** Matches the issue's stated direction and Espressif's example, and is the simpler peripheral. Caps at ~4 panels, which forecloses the size range that makes the feature worth having.
+
+**C. Both, behind one platform seam.** *(chosen)* Costs one extra implementation. Buys the full size range, keeps the driver ignorant of the peripheral, and means the ceiling moves when silicon does.
+
+**D. A `LedPeripheral` backend.** Maximum apparent reuse. Rejected: the seam is WS2812-shaped and HUB75 would ignore most of it, producing exactly the abstraction that has to be unpicked later.
+
+## Steps
+
+1. **Platform seam + LCD_CAM implementation.** `hub75*` in `platform.h`, an S3/P4/S31 implementation, a desktop stub. Verified by a build on every target.
+2. **The encoder, with its host test.** Bit-plane packing pinned byte-for-byte, walking-one per colour line.
+3. **`Hub75Driver`.** Controls, pin claims through the existing `PinsModule` registry, status line reporting geometry and achieved refresh, registration in `main.cpp`.
+4. **A backend registry, then the PARLIO backend.** The seam built in step 1 picks its peripheral internally; this turns it into an interface the driver selects, mirroring `LedPeripheral` without reusing it (that one is WS2812-shaped — same pattern, own interface). Then PARLIO behind it, and the `peripheral` select on the card. This REVISES step 1 rather than only adding to it.
+5. **Board catalog entries**: Adafruit S3 Portal, Waveshare RGB Matrix, Lilygo S3, ESP32-S3-16MB-PSRAM. Catalog data in `deviceModels.json`, not driver work, and it is what makes the feature findable.
+6. **Docs**: a how-to beside [panel-cards.md](../../how-to/panel-cards.md), the driver's catalog card, and a line in panel-cards.md saying which of the two paths a given wall wants.
+
+Steps 1-3 are the shippable unit. Steps 4-6 each stand alone.
+
+## Verification
+
+**No bench session gates this.** The PO has no HUB75 panel, and buying one to prove a driver that users will test for free is the wrong order. So the driver ships behind the same rule every other unproven path follows: it is offered only where it can run, it degrades visibly, and it says what it is doing.
+
+- **Host, and this is the whole of the pre-merge gate**: encoder byte-for-byte (walking-one per colour line, per bit plane), driver control and claim behaviour, a scenario pinning tick cost and memory. All of it runs on a desktop with no panel attached.
+- **The driver measures itself.** Its status line reports geometry, `bitDepth`, and the **achieved** refresh in Hz. That is a few lines of code and it is what makes community testing useful: a report reading "128×128, 8-bit, 87 Hz, flickers" is a data point, where "it flickers" is not.
+- **The docs carry the predicted refresh** (§ Refresh, computed not measured), so a tester can see whether what they observe is the predicted ceiling or a defect. A prediction that turns out wrong is itself a useful report.
+- **Community testing is the hardware gate**, after merge. The board catalog entries (step 5) exist to make that possible: a tester should not have to pick 13 pins correctly before the driver can be judged.
+
+What would make Phase 1 *proven* rather than shipped: a 128×128 wall at 8-bit depth, flicker-free, reported by someone who is not us. Until then the driver's docs say it is new and what is untested about it.
+
+## Risks
+
+- **Refresh at depth may disappoint on 16 panels.** The encoder cost is linear in bit planes and the scan is serial. **Settled by publishing the prediction, not by measuring first**: § Refresh computes the ceiling per geometry and depth, the driver reports what it actually achieves, and the docs name PanelCard as the path above whatever that turns out to be. A user meets the limit with an explanation rather than a surprise.
+- **The [48×256 white-flash](../future/backlog-light.md) is open on the i80 path.** Six theories ruled out, cause unfound. A HUB75 driver rides the same peripheral, so it may inherit it. **Settled by shipping and watching**: Phase 1 targets 1-4 panels, a size at which the flash has never been seen, so it does not block. If a tester on a large HUB75 wall reports it, that is the seventh theory the backlog entry is missing — the flash following a non-WS2812 encoder through the same peripheral would exonerate the WS2812 slot encoding, which six hardware tests could not do.
+- **Pin count is the tightest constraint on the classic ESP32 and shapes the target list.** See § GPIO requirements: the classic cannot host a full port without a strap pin, so it is excluded rather than half-supported.
+
+## What this plan does not do
+
+**Phase 2 (PWM panels) is not a continuation of Phase 1.** The issue lists it as the next step, but PWM panels need per-panel calibration data and a different drive model; `rpi-rgb-led-matrix` achieves it with a Pi's GPIO and a real OS. It is a separate project with its own spec, and bundling it here would make Phase 1 unshippable. Revisit once Phase 1 is on real walls.
+
+**No second HUB75 port.** One port, one chain. A second doubles the pin cost and the DMA, and nothing in the issue asks for it.
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 57921793..35351950 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -29,6 +29,7 @@ idf_component_register(
"../../src/platform/esp32/platform_esp32_worker.cpp"
"../../src/platform/esp32/platform_esp32_gpio.cpp"
"../../src/platform/esp32/platform_esp32_i80.cpp"
+ "../../src/platform/esp32/platform_esp32_hub75.cpp"
"../../src/platform/esp32/platform_esp32_moon_i80.cpp"
"../../src/platform/esp32/platform_esp32_parlio.cpp"
"../../src/platform/esp32/platform_esp32_i2s.cpp"
diff --git a/mkdocs.yml b/mkdocs.yml
index d9ca0dd3..86dafe4c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -47,6 +47,13 @@ theme:
- navigation.instant # SPA-style nav, no full reload
- navigation.tracking # URL reflects the active anchor
- navigation.top # back-to-top button
+ # The ten top-level groups ride in a bar UNDER THE HEADER rather than stacking down the left
+ # edge. Expanded in the sidebar they ran past a screen height, so the reader scrolled a menu to
+ # find out what the site holds: the shape of the documentation was the thing hardest to see.
+ # As tabs the ten are visible at once, and the sidebar narrows to the section being read.
+ - navigation.tabs
+ # Only the ACTIVE tab's pages in the sidebar, so its length follows one section rather than
+ # the whole site. Needs navigation.tabs above to mean anything.
- navigation.sections # top-level nav groups render as sections
- search.suggest
- search.highlight
@@ -124,41 +131,59 @@ validation:
# internal and deliberately absent from the published site.
nav:
- Home: index.md
+ # A top-level tab, and the FILE stays at docs/gettingstarted.md: the web installer's Help
+ # link is a hard-coded https://moonmodules.org/projectMM/gettingstarted.html, shipped
+ # outside MkDocs, so the root URL is a contract rather than a layout choice.
- Getting started:
- Install & first light: gettingstarted.md
# The web installer is a separate app deployed at /install/ (staged verbatim by the
# release workflow, not built by MkDocs). A full URL (not a site-relative path) so
# MkDocs treats it as the external resource it is.
- Web installer: https://moonmodules.org/projectMM/install/
+ # What MoonCloud IS to a user, kept apart from how it is built (Architecture > MoonCloud).
+ # Two pages, two audiences: the device card deep-links this one from its consent prompt
+ # (app.js, mooncloud.html#why-you-might-like-this), so its URL is a shipped contract.
+ - MoonCloud: explanation/mooncloud.md
- Log an issue: how-to/logging-an-issue.md
# The FOLDER under docs/ is the Diataxis type: tutorials/, how-to/, explanation/,
# reference/. The nav labels stay reader-facing, so the path tells a writer where a page
# belongs while a reader never meets the word "explanation". The rules (contributing/)
# and legal/ sit outside the grid on purpose — see contributing/documentation-standards.md.
+ # ONE learning path, in the order a reader walks it: what it is, then building a show,
+ # then making it beautiful, then writing your own. The install pages left for how-to/:
+ # putting projectMM on a machine is a task somebody already has, not a lesson.
- Tutorials:
- How projectMM works: tutorials/how-projectmm-works.md
- - Installing projectMM on a desktop: tutorials/installing-to-desktop.md
- - Running projectMM on a Linux machine: tutorials/installing-on-linux.md
- - Driving LED panels with a receiving card: tutorials/panel-cards.md
- - Driving projectMM from a phone or tablet: tutorials/control-surface.md
+ - Build your first light show: tutorials/first-light-show.md
+ - Write your first script: tutorials/first-script.md
- Making beautiful effects: tutorials/generative-effects.md
- Build your own MoonModules: tutorials/build-your-own-moonmodules.md
# Diataxis keeps how-to guides apart from tutorials: a tutorial is a lesson for someone
# learning, a how-to solves a task the reader already has.
+ # Grouped by what the reader is trying to DO: get it running, wire the output, connect it
+ # to something, keep it working.
- How-to guides:
+ - Installing on a desktop: how-to/installing-to-desktop.md
+ - Running on a Linux machine: how-to/installing-on-linux.md
- Building, running, flashing: how-to/building.md
- - Home automation: how-to/home-automation.md
+ - Driving LED panels with a receiving card: how-to/panel-cards.md
- LED signal integrity: how-to/led-signal-integrity.md
- - Effects & building shows:
+ - Driving projectMM from a phone or tablet: how-to/control-surface.md
+ - Home automation: how-to/home-automation.md
+ - Save and recall presets: how-to/presets.md
+ - Updating firmware: how-to/updating-firmware.md
+ - Back up and restore: how-to/backup-and-restore.md
+ - Troubleshooting: how-to/troubleshooting.md
+ - Lights:
+ - Overview: moonmodules/light/index.md
- Effects: moonmodules/light/effects.md
- Layouts: moonmodules/light/layouts.md
- Modifiers: moonmodules/light/modifiers.md
- Drivers: moonmodules/light/drivers.md
- - Live scripting: moonmodules/light/MoonLiveEffect.md
- # The other two jobs the same engine does: a scripted layout and a scripted
- # modifier. Reachable by link before, now on the menu beside the effect page.
- - "Live scripting: layouts": moonmodules/light/MoonLiveLayout.md
- - "Live scripting: modifiers": moonmodules/light/MoonLiveModifier.md
+ # ONE MoonLive page, not three. The engine is the same for an effect, a layout and a
+ # modifier, so three pages repeated the engine and differed only in which moment the
+ # host calls: the roles are now sections under it.
+ - MoonLive: moonmodules/light/MoonLiveEffect.md
# The script LANGUAGE reference. Authored at moonlive/README.md (beside the scripts
# it documents) and staged into the tree by mkdocs_hooks.on_files — it is not a file
# under docs/, so it appears here by its staged URI.
@@ -171,21 +196,26 @@ nav:
- Services: moonmodules/core/services.md
- Supporting: moonmodules/core/supporting.md
- Web UI: moonmodules/core/ui.md
- - Understanding projectMM:
- - Architecture: explanation/architecture/index.md
+ - Architecture:
+ # "Overview", not the section's own name: the tab already says Architecture, and a first
+ # child repeating it reads as a page about the tab rather than the way into it. The
+ # convention is for INDEX pages only: a section whose first child is real content keeps
+ # that page's own title, which says what it is ("Install & first light", "Testing strategy").
+ - Overview: explanation/architecture/index.md
- MoonModule: explanation/architecture/moonmodule.md
- MoonCore: explanation/architecture/mooncore.md
- MoonLight: explanation/architecture/moonlight.md
- MoonLive: explanation/architecture/moonlive.md
- MoonBase: explanation/architecture/moonbase.md
- MoonInstaller: explanation/architecture/mooninstaller.md
- - MoonCloud, how it is built: explanation/architecture/mooncloud.md
+ - MoonCloud: explanation/architecture/mooncloud.md
- MoonDeck: explanation/architecture/moondeck.md
- - MoonCloud, what it does: explanation/mooncloud.md
- Why we write our own code: explanation/why-we-write-our-own.md
- Reference:
- Testing strategy: reference/testing.md
- Performance: reference/performance.md
+ # The CMake layout, moved out of the how-to: a tree to look up, not a step to follow.
+ - Build system: reference/build-system.md
- Migrating (breaking changes): reference/MIGRATING.md
# Generated by moondeck/check/repo_health.py on every KPI-gate run — the size/LOC/
# docs ratchet. Next to Performance because both are measured state, not prose.
diff --git a/mooncloud/worker.js b/mooncloud/worker.js
index 963ce720..1ba65017 100644
--- a/mooncloud/worker.js
+++ b/mooncloud/worker.js
@@ -211,8 +211,12 @@ async function handleStats(env, url) {
for (const role of ["driver", "service", "layout", "effect", "modifier"]) {
const value = url?.searchParams.get(role);
if (!value) continue;
- where.push("(',' || modules || ',') LIKE ?");
- binds.push(`%,${role}:${value},%`);
+ // ESCAPE, because the value is a module NAME and LIKE reads _ and % as wildcards. Without it
+ // `?effect=A_B` also matches `AxB`, which is a filter quietly answering a different question
+ // than the one asked. The backslash is escaped first, or it would escape the escapes.
+ const literal = value.replace(/([\\%_])/g, "\\$1");
+ where.push("(',' || modules || ',') LIKE ? ESCAPE '\\'");
+ binds.push(`%,${role}:${literal},%`);
}
// A bucketed chart filters by BOUNDS: its slices name ranges ("64-128 KB"), and the column holds
// the raw number, so there is no value to match on. `?freeHeapMin=65536&freeHeapMax=131072`.
@@ -501,11 +505,24 @@ const PAGE = `
.legend .n { margin-left: auto; opacity: .6; font-variant-numeric: tabular-nums; padding-left: 1rem; }
svg .slice { stroke: #fff; stroke-width: 1.5; }
@media (prefers-color-scheme: dark) { svg .slice { stroke: #111; } }
+ /* A slice inside a link is a control: say so on hover, and leave the read-only ones alone. */
+ svg a { cursor: pointer; }
+ svg a:hover .slice { opacity: .75; }
+ svg a:focus-visible .slice { outline: 2px solid currentColor; }
+ .legend a { text-decoration: none; border-bottom: 1px dotted currentColor; }
+ .legend a:hover { opacity: .7; }
+ .filters { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: -1.5rem 0 2.5rem; }
+ .filter-lead { font-size: .85rem; opacity: .6; }
+ .chip { display: inline-block; font-size: .8rem; padding: .15rem .55rem; border-radius: 1rem;
+ border: 1px solid currentColor; opacity: .75; text-decoration: none; }
+ .chip:hover { opacity: 1; }
+ .chip.clear { border-style: dashed; }
footer { margin-top: 3.5rem; font-size: .85rem; opacity: .6; }
a { color: inherit; }