From 2060c85fb6c49a481ef33f0fa191e2cce97166f3 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 1 Sep 2026 11:01:31 +0200 Subject: [PATCH 1/5] Highlight MoonLive scripts, and declare every loop counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script editor now colors the code, shows where the caret is, and marks the line a compile failed on. MoonLive loops are written the way C++ writes them, `for (int i = 0; ...)`, so every shipped script is now valid C++ exactly as written, and a compiler proves it on every PR. Performance: not collected (no board attached this cycle; no tick-path code changed). **Core** - A compile failure reports its position: `errorPos()` on the engine, cleared on every successful compile, so a stale offset cannot survive into a good status. - A `for` declares its counter. This closes the last exception to a rule the language already held everywhere else: members carry a type and assigning to an undeclared name was already refused, so the loop counter was the only variable that appeared from nowhere. `int` is also the only type it could be. - The longest diagnostics were shortened (73 to 51, 70 to 30, 69 to 38, 65 to 35). A diagnostic earns its length. **Light domain** - The module status carries `message @`, the one channel a failure reaches the UI through. Its buffer was 48 bytes while messages ran to 73, so the longest errors had been silently truncating; at 72 they fit, offset included. **UI** - Syntax highlighting in the script editor: a vendored Prism over a transparent textarea, themed in the app's own palette rather than a stock theme. - The failing line is marked with a positioned band, and the status shows line and column instead of a character offset nobody can count to. - A caret readout (Ln, Col) in the editor footer. - Fixed: clicks below roughly line 6 did nothing. The textarea carried its own height while the stack around it was taller, so clicks past its real bottom landed on the stack. The stack now owns the height and the resize grip. - Fixed: the caret sat one character off and the last lines were unreachable, from a textarea excluding right padding from scrollWidth where a
 includes it.
- Both status render paths go through one `setStatusText`, so a rule cannot apply
  on one and not the other.

**Scripts/MoonDeck**
- A Host Tests card, running the same Python and JS suites the gate runs.

**Tests**
- `test_scripts_are_cpp.py` compiles all 34 shipped scripts as real C++, with a
  prelude generated from the engine's own builtin table and a control that must
  fail. It no longer rewrites loop headers: the scripts compile as written.
- A test pins the status buffer against the compiler's own messages, so the two
  cannot drift; verified to fail at 48 and pass at 72.
- `setStatusText` added to the live-patch audit, which guards against text writes
  that collapse a user's selection.

**Docs**
- The language reference drops from two deliberate C++ divergences to one.

**Reviews**
- 👾 Two CSS-escape helpers for the same selector: used the file's own `cssEscape`;
  deleting the other is a 13-site refactor, backlogged rather than smuggled in here.
- 👾 The JS glob is expanded by node, not the shell: documented at the call site.
- 👾 The buffer-size comment claimed a number nothing enforced: pinned by a test.
- 👾 `setStatusText` read like a fold and was not one: rewritten to say plainly that
  the text does not depend on which editor supplied it.
- 👾 The error mark split Prism's serialized HTML on newlines, which would cut a
  multi-line token: replaced with a positioned band. Narrower than reported (the
  language has no block comments) but the fragile mechanism is gone.
- 👾 Dead `publishEditor` with a comment describing a mechanism that never shipped:
  deleted.
- 👾 Card editors registered but never unregistered, leaking one per re-render and
  re-running the highlighter on detached DOM: the registry now drops detached
  editors. Verified six re-render cycles leave exactly one.

Skipped this cycle: ESP32 firmware build and the Improv smoke test (no board
attached, at the product owner's direction); device-model catalog, firmware list
and the no-backend build (untriggered by this diff).

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .github/workflows/test.yml                    |   4 +
 CLAUDE.md                                     |   2 +-
 CMakeLists.txt                                |   2 +-
 docs/moonmodules/light/MoonLiveLayout.md      |  16 +-
 moondeck/MoonDeck.md                          |  16 ++
 moondeck/check/check_prose.py                 |   1 +
 moondeck/moondeck_config.json                 |   9 +
 moondeck/test/test_host.py                    |  70 ++++++
 moonlive/README.md                            |  16 +-
 moonlive/effects/aim.mle                      |   2 +-
 moonlive/effects/balls.mle                    |   6 +-
 moonlive/effects/chase.mle                    |   2 +-
 moonlive/effects/crosshair.mle                |   6 +-
 moonlive/effects/dot.mle                      |   2 +-
 moonlive/effects/ember.mle                    |   6 +-
 moonlive/effects/fractal.mle                  |   4 +-
 moonlive/effects/gradient.mle                 |   2 +-
 moonlive/effects/metal.mle                    |   4 +-
 moonlive/effects/noise.mle                    |   4 +-
 moonlive/effects/octopus.mle                  |   4 +-
 moonlive/effects/plasma.mle                   |   4 +-
 moonlive/effects/ripples.mle                  |   4 +-
 moonlive/effects/sparkle.mle                  |   2 +-
 moonlive/effects/spectrum.mle                 |   4 +-
 moonlive/effects/sweep.mle                    |   2 +-
 moonlive/layouts/diagonal.mll                 |   2 +-
 moonlive/layouts/grid.mll                     |   4 +-
 moonlive/layouts/lattice.mll                  |   6 +-
 moonlive/layouts/reversed-row.mll             |   2 +-
 moonlive/layouts/ring.mll                     |   2 +-
 moonlive/layouts/rose.mll                     |   2 +-
 moonlive/layouts/two-rows.mll                 |   4 +-
 src/core/HttpServerModule.cpp                 |   2 +
 src/core/moonlive/MoonLive.cpp                |   5 +-
 src/core/moonlive/MoonLive.h                  |   6 +
 src/core/moonlive/MoonLiveCompiler.cpp        |  22 +-
 src/light/moonlive/MoonLiveScript.h           |  24 +-
 src/ui/app.js                                 | 235 ++++++++++++++++--
 src/ui/embed_ui.cmake                         |   6 +
 src/ui/index.html                             |   3 +
 src/ui/style.css                              |  89 ++++++-
 src/ui/vendor/prism.js                        |   5 +
 test/js/ui-live-patch-text.test.mjs           |   5 +-
 test/python/test_scripts_are_cpp.py           | 116 +++++++++
 .../light/scenario_MoonLive_pipeline.json     |   6 +-
 test/unit/core/moonlive_device_codegen.inc    |   8 +-
 test/unit/core/moonlive_structural.inc        |  18 +-
 .../core/unit_moonlive_codegen_x86_64.cpp     |   4 +-
 test/unit/core/unit_moonlive_compiler.cpp     |  44 +++-
 test/unit/core/unit_moonlive_fill.cpp         |  28 +--
 test/unit/core/unit_moonlive_spill.cpp        |  16 +-
 test/unit/light/unit_MoonLiveLayout.cpp       | 108 ++++----
 test/unit/light/unit_MoonLiveModifier.cpp     |   8 +-
 test/unit/light/unit_MoonLiveMotion.cpp       |   8 +-
 test/unit/light/unit_MoonLiveScripts.cpp      |  64 ++++-
 55 files changed, 832 insertions(+), 214 deletions(-)
 create mode 100644 moondeck/test/test_host.py
 create mode 100644 src/ui/vendor/prism.js
 create mode 100644 test/python/test_scripts_are_cpp.py

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 45a6fa94..3f3d3627 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,6 +26,10 @@ on:
       - 'src/platform/esp32/platform_esp32_improv.cpp'
       - 'test/python/**'
       - 'test/js/**'
+      # A MoonLive script IS the subject of test_scripts_are_cpp.py: editing one is exactly when
+      # the "this is a subset of C++" claim needs re-checking.
+      - 'moonlive/**'
+      - 'src/light/moonlive/MoonLiveBuiltins_light.h'
       - '.github/workflows/test.yml'
   push:
     branches:
diff --git a/CLAUDE.md b/CLAUDE.md
index f7c92e7f..fb2668a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,7 +88,7 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l
 | platform boundary | `uv run moondeck/check/check_platform_boundary.py` | `src/`, except `src/platform/` |
 | hot-path discipline | `uv run moondeck/check/check_nonblocking.py --incremental` | `src/` |
 | ESP32 firmware fresh | `uv run moondeck/check/check_esp32_built.py --firmware ` | `src/`, `esp32/`, `CMakeLists.txt`, `library.json`, except `src/platform/desktop/` |
-| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/` |
+| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/`, `moonlive/` |
 | host tests (JS) | `node --test "test/js/**/*.test.mjs"` | `mooninstaller/`, `test/js/`, `src/ui/` |
 | desktop build (zero warnings) 🐢 | `cmake --build build` | `src/`, `test/`, `CMakeLists.txt`, `library.json` |
 | unit tests 🐢 | `ctest --test-dir build --output-on-failure --no-tests=error -C Release` | same as the desktop build |
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 94cc565e..dde8a826 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -198,7 +198,7 @@ add_custom_target(build_info_gen ALL
 add_custom_command(
     OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h
     COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
-    DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
+    DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/vendor/prism.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
     COMMENT "Embedding UI files"
 )
 add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h)
diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md
index 3da9a10e..51051490 100644
--- a/docs/moonmodules/light/MoonLiveLayout.md
+++ b/docs/moonmodules/light/MoonLiveLayout.md
@@ -21,8 +21,8 @@ class GridLayout {
   }
 
   void placeLights() {
-    for (y = 0; y < rows; y = y + 1) {
-      for (x = 0; x < cols; x = x + 1) {
+    for (int y = 0; y < rows; y = y + 1) {
+      for (int x = 0; x < cols; x = x + 1) {
         addLight(x, y, 0);
       }
     }
@@ -40,17 +40,17 @@ A few shapes that are one line here and a new class otherwise:
 
 ```c
 // a strand that runs right to left
-for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }
+for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }
 
 // a diagonal
-for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }
+for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }
 
 // two rows, stacked
-for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }
+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 (i = 0; i < count; i = i + 1) {
+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);
 }
@@ -83,8 +83,8 @@ A serpentine (every other row reversed) is what `if` makes expressible, and it i
 
 ```c
 byte odd = 0;
-for (y = 0; y < rows; y = y + 1) {
-  for (x = 0; x < cols; x = x + 1) {
+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); }
   }
diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md
index c5b3048b..1f9ca6cd 100644
--- a/moondeck/MoonDeck.md
+++ b/moondeck/MoonDeck.md
@@ -54,6 +54,22 @@ uv run moondeck/test/test_desktop.py
 
 Runs `./build//test/mm_tests -s` (doctest with all test cases shown) — same per-host build dir as the desktop build above.
 
+### test_host
+
+Run the host test suites: the Python ones and the JS ones.
+
+```bash
+uv run moondeck/test/test_host.py            # both
+uv run moondeck/test/test_host.py --python   # just Python
+uv run moondeck/test/test_host.py --js       # just JS
+```
+
+The tests the C++ binary cannot reach: the cross-language contracts (the Improv frame's wire format,
+WLED's `/json` shape), the MoonDeck scripts themselves, the browser code under `src/ui`, and the
+claim that every shipped MoonLive script is valid C++ (`test_scripts_are_cpp.py` hands each one to a
+real compiler). The commit gate and CI run the same two commands; this is the card in front of them.
+JS reports SKIP rather than failing when node is absent, since a Python-only bench is a normal setup.
+
 ### run_desktop
 
 Launch the desktop executable as a detached background process and exit. The app keeps running across other MoonDeck scripts and outlives MoonDeck itself — the same model as flashing an ESP32, where the device runs independently of this console.
diff --git a/moondeck/check/check_prose.py b/moondeck/check/check_prose.py
index 09df9f9e..af06c640 100755
--- a/moondeck/check/check_prose.py
+++ b/moondeck/check/check_prose.py
@@ -36,6 +36,7 @@
     "docs/tests/",        # generated from test comments (fix the test, not the page)
     "docs/moonmodules/",  # partly generated technical pages
     "src/platform/desktop/vendor/",   # upstream single-header code (miniaudio): not our prose
+    "src/ui/vendor/",                 # upstream browser code (Prism): not our prose either
     "moondeck/check/check_prose.py",  # the detector: its rule table spells the very patterns
 )
 
diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json
index fb6f9108..f2d9bc97 100644
--- a/moondeck/moondeck_config.json
+++ b/moondeck/moondeck_config.json
@@ -31,6 +31,15 @@
       "script": "test/test_desktop.py",
       "needs_module": true
     },
+    {
+      "id": "test_host",
+      "tab": "desktop",
+      "group": "test",
+      "label": "Host Tests",
+      "speed": "medium",
+      "help": "test_host",
+      "script": "test/test_host.py"
+    },
     {
       "id": "scenario_pipeline",
       "tab": "desktop",
diff --git a/moondeck/test/test_host.py b/moondeck/test/test_host.py
new file mode 100644
index 00000000..53906b3a
--- /dev/null
+++ b/moondeck/test/test_host.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Run the host test suites: the Python ones, the JS ones, or both.
+
+These are the tests that live outside the C++ binary, and they cover what it cannot reach: the
+cross-language contracts (the Improv frame's wire format, WLED's /json shape), the MoonDeck scripts
+themselves, the browser code in src/ui, and the claim that every shipped MoonLive script is valid
+C++. The commit gate and CI both run them; this is the same command with a card in front of it.
+
+  uv run moondeck/test/test_host.py            # both suites
+  uv run moondeck/test/test_host.py --python   # just Python
+  uv run moondeck/test/test_host.py --js       # just JS
+
+The Python deps ride in a PEP-723 block per test file, so they are named here rather than
+installed: `uv run --with` resolves them per run and leaves no environment behind.
+"""
+
+import argparse
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+# Named here rather than in a requirements file: each is a test's own dependency (pyserial for the
+# flash tests, wled for the /json shim, markdown for the docs checks), and `uv run --with` is how
+# every other script in MoonDeck reaches one.
+PY_DEPS = ("pytest", "pyserial", "markdown", "wled")
+
+
+def run(cmd, label):
+    print(f"\n=== {label} ===", flush=True)
+    r = subprocess.run(cmd, cwd=ROOT)
+    return r.returncode
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+    ap.add_argument("--python", action="store_true", help="run only the Python suite")
+    ap.add_argument("--js", action="store_true", help="run only the JS suite")
+    args = ap.parse_args()
+    both = not (args.python or args.js)
+
+    rc = 0
+    if both or args.python:
+        cmd = ["uv", "run"]
+        for d in PY_DEPS:
+            cmd += ["--with", d]
+        cmd += ["pytest", "test/python", "-q"]
+        rc |= run(cmd, "Python (test/python)")
+
+    if both or args.js:
+        # node, not uv: the JS suite is the browser code's own runner. Reported rather than failed
+        # when node is absent, because a Python-only bench is a normal setup and a missing runtime
+        # is "this does not apply here", which is what SKIP means.
+        if shutil.which("node") is None:
+            print("\n=== JS (test/js) ===\nSKIP: node is not on PATH", flush=True)
+        else:
+            # NODE expands this pattern, not a shell: the call takes a list and no shell=True, so
+            # the literal `**` reaches node, which globs it itself (node 22+). The gate and CI spell
+            # the same command inside a shell, where the shell expands it first; both reach the same
+            # files, by two different mechanisms.
+            rc |= run(["node", "--test", "test/js/**/*.test.mjs"], "JS (test/js)")
+
+    print("\nDONE" if rc == 0 else "\nFAILED", flush=True)
+    return rc
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/moonlive/README.md b/moonlive/README.md
index 1a0b2a4c..dcbd06fc 100644
--- a/moonlive/README.md
+++ b/moonlive/README.md
@@ -17,7 +17,7 @@ class CrosshairEffect {
 
   void defineControls() { addControl("bpm", bpm, 1, 240); }
 
-  void column() { for (y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
+  void column() { for (int y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
   void tick()   { fill(0, 0, 0); column(); }
 }
 ```
@@ -26,6 +26,16 @@ These are real calls, not pasted-in text: the callee gets its own frame when it
 lets one helper call another and lets a function recurse. A function takes no arguments yet, so a
 helper is parameterized through the class's members. `effects/crosshair.mle` is the worked example.
 
+**A script is C++, and a compiler checks that.** Every shipped script compiles under
+`c++ -std=c++20 -fsyntax-only` (`test/python/test_scripts_are_cpp.py`), so the language cannot drift
+into a dialect one feature at a time: an editor highlights a script correctly, a reader brings their
+C++ intuition, and a script stands a good chance in any engine that speaks the same subset.
+
+One shape difference is deliberate, and the test bridges exactly that one: a class here needs no
+`public:` and no trailing semicolon.
+
+Anything else a compiler rejects is a divergence, and the test is where it surfaces.
+
 **Every function declares what it returns**, the way the compiled module a script stands in for
 does: `void tick()` beside `void tick() override`. Three types, which is all the language has values
 for:
@@ -83,6 +93,10 @@ int phase = 900;      // a value a byte cannot hold
 byte  heat[16];         // sixteen elements, all zero to begin with
 ```
 
+**Every variable is declared, including a loop's counter.** A member states its type, an assignment
+to a name that was never declared is refused, and a `for` writes `for (int i = 0; ...)`. One rule
+with no exception, and the same line C++ would take.
+
 An index is an arbitrary expression (`heat[i * 2 + 1]`), and an index outside the array is
 **clamped to the last element** rather than refused or allowed through: a script computes indices
 from live control values, so out of range is a normal run-time state, and the fixture shows a
diff --git a/moonlive/effects/aim.mle b/moonlive/effects/aim.mle
index fc8e5ab9..9c239283 100644
--- a/moonlive/effects/aim.mle
+++ b/moonlive/effects/aim.mle
@@ -22,7 +22,7 @@ class AimEffect {
 
   void tick() {
     fill(bright, bright, bright);
-    for (i = 0; i < height; i = i + 1) {
+    for (int i = 0; i < height; i = i + 1) {
       lean = div(spread * i, height);
       p = pan + lean - div(spread, 2);
       if (p < 0) { p = 0; }
diff --git a/moonlive/effects/balls.mle b/moonlive/effects/balls.mle
index 9c6d022b..c1cdadf8 100644
--- a/moonlive/effects/balls.mle
+++ b/moonlive/effects/balls.mle
@@ -21,8 +21,8 @@ class BallsEffect {
   }
 
   void drawBall() {
-    for (dy = 0; dy < 21; dy = dy + 1) {
-      for (dx = 0; dx < 21; dx = dx + 1) {
+    for (int dy = 0; dy < 21; dy = dy + 1) {
+      for (int dx = 0; dx < 21; dx = dx + 1) {
         if ((dx - radius) * (dx - radius) + (dy - radius) * (dy - radius) <= radius * radius) {
           setPaletteColor(px + dx, py + dy, b * 64,
                           255 - scale(((dx - radius) * (dx - radius)
@@ -41,7 +41,7 @@ class BallsEffect {
     if (width < radius * 2 + 2) { radius = 0; }
     if (height < radius * 2 + 2) { radius = 0; }
 
-    for (i = 0; i < 4; i = i + 1) {
+    for (int i = 0; i < 4; i = i + 1) {
       b = i;
       if (b < count) {
         px = beatsin(bpm + b * 7, t, width - radius * 2 - 1);
diff --git a/moonlive/effects/chase.mle b/moonlive/effects/chase.mle
index 1debf31e..85c5dcda 100644
--- a/moonlive/effects/chase.mle
+++ b/moonlive/effects/chase.mle
@@ -26,7 +26,7 @@ class ChaseEffect {
     fill(0, 0, 0);
     n = width * height * depth;
     head = scale(beat(bpm, t), n);
-    for (i = 0; i < n; i = i + 1) {
+    for (int i = 0; i < n; i = i + 1) {
       d = head - i;
       if (d < 0) { d = d + n; }
       if (d < spread) {
diff --git a/moonlive/effects/crosshair.mle b/moonlive/effects/crosshair.mle
index e6e2a208..e99efa72 100644
--- a/moonlive/effects/crosshair.mle
+++ b/moonlive/effects/crosshair.mle
@@ -18,15 +18,15 @@ class CrosshairEffect {
   }
 
   void column() {
-    for (y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); }
+    for (int y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); }
   }
 
   void row() {
-    for (x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); }
+    for (int x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); }
   }
 
   void center() {
-    for (i = 0; i < spread + spread + 1; i = i + 1) {
+    for (int i = 0; i < spread + spread + 1; i = i + 1) {
       d = i - spread;
       if (cx + d >= 0) { if (cx + d < width) { setRGB(cy * width + cx + d, 255, 255, 255); } }
       if (cy + d >= 0) { if (cy + d < height) { setRGB((cy + d) * width + cx, 255, 255, 255); } }
diff --git a/moonlive/effects/dot.mle b/moonlive/effects/dot.mle
index 9b6bf27b..7cf0a4c9 100644
--- a/moonlive/effects/dot.mle
+++ b/moonlive/effects/dot.mle
@@ -13,7 +13,7 @@ class DotEffect {
 
   void tick() {
     fill(0, 0, 0);
-    for (x = 0; x < width; x = x + 1) {
+    for (int x = 0; x < width; x = x + 1) {
       setRGB(x + scale(beat(bpm, t), height) * width, 0, 255, 0);
     }
   }
diff --git a/moonlive/effects/ember.mle b/moonlive/effects/ember.mle
index 64ffacb7..70d8a5f4 100644
--- a/moonlive/effects/ember.mle
+++ b/moonlive/effects/ember.mle
@@ -17,15 +17,15 @@ class EmberEffect {
   }
 
   void tick() {
-    for (i = 0; i < 16; i = i + 1) {
+    for (int i = 0; i < 16; i = i + 1) {
       if (heat[i] > cool) { heat[i] = heat[i] - cool; } else { heat[i] = 0; }
     }
 
-    for (j = 0; j < 16; j = j + 1) {
+    for (int j = 0; j < 16; j = j + 1) {
       if (random16(256) < scale(spark, 65535 - beat(cycle, t))) { heat[j] = 255; }
     }
 
-    for (k = 0; k < 16; k = k + 1) {
+    for (int k = 0; k < 16; k = k + 1) {
       setRGB(k, paletteR(heat[k], heat[k]), paletteG(heat[k], heat[k]),
                 paletteB(heat[k], heat[k]));
     }
diff --git a/moonlive/effects/fractal.mle b/moonlive/effects/fractal.mle
index a59e6bd5..f7fbb584 100644
--- a/moonlive/effects/fractal.mle
+++ b/moonlive/effects/fractal.mle
@@ -30,8 +30,8 @@ class FractalEffect {
         - toFixed(sin(beat(bpm, t) * 2) - 32768) / 5120 * toFixed(seed) / 3277)
        * toFixed(870 + noise(t / 4, 0, 0)) / 1000;
 
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         cx = uvX(x, width, height) * toFixed(zoom) / 40;
         if (seed == 0) { cx = cx - 0.55; }
 
diff --git a/moonlive/effects/gradient.mle b/moonlive/effects/gradient.mle
index 9a59ab30..e347f939 100644
--- a/moonlive/effects/gradient.mle
+++ b/moonlive/effects/gradient.mle
@@ -9,7 +9,7 @@ class GradientEffect {
 
   void tick() {
     n = width * height * depth;
-    for (i = 0; i < n; i = i + 1) {
+    for (int i = 0; i < n; i = i + 1) {
       setRGB(i, div(i * 255, n), 255 - div(i * 255, n), 60);
     }
   }
diff --git a/moonlive/effects/metal.mle b/moonlive/effects/metal.mle
index 5969ba52..9cd7c77d 100644
--- a/moonlive/effects/metal.mle
+++ b/moonlive/effects/metal.mle
@@ -22,8 +22,8 @@ class MetalEffect {
   }
 
   void tick() {
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         ux = uvX(x, width, height);
         uy = uvY(y, width, height);
 
diff --git a/moonlive/effects/noise.mle b/moonlive/effects/noise.mle
index d3342ce1..30ea1cde 100644
--- a/moonlive/effects/noise.mle
+++ b/moonlive/effects/noise.mle
@@ -14,8 +14,8 @@ class NoiseEffect {
   }
 
   void tick() {
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         setPaletteColor(x, y, noise(x * zoom, y * zoom, scale(t, speed * 280)), 255);
       }
     }
diff --git a/moonlive/effects/octopus.mle b/moonlive/effects/octopus.mle
index fd13b212..094cc98b 100644
--- a/moonlive/effects/octopus.mle
+++ b/moonlive/effects/octopus.mle
@@ -19,8 +19,8 @@ class OctopusEffect {
     cx = scale(32768, width);
     cy = scale(32768, height);
 
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         setPaletteColor(x, y,
                         polarR(x - cx, y - cy) * 8 + scale(beat(speed, t), 256),
                         scale(sin(polarA(x - cx, y - cy) * branches
diff --git a/moonlive/effects/plasma.mle b/moonlive/effects/plasma.mle
index 30dcfe71..655016e6 100644
--- a/moonlive/effects/plasma.mle
+++ b/moonlive/effects/plasma.mle
@@ -14,8 +14,8 @@ class PlasmaEffect {
   }
 
   void tick() {
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         setRGB(y * width + x,
                scale(sin(x * zoom * 8 + beat(bpm, t)), 256),
                scale(sin(y * zoom * 8 + beat(bpm, t)), 256),
diff --git a/moonlive/effects/ripples.mle b/moonlive/effects/ripples.mle
index 5e6e6240..791d5783 100644
--- a/moonlive/effects/ripples.mle
+++ b/moonlive/effects/ripples.mle
@@ -14,8 +14,8 @@ class RipplesEffect {
   }
 
   void tick() {
-    for (y = 0; y < height; y = y + 1) {
-      for (x = 0; x < width; x = x + 1) {
+    for (int y = 0; y < height; y = y + 1) {
+      for (int x = 0; x < width; x = x + 1) {
         setRGB(y * width + x,
           scale(sin(((x - beatsin(bpm, t, width - 1)) * (x - beatsin(bpm, t, width - 1))
                    + (y - beatsin(bpm + 4, t, height - 1)) * (y - beatsin(bpm + 4, t, height - 1)))
diff --git a/moonlive/effects/sparkle.mle b/moonlive/effects/sparkle.mle
index b3949944..d461a798 100644
--- a/moonlive/effects/sparkle.mle
+++ b/moonlive/effects/sparkle.mle
@@ -21,7 +21,7 @@ class SparkleEffect {
   void tick() {
     fade(fadeAmt);
     n = width * height * depth;
-    for (i = 0; i < density; i = i + 1) {
+    for (int i = 0; i < density; i = i + 1) {
       p = random16(256);
       if (p > hueSpread) { p = hueSpread; }
       setRGB(random16(n), paletteR(p, 255), paletteG(p, 255), paletteB(p, 255));
diff --git a/moonlive/effects/spectrum.mle b/moonlive/effects/spectrum.mle
index 95f9b3a9..a794216a 100644
--- a/moonlive/effects/spectrum.mle
+++ b/moonlive/effects/spectrum.mle
@@ -26,7 +26,7 @@ class SpectrumEffect {
 
     bars = width;
     if (width < 2) { bars = height; }
-    for (x = 0; x < bars; x = x + 1) {
+    for (int x = 0; x < bars; x = x + 1) {
       b = div(x * 16, bars);
       mag = div(audioBand(b) * gain, 100);
       if (mag > 255) { mag = 255; }
@@ -36,7 +36,7 @@ class SpectrumEffect {
       }
       top = div(mag * height, 256);
       if (width < 2) { top = 0; }
-      for (y = 0; y < top; y = y + 1) {
+      for (int y = 0; y < top; y = y + 1) {
         setRGB((height - 1 - y) * width + x, paletteR(b * 16, 255), paletteG(b * 16, 255),
                paletteB(b * 16, 255));
       }
diff --git a/moonlive/effects/sweep.mle b/moonlive/effects/sweep.mle
index de653a25..5d99128a 100644
--- a/moonlive/effects/sweep.mle
+++ b/moonlive/effects/sweep.mle
@@ -23,7 +23,7 @@ class SweepEffect {
   }
 
   void tick() {
-    for (i = 0; i < height; i = i + 1) {
+    for (int i = 0; i < height; i = i + 1) {
       spread = 0;
       dir = 1;
       if (formation == 1) {
diff --git a/moonlive/layouts/diagonal.mll b/moonlive/layouts/diagonal.mll
index dc2cbab9..36b4f846 100644
--- a/moonlive/layouts/diagonal.mll
+++ b/moonlive/layouts/diagonal.mll
@@ -12,7 +12,7 @@ class DiagonalLayout {
   }
 
   void placeLights() {
-    for (i = 0; i < count; i = i + 1) {
+    for (int i = 0; i < count; i = i + 1) {
       addLight(i, i, 0);
     }
   }
diff --git a/moonlive/layouts/grid.mll b/moonlive/layouts/grid.mll
index 8cd79a86..f11db6c1 100644
--- a/moonlive/layouts/grid.mll
+++ b/moonlive/layouts/grid.mll
@@ -14,8 +14,8 @@ class GridLayout {
   }
 
   void placeLights() {
-    for (y = 0; y < rows; y = y + 1) {
-      for (x = 0; x < cols; x = x + 1) {
+    for (int y = 0; y < rows; y = y + 1) {
+      for (int x = 0; x < cols; x = x + 1) {
         addLight(x, y, 0);
       }
     }
diff --git a/moonlive/layouts/lattice.mll b/moonlive/layouts/lattice.mll
index d21529bf..3c5ed2bb 100644
--- a/moonlive/layouts/lattice.mll
+++ b/moonlive/layouts/lattice.mll
@@ -16,9 +16,9 @@ class LatticeLayout {
   }
 
   void placeLights() {
-    for (z = 0; z < layers; z = z + 1) {
-      for (y = 0; y < rows; y = y + 1) {
-        for (x = 0; x < cols; x = x + 1) {
+    for (int z = 0; z < layers; z = z + 1) {
+      for (int y = 0; y < rows; y = y + 1) {
+        for (int x = 0; x < cols; x = x + 1) {
           addLight(x, y, z);
         }
       }
diff --git a/moonlive/layouts/reversed-row.mll b/moonlive/layouts/reversed-row.mll
index ca09c80e..106b76c9 100644
--- a/moonlive/layouts/reversed-row.mll
+++ b/moonlive/layouts/reversed-row.mll
@@ -12,7 +12,7 @@ class ReversedRowLayout {
   }
 
   void placeLights() {
-    for (i = 0; i < cols; i = i + 1) {
+    for (int i = 0; i < cols; i = i + 1) {
       addLight(cols - 1 - i, 0, 0);
     }
   }
diff --git a/moonlive/layouts/ring.mll b/moonlive/layouts/ring.mll
index 01151392..72d46826 100644
--- a/moonlive/layouts/ring.mll
+++ b/moonlive/layouts/ring.mll
@@ -14,7 +14,7 @@ class RingLayout {
   }
 
   void placeLights() {
-    for (i = 0; i < count; i = i + 1) {
+    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);
     }
diff --git a/moonlive/layouts/rose.mll b/moonlive/layouts/rose.mll
index 71fb765f..bd4b70e6 100644
--- a/moonlive/layouts/rose.mll
+++ b/moonlive/layouts/rose.mll
@@ -14,7 +14,7 @@ class RoseLayout {
   }
 
   void placeLights() {
-    for (i = 0; i < 256; i = i + 1) {
+    for (int i = 0; i < 256; i = i + 1) {
       addLight(radius - scale(sin(i * turn(256) * petals), radius + 1)
                  + scale(cos(i * turn(256)),
                          2 * scale(sin(i * turn(256) * petals), radius + 1) + 1),
diff --git a/moonlive/layouts/two-rows.mll b/moonlive/layouts/two-rows.mll
index 8cc27dae..7c6001e7 100644
--- a/moonlive/layouts/two-rows.mll
+++ b/moonlive/layouts/two-rows.mll
@@ -12,10 +12,10 @@ class TwoRowsLayout {
   }
 
   void placeLights() {
-    for (i = 0; i < cols; i = i + 1) {
+    for (int i = 0; i < cols; i = i + 1) {
       addLight(i, 0, 0);
     }
-    for (i = 0; i < cols; i = i + 1) {
+    for (int i = 0; i < cols; i = i + 1) {
       addLight(cols - 1 - i, 1, 0);
     }
   }
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index 437dd6fd..a99531d4 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -267,6 +267,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) {
         else if (std::strcmp(path, "/app.js") == 0) serveFile(conn, "app.js", "application/javascript");
         else if (std::strcmp(path, "/install-picker.js") == 0) serveFile(conn, "install-picker.js", "application/javascript");
         else if (std::strcmp(path, "/semver.js") == 0) serveFile(conn, "semver.js", "application/javascript");
+        else if (std::strcmp(path, "/prism.js") == 0) serveFile(conn, "prism.js", "application/javascript");
         else if (std::strcmp(path, "/preview3d.js") == 0) serveFile(conn, "preview3d.js", "application/javascript");
         else if (std::strcmp(path, "/preview-adapt.js") == 0) serveFile(conn, "preview-adapt.js", "application/javascript");
         else if (std::strcmp(path, "/migrate.js") == 0) serveFile(conn, "migrate.js", "application/javascript");
@@ -1019,6 +1020,7 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file
     else if (std::strcmp(filename, "app.js") == 0) { data = ui::appJs; dataLen = ui::appJsLen; gzipped = true; }
     else if (std::strcmp(filename, "install-picker.js") == 0) { data = ui::installPickerJs; dataLen = ui::installPickerJsLen; gzipped = true; }
     else if (std::strcmp(filename, "semver.js") == 0) { data = ui::semverJs; dataLen = ui::semverJsLen; gzipped = true; }
+    else if (std::strcmp(filename, "prism.js") == 0) { data = ui::prismJs; dataLen = ui::prismJsLen; gzipped = true; }
     else if (std::strcmp(filename, "preview3d.js") == 0) { data = ui::preview3dJs; dataLen = ui::preview3dJsLen; gzipped = true; }
     else if (std::strcmp(filename, "preview-adapt.js") == 0) { data = ui::previewAdaptJs; dataLen = ui::previewAdaptJsLen; gzipped = true; }
     else if (std::strcmp(filename, "migrate.js") == 0) { data = ui::migrateJs; dataLen = ui::migrateJsLen; gzipped = true; }
diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp
index 8728776c..98015e62 100644
--- a/src/core/moonlive/MoonLive.cpp
+++ b/src/core/moonlive/MoonLive.cpp
@@ -50,6 +50,7 @@ void* MoonLive::place(const uint8_t* staged, size_t len) {
     codeCap_ = cap;
     codeLen_ = len;
     error_ = "";
+    errorPos_ = 0;
     return block;
 }
 
@@ -99,7 +100,9 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysV
     // through that name, so a broken script silently unbound the user's own sliders.
     CompileResult cr = compileSource(source, table, sysvars, staging.p, staging.n,
                                      nullptr, nullptr, strings_, CompileResult::kStringPool);
-    if (!cr.ok) { freeCode(); error_ = cr.error; return false; }   // surface the parse diagnostic
+    // The diagnostic AND where it happened: an editor can only mark the line if it is told one,
+    // and the parser has already computed the offset (Parser::fail records lex.col()).
+    if (!cr.ok) { freeCode(); error_ = cr.error; errorPos_ = cr.errorCol; return false; }
     // Allocate the control arena (fixed address) and seed new slots, BEFORE publishing the control
     // set — ensureArena reads the previous controlCount_ to know which slots are new.
     // Seeded from the MEMBERS, not the controls: a member the UI never shows still has an
diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h
index f3b6a5b2..89349661 100644
--- a/src/core/moonlive/MoonLive.h
+++ b/src/core/moonlive/MoonLive.h
@@ -87,6 +87,11 @@ class MoonLive {
     uint8_t entryCount() const { return entryCount_; }
     const char* error() const { return error_; }
 
+    /// WHERE the last compile failed: a character offset into the source, 0 when it did not.
+    /// The editor turns it into a line to mark; the parser already knows it, and throwing it
+    /// away meant a user was told what was wrong but never where.
+    uint16_t errorPos() const { return errorPos_; }
+
     // The hot path: run the compiled routine over the host's buffer. `t` is the host's
     // elapsed() ms; a static routine ignores it, an animated one derives its color from
     // it. No-op if !ok() (a failed compile renders nothing). The emitted routines write
@@ -355,6 +360,7 @@ class MoonLive {
     AnimFn  anim_ = nullptr;     // animated fill (4-arg, reads t), or nullptr
     CtrlFn  ctrl_ = nullptr;     // front-end-compiled routine (5-arg, reads the controls arena)
     const char* error_ = "";
+    uint16_t    errorPos_ = 0;
 
     // The functions the script defined, with their offsets into `code_`, and their names owned here
     // for the same reason the control names are: a CompileResult's `name` points into source text
diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp
index 8088e48e..87f1f983 100644
--- a/src/core/moonlive/MoonLiveCompiler.cpp
+++ b/src/core/moonlive/MoonLiveCompiler.cpp
@@ -325,7 +325,7 @@ struct Parser {
             outFixed = true;
             return true;
         }
-        fail("this mixes a whole number and a fixed value: write toFixed(x) or toInt(x)");
+        fail("mixes whole and fixed: write toFixed(x) or toInt(x)");
         return false;
     }
 
@@ -1035,7 +1035,7 @@ struct Parser {
     // program := { decl } { stmt }.  Declarations (control vars) come first, then one-or-more
     // call statements. (Multi-statement now: a script has decl lines AND a statement line.)
     /// stmt := call ";" | forStmt
-    /// forStmt := "for" "(" ident "=" expr ";" ident "<" expr ";" ident "=" expr ")" "{" {stmt} "}"
+    /// forStmt := "for" "(" "int" ident "=" expr ";" ident "<" expr ";" ident "=" expr ")" "{" {stmt} "}"
     ///
     /// C-style deliberately: it is the form a script author already knows, and the third clause is
     /// what a serpentine layout needs (`i = i + 2`, or counting down) without inventing more syntax.
@@ -1059,7 +1059,17 @@ struct Parser {
         lex.advance();                                     // consume `for`
         if (!expect(Tok::LParen, "expected '(' after for")) return false;
 
-        // --- init: ident = expr ---
+        // --- init: "int" ident = expr ---
+        // The counter is DECLARED, like every other variable in the language: a member carries its
+        // type and an assignment to an undeclared name is refused, so a counter that appeared out of
+        // nowhere was the one exception left. `int` is also the only type it could be (a fixed init
+        // is rejected below), so the word adds no meaning for the compiler: it is here because it is
+        // what C++ writes and what a script author types by habit.
+        if (!atKeyword("int", 3)) {
+            fail("a loop counter is declared: for (int i = 0; ...)");
+            return false;
+        }
+        lex.advance();                                     // consume `int`
         if (lex.kind != Tok::Ident) { fail("expected a loop variable"); return false; }
         // Two slots per loop: the counter and the limit. Both must outlive the body, and both live
         // in the frame — nesting depth is now bounded by frame slots, not by the register file.
@@ -1256,14 +1266,14 @@ struct Parser {
         const int li = findLocal(name, nameLen);
         const int mi = li >= 0 ? -1 : findMember(name, nameLen);
         if (mi >= 0 && members[mi].count > 1) {
-            fail("an array is assigned one element at a time: write name[i] = value");
+            fail("assign one element: name[i] = value");
             return false;
         }
         if (li < 0 && mi < 0) {
             if (sysvars.find(name, nameLen)) {
-                fail("a system variable is read-only: the engine writes it before every call");
+                fail("a system variable is read-only");
             } else {
-                fail("no member or loop variable of that name: declare it in the class body");
+                fail("not declared: add it to the class body");
             }
             return false;
         }
diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h
index c4ac5830..9838d124 100644
--- a/src/light/moonlive/MoonLiveScript.h
+++ b/src/light/moonlive/MoonLiveScript.h
@@ -22,6 +22,15 @@ namespace mm::moonlive {
 /// re-read the file each time. Same question, one answer.
 class MoonLiveScript {
 public:
+    /// The longest status this module reports, " @" included.
+    ///
+    /// Sized for the longest diagnostic the compiler emits plus the suffix. At 48 the longer
+    /// messages truncated, and once the offset moved into this string a truncation also cost the
+    /// editor the position it marks the failing line from: the errors hardest to read were exactly
+    /// the ones that lost their explanation AND their highlight. Public because a unit test pins it
+    /// against the compiler's own messages, which is what stops the two drifting apart again.
+    static constexpr size_t kMaxStatus = 72;
+
     /// Let a binding that owns a particle pool size it from the script's defineControls(). Null for
     /// a binding with no particles, which is every binding but the effect today.
     void setPoolSizer(PoolSizeFn fn, void* ctx) { sizePool_ = fn; poolCtx_ = ctx; }
@@ -84,7 +93,18 @@ class MoonLiveScript {
             owner.setStatus(statusBuf_, MoonModule::Severity::Status);
             compileFailed_ = false;
         } else {
-            owner.setStatus(err, MoonModule::Severity::Error);
+            // "message @": the message a user reads, and the position the editor marks.
+            // One string because status IS the channel a module reports through, and a second
+            // control for the number would be a field every non-scripted module carries for nothing.
+            // The suffix is machine-read, so it stays a fixed shape rather than a sentence.
+            if (engine_.errorPos() > 0) {
+                std::snprintf(statusBuf_, sizeof(statusBuf_), "%s @%u",
+                              err ? err : "compile failed",
+                              static_cast(engine_.errorPos()));
+                owner.setStatus(statusBuf_, MoonModule::Severity::Error);
+            } else {
+                owner.setStatus(err, MoonModule::Severity::Error);
+            }
             // Forget what the LAST script said it was. A failed compile has already interned its
             // strings into the same pool from offset zero, so a tags_ kept from the previous
             // program now points at whatever those bytes became, and the card would show it.
@@ -242,7 +262,7 @@ class MoonLiveScript {
     const char* tags_ = nullptr;
     // Backing store for the status line: MoonModule::setStatus keeps a POINTER, so the text has to
     // outlive the call. The same module-owned pattern NetworkModule uses.
-    char     statusBuf_[48] = {};
+    char     statusBuf_[kMaxStatus] = {};
     // The script's FILE NAME, inside the shared script directory. Empty on a fresh card: it reports
     // "no script" until one is named, rather than every new module compiling the same default.
     char     name_[kMaxScriptName + 1] = "";
diff --git a/src/ui/app.js b/src/ui/app.js
index aed6a756..8fa41031 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -1378,6 +1378,68 @@ function surfaceBreak() {
     return br;
 }
 
+/// The live editors on each module's script, by module name.
+///
+/// A card's status row and its script editor are built independently, so neither can reach the
+/// other directly; this is the seam between them. A SET per module, because a module can have two
+/// editors open on the same file at once (the card's pane and the modal it expands into) and both
+/// have to mark the failing line.
+const mlEditors = new Map();
+
+/// Register an editor under a module, and return its own removal.
+///
+/// Handing back the unregister rather than exposing a remove(name, ed) keeps the two halves from
+/// drifting: the modal in particular learns its module from whoever opened it, and cannot then
+/// unregister under a different name.
+function mlEditorAdd(name, ed) {
+    if (!name) return () => {};
+    if (!mlEditors.has(name)) mlEditors.set(name, new Set());
+    mlEditors.get(name).add(ed);
+    return () => {
+        const set = mlEditors.get(name);
+        if (!set) return;
+        set.delete(ed);
+        if (!set.size) mlEditors.delete(name);
+    };
+}
+
+/// The editors on a module that are still on the page.
+///
+/// renderCards rebuilds every card by clearing its host, which orphans an inline editor without
+/// ever calling dispose: registering on create with no counterpart leaked one editor per re-render,
+/// and each leaked one kept re-running the highlighter on detached DOM every time a status arrived.
+/// Testing the DOM is what makes that impossible to get wrong, since it asks the only question that
+/// matters (is this editor still showing?) rather than trusting every teardown path to report.
+function mlLiveEditors(name) {
+    const set = mlEditors.get(name);
+    if (!set) return [];
+    for (const ed of set) if (!ed.isMounted()) set.delete(ed);
+    if (!set.size) { mlEditors.delete(name); return []; }
+    return [...set];
+}
+
+/// Write a module's status, and mark the line it names in every editor showing that script.
+///
+/// Both render paths (createCard and updateModuleControls) come here, because a rule that lives in
+/// only one of them is a rule that applies half the time. A compile failure arrives as
+/// "message @"; an EDITOR turns that into a line and column, since only it holds the text
+/// the offset counts into. Each returns the same rewritten text, so which one supplies it does not
+/// matter; a module with no editor open shows the raw status unchanged.
+function setStatusText(valEl, mod) {
+    // Every editor on this module marks the line; each returns the same rewritten text, since they
+    // hold the same file. Keeping the first answer rather than the last says that plainly: the
+    // string does not depend on which editor supplied it.
+    let text = mod.status;
+    let first = true;
+    for (const ed of mlLiveEditors(mod.name)) {
+        const rewritten = ed.markError(mod.status);
+        if (first) { text = rewritten; first = false; }
+    }
+    // setText, not a bare assignment: this runs on every state push, and rewriting an unchanged
+    // node throws away a selection the user may be holding on it.
+    setText(valEl, text);
+}
+
 function createCard(mod, depth) {
     const card = document.createElement("div");
     card.className = "card";
@@ -1561,7 +1623,7 @@ function createCard(mod, depth) {
         const val = document.createElement("span");
         val.className = "status-value";
         val.dataset.sev = mod.severity || "status";
-        val.textContent = mod.status;
+        setStatusText(val, mod);
         row.appendChild(label);
         row.appendChild(val);
         controlsHost.appendChild(row);
@@ -2633,6 +2695,7 @@ function createControl(moduleName, moduleType, ctrl) {
                     fillPicker().then(() => { picker.value = sel; refreshDelLabel(); });
                 },
             });
+            mlEditorAdd(moduleName, editor);
 
             // Re-read after the modal closes: it edits the same file through the same endpoints, so
             // whatever it saved is what this pane should now show.
@@ -2690,7 +2753,7 @@ function createControl(moduleName, moduleType, ctrl) {
                 await editor.save();
                 if (editor.isDirty()) { alert("Not opening: this script still has unsaved changes."); return; }
                 const p = await scriptPathOf(picker.value);
-                await openFileEditor(p);
+                await openFileEditor(p, undefined, moduleName);
                 await editor.load(p);
             });
 
@@ -4161,7 +4224,7 @@ function updateValues() {
                     const val = document.createElement("span");
                     val.className = "status-value";
                     val.dataset.sev = mod.severity || "status";
-                    val.textContent = mod.status;
+                    setStatusText(val, mod);
                     statusRow.appendChild(label);
                     statusRow.appendChild(val);
                     // Insert before first .control-row, or append.
@@ -4172,7 +4235,7 @@ function updateValues() {
                 statusRow.style.display = "";
                 const val = statusRow.querySelector(".status-value");
                 if (val) {
-                    setText(val, mod.status);
+                    setStatusText(val, mod);
                     const sev = mod.severity || "status";
                     if (val.dataset.sev !== sev) val.dataset.sev = sev;
                 }
@@ -6084,27 +6147,107 @@ async function fmCreateFile(dir, name, content = "") {
 //
 // `onSaved(relPath)` fires after each successful save. Returns a handle so a caller can point the
 // same pane at a different file without rebuilding it.
+/// Prism's C++ grammar plus the three type names MoonLive adds.
+///
+/// `byte`, `fixed` and `string` are the language's own aliases (a C++ reader would meet uint8_t,
+/// a Q16.16 int and a const char*), so a stock C++ grammar leaves them unpainted beside the `int`
+/// next to them. Extending rather than writing a grammar: everything else in a script IS C++, which
+/// is what test_scripts_are_cpp.py holds each shipped script to.
+///
+/// Built once and cached: the highlighter runs on every keystroke.
+let mlGrammarCache = null;
+function mlGrammar() {
+    if (mlGrammarCache) return mlGrammarCache;
+    // A COPY of the C++ grammar with one rule replaced: its keyword pattern, widened to also match
+    // MoonLive's three type names. Prism.languages.insertBefore was the other route and it is the
+    // wrong tool here (it rebuilds the language in place and expects a rule object, not a bare
+    // RegExp), which silently produced a grammar that highlighted nothing at all.
+    const cpp = Prism.languages.cpp;
+    mlGrammarCache = Object.assign({}, cpp, {
+        keyword: [/\b(?:byte|fixed|string)\b/].concat(cpp.keyword || []),
+    });
+    return mlGrammarCache;
+}
+
 function fmMountEditor(host, relPath, opts = {}) {
     // `savePath(readPath)` lets a caller WRITE somewhere other than it read. The script picker uses
     // it: a factory script is read from the read-only library directory, and editing it must create
     // the user's own copy rather than overwrite what shipped. Defaults to writing back where it
     // read, which is what every other caller wants.
-    const { expectedSize, onSaved, sizeKey, saveButton, statusEl, savePath } = opts;
+    const { expectedSize, onSaved, onDispose, sizeKey, saveButton, statusEl, savePath } = opts;
     const wrap = document.createElement("div");
     wrap.className = "fm-editor-pane";
     // The footer carries Save and the status line, UNLESS the host supplies both: a card already has
     // a toolbar of file actions, so they belong there, and an empty strip under the box is a gap
     // rather than a layout.
-    const ownFooter = !saveButton || !statusEl;
+    // Highlighting is for SCRIPTS: a .mle/.mll/.mlm is MoonLive, which is C++, so Prism's own C++
+    // grammar paints it with nothing of ours to maintain. A .json or a .txt edits as plain text.
+    const hlOn = /\.(mle|mll|mlm)$/i.test(relPath || "");
+
+    // The highlight layer sits BEHIND a transparent textarea, both sharing one box and one set of
+    // font metrics: a textarea cannot color its own text, and this is the standard way around that.
+    // The textarea keeps every editing behavior (caret, selection, undo, IME); the 
 only paints.
     wrap.innerHTML =
+        '
' + + '' + '' + - (ownFooter - ? '
' + - (statusEl ? '' : ' ') + - (saveButton ? '' : ' ') + - '
' - : ''); + '
' + + '
' + + (statusEl ? '' : ' ') + + ' ' + + (saveButton ? '' : ' ') + + '
'; const body = wrap.querySelector(".fm-editor-body"); + if (!hlOn) wrap.querySelector(".fm-editor-stack").classList.add("plain"); + let errorLine = -1; // 0-based, -1 for none: set by markError below + + /// A character offset as the line and column a person counts in, both 1-based. + /// + /// The device reports an OFFSET, because that is what the parser has; nobody reads a script by + /// offset. The editor holds the same text, so it is the one place that can do the conversion. + const lineColAt = (off) => { + const upto = body.value.slice(0, Math.max(0, Math.min(off, body.value.length))); + const nl = upto.lastIndexOf("\n"); + return { line: upto.split("\n").length, col: upto.length - nl }; + }; + const hl = wrap.querySelector(".fm-editor-hl"); + const hlCode = hl.querySelector("code"); + const errBand = hl.querySelector(".fm-editor-err"); + + /// Repaint the layer under the caret, and keep it aligned. + /// + /// Only for a MoonLive script: Prism is given the C++ grammar because that is what the language + /// is (test/python/test_scripts_are_cpp.py holds every shipped script to it), so `class`, the + /// types and the comments light up with no grammar of our own. Any other file edits as plain + /// text, which is what a .json or a .txt should look like. + /// + /// A trailing newline gets a space: a
 collapses the last empty line where a textarea
+    /// keeps it, and without this the two drift by one line at the end of a file.
+    const paintHighlight = () => {
+        if (!hlOn) return;
+        const src = body.value;
+        hlCode.textContent = src.endsWith("\n") ? src + " " : src;
+        if (window.Prism && Prism.languages.cpp) {
+            hlCode.innerHTML = Prism.highlight(hlCode.textContent, mlGrammar(), "cpp");
+        }
+        // The failing line, marked with a BAND positioned over it rather than by wrapping its text.
+        // Wrapping meant splitting Prism's serialized HTML on newlines, which cuts any token that
+        // spans lines (a block comment is one element) in half and leaves unbalanced tags for the
+        // parser to re-balance, shifting the coloring of everything after it. A band cannot touch
+        // the markup at all, and it lands on the same line because both use the same line height.
+        errBand.hidden = errorLine < 0;
+        if (errorLine >= 0) errBand.style.top = `calc(${errorLine} * 1.5em)`;
+        syncHighlightScroll();
+    };
+    /// Move the paint under the text by TRANSFORM rather than by scrolling it: a scrollTop the
+    /// element cannot reach (it has overflow:hidden) is silently clamped, which is what left the
+    /// last lines of a file unreachable.
+    const syncHighlightScroll = () => {
+        // The band follows VERTICALLY only: it spans the full width, so a horizontal shift would
+        // just walk it off the box while the line it marks stays put.
+        hlCode.style.transform = `translate(${-body.scrollLeft}px, ${-body.scrollTop}px)`;
+        errBand.style.transform = `translateY(${-body.scrollTop}px)`;
+    };
     const status = statusEl || wrap.querySelector(".fm-editor-status");
     const saveBtn = saveButton || wrap.querySelector(".fm-editor-save");
     host.appendChild(wrap);
@@ -6126,14 +6269,15 @@ function fmMountEditor(host, relPath, opts = {}) {
     // an editor a user sized once stays that size. Keyed per control, or per path in the modal.
     const key = sizeKey || ("fm:" + path);
     const savedH = textareaSizes[key];
-    if (typeof savedH === "number" && savedH > 0) body.style.height = savedH + "px";
+    const stack = wrap.querySelector(".fm-editor-stack");
+    if (typeof savedH === "number" && savedH > 0) stack.style.height = savedH + "px";
     let taRaf = 0, taPrevH = Math.round(savedH > 0 ? savedH : 0);
     const taObserver = new ResizeObserver((entries) => {
         const h = Math.round(entries[0].contentRect.height);
         if (taRaf || h <= 0 || h === taPrevH) return;
         taRaf = requestAnimationFrame(() => { taRaf = 0; taPrevH = h; saveTextareaSize(key, h); });
     });
-    taObserver.observe(body);
+    taObserver.observe(stack);
 
     // Blur, Cmd+S and the Save button all call save(), and a blur fires when the button takes
     // focus: so without this guard one edit issues overlapping POSTs of the same file. `dirty`
@@ -6166,7 +6310,20 @@ function fmMountEditor(host, relPath, opts = {}) {
         return saving;
     };
 
-    body.addEventListener("input", () => { if (!body.readOnly) setDirty(true); });
+    // Where the caret IS, so a reported line and column can be found by moving to it. Updated from
+    // every event that can move a caret; `selectionchange` alone does not fire on a plain textarea
+    // in every browser, so the input and key/click events cover it.
+    const caretEl = wrap.querySelector(".fm-editor-caret");
+    const showCaret = () => {
+        if (!caretEl) return;
+        const at = lineColAt(body.selectionStart);
+        caretEl.textContent = `Ln ${at.line}, Col ${at.col}`;
+    };
+    ["input", "click", "keyup", "select", "focus"].forEach(e => body.addEventListener(e, showCaret));
+
+    body.addEventListener("input", () => { if (!body.readOnly) setDirty(true); paintHighlight(); });
+    // Scroll is not an input event: the layer has to follow the box it sits under.
+    body.addEventListener("scroll", () => syncHighlightScroll());
     body.addEventListener("blur", save);
     body.addEventListener("keydown", (e) => {
         if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "S")) { e.preventDefault(); save(); }
@@ -6189,6 +6346,8 @@ function fmMountEditor(host, relPath, opts = {}) {
         if (r.aborted || ac !== loadAbort) return;   // a newer load started: that one owns the pane
         body.readOnly = r.readOnly;
         saveBtn.disabled = r.readOnly;
+        paintHighlight();          // the file just arrived: paint what it says
+        showCaret();
         status.textContent = r.message;
     };
     load(path, expectedSize);
@@ -6200,13 +6359,41 @@ function fmMountEditor(host, relPath, opts = {}) {
         // Resolves once any in-flight save has completed, so the caller can safely re-read.
         save,
         isDirty: () => dirty,
-        dispose: () => { taObserver.disconnect(); wrap.remove(); },
+        /// Still on the page? A card rebuild detaches an inline editor without disposing it.
+        isMounted: () => wrap.isConnected,
+        /// Mark the line a compile failed on, from the module's own status.
+        ///
+        /// The device reports "message @", an offset into the source it compiled, which is
+        /// the only position anyone has: the parser records it and nothing else can reconstruct it.
+        /// Passing "" or a status with no @ clears the mark, so a fixed script stops being flagged
+        /// the moment it compiles.
+        markError: (statusText) => {
+            errorLine = -1;
+            let shown = statusText || "";
+            // Two forms, because this is called with both: the raw device status "message @"
+            // on every update, and an ALREADY rewritten "message (line L, col C)" when a second
+            // editor opens on a module whose status was converted before it existed.
+            const at = /@(\d+)\s*$/.exec(shown);
+            const lc = /\(line (\d+), col \d+\)\s*$/.exec(shown);
+            if (at) {
+                const p = lineColAt(Number(at[1]));
+                errorLine = p.line - 1;
+                // The offset is replaced, not appended to: line and column is the only half a person
+                // can act on, and the editor marks the line anyway.
+                shown = shown.slice(0, at.index).trimEnd() + ` (line ${p.line}, col ${p.col})`;
+            } else if (lc) {
+                errorLine = Number(lc[1]) - 1;
+            }
+            paintHighlight();
+            return shown;
+        },
+        dispose: () => { taObserver.disconnect(); wrap.remove(); if (onDispose) onDispose(); },
     };
 }
 
 // Open the shared editor in a modal, for the File Manager's tree rows. Uses the native ,
 // no bespoke overlay code, and mounts exactly the pane a card mounts inline.
-async function openFileEditor(relPath, expectedSize) {
+async function openFileEditor(relPath, expectedSize, moduleName) {
     const dlg = document.createElement("dialog");
     dlg.className = "fm-editor";
     dlg.innerHTML =
@@ -6216,7 +6403,19 @@ async function openFileEditor(relPath, expectedSize) {
         '';
     dlg.querySelector(".fm-editor-path").textContent = relPath;
     document.body.appendChild(dlg);
-    const ed = fmMountEditor(dlg, relPath, { expectedSize });
+    // Registered under the module that opened it, exactly as the card's pane is: the status row is
+    // rendered by the card either way, and setStatusText marks every editor on that module. Without
+    // a module name (the File Manager's own rows) nothing registers and the modal simply highlights.
+    let unregister = () => {};
+    const ed = fmMountEditor(dlg, relPath, { expectedSize, onDispose: () => unregister() });
+    unregister = mlEditorAdd(moduleName, ed);
+    // Mark it NOW, from the status already on the card: registration only catches the next update,
+    // and a compile failure that happened before the modal opened would otherwise show unmarked
+    // until the module recompiles. The row is the card's, so it holds the same text either way.
+    if (moduleName) {
+        const row = document.querySelector(`[data-status-mid="${cssEscape(moduleName)}"] .status-value`);
+        if (row) ed.markError(row.textContent);
+    }
     dlg.showModal();
     // Resolves when the dialog CLOSES, not when it opens: a caller that re-reads the file
     // afterwards (the card's pane shows the same file) would otherwise read it before any edit.
diff --git a/src/ui/embed_ui.cmake b/src/ui/embed_ui.cmake
index 10075d07..78a7c4e4 100644
--- a/src/ui/embed_ui.cmake
+++ b/src/ui/embed_ui.cmake
@@ -53,6 +53,7 @@ gzip_file_hex("app.js" APP_JS)
 gzip_file_hex("style.css" STYLE_CSS)
 gzip_file_hex("install-picker.js" INSTALL_PICKER_JS)
 gzip_file_hex("semver.js" SEMVER_JS)
+gzip_file_hex("vendor/prism.js" PRISM_JS)
 gzip_file_hex("preview3d.js" PREVIEW3D_JS)
 gzip_file_hex("preview-adapt.js" PREVIEW_ADAPT_JS)
 gzip_file_hex("migrate.js" MIGRATE_JS)
@@ -78,6 +79,7 @@ hex_to_c_array("${APP_JS}" "appJs" APP_ARRAY)
 hex_to_c_array("${STYLE_CSS}" "styleCss" STYLE_ARRAY)
 hex_to_c_array("${INSTALL_PICKER_JS}" "installPickerJs" INSTALL_PICKER_ARRAY)
 hex_to_c_array("${SEMVER_JS}" "semverJs" SEMVER_ARRAY)
+hex_to_c_array("${PRISM_JS}" "prismJs" PRISM_ARRAY)
 hex_to_c_array("${PREVIEW3D_JS}" "preview3dJs" PREVIEW3D_ARRAY)
 hex_to_c_array("${PREVIEW_ADAPT_JS}" "previewAdaptJs" PREVIEW_ADAPT_ARRAY)
 hex_to_c_array("${MIGRATE_JS}" "migrateJs" MIGRATE_ARRAY)
@@ -88,6 +90,7 @@ string(LENGTH "${APP_JS}" APP_HEX_LEN)
 string(LENGTH "${STYLE_CSS}" STYLE_HEX_LEN)
 string(LENGTH "${INSTALL_PICKER_JS}" INSTALL_PICKER_HEX_LEN)
 string(LENGTH "${SEMVER_JS}" SEMVER_HEX_LEN)
+string(LENGTH "${PRISM_JS}" PRISM_HEX_LEN)
 string(LENGTH "${PREVIEW3D_JS}" PREVIEW3D_HEX_LEN)
 string(LENGTH "${PREVIEW_ADAPT_JS}" PREVIEW_ADAPT_HEX_LEN)
 string(LENGTH "${MIGRATE_JS}" MIGRATE_HEX_LEN)
@@ -97,6 +100,7 @@ math(EXPR APP_LEN "${APP_HEX_LEN} / 2")
 math(EXPR STYLE_LEN "${STYLE_HEX_LEN} / 2")
 math(EXPR INSTALL_PICKER_LEN "${INSTALL_PICKER_HEX_LEN} / 2")
 math(EXPR SEMVER_LEN "${SEMVER_HEX_LEN} / 2")
+math(EXPR PRISM_LEN "${PRISM_HEX_LEN} / 2")
 math(EXPR PREVIEW3D_LEN "${PREVIEW3D_HEX_LEN} / 2")
 math(EXPR PREVIEW_ADAPT_LEN "${PREVIEW_ADAPT_HEX_LEN} / 2")
 math(EXPR MIGRATE_LEN "${MIGRATE_HEX_LEN} / 2")
@@ -114,6 +118,8 @@ file(APPEND "${OUT}" "constexpr uint8_t installPickerJs[] = {${INSTALL_PICKER_AR
 file(APPEND "${OUT}" "constexpr size_t installPickerJsLen = ${INSTALL_PICKER_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t semverJs[] = {${SEMVER_ARRAY}};\n")
 file(APPEND "${OUT}" "constexpr size_t semverJsLen = ${SEMVER_LEN};\n")
+file(APPEND "${OUT}" "constexpr uint8_t prismJs[] = {${PRISM_ARRAY}};\n")
+file(APPEND "${OUT}" "constexpr size_t prismJsLen = ${PRISM_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t preview3dJs[] = {${PREVIEW3D_ARRAY}};\n")
 file(APPEND "${OUT}" "constexpr size_t preview3dJsLen = ${PREVIEW3D_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t previewAdaptJs[] = {${PREVIEW_ADAPT_ARRAY}};\n")
diff --git a/src/ui/index.html b/src/ui/index.html
index f92b997e..beb37a2e 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -64,6 +64,9 @@
             
         
     
+    
+    
     
 
 
diff --git a/src/ui/style.css b/src/ui/style.css
index 426d1e67..c73bfd5f 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -1748,19 +1748,94 @@ body.cards-resizing {
 .fm-editor-path { font-family: ui-monospace, monospace; font-size: 0.9rem; color: var(--fg-muted); }
 .fm-editor-x { background: none; border: none; color: var(--fg-muted); cursor: pointer; font-size: 1rem; }
 .fm-editor-x:hover { color: var(--fg); }
+/* The editor is TWO layers in one box: a 
 that paints the syntax and a textarea that does the
+   editing, the textarea on top with transparent text so the paint shows through its caret and
+   selection. They must agree on every metric that positions a glyph, so the font, size, line
+   height, padding and whitespace handling are declared once and shared: any drift between them and
+   the colors slide off the characters. */
+/* The STACK carries the height and the resize grip, not the textarea inside it. When the textarea
+   owned its own height it could end up shorter than the stack, and every click below its real
+   bottom landed on the stack instead of the text: the editor stopped responding partway down. */
+.fm-editor-stack {
+    position: relative; display: flex; flex: 1 1 auto;
+    min-height: 240px; overflow: hidden; resize: vertical;
+}
+.fm-editor-body, .fm-editor-hl {
+    margin: 0; padding: 12px 14px; border: none;
+    font-family: ui-monospace, monospace; font-size: 0.85rem; line-height: 1.5;
+    white-space: pre; overflow: auto; tab-size: 4;
+}
+.fm-editor-hl {
+    position: absolute; inset: 0; pointer-events: none;
+    background: var(--bg-1); color: var(--fg);
+    /* The layer never scrolls itself: the textarea owns the scroll and this follows by transform,
+       so the two cannot disagree about a scroll position. `overflow: hidden` also stops it from
+       claiming a scrollbar and shrinking its own content box below the textarea's. */
+    overflow: hidden;
+}
 .fm-editor-body {
-    flex: 1 1 auto; min-height: 240px; margin: 0; padding: 12px 14px;
-    border: none; resize: vertical; font-family: ui-monospace, monospace;
-    font-size: 0.85rem; line-height: 1.5; background: var(--bg-1); color: var(--fg);
+    position: absolute; inset: 0; width: 100%; height: 100%; resize: none;
+    background: transparent; color: transparent; caret-color: var(--fg);
+}
+/* A textarea leaves its RIGHT padding out of scrollWidth where a 
 includes it, so the two
+   boxes drift by one padding once a line is wider than the box: the caret then sits a character
+   away from the glyph under it. The paint carries its padding on the inner  instead, and the
+   layer itself has none, which makes the two geometries identical. */
+.fm-editor-hl { padding: 0; }
+.fm-editor-hl > code {
+    display: block; padding: 12px 14px;
+    font: inherit; line-height: inherit; white-space: pre; tab-size: 4;
+}
+/* The selection has to stay visible through transparent text. */
+.fm-editor-body::selection { background: var(--accent-soft, rgba(122,162,247,0.35)); color: transparent; }
+/* A file with no highlighting (a .json, a .txt) paints nothing, so the textarea shows its own text
+   and the empty layer stays out of the way. The class is set by the editor, which knows the
+   extension: deriving it from the DOM would guess at what the JS already decided. */
+/* The token colors, in the app's OWN palette rather than one of Prism's stock themes: those ship
+   their own background and their own idea of a foreground, and a card that reads as part of this UI
+   is worth more than a familiar-looking editor. Only the roles a MoonLive script actually produces
+   are named; anything else inherits --fg and simply reads as text.
+
+   Comments are muted rather than colored: a script's comments are long by design (one header line
+   and a note per control), and coloring them makes the file harder to scan, not easier. */
+.fm-editor-hl .token.comment { color: var(--fg-muted); font-style: italic; }
+.fm-editor-hl .token.keyword { color: var(--accent); }
+.fm-editor-hl .token.class-name { color: var(--yellow); }
+.fm-editor-hl .token.function { color: #7ec8e3; }
+.fm-editor-hl .token.string { color: var(--green); }
+.fm-editor-hl .token.number { color: #e08a5a; }
+/* The line a compile failed on: a band UNDER the text, not a wrapper around it. Wrapping meant
+   cutting Prism's markup at a newline, which breaks any token spanning lines; a band positioned by
+   line height cannot disturb the markup at all. It sits behind the tokens, so the syntax colors on
+   that line stay readable: the mark says WHERE, the status line says what. */
+.fm-editor-err {
+    position: absolute; left: 0; right: 0; height: 1.5em;
+    margin-top: 12px;                    /* the paint layer's own top padding, so line 0 lines up */
+    background: var(--red-soft, rgba(247,118,142,0.16));
+    box-shadow: inset 2px 0 0 var(--red, #f7768e);
+    pointer-events: none;
 }
+.fm-editor-hl .token.operator,
+.fm-editor-hl .token.punctuation { color: var(--fg-muted); }
+
+.fm-editor-stack.plain .fm-editor-body { background: var(--bg-1); color: var(--fg); }
+.fm-editor-stack.plain .fm-editor-hl { display: none; }
 .fm-editor-body:focus { outline: none; }
-/* wrap="off": code reads by indentation, and a wrapped line hides its structure. The attribute
-   stops the wrapping, this makes the overflow reachable. */
-.fm-editor-body { overflow-x: auto; white-space: pre; }
 .fm-editor-foot {
-    display: flex; align-items: center; justify-content: space-between;
+    display: flex; align-items: center; justify-content: space-between; gap: 10px;
     padding: 10px 14px; border-top: 1px solid var(--border);
 }
+/* A footer holding only the caret readout carries no rule: the row belongs to the editor, but a
+   host that supplied its own save button and status line has nothing here to separate from. */
+.fm-editor-foot:not(:has(.fm-editor-save)):not(:has(.fm-editor-status)) {
+    border-top: none; padding: 4px 14px 6px;
+}
+/* The caret readout: quiet, and on tabular figures so the numbers do not jitter as they change. */
+.fm-editor-caret {
+    margin-left: auto; color: var(--fg-muted);
+    font-family: ui-monospace, monospace; font-size: 0.75rem;
+    font-variant-numeric: tabular-nums; white-space: nowrap;
+}
 .fm-editor-status { color: var(--fg-muted); font-size: 0.85rem; }
 
 /* The same editor pane mounted INLINE on a module card rather than in the dialog. Only the frame
diff --git a/src/ui/vendor/prism.js b/src/ui/vendor/prism.js
new file mode 100644
index 00000000..ba3f424e
--- /dev/null
+++ b/src/ui/vendor/prism.js
@@ -0,0 +1,5 @@
+// Prism 1.29.0, vendored: core + clike + c + cpp, the minimum that highlights a MoonLive
+// script. Vendored rather than fetched from a CDN because a rig at a venue is on an isolated
+// network: the editor must look the same there as at a desk. 12 KB against app.js's 333 KB.
+// Upstream: https://prismjs.com  (MIT). Regenerate by concatenating those four components.
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/};Prism.languages.c=Prism.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),Prism.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),Prism.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},Prism.languages.c.string],char:Prism.languages.c.char,comment:Prism.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:Prism.languages.c}}}}),Prism.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete Prism.languages.c.boolean;!function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n="\\b(?!)\\w+(?:\\s*\\.\\s*\\w+)*\\b".replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp("(\\b(?:class|concept|enum|struct|typename)\\s+)(?!)\\w+".replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp('(\\b(?:import|module)\\s+)(?:"(?:\\\\(?:\r\n|[^])|[^"\\\\\r\n])*"|<[^<>\r\n]*>|'+"(?:\\s*:\\s*)?|:\\s*".replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(Prism);
\ No newline at end of file
diff --git a/test/js/ui-live-patch-text.test.mjs b/test/js/ui-live-patch-text.test.mjs
index b4ac7a4e..b5bc7d5d 100644
--- a/test/js/ui-live-patch-text.test.mjs
+++ b/test/js/ui-live-patch-text.test.mjs
@@ -39,7 +39,8 @@ function functionBody(name) {
 // back twice after being "fixed" because the audit was per-function: the status bar and the tab
 // dot are patched on the same tick and were missed. Adding a function to updateValues without
 // adding it here is the gap this list closes.
-for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot"]) {
+for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot",
+                  "setStatusText"]) {
     test(`${fn} writes text only through setText, so a selection survives the patch`, () => {
         const body = functionBody(fn);
         const offenders = body
@@ -114,7 +115,7 @@ for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "ap
 // calls into (our own, not DOM builtins) is one this file checks.
 test("every function on the patch path is covered by this file", () => {
     const checked = ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot",
-                     "updateTabDot", "setText", "setUrlDisplay"];
+                     "updateTabDot", "setText", "setUrlDisplay", "setStatusText"];
     const keywords = ["if", "for", "while", "switch", "catch", "return", "typeof"];
     const dom = ["querySelector", "querySelectorAll", "createElement", "appendChild",
                  "insertBefore", "toggle", "setAttribute", "getAttribute", "remove", "closest",
diff --git a/test/python/test_scripts_are_cpp.py b/test/python/test_scripts_are_cpp.py
new file mode 100644
index 00000000..29120fbf
--- /dev/null
+++ b/test/python/test_scripts_are_cpp.py
@@ -0,0 +1,116 @@
+"""Every shipped MoonLive script is valid C++.
+
+MoonLive is documented as a subset of C++, and that claim is worth more than a description: it is
+what lets someone read a script without learning a language, and what keeps the grammar from
+drifting into a dialect one feature at a time. A claim nothing checks is a claim that decays.
+
+So each script is wrapped in a generated prelude and handed to a real C++ compiler. The prelude
+declares the vocabulary (the builtins, the system variables, the type aliases) and nothing else:
+the SCRIPT's own text is compiled unmodified except for the one shape difference the language
+deliberately has, applied mechanically here and listed in the language reference:
+
+  - a class needs `public:` and a trailing semicolon in C++
+
+Anything else a compiler rejects is a real divergence, and this test is where it surfaces.
+
+The prelude is GENERATED from the engine's own builtin table rather than hand-written, so a builtin
+added to the language cannot leave this test asserting against a stale vocabulary. Signatures are
+`int` throughout: this is a syntax check (`-fsyntax-only`), never a run, so the shapes that matter
+are the arity and the name.
+
+Skips when no C++ compiler is on PATH, which is the case in a bare CI container. Run:
+`uv run --with pytest pytest test/python/test_scripts_are_cpp.py -q`.
+"""
+
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+BUILTINS = ROOT / "src" / "light" / "moonlive" / "MoonLiveBuiltins_light.h"
+SYSVARS = ("t", "width", "height", "depth", "xPos", "yPos", "zPos")
+
+CXX = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++")
+pytestmark = pytest.mark.skipif(CXX is None, reason="no C++ compiler on PATH")
+
+
+def builtins():
+    """Every builtin the light vocabulary registers: (name, argc).
+
+    Read from the registration table itself. A hand-kept list here would pass while the engine
+    moved on, which is the exact failure this test exists to prevent one level down.
+    """
+    text = BUILTINS.read_text(encoding="utf-8")
+    found = re.findall(r't\.add\(\{"([A-Za-z0-9_]+)",\s*(\d+)', text)
+    assert found, "no builtins parsed: the registration shape changed"
+    # A name can register twice (an overload by arity); keep the widest, since a call with fewer
+    # arguments still matches a declaration with more only if defaults exist, which these lack.
+    widest: dict[str, int] = {}
+    for name, argc in found:
+        widest[name] = max(widest.get(name, 0), int(argc))
+    return sorted(widest.items())
+
+
+def prelude() -> str:
+    lines = [
+        "#include ",
+        "using byte = uint8_t;",
+        "using fixed = int32_t;",
+        "using string = const char*;",
+    ]
+    for v in SYSVARS:
+        lines.append(f"inline int {v} = 0;")
+    for name, argc in builtins():
+        args = ", ".join(["int"] * argc)
+        lines.append(f"int {name}({args});")
+    # toFixed/toInt are KEYWORDS, not builtins: the compiler recognizes them inline so each costs
+    # one shift instruction rather than a host call (MoonLiveCompiler.cpp), so the registration
+    # table above does not carry them.
+    lines.append("int toFixed(int); int toInt(int);")
+    # addControl binds a member BY REFERENCE and takes its label as a string, so the generated
+    # int-only declaration cannot express it. Both member widths, spelled out.
+    lines.append("void addControl(string, int&, int, int);")
+    lines.append("void addControl(string, byte&, int, int);")
+    lines.append("void addControl(string, bool&);")
+    return "\n".join(lines) + "\n"
+
+
+def as_cpp(script: str) -> str:
+    """One script, as the C++ it claims to be."""
+    src = re.sub(r"^(class \w+ \{)", r"\1\npublic:", script, count=1, flags=re.M)
+    return src.rstrip() + ";\n"
+
+
+def scripts():
+    out = sorted((ROOT / "moonlive").rglob("*.ml*"))
+    assert out, "no scripts found: this test would pass without checking anything"
+    return out
+
+
+@pytest.mark.parametrize("script", scripts(), ids=lambda p: p.name)
+def test_script_compiles_as_cpp(script, tmp_path):
+    cpp = tmp_path / "probe.cpp"
+    (tmp_path / "prelude.h").write_text(prelude(), encoding="utf-8")
+    cpp.write_text('#include "prelude.h"\n' + as_cpp(script.read_text(encoding="utf-8")),
+                   encoding="utf-8")
+    r = subprocess.run([CXX, "-std=c++20", "-fsyntax-only", str(cpp)],
+                       capture_output=True, text=True, cwd=tmp_path)
+    assert r.returncode == 0, f"{script.name} is not valid C++:\n{r.stderr}"
+
+
+def test_the_check_can_fail(tmp_path):
+    """A control: a script with a real C++ error must be rejected.
+
+    Without this the suite above is indistinguishable from one where the compiler never ran, which
+    is the failure mode a green test cannot show on its own.
+    """
+    (tmp_path / "prelude.h").write_text(prelude(), encoding="utf-8")
+    cpp = tmp_path / "bad.cpp"
+    cpp.write_text('#include "prelude.h"\nclass T {\npublic:\n  void tick() { nosuchcall(1); }\n};\n',
+                   encoding="utf-8")
+    r = subprocess.run([CXX, "-std=c++20", "-fsyntax-only", str(cpp)],
+                       capture_output=True, text=True, cwd=tmp_path)
+    assert r.returncode != 0, "the compiler accepted an undeclared call: this check proves nothing"
diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json
index cec0e461..7435409c 100644
--- a/test/scenarios/light/scenario_MoonLive_pipeline.json
+++ b/test/scenarios/light/scenario_MoonLive_pipeline.json
@@ -359,7 +359,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 24; i = i + 1) { addLight(i, 0, 0); } } }\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 24; i = i + 1) { addLight(i, 0, 0); } } }\n"
     },
     {
       "name": "layout-a-row",
@@ -817,7 +817,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 4; i = i + 1) { addLight(i, i\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 4; i = i + 1) { addLight(i, i\n"
     },
     {
       "name": "break-the-layout-resync",
@@ -968,7 +968,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 12; i = i + 1) { addLight(i, 0, 0); } } }\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 12; i = i + 1) { addLight(i, 0, 0); } } }\n"
     },
     {
       "name": "recover-the-layout-resync",
diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc
index 03e82fe8..194bcbdd 100644
--- a/test/unit/core/moonlive_device_codegen.inc
+++ b/test/unit/core/moonlive_device_codegen.inc
@@ -47,8 +47,8 @@ const char* kGridLayout =
     "  byte cols = 16;\n"
     "  byte rows = 16;\n"
     "  void tick() {\n"
-    "    for (y = 0; y < rows; y = y + 1) {\n"
-    "      for (x = 0; x < cols; x = x + 1) {\n"
+    "    for (int y = 0; y < rows; y = y + 1) {\n"
+    "      for (int x = 0; x < cols; x = x + 1) {\n"
     "        addLight(x, y, 0);\n"
     "      }\n"
     "    }\n"
@@ -62,7 +62,7 @@ const char* kEffectSimple =
     "class SimpleEffect {\n  void tick() { setRGB(0, 255, 0, 0); }\n}\n";
 const char* kEffectLoop =
     "class LoopEffect {\n"
-    "  void tick() { for (i = 0; i < 64; i = i + 1) { setRGB(i, i, 255 - i, 60); } }\n"
+    "  void tick() { for (int i = 0; i < 64; i = i + 1) { setRGB(i, i, 255 - i, 60); } }\n"
     "}\n";
 
 /// Compile for THIS TU's backend. `sysvars` is the ONE light vocabulary; the role-named
@@ -132,7 +132,7 @@ TEST_CASE("a scripted effect compiles for " MM_ISA_NAME ", straight-line and loo
 // that pushes any backend back over the edge shows up here first.
 TEST_CASE("fill plus a loop on " MM_ISA_NAME " emits what it currently can") {
     bool ok = false;
-    CHECK(emitLen(mmScript("fill(0,0,0);\nfor (y = 0; y < height; y = y + 1) { setRGB(y, 255, 0, 0); }"),
+    CHECK(emitLen(mmScript("fill(0,0,0);\nfor (int y = 0; y < height; y = y + 1) { setRGB(y, 255, 0, 0); }"),
                   mm::moonlive::effectSysVars(), ok) == MM_GOLD_FILLLOOP_LEN);
 }
 
diff --git a/test/unit/core/moonlive_structural.inc b/test/unit/core/moonlive_structural.inc
index c66179e0..544a923b 100644
--- a/test/unit/core/moonlive_structural.inc
+++ b/test/unit/core/moonlive_structural.inc
@@ -32,8 +32,8 @@ TEST_CASE("emitted " MM_ISA_NAME " code keeps every frame offset inside the fram
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
         {"a bare write",      mmScript("setRGB(0, 255, 0, 0);\n"), 1},
-        {"a loop",            mmScript("for (i = 0; i < 3; i = i + 1) { }\n"), 0},
-        {"a call in a loop",  mmScript("for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
+        {"a loop",            mmScript("for (int i = 0; i < 3; i = i + 1) { }\n"), 0},
+        {"a call in a loop",  mmScript("for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
         {"the shipped grid",  kGridLayout, 0},
         // Several routines in one block, which is what a class emits. Every function after the
         // first is what a checker reading only the prologue at byte 0 would never look at.
@@ -101,11 +101,11 @@ TEST_CASE("emitted " MM_ISA_NAME " code keeps every frame offset inside the fram
 TEST_CASE("emitted " MM_ISA_NAME " code branches only to instruction boundaries") {
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
-        {"one loop",     mmScript("for (i = 0; i < 3; i = i + 1) { }\n"), 0},
+        {"one loop",     mmScript("for (int i = 0; i < 3; i = i + 1) { }\n"), 0},
         {"nested loops", kGridLayout, 0},
         // Long enough that a short-displacement branch would have to be relaxed to reach.
         {"a long loop body",
-         mmScript("for (i = 0; i < 8; i = i + 1) {\n"
+         mmScript("for (int i = 0; i < 8; i = i + 1) {\n"
          "  addLight(i, 0, 0); addLight(i, 1, 0); addLight(i, 2, 0); addLight(i, 3, 0);\n"
          "  addLight(i, 4, 0); addLight(i, 5, 0); addLight(i, 6, 0); addLight(i, 7, 0);\n"
          "}\n"), 0},
@@ -169,18 +169,18 @@ TEST_CASE("emitted " MM_ISA_NAME " code reads no register a call destroyed") {
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
         {"one call",            mmScript("setRGB(0, random16(256), 0, 0);\n"), 1},
-        {"a call in a loop",    mmScript("for (i = 0; i < 4; i = i + 1) { setRGB(i, random16(256), 0, 0); }\n"), 1},
+        {"a call in a loop",    mmScript("for (int i = 0; i < 4; i = i + 1) { setRGB(i, random16(256), 0, 0); }\n"), 1},
         {"nested calls",        mmScript("setRGB(random16(256), random16(256), random16(256), random16(256));\n"), 1},
-        {"a call in a layout",  mmScript("for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
+        {"a call in a layout",  mmScript("for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
         {"the shipped grid",    kGridLayout, 0},
         // The shape that crashed on hardware, and the nearest ones that did not.
         {"sysvar bound + call in body",
-         mmScript("for (x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
+         mmScript("for (int x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
         {"member bound + call in body",
          mmScript("byte n = 8;\n"
-         "for (x = 0; x < n; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
+         "for (int x = 0; x < n; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
         {"sysvar read inside the body, with a call",
-         mmScript("for (x = 0; x < 4; x = x + 1) { setRGB(x, width, random16(256), 0); }\n"), 1},
+         mmScript("for (int x = 0; x < 4; x = x + 1) { setRGB(x, width, random16(256), 0); }\n"), 1},
     };
     for (const auto& c : cases) {
         bool ok = false;
diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp
index 7630f78b..113359d2 100644
--- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp
+++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp
@@ -556,8 +556,8 @@ TEST_CASE("x86_64: two sequential call-bearing loops stay under the density boun
     // backend's density canary.
     const char* src =
         "class T { void tick() { "
-        "for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } "
-        "for (i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); } "
+        "for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } "
+        "for (int i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); } "
         "} }\n";
     uint8_t out[2048];
     auto r = mm::moonlive::compileSource(src, mm::moonlive::lightBuiltins(),
diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp
index 8e213a84..0363b1b3 100644
--- a/test/unit/core/unit_moonlive_compiler.cpp
+++ b/test/unit/core/unit_moonlive_compiler.cpp
@@ -376,9 +376,9 @@ TEST_CASE("a script cannot declare a name the engine already defines") {
     const Case refused[] = {
         {mmScript("byte width = 16;\nsetRGB(0, 0, 0, 0);"), "a control named width"},
         {mmScript("byte t = 5;\nsetRGB(0, 0, 0, 0);"),                        "a control named t"},
-        {mmScript("for (xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"),
+        {mmScript("for (int xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"),
                                                                         "a loop variable named xPos"},
-        {mmScript("for (height = 0; height < 4; height = height + 1) { setRGB(0, 0, 0, 0); }"),
+        {mmScript("for (int height = 0; height < 4; height = height + 1) { setRGB(0, 0, 0, 0); }"),
                                                                         "a loop variable named height"},
     };
     for (const Case& c : refused) {
@@ -417,13 +417,13 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
     // unit_moonlive_codegen_x86_64.cpp's canary.
     uint8_t out[1024];
     auto r = moonlive::compileSource(
-        mmScript("for (i = 0; i < 2; i = i + 1) { for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } }"),
+        mmScript("for (int i = 0; i < 2; i = i + 1) { for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } }"),
         kTable, kSys, out, sizeof(out));
     CHECK_FALSE(r.ok);
     CHECK(std::string(r.error) == "loop variable already in use");
     // Distinct names nest fine — the check must not refuse the ordinary case it exists to protect.
     auto ok = moonlive::compileSource(
-        mmScript("for (yy = 0; yy < 2; yy = yy + 1) { for (xx = 0; xx < 2; xx = xx + 1) { addLight(xx, yy, 0); } }"),
+        mmScript("for (int yy = 0; yy < 2; yy = yy + 1) { for (int xx = 0; xx < 2; xx = xx + 1) { addLight(xx, yy, 0); } }"),
         kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(ok.ok);
@@ -433,7 +433,7 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
     // Sequential loops REUSE a name legitimately: the first has left scope by the time the second
     // binds, so this must still compile (two-rows.mll is exactly this shape).
     auto seq = moonlive::compileSource(
-        mmScript("for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } for (i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); }"),
+        mmScript("for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } for (int i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); }"),
         kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(seq.ok);
@@ -445,21 +445,39 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
 // The emitted loop tests and advances its OWN counter whatever name the condition and step clauses
 // write, so a mistyped name used to compile clean and run as though it said the right thing — a
 // wrong fixture with no diagnostic anywhere. Found by review.
+TEST_CASE("a for loop declares its counter, as every other variable in the language does") {
+    uint8_t out[512];
+    // The rule the language already held everywhere else: a member carries its type and an
+    // assignment to an undeclared name is refused, so a counter appearing out of nowhere was the
+    // last exception. Requiring `int` also makes the loop header identical to the C++ one, which is
+    // what test/python/test_scripts_are_cpp.py compiles every shipped script as.
+    auto bare = moonlive::compileSource(
+        mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        kTable, kSys, out, sizeof(out));
+    CHECK_FALSE(bare.ok);
+    CHECK(std::string(bare.error) == "a loop counter is declared: for (int i = 0; ...)");
+
+    auto declared = moonlive::compileSource(
+        mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        kTable, kSys, out, sizeof(out));
+    CHECK(declared.ok);
+}
+
 TEST_CASE("a for loop's condition and step must name the loop variable") {
     uint8_t out[512];
     struct Case { const char* src; const char* err; const char* what; };
     const Case refused[] = {
-        {mmScript("for (i = 0; j < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; j < 3; i = i + 1) { addLight(i, 0, 0); }"),
          "the condition must test the loop variable", "a typo in the condition"},
-        {mmScript("for (i = 0; i < 3; j = j + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; i < 3; j = j + 1) { addLight(i, 0, 0); }"),
          "the step must advance the loop variable",   "a typo in the step"},
         // Plain names, not x/y: those are system variables in this table and would be refused a
         // step earlier, hiding what this case is about.
-        {mmScript("for (a = 0; a < 4; a = a + 1) { for (b = 0; a < 4; b = b + 1) { addLight(b, a, 0); } }"),
+        {mmScript("for (int a = 0; a < 4; a = a + 1) { for (int b = 0; a < 4; b = b + 1) { addLight(b, a, 0); } }"),
          "the condition must test the loop variable", "an inner loop testing the OUTER variable"},
         // The step is re-lexed from the source it was skipped over, and an expression parser stops
         // at the first token it cannot use — so trailing junk was silently dropped.
-        {mmScript("for (i = 0; i < 3; i = i + 1 garbage) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; i < 3; i = i + 1 garbage) { addLight(i, 0, 0); }"),
          "unexpected token in the for's step", "trailing junk after the step expression"},
     };
     for (const Case& c : refused) {
@@ -469,7 +487,7 @@ TEST_CASE("a for loop's condition and step must name the loop variable") {
         CHECK(std::string(r.error) == c.err);
     }
     // The ordinary loop is untouched.
-    auto ok = moonlive::compileSource(mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+    auto ok = moonlive::compileSource(mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
                                       kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(ok.ok);
@@ -740,7 +758,7 @@ TEST_CASE("a brightness that went below zero renders black rather than full") {
 // The loop guard deliberately stayed UNSIGNED when comparisons went signed: a loop counter is a
 // count, and `for (i = 0; i < width; ...)` must run whatever a signed reading would make of it.
 TEST_CASE("a loop over a count still runs every step after comparisons became signed") {
-    auto px = render(mmScript("for (i = 0; i < 4; i = i + 1) { setRGB(i, 9, 0, 0); }"), 4);
+    auto px = render(mmScript("for (int i = 0; i < 4; i = i + 1) { setRGB(i, 9, 0, 0); }"), 4);
     CHECK(px[0] == 9);
     CHECK(px[3 * 3] == 9);
 }
@@ -1148,7 +1166,7 @@ TEST_CASE("an array element refuses a value of the wrong type") {
 TEST_CASE("a loop header refuses a fixed value in any of its three clauses") {
     moonlive::MoonLive eng;
     CHECK_FALSE(eng.compile("class T { fixed f = 3.0;\n"
-                            "  void tick() { for (i = 0; i < f; i = i + 1) { setRGB(0, 1, 0, 0); } } }",
+                            "  void tick() { for (int i = 0; i < f; i = i + 1) { setRGB(0, 1, 0, 0); } } }",
                             kTable, kSys));
     eng.free();
 }
@@ -1310,7 +1328,7 @@ TEST_CASE("return leaves tick() early, and the statements after it do not run")
 
 // A return inside a loop leaves the FUNCTION, not just the iteration: the classic early-out.
 TEST_CASE("return inside a loop leaves the whole function") {
-    auto buf = render(mmScript("for (i = 0; i < 4; i = i + 1) {"
+    auto buf = render(mmScript("for (int i = 0; i < 4; i = i + 1) {"
                                "  setRGB(i, 9, 0, 0);"
                                "  if (i >= 1) { return; }"
                                "}"), 4);
diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp
index 9e476e23..2fac38a3 100644
--- a/test/unit/core/unit_moonlive_fill.cpp
+++ b/test/unit/core/unit_moonlive_fill.cpp
@@ -158,7 +158,7 @@ static moonlive::SysVarTable kSys = moonlive::modifierSysVars();
 TEST_CASE("a loop counter survives a call in the body") {
     moonlive::MoonLive eng;
     // random16 is a Call; `i` and the limit `w` are both live around it.
-    REQUIRE(eng.compile(mmScript("byte w = 8;\nfor (i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"),
+    REQUIRE(eng.compile(mmScript("byte w = 8;\nfor (int i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"),
                         kCtrlTable, kSys));
     uint8_t buf[8 * 3] = {};
     eng.run(buf, 8, 3, 0);
@@ -592,7 +592,7 @@ TEST_CASE("a loop variable can be assigned in the loop body") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T {\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 8; i = i + 1) {\n"
+                        "    for (int i = 0; i < 8; i = i + 1) {\n"
                         "      i = i + 1;\n"          // skips every other light
                         "      setRGB(i, 99, 0, 0);\n"
                         "    }\n"
@@ -678,7 +678,7 @@ TEST_CASE("an if inside a for runs the body every iteration") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T {\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 6; i = i + 1) {\n"
+                        "    for (int i = 0; i < 6; i = i + 1) {\n"
                         "      if (i < 3) { setRGB(i, 50, 0, 0); }\n"
                         "      else { setRGB(i, 200, 0, 0); }\n"
                         "    }\n"
@@ -987,8 +987,8 @@ TEST_CASE("an array element written in one loop is read in the next") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte heat[8];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 8; i = i + 1) { heat[i] = i * 10; }\n"
-                        "    for (j = 0; j < 8; j = j + 1) { setRGB(j, heat[j], 0, 0); }\n"
+                        "    for (int i = 0; i < 8; i = i + 1) { heat[i] = i * 10; }\n"
+                        "    for (int j = 0; j < 8; j = j + 1) { setRGB(j, heat[j], 0, 0); }\n"
                         "  }\n"
                         "}\n", kCtrlTable, kSys));
     uint8_t px[24] = {};
@@ -1004,7 +1004,7 @@ TEST_CASE("array contents survive from one tick to the next") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte acc[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { acc[i] = acc[i] + 5; setRGB(i, acc[i], 0, 0); }\n"
+                        "    for (int i = 0; i < 4; i = i + 1) { acc[i] = acc[i] + 5; setRGB(i, acc[i], 0, 0); }\n"
                         "  }\n"
                         "}\n", kCtrlTable, kSys));
     uint8_t px[12] = {};
@@ -1027,9 +1027,9 @@ TEST_CASE("an out-of-range array index is clamped, not written past the end") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte a[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { a[i] = 1; }\n"
+                        "    for (int i = 0; i < 4; i = i + 1) { a[i] = 1; }\n"
                         "    a[9] = 200;\n"                    // far past the end
-                        "    for (j = 0; j < 4; j = j + 1) { setRGB(j, a[j], 0, 0); }\n"
+                        "    for (int j = 0; j < 4; j = j + 1) { setRGB(j, a[j], 0, 0); }\n"
                         "  }\n"
                         "}\n", kCtrlTable, kSys));
     uint8_t px[12] = {};
@@ -1088,7 +1088,7 @@ TEST_CASE("a int array holds per-element values above 255") {
     REQUIRE(eng.compile("class T {\n"
                         "  int v[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { v[i] = 300 + i; }\n"
+                        "    for (int i = 0; i < 4; i = i + 1) { v[i] = 300 + i; }\n"
                         "    if (v[0] == 300) { setRGB(0, 1, 0, 0); }\n"
                         "    if (v[3] == 303) { setRGB(1, 1, 0, 0); }\n"
                         "  }\n"
@@ -1246,7 +1246,7 @@ TEST_CASE("a shape's outside stays dark once the distance passes its edge") {
     moonlive::MoonLive eng;
     // Sweep the distance from inside the edge to well outside it, one light each.
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (i = 0; i < 8; i = i + 1) {"
+                        "  for (int i = 0; i < 8; i = i + 1) {"
                         "    setRGB(i, scale(smoothstep(0, 400, 400 - i * 100), 256), 0, 0);"
                         "  } } }", kCtrlTable, kSys));
     uint8_t px[8 * 3] = {};
@@ -1264,7 +1264,7 @@ TEST_CASE("a shape's outside stays dark once the distance passes its edge") {
 TEST_CASE("smoothstep is a soft ramp rather than a hard threshold") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (i = 0; i < 8; i = i + 1) {"
+                        "  for (int i = 0; i < 8; i = i + 1) {"
                         "    setRGB(i, scale(smoothstep(0, 800, i * 100), 256), 0, 0);"
                         "  } } }", kCtrlTable, kSys));
     uint8_t px[8 * 3] = {};
@@ -1294,8 +1294,8 @@ TEST_CASE("a circle drawn through uv stays circular on a wide panel") {
     // conversion: toInt() alone discards the fraction, which on this grid rounds every cell to
     // the same handful of integers and lights the lot.
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (y = 0; y < 8; y = y + 1) {"
-                        "    for (x = 0; x < 32; x = x + 1) {"
+                        "  for (int y = 0; y < 8; y = y + 1) {"
+                        "    for (int x = 0; x < 32; x = x + 1) {"
                         "      if (polarR(toInt(uvX(x, 32, 8) * 1024), "
                         "                 toInt(uvY(y, 32, 8) * 1024)) < 650) {"
                         "        setRGB(y * 32 + x, 255, 0, 0);"
@@ -1334,7 +1334,7 @@ TEST_CASE("uv places the grid center at the origin, with the left half negative"
 TEST_CASE("blending two shapes with smin produces one surface, not two") {
     // Two circles far enough apart that a plain union leaves a gap between them.
     const char* src = "class T { int k = 0; void tick() {"
-                      "  for (x = 0; x < 16; x = x + 1) {"
+                      "  for (int x = 0; x < 16; x = x + 1) {"
                       "    if (smin(polarR(x - 4, 0) - 2, polarR(x - 11, 0) - 2, k) < 0) {"
                       "      setRGB(x, 255, 0, 0); } } } }";
     moonlive::MoonLive hard;
diff --git a/test/unit/core/unit_moonlive_spill.cpp b/test/unit/core/unit_moonlive_spill.cpp
index 7a39142f..b60e719e 100644
--- a/test/unit/core/unit_moonlive_spill.cpp
+++ b/test/unit/core/unit_moonlive_spill.cpp
@@ -73,7 +73,7 @@ int litCount(const std::vector& b) {
 TEST_CASE("a script renders identical pixels at a squeezed register budget as at the full one") {
     // Enough live values that a nine-register budget cannot hold them all: three independent
     // colour components plus two loop-carried values.
-    const char* src = mmScript("for (i = 0; i < 6; i = i + 1) { setRGB(i, i + 1, i + 2, i + 3); }");
+    const char* src = mmScript("for (int i = 0; i < 6; i = i + 1) { setRGB(i, i + 1, i + 2, i + 3); }");
 
     bool fullOk = false, tightOk = false;
     auto full = renderAt(src, 8, nullptr, fullOk);
@@ -91,8 +91,8 @@ TEST_CASE("a script renders identical pixels at a squeezed register budget as at
 // value — placing lights twice, or not at all. Nested, so the extension has to apply innermost-first.
 TEST_CASE("a nested loop at a squeezed budget places every light exactly once") {
     const char* src =
-        mmScript("for (i = 0; i < 4; i = i + 1) {\n"
-        "  for (j = 0; j < 4; j = j + 1) {\n"
+        mmScript("for (int i = 0; i < 4; i = i + 1) {\n"
+        "  for (int j = 0; j < 4; j = j + 1) {\n"
         "    setRGB(i * 4 + j, 200, 100, 50);\n"
         "  }\n"
         "}\n");
@@ -123,7 +123,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards"
     // and at a squeezed budget it is one of the values that has nowhere to live but a slot.
     const char* src =
         mmScript("byte idx = 5;\n"
-        "for (i = 0; i < 3; i = i + 1) {\n"
+        "for (int i = 0; i < 3; i = i + 1) {\n"
         "  setRGB(idx + i, random16(1) + 111, i + 1, 222);\n"
         "}\n");
 
@@ -149,7 +149,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards"
 TEST_CASE("a declared control still reads live at a squeezed budget") {
     const char* src =
         mmScript("byte pos = 0;\n"
-        "for (i = 0; i < 2; i = i + 1) {\n"
+        "for (int i = 0; i < 2; i = i + 1) {\n"
         "  setRGB(pos + i, 10, 20, 30);\n"
         "}\n");
     uint8_t code[moonlive::kCodeCap];
@@ -183,8 +183,8 @@ TEST_CASE("an impossible register budget refuses the compile instead of emitting
     // A script whose live values genuinely exceed the budgets below, so each really does have to
     // spill and really does have nowhere to put the result.
     const char* src =
-        mmScript("for (i = 0; i < 4; i = i + 1) {\n"
-        "  for (j = 0; j < 4; j = j + 1) {\n"
+        mmScript("for (int i = 0; i < 4; i = i + 1) {\n"
+        "  for (int j = 0; j < 4; j = j + 1) {\n"
         "    setRGB(i * 4 + j, 200, 100, 50);\n"
         "  }\n"
         "}\n");
@@ -297,7 +297,7 @@ TEST_CASE("a system variable read in a loop survives a host call in that loop")
     // means a loop that runs PAST width still writes 0 there, so the past-width check could not
     // detect the runaway it is named for. A constant 7 fixes both while keeping the call, which is
     // the ingredient this test exists for.
-    const char* src = mmScript("for (x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 7, 0); }\n");
+    const char* src = mmScript("for (int x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 7, 0); }\n");
 
     // At the host's full budget AND at a squeezed one: Xtensa has ten registers where arm64 has
     // fourteen, so the squeezed run is the closest a host test gets to the pressure the device is
diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp
index f66051c1..641c5eeb 100644
--- a/test/unit/light/unit_MoonLiveLayout.cpp
+++ b/test/unit/light/unit_MoonLiveLayout.cpp
@@ -60,8 +60,8 @@ TEST_CASE("the default script lays out a grid, one light per cell") {
     const std::vector p = place(
         mmScriptAs("placeLights", "byte cols = 4;\n"
         "byte rows = 2;\n"
-        "for (yy = 0; yy < rows; yy = yy + 1) {"
-        "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"));
+        "for (int yy = 0; yy < rows; yy = yy + 1) {"
+        "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"));
     REQUIRE(p.size() == 8);
     CHECK(p[0] == Coord3D{0, 0, 0});
     CHECK(p[3] == Coord3D{3, 0, 0});
@@ -76,8 +76,8 @@ TEST_CASE("the light count is known before any coordinate is asked for") {
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 5;\n"
                 "byte rows = 3;\n"
-                "for (yy = 0; yy < rows; yy = yy + 1) {"
-                "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
+                "for (int yy = 0; yy < rows; yy = yy + 1) {"
+                "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
     l.prepare();
     CHECK(l.lightCount() == 15);           // answered without anyone calling placeLights
 }
@@ -86,7 +86,7 @@ TEST_CASE("the count and the coordinates always agree, because one script produc
     // The property SphereLayout names: count and emit run the same code, so they cannot drift.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
 
     std::vector seen;
@@ -102,7 +102,7 @@ TEST_CASE("a scripted layout allocates nothing, like every other layout") {
     // have. The script calls out per light instead, so the only heap here is the compiled program.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4096; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4096; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 4096);
     // dynamicBytes is the JIT'd program only — no coordinate storage grows with the light count.
@@ -113,7 +113,7 @@ TEST_CASE("a script places lights wherever it likes, which is the point of scrip
     // A strand that runs right to left: one line here, a new C++ class otherwise.
     const std::vector p = place(
         mmScriptAs("placeLights", "byte cols = 4;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
+        "for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
     REQUIRE(p.size() == 4);
     CHECK(p[0] == Coord3D{3, 0, 0});
     CHECK(p[3] == Coord3D{0, 0, 0});
@@ -121,7 +121,7 @@ TEST_CASE("a script places lights wherever it likes, which is the point of scrip
 
 TEST_CASE("a script can place a shape no rectangular layout can express") {
     // A diagonal — light i at (i, i).
-    const std::vector p = place(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i, 0); }"));
+    const std::vector p = place(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i, 0); }"));
     REQUIRE(p.size() == 4);
     CHECK(p[0] == Coord3D{0, 0, 0});
     CHECK(p[3] == Coord3D{3, 3, 0});
@@ -132,7 +132,7 @@ TEST_CASE("a broken script leaves an empty fixture rather than taking the pipeli
     // fixture reports no lights, the module carries the diagnostic, and the device keeps running.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
     l.prepare();
     CHECK(l.lightCount() == 0);
     CHECK(l.severity() == MoonModule::Severity::Error);
@@ -142,11 +142,11 @@ TEST_CASE("editing the script changes the fixture") {
     // The live-edit loop: the same module, a new script, a different physical shape.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 4);
 
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 2);
 }
@@ -159,19 +159,19 @@ TEST_CASE("the scripts the documentation shows all compile") {
         // the default
         mmScriptAs("placeLights", "byte cols = 16;\n"
         "byte rows = 16;\n"
-        "for (yy = 0; yy < rows; yy = yy + 1) {"
-        "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"),
+        "for (int yy = 0; yy < rows; yy = yy + 1) {"
+        "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"),
         // right to left
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"),
+        "for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"),
         // a diagonal
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"),
+        "for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"),
         // two rows, stacked
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }"),
+        "for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }"),
         // print wrapping an argument
-        mmScriptAs("placeLights", "for (i = 0; i < 2; i = i + 1) { addLight(print(i), 0, 0); }"),
+        mmScriptAs("placeLights", "for (int i = 0; i < 2; i = i + 1) { addLight(print(i), 0, 0); }"),
     };
     for (const char* s : fromDocs) {
         MoonLiveLayout l;
@@ -191,7 +191,7 @@ TEST_CASE("the scripts the documentation shows all compile") {
 TEST_CASE("a layout answers count and coordinates every time it is asked") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
 
     CHECK(l.lightCount() == 6);
@@ -222,13 +222,13 @@ TEST_CASE("a subtraction feeding a loop bound produces the whole value") {
     MoonLiveLayout l;
     l.defineControls();
     // 10 - 4 must be 6 lights. A widened -1 makes the bound enormous and the count is not 6.
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 10 - 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 10 - 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 6);
 
     // And a subtraction inside the placement, where the coordinate is the observable.
     std::vector p = place(mmScriptAs("placeLights", "byte cols = 4;\n"
-                                   "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
+                                   "for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
     REQUIRE(p.size() == 4);
     CHECK(p[0] == Coord3D{3, 0, 0});      // 4 - 1 - 0
     CHECK(p[3] == Coord3D{0, 0, 0});      // 4 - 1 - 3
@@ -247,13 +247,13 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
     MoonLiveLayout l;
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 16;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
+                "for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 16);
 
     // A second script declaring cols at the same offset inherits the live 16, not its own 8.
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 8;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }")));
+                "for (int i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 16);
 
@@ -262,15 +262,15 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
     // pad must take its own 4 rather than inherit the 16 the user had dialed into cols.
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte pad = 4;\n"
                 "byte cols = 7;\n"
-                "for (i = 0; i < pad; i = i + 1) { addLight(i, 2, 0); }")));
+                "for (int i = 0; i < pad; i = i + 1) { addLight(i, 2, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 4);
 
     // A script whose first control is a NEW slot gets its own initialiser: nothing to inherit.
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 16;\n"
                 "byte rows = 3;\n"
-                "for (yy = 0; yy < rows; yy = yy + 1) {"
-                "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
+                "for (int yy = 0; yy < rows; yy = yy + 1) {"
+                "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
     l.prepare();
     CHECK(l.lightCount() == 48);          // 16 inherited, rows 3 its own
 }
@@ -284,8 +284,8 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
 // the behaviour: a nested loop places every light of the grid it describes.
 TEST_CASE("a nested loop lays out a full grid, on every target's register budget") {
     const std::vector p = place(
-        mmScriptAs("placeLights", "for (yy = 0; yy < 3; yy = yy + 1) {"
-        "  for (xx = 0; xx < 5; xx = xx + 1) { addLight(xx, yy, 0); } }"));
+        mmScriptAs("placeLights", "for (int yy = 0; yy < 3; yy = yy + 1) {"
+        "  for (int xx = 0; xx < 5; xx = xx + 1) { addLight(xx, yy, 0); } }"));
     REQUIRE(p.size() == 15);               // 3 rows x 5 columns, none dropped
     CHECK(p[0]  == Coord3D{0, 0, 0});
     CHECK(p[4]  == Coord3D{4, 0, 0});      // end of the first row
@@ -305,7 +305,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
     SUBCASE("through a call — addLight") {
         MoonLiveLayout l;
         l.defineControls();
-        l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, i, 0); }")));
+        l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, i, 0); }")));
         l.prepare();
         CHECK(l.lightCount() == 6);      // a clobbered counter gives some other number
     }
@@ -315,7 +315,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
         // — addLight is a Call and takes a different path — so this drives the emitted code and
         // checks every light was written, which is what a wrong counter changes.
         uint8_t code[4096];
-        auto r = moonlive::compileSource(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { setRGB(i, 200, 0, 0); }"),
+        auto r = moonlive::compileSource(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { setRGB(i, 200, 0, 0); }"),
                                          moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
         REQUIRE(r.ok);
         void* blk = platform::allocExec(r.len);
@@ -339,7 +339,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
 TEST_CASE("a stray character in a for header is rejected, not spun on") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i @ 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i @ 1) { addLight(i, 0, 0); }")));
     l.prepare();                                    // must return — a hang fails by timeout
     CHECK(l.severity() == MoonModule::Severity::Error);
     CHECK(l.lightCount() == 0);
@@ -357,7 +357,7 @@ TEST_CASE("two threads can run scripts at once without stealing each other's sin
         MoonLiveLayout l;
         l.defineControls();
         char src[128];
-        std::snprintf(src, sizeof(src), mmScriptAs("placeLights", "for (i = 0; i < %d; i = i + 1) { addLight(i, 0, 0); }"), cols);
+        std::snprintf(src, sizeof(src), mmScriptAs("placeLights", "for (int i = 0; i < %d; i = i + 1) { addLight(i, 0, 0); }"), cols);
         l.setScript(mmWriteScript(src));
         l.prepare();
         for (int r = 0; r < reps; r++)
@@ -384,7 +384,7 @@ TEST_CASE("two threads can run scripts at once without stealing each other's sin
 TEST_CASE("a disabled scripted layout stops reporting the memory it freed") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     REQUIRE(l.dynamicBytes() > 0);
     l.release();
@@ -395,14 +395,14 @@ TEST_CASE("a scripted layout reports every heap byte it holds, compiled or not")
     MoonLiveLayout l;
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 4;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
+                "for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     const size_t compiled = l.dynamicBytes();
     CHECK(compiled > 0);
     CHECK(l.lightCount() == 4);
 
     // A broken script frees the code but keeps the arena, so the figure drops without reaching zero.
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
     l.prepare();
     CHECK(l.severity() == MoonModule::Severity::Error);
     CHECK(l.dynamicBytes() < compiled);      // the code block is gone
@@ -426,7 +426,7 @@ TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") {
         "class GrowLayout {\n"
         "  byte cols = 4;\n"
         "  void defineControls() { addControl(\"cols\", cols, 1, 64); }\n"
-        "  void placeLights() { for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } }\n"
+        "  void placeLights() { for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } }\n"
         "}\n"));
     layout.prepare();
     // The script's own controls (`cols`) exist only once it has COMPILED, and a module starts with
@@ -471,13 +471,13 @@ TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") {
 TEST_CASE("naming a different script through the control actually swaps the program") {
     MoonLiveLayout l;
     l.defineControls();
-    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(four);
     l.prepare();
     REQUIRE(l.lightCount() == 4);
 
     // Write the OTHER script the way the API does: straight into the bound control buffer.
-    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
     const auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -516,7 +516,7 @@ TEST_CASE("a layout whose script is missing reports it without retrying forever"
 
     // A working script after a failed one must still compile — the give-up is per script name, not
     // permanent, or fixing a typo would need a reboot.
-    const char* good = mmScriptAs("placeLights", "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+    const char* good = mmScriptAs("placeLights", "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     l.setScript(mmWriteScript(good));
     l.prepare();
     // The COUNT needs an emitting backend; the give-up-is-per-name behaviour above does not, so
@@ -548,7 +548,7 @@ TEST_CASE("a layout that starts empty still compiles the first script it is give
     // Write the control the way the UI does — straight into the bound buffer, then
     // onControlChanged — because addText binds `script_` directly and setScript() is NOT called on
     // that path. That is exactly how a device sets a script, and where the latch survived.
-    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
     auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -644,8 +644,8 @@ TEST_CASE("a serpentine layout places every light exactly once") {
         "byte cols = 4;\n"
         "byte rows = 3;\n"
         "byte odd = 0;\n"
-        "for (y = 0; y < rows; y = y + 1) {\n"
-        "  for (x = 0; x < cols; x = x + 1) {\n"
+        "for (int y = 0; y < rows; y = y + 1) {\n"
+        "  for (int x = 0; x < cols; x = x + 1) {\n"
         "    if (odd == 0) { addLight(x, y, 0); }\n"
         "    else { addLight(cols - 1 - x, y, 0); }\n"
         "  }\n"
@@ -665,7 +665,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     MoonLiveLayout l;
     l.defineControls();
     const char* name = mmWriteScript(mmScriptAs("placeLights",
-        "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
+        "for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(name);
     l.prepare();
     CHECK(l.lightCount() == 3);
@@ -674,7 +674,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     char path[96];
     std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name);
     const std::string edited = mmScriptAs("placeLights",
-        "for (i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
+        "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, edited.c_str(), edited.size()));
 
     l.prepare();
@@ -722,7 +722,7 @@ TEST_CASE("a broken script fixed in place compiles, without being renamed") {
     char path[96];
     std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name);
     const std::string fixed = mmScriptAs("placeLights",
-        "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+        "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, fixed.c_str(), fixed.size()));
 
     CHECK(l.lightCount() == 5);          // the content moved, so the failure latch released
@@ -773,7 +773,7 @@ TEST_CASE("a disabled scripted module publishes no controls bound to freed memor
         "class T {\n"
         "  byte cols = 7;\n"
         "  void defineControls() { addControl(\"cols\", cols, 1, 64); }\n"
-        "  void placeLights() { for (x = 0; x < cols; x = x + 1) { addLight(x, 0, 0); } }\n"
+        "  void placeLights() { for (int x = 0; x < cols; x = x + 1) { addLight(x, 0, 0); } }\n"
         "}\n"));
     l.prepare();
 
@@ -806,13 +806,13 @@ TEST_CASE("a disabled scripted module publishes no controls bound to freed memor
 TEST_CASE("naming a different script through the control actually swaps the program") {
     MoonLiveLayout l;
     l.defineControls();
-    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(four);
     l.prepare();
     REQUIRE(l.lightCount() == 4);
 
     // Write the OTHER script the way the API does: straight into the bound control buffer.
-    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
     const auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -851,7 +851,7 @@ TEST_CASE("a layout whose script is missing reports it without retrying forever"
 
     // A working script after a failed one must still compile — the give-up is per script name, not
     // permanent, or fixing a typo would need a reboot.
-    const char* good = mmScriptAs("placeLights", "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+    const char* good = mmScriptAs("placeLights", "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     l.setScript(mmWriteScript(good));
     l.prepare();
     // The COUNT needs an emitting backend; the give-up-is-per-name behaviour above does not, so
@@ -883,7 +883,7 @@ TEST_CASE("a layout that starts empty still compiles the first script it is give
     // Write the control the way the UI does — straight into the bound buffer, then
     // onControlChanged — because addText binds `script_` directly and setScript() is NOT called on
     // that path. That is exactly how a device sets a script, and where the latch survived.
-    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
     auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -979,8 +979,8 @@ TEST_CASE("a serpentine layout places every light exactly once") {
         "byte cols = 4;\n"
         "byte rows = 3;\n"
         "byte odd = 0;\n"
-        "for (y = 0; y < rows; y = y + 1) {\n"
-        "  for (x = 0; x < cols; x = x + 1) {\n"
+        "for (int y = 0; y < rows; y = y + 1) {\n"
+        "  for (int x = 0; x < cols; x = x + 1) {\n"
         "    if (odd == 0) { addLight(x, y, 0); }\n"
         "    else { addLight(cols - 1 - x, y, 0); }\n"
         "  }\n"
@@ -1000,7 +1000,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     MoonLiveLayout l;
     l.defineControls();
     const char* name = mmWriteScript(mmScriptAs("placeLights",
-        "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
+        "for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(name);
     l.prepare();
     CHECK(l.lightCount() == 3);
@@ -1009,7 +1009,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     char path[96];
     std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name);
     const std::string edited = mmScriptAs("placeLights",
-        "for (i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
+        "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, edited.c_str(), edited.size()));
 
     l.prepare();
@@ -1057,7 +1057,7 @@ TEST_CASE("a broken script fixed in place compiles, without being renamed") {
     char path[96];
     std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name);
     const std::string fixed = mmScriptAs("placeLights",
-        "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+        "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, fixed.c_str(), fixed.size()));
 
     CHECK(l.lightCount() == 5);          // the content moved, so the failure latch released
diff --git a/test/unit/light/unit_MoonLiveModifier.cpp b/test/unit/light/unit_MoonLiveModifier.cpp
index fa9bf17e..fbd3d037 100644
--- a/test/unit/light/unit_MoonLiveModifier.cpp
+++ b/test/unit/light/unit_MoonLiveModifier.cpp
@@ -344,7 +344,7 @@ TEST_CASE("a subtraction produces the whole value, not just its low byte") {
 TEST_CASE("a for loop runs its body once per step") {
     MoonLiveModifier m;
     m.defineControls();
-    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 4; i = i + 1) { print(i); } setXYZ(xPos, yPos, zPos);")));
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int i = 0; i < 4; i = i + 1) { print(i); } setXYZ(xPos, yPos, zPos);")));
     m.prepare();
     CHECK(m.severity() != MoonModule::Severity::Error);   // it compiles at all
     Coord3D box{16, 16, 1}; m.modifyLogicalSize(box);
@@ -357,7 +357,7 @@ TEST_CASE("a loop over an empty range runs its body no times") {
     // The entry guard: `i < 0` must skip the body entirely rather than wrap and run forever.
     MoonLiveModifier m;
     m.defineControls();
-    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 0; i = i + 1) { print(99); } setXYZ(xPos, yPos, zPos);")));
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int i = 0; i < 0; i = i + 1) { print(99); } setXYZ(xPos, yPos, zPos);")));
     m.prepare();
     CHECK(m.severity() != MoonModule::Severity::Error);
     Coord3D box{16, 16, 1}; m.modifyLogicalSize(box);
@@ -369,7 +369,7 @@ TEST_CASE("a loop over an empty range runs its body no times") {
 TEST_CASE("loops nest, which is what placing a grid of lights needs") {
     MoonLiveModifier m;
     m.defineControls();
-    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (a = 0; a < 2; a = a + 1) { for (b = 0; b < 2; b = b + 1) { print(a); } }"
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int a = 0; a < 2; a = a + 1) { for (int b = 0; b < 2; b = b + 1) { print(a); } }"
                 " setXYZ(xPos, yPos, zPos);")));
     m.prepare();
     CHECK(m.severity() != MoonModule::Severity::Error);
@@ -394,7 +394,7 @@ TEST_CASE("a loop in an effect script paints every light it walks") {
     layer.setChannelsPerLight(3);
     auto* fx = new MoonLiveEffect();
     fx->defineControls();
-    fx->setScript(mmWriteScript(mmScript("for (i = 0; i < 8; i = i + 1) { setRGB(i, i, 0, 0); }")));
+    fx->setScript(mmWriteScript(mmScript("for (int i = 0; i < 8; i = i + 1) { setRGB(i, i, 0, 0); }")));
     layer.addChild(fx);
     layouts.applyState();
     layer.applyState();
diff --git a/test/unit/light/unit_MoonLiveMotion.cpp b/test/unit/light/unit_MoonLiveMotion.cpp
index 071dbd4e..e8d43ef7 100644
--- a/test/unit/light/unit_MoonLiveMotion.cpp
+++ b/test/unit/light/unit_MoonLiveMotion.cpp
@@ -95,7 +95,7 @@ TEST_CASE("a script aims each head with setPan and setTilt") {
     HeadRig rig;
     rig.run("class Aim {"
             "  void tick() {"
-            "    for (i = 0; i < height; i = i + 1) {"
+            "    for (int i = 0; i < height; i = i + 1) {"
             "      setPan(i, 10 + i * 20);"
             "      setTilt(i, 200 - i * 20);"
             "    }"
@@ -115,7 +115,7 @@ TEST_CASE("setPan on a light with no motion channel writes nothing") {
     strip.run("class Aim {"
               "  void tick() {"
               "    fill(0, 0, 0);"
-              "    for (i = 0; i < height; i = i + 1) { setPan(i, 255); setTilt(i, 255); }"
+              "    for (int i = 0; i < height; i = i + 1) { setPan(i, 255); setTilt(i, 255); }"
               "  }"
               "}");
 
@@ -210,7 +210,7 @@ TEST_CASE("an audio script runs on a device with no audio, and paints nothing")
     rig.run("class A {"
             "  void tick() {"
             "    fill(0, 0, 0);"
-            "    for (i = 0; i < height; i = i + 1) {"
+            "    for (int i = 0; i < height; i = i + 1) {"
             "      setRGB(i, audioLevel(), audioBand(i), audioBeat() * 255);"
             "    }"
             "  }"
@@ -315,7 +315,7 @@ TEST_CASE("a script that declares D1 is extruded across the width") {
         "  int dimensions() { return 1; }"
         "  void tick() {"
         "    fill(0, 0, 0);"
-        "    for (y = 0; y < height; y = y + 1) { setRGB(y * width, 200, 0, 0); }"
+        "    for (int y = 0; y < height; y = y + 1) { setRGB(y * width, 200, 0, 0); }"
         "  }"
         "}"));
     layouts.applyState();
diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp
index 4613a21c..e0fe697d 100644
--- a/test/unit/light/unit_MoonLiveScripts.cpp
+++ b/test/unit/light/unit_MoonLiveScripts.cpp
@@ -18,6 +18,7 @@
 #include "core/moonlive/moonlive_emit.h"
 #include "light/moonlive/MoonLiveBuiltins_light.h"
 #include "light/moonlive/MoonLiveScriptFile.h"   // the role extensions the sweep filters on
+#include "light/moonlive/MoonLiveScript.h"       // kMaxStatus: the status line a failure reports through
 #include "light/moonlive/script_catalog.h"       // generated: what the device offers
 
 #include 
@@ -163,10 +164,10 @@ TEST_CASE("the shipped catalog names every script in moonlive/") {
 TEST_CASE("every script reads the same system-variable vocabulary") {
     struct Case { const char* src; bool ok; const char* what; };
     const Case cases[] = {
-        {mmScript("for (y = 0; y < 2; y = y + 1) { for (x = 0; x < 3; x = x + 1) { addLight(x, y, 0); } }"),
+        {mmScript("for (int y = 0; y < 2; y = y + 1) { for (int x = 0; x < 3; x = x + 1) { addLight(x, y, 0); } }"),
          true,  "x and y are ordinary loop counters, in EVERY role: they are the names an author "
                 "reaches for, which is why the coordinate is xPos/yPos/zPos instead"},
-        {mmScript("for (i = 0; i < width; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; i < width; i = i + 1) { addLight(i, 0, 0); }"),
          true,  "a layout may read width: same name, same meaning, whoever asks"},
         {mmScript("setRGB(width, 0, 0, 0);"),           true,  "an effect reads the layer's width"},
         {mmScript("setXYZ(width - 1 - xPos, yPos, zPos);"),
@@ -215,11 +216,11 @@ TEST_CASE("a comment is whitespace, wherever it appears") {
     const Case cases[] = {
         {mmScript("// leading comment\naddLight(1, 2, 3);"), true, "a comment before the code"},
         {mmScript("addLight(1, 2, 3); // trailing comment"), true, "a comment after the code"},
-        {mmScript("for (i = 0; i < 2; i = i + 1) {\n  // inside the body\n  addLight(i, 0, 0);\n}"), true,
+        {mmScript("for (int i = 0; i < 2; i = i + 1) {\n  // inside the body\n  addLight(i, 0, 0);\n}"), true,
          "a comment inside a loop body"},
         {mmScript("// @control 1..64 is just text now\naddLight(1, 2, 3);"), true,
          "the old annotation is an ordinary comment"},
-        {mmScript("byte n = 4; // anything at all !!\nfor (i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("byte n = 4; // anything at all !!\nfor (int i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"),
          true, "a comment after a member declaration"},
     };
     for (const Case& c : cases) {
@@ -240,10 +241,10 @@ TEST_CASE("a comment changes nothing about what a script does") {
     // with no code emitted, "same length" is two zeroes and proves nothing.
 #if MM_MOONLIVE_HAS_HOST_JIT
     moonlive::MoonLive bare, commented;
-    CHECK(bare.compile(mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+    CHECK(bare.compile(mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
                        moonlive::lightBuiltins(), moonlive::modifierSysVars()));
     CHECK(commented.compile(mmScript("// place three lights in a row\n"
-                            "for (i = 0; i < 3; i = i + 1) {\n"
+                            "for (int i = 0; i < 3; i = i + 1) {\n"
                             "  addLight(i, 0, 0);   // one per step\n"
                             "}"),
                             moonlive::lightBuiltins(), moonlive::modifierSysVars()));
@@ -295,7 +296,7 @@ TEST_CASE("noise is smooth across neighbouring points, and varies across the fie
     // One light per sample: light i gets the noise at x = i * 64, so the 32 lights walk 8 whole
     // cells (256 units each) and the buffer IS a real slice of the field, not a corner of one cell.
     auto r = moonlive::compileSource(
-        mmScript("for (i = 0; i < 32; i = i + 1) { setRGB(i, noise(i * 64, 0, 0), 0, 0); }"),
+        mmScript("for (int i = 0; i < 32; i = i + 1) { setRGB(i, noise(i * 64, 0, 0), 0, 0); }"),
         moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
     REQUIRE(r.ok);
     void* blk = platform::allocExec(r.len);
@@ -336,7 +337,7 @@ TEST_CASE("mod wraps a sweep, so an animation repeats instead of running off the
     uint8_t code[4096];
     auto r = moonlive::compileSource(
         mmScript("byte w = 16;\n"
-        "for (yy = 0; yy < w; yy = yy + 1) { setRGB(yy * w + mod(t, w), 255, 0, 0); }"),
+        "for (int yy = 0; yy < w; yy = yy + 1) { setRGB(yy * w + mod(t, w), 255, 0, 0); }"),
         moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
     REQUIRE(r.ok);
     void* blk = platform::allocExec(r.len);
@@ -370,10 +371,10 @@ TEST_CASE("sequential loops reuse the same register, so a script is not billed p
     // Four loops, each with a call in the body — comfortably over budget if counters accumulate.
     auto r = moonlive::compileSource(
         mmScript("byte w = 16;\n"
-        "for (a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n"
-        "for (b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n"
-        "for (c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n"
-        "for (d = 0; d < w; d = d + 1) { setRGB(d, 255, 255, 0); }"),
+        "for (int a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n"
+        "for (int b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n"
+        "for (int c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n"
+        "for (int d = 0; d < w; d = d + 1) { setRGB(d, 255, 255, 0); }"),
         moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
     if (!r.ok) INFO(r.error);
     // What this pins is REGISTER REUSE, which the front-end does on every host — but proving it
@@ -396,6 +397,45 @@ TEST_CASE("sequential loops reuse the same register, so a script is not billed p
 //
 // Read from the .md files rather than pasted here: a pasted copy stops being the documented one the
 // first time someone edits the real page.
+TEST_CASE("every compile error fits the status line whole, its position included") {
+    // A failure reaches the UI as ONE string, " @": the sentence a user reads and
+    // the position the editor marks the failing line from. MoonLiveScript formats it into a fixed
+    // buffer, so a message longer than that buffer loses its explanation, and a slightly longer one
+    // eats the offset and the line marking silently stops working. Both happened.
+    //
+    // Read from the SOURCE rather than a list kept here: a hand-kept copy would agree with the
+    // buffer while the compiler moved on, which is the drift this exists to prevent.
+    const std::filesystem::path repo =
+        std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path();
+    size_t longest = 0;
+    const char* longestText = "";
+    for (const char* rel : {"src/core/moonlive/MoonLiveCompiler.cpp",
+                            "src/core/moonlive/MoonLiveCompiler.h"}) {
+        std::ifstream in(repo / rel);
+        REQUIRE(in.good());
+        std::string line;
+        while (std::getline(in, line)) {
+            // Both shapes a diagnostic is written in: fail("...") and a kName = "..." constant.
+            for (const char* lead : {"fail(\"", "= \""}) {
+                size_t at = 0;
+                while ((at = line.find(lead, at)) != std::string::npos) {
+                    const size_t beg = at + std::strlen(lead);
+                    const size_t end = line.find('"', beg);
+                    if (end == std::string::npos) break;
+                    const size_t len = end - beg;
+                    if (len > longest) { longest = len; }
+                    at = end;
+                }
+            }
+        }
+    }
+    INFO("longest diagnostic is " << longest << " chars");
+    CHECK(longest >= 40);              // a control: a parse that found nothing would pass silently
+    // " @" + up to 5 digits + the terminator, against the buffer MoonLiveScript declares.
+    CHECK(longest + 8 <= mm::moonlive::MoonLiveScript::kMaxStatus);
+    (void)longestText;
+}
+
 TEST_CASE("every script example in the docs compiles") {
     const std::filesystem::path repo = scriptRoot().parent_path();
     const std::filesystem::path pages[] = {

From d10051bd23989311fe5640a2089fe99bb543e3fe Mon Sep 17 00:00:00 2001
From: ewowi 
Date: Tue, 1 Sep 2026 13:25:20 +0200
Subject: [PATCH 2/5] Add scripts to the module picker, and fix what that
 exposed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A MoonLive script is now something you add or replace like any other module:
one alphabetical list, scripts marked, and the card takes the script's name. To
a user there is no longer a difference between a compiled effect and a live one.

Performance: not collected (no board attached this cycle; no tick-path code changed).

**Core**
- A compile error's position is zero-based, converted once where the compiler's
  one-based column meets everyone else. `hasErrorPos()` says whether there is a
  position at all, because zero became a legal offset.
- Both are cleared in `freeCode()`, so a failure with no offset of its own (no
  control memory, codegen refused) cannot report a previous error's position.
- `POST /api/modules//replace` takes an optional `name`, the counterpart of
  `id` on create. `replacementName` decides: a requested name wins, else a custom
  one is kept, else the fresh module keeps its own type's default.

**UI**
- Scripts and compiled modules merge into one alphabetical list in both the add
  and the replace picker, with scripted and compiled filter chips leading the
  chip row. A script the device lacks is marked and downloaded before the card is
  made, so a card never points at a file that is not there.
- Picking a script names the card after it, deduplicated on collision. A replace
  renames too: a card is named after what it runs.
- Fixed: a refresh landed on Control instead of the open Layer or File Manager.
  The WebSocket full state and the /api/state fetch race, and only the fetch
  restored the saved root, so whichever arrived first decided.
- Fixed: pointing a card at a broken script left it showing "(none)" over an
  empty editor. A script that compiles changes the module's schema and gets a
  free resync; one that fails defines nothing, so only the status text arrived.
- Fixed: the editor's resize stopped at its minimum height, so it could only ever
  grow. The grip is now drawn and dragged by us, since the native one is
  unreachable under a textarea that covers the corner.
- The failing line is marked with a positioned band rather than by splicing the
  highlighter's markup, which cannot survive a token that spans lines.

**Scripts/MoonDeck**
- The JS suite's glob is expanded in Python rather than by the runtime, and the
  two suite flags are mutually exclusive.
- CI names the MoonLive C++ check as its own step, so "all 34 scripts are valid
  C++" is readable in the log instead of folded into one count.

**Tests**
- The offset contract is pinned on a multi-line script whose failure is not at a
  line start, so an off-by-one shows.
- The picker's merge, marker, download flag, ordering, chip partition and
  position-based slot lookup; the saved-root restore on both arrival paths.
- Every compile error fits the status line whole, offset included, read from the
  compiler's own messages so the two cannot drift.

**Reviews**
- 👾 A replace resolved its slot by the old name (gone) and by the requested name,
  which on a collision is a DIFFERENT card: the script landed on the wrong module.
  Now resolved by position, captured before the replace. Fixed, with a test.
- 👾 A docstring said the name is left alone while the code always renames: fixed.
- 👾 Adding failed silently after twenty name collisions: it now says so.
- 👾 The remote-download block was duplicated across both paths: now mlEnsureLocal.
- 👾 A test constant contradicted the source it was checking: fixed.
- 👾 A hand-rolled first-flag loop and a dead child-key branch: both removed.
- 👾 The editor registry's sweep is status-gated, so a module that never reports
  one keeps inert entries: documented rather than changed, they go with the module.
- 👾 Duplicate `struct Case` in a test: skipped, the two are in different functions
  with different members and the file compiles clean.
- 👾 Prism was marked MIT without carrying the license: full notice added.

Skipped this cycle: the ESP32 firmware build and the Improv smoke test (no board
attached, at the product owner's direction); the GCC build (it runs on a failing
CI run, and CI is green); device-model catalog and firmware list (untriggered).

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .github/workflows/test.yml                    |  12 +-
 moondeck/test/test_host.py                    |  23 +-
 src/core/HttpServerModule.cpp                 |  31 +-
 src/core/HttpServerModule.h                   |   5 +
 src/core/moonlive/MoonLive.cpp                |  17 +-
 src/core/moonlive/MoonLive.h                  |   5 +
 src/light/moonlive/MoonLiveScript.h           |   2 +-
 src/ui/app.js                                 | 359 +++++++++++++++---
 src/ui/style.css                              |  24 +-
 src/ui/vendor/prism.js                        |  24 +-
 test/js/ui-picker-scripts.test.mjs            | 171 +++++++++
 test/js/ui-selected-root.test.mjs             |  84 ++++
 .../unit/core/unit_HttpServerModule_apply.cpp |  21 +
 test/unit/core/unit_moonlive_compiler.cpp     |  35 ++
 14 files changed, 750 insertions(+), 63 deletions(-)
 create mode 100644 test/js/ui-picker-scripts.test.mjs
 create mode 100644 test/js/ui-selected-root.test.mjs

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3f3d3627..cdf6b74d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -54,8 +54,18 @@ jobs:
       # against Python-Markdown's real toc slugify. `wled` is the frenck/python-wled
       # library HA's WLED integration uses; test_wled_json_shape.py parses the
       # device /json vector through its Device.from_dict to pin the wire contract.
+      # The MoonLive scripts compile as C++ FIRST, and by name: it is the one suite whose subject is
+      # the shipped content rather than the tooling, so "all 34 scripts are valid C++" should be
+      # readable in the log instead of folded into a single count. Named here rather than split into
+      # its own step so the compiler still runs exactly once.
+      - name: every MoonLive script is valid C++
+        run: uv run --with pytest pytest test/python/test_scripts_are_cpp.py -v
+      # The rest, quietly. This directory is excluded because the step above already ran it: a bare
+      # `pytest test/python` here would compile all 34 scripts a second time.
       - name: pytest
-        run: uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q
+        run: >
+          uv run --with pytest --with pyserial --with markdown --with wled
+          pytest test/python -q --ignore=test/python/test_scripts_are_cpp.py
 
   js:
     runs-on: ubuntu-latest
diff --git a/moondeck/test/test_host.py b/moondeck/test/test_host.py
index 53906b3a..735cfbb1 100644
--- a/moondeck/test/test_host.py
+++ b/moondeck/test/test_host.py
@@ -30,14 +30,17 @@
 
 def run(cmd, label):
     print(f"\n=== {label} ===", flush=True)
-    r = subprocess.run(cmd, cwd=ROOT)
+    r = subprocess.run(cmd, cwd=ROOT, check=False)   # the caller collects the code; a raise would skip the other suite
     return r.returncode
 
 
 def main() -> int:
     ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
-    ap.add_argument("--python", action="store_true", help="run only the Python suite")
-    ap.add_argument("--js", action="store_true", help="run only the JS suite")
+    # Mutually exclusive: each flag means "ONLY this suite", so passing both asks for two
+    # contradictory things. Neither flag still means both suites, which is the common case.
+    only = ap.add_mutually_exclusive_group()
+    only.add_argument("--python", action="store_true", help="run only the Python suite")
+    only.add_argument("--js", action="store_true", help="run only the JS suite")
     args = ap.parse_args()
     both = not (args.python or args.js)
 
@@ -56,11 +59,15 @@ def main() -> int:
         if shutil.which("node") is None:
             print("\n=== JS (test/js) ===\nSKIP: node is not on PATH", flush=True)
         else:
-            # NODE expands this pattern, not a shell: the call takes a list and no shell=True, so
-            # the literal `**` reaches node, which globs it itself (node 22+). The gate and CI spell
-            # the same command inside a shell, where the shell expands it first; both reach the same
-            # files, by two different mechanisms.
-            rc |= run(["node", "--test", "test/js/**/*.test.mjs"], "JS (test/js)")
+            # Expanded HERE, not by node and not by a shell. The call passes a list with no
+            # shell=True, so a literal `test/js/**/*.test.mjs` would reach node and depend on its
+            # own glob support, which older runtimes lack. Resolving the paths in Python makes the
+            # command work on any node, and names the files it ran.
+            tests = sorted(str(f.relative_to(ROOT)) for f in (ROOT / "test/js").rglob("*.test.mjs"))
+            if not tests:
+                print("\n=== JS (test/js) ===\nSKIP: no test files found", flush=True)
+            else:
+                rc |= run(["node", "--test", *tests], "JS (test/js)")
 
     print("\nDONE" if rc == 0 else "\nFAILED", flush=True)
     return rc
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index a99531d4..4df5bd1b 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -2108,6 +2108,22 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c
     sendResponse(conn, 200, "application/json", "{\"ok\":true}");
 }
 
+/// What a replaced module should be called: the requested name, the old one, or neither.
+///
+/// Three cases, in order. A name the CALLER asked for wins: it knows what the slot now holds, and a
+/// card swapped to a different script must not stay labeled after the old one. Otherwise a CUSTOM
+/// name is kept, so a scenario id or a name a user chose survives a type swap. Otherwise null, and
+/// the fresh module keeps the default name its own type gave it: a Multiply replaced by a
+/// Checkerboard reads as "Checkerboard", not as a mislabeled "Multiply".
+///
+/// Returns null for "leave it alone", never an empty string, so a caller cannot blank a name.
+const char* HttpServerModule::replacementName(const char* requested, const char* current,
+                                              const char* oldDefault) {
+    if (requested && requested[0] != 0) return requested;
+    if (current && oldDefault && std::strcmp(current, oldDefault) != 0) return current;
+    return nullptr;
+}
+
 void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const char* moduleName, const char* body) {
     auto* mod = findModuleByName(moduleName);
     if (!mod) {
@@ -2133,6 +2149,12 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const
         sendResponse(conn, 400, "application/json", "{\"error\":\"missing type\"}");
         return;
     }
+    // An optional name for the replacement, the counterpart of `id` on create. Without it a replace
+    // keeps whatever the slot was called, which is right when the type is the only thing changing
+    // and wrong when the caller knows what the slot now holds: swapping a card to a different
+    // MoonLive script leaves it labeled after the old one.
+    char wantName[32] = {};
+    mm::json::parseString(body, "name", wantName, sizeof(wantName));
 
     // Find the child's index within the parent.
     uint8_t index = 0;
@@ -2167,13 +2189,12 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const
     // the old name was just the old type's factory display name ("Multiply" for
     // a MultiplyModifier), let the fresh module keep its own factory name
     // ("Checkerboard"): otherwise a Multiply→Checkerboard replace leaves a
-    // Checkerboard mislabelled "Multiply". `fresh` already arrives with its
+    // Checkerboard mislabeled "Multiply". `fresh` already arrives with its
     // correct default name from ModuleFactory::create, so we only override for a
     // custom name; then re-run uniqueness so two same-type siblings don't collide.
-    const char* oldDefault = ModuleFactory::displayNameFor(mod->typeName(), mod->role());
-    if (std::strcmp(mod->name(), oldDefault) != 0) {
-        fresh->setName(mod->name());  // custom name: preserve the slot identity
-    }
+    const char* keep = replacementName(wantName, mod->name(),
+                                       ModuleFactory::displayNameFor(mod->typeName(), mod->role()));
+    if (keep) fresh->setName(keep);
 
     // Swap in place; replaceChildAt returns the old module, which we own.
     MoonModule* old = parent->replaceChildAt(index, fresh);
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index 7e7e7efd..bf4559e1 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -258,6 +258,11 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
     /// "content-length:"; the case-sensitive strstr it replaces silently read a length of 0 and
     /// committed EMPTY files with a 200). Public + static so it's unit-testable without a socket.
     static const char* findHeaderCI(const char* hay, const char* needle);
+    /// What a replaced module should be called: requested name, else a custom one, else null
+    /// ("keep the fresh module's own default"). Static and public so the rule is unit-testable:
+    /// it decides what a card is labeled after a swap, which is not something to discover in the UI.
+    static const char* replacementName(const char* requested, const char* current,
+                                       const char* oldDefault);
 
     /// Apply a WLED `{on?, bri?}` state body onto the Drivers `on` / `brightness` controls through
     /// the shared apply-core (`on` and `bri` independent — off preserves the level). The transport-
diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp
index 98015e62..d36fa3be 100644
--- a/src/core/moonlive/MoonLive.cpp
+++ b/src/core/moonlive/MoonLive.cpp
@@ -17,6 +17,12 @@ void MoonLive::freeCode() {
     fn_ = nullptr;
     anim_ = nullptr;
     ctrl_ = nullptr;
+    // Likewise the error position. Only a PARSE failure has one, and it is set by the caller right
+    // after this returns; the later failures (no control memory, codegen refused) have no offset of
+    // their own, and without this they would report the position of whatever failed last. An editor
+    // would then mark a line that has nothing to do with the error on screen.
+    errorPos_ = 0;
+    hasErrorPos_ = false;
     // The entry table describes code that no longer exists. Left behind, entry() would hand a
     // binding an address into a freed block: the same stale-state trap the control arena has.
     entryCount_ = 0;
@@ -102,7 +108,16 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysV
                                      nullptr, nullptr, strings_, CompileResult::kStringPool);
     // The diagnostic AND where it happened: an editor can only mark the line if it is told one,
     // and the parser has already computed the offset (Parser::fail records lex.col()).
-    if (!cr.ok) { freeCode(); error_ = cr.error; errorPos_ = cr.errorCol; return false; }
+    // errorCol is ONE-based (Lexer::col() adds 1), and every consumer counts from zero: the editor
+    // slices the source up to this offset to find the line. Converted here, at the boundary between
+    // the compiler's convention and everyone else's, rather than in each reader.
+    if (!cr.ok) {
+        freeCode();
+        error_ = cr.error;
+        errorPos_ = cr.errorCol > 0 ? static_cast(cr.errorCol - 1) : 0;
+        hasErrorPos_ = true;
+        return false;
+    }
     // Allocate the control arena (fixed address) and seed new slots, BEFORE publishing the control
     // set — ensureArena reads the previous controlCount_ to know which slots are new.
     // Seeded from the MEMBERS, not the controls: a member the UI never shows still has an
diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h
index 89349661..e5113fa5 100644
--- a/src/core/moonlive/MoonLive.h
+++ b/src/core/moonlive/MoonLive.h
@@ -91,6 +91,10 @@ class MoonLive {
     /// The editor turns it into a line to mark; the parser already knows it, and throwing it
     /// away meant a user was told what was wrong but never where.
     uint16_t errorPos() const { return errorPos_; }
+    /// Whether errorPos() means anything. Zero is a VALID offset (a failure on the first character),
+    /// so the value cannot also stand for "no position": only a parse failure has one, while the
+    /// later failures (no control memory, codegen refused) do not.
+    bool     hasErrorPos() const { return hasErrorPos_; }
 
     // The hot path: run the compiled routine over the host's buffer. `t` is the host's
     // elapsed() ms; a static routine ignores it, an animated one derives its color from
@@ -361,6 +365,7 @@ class MoonLive {
     CtrlFn  ctrl_ = nullptr;     // front-end-compiled routine (5-arg, reads the controls arena)
     const char* error_ = "";
     uint16_t    errorPos_ = 0;
+    bool        hasErrorPos_ = false;
 
     // The functions the script defined, with their offsets into `code_`, and their names owned here
     // for the same reason the control names are: a CompileResult's `name` points into source text
diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h
index 9838d124..d633c421 100644
--- a/src/light/moonlive/MoonLiveScript.h
+++ b/src/light/moonlive/MoonLiveScript.h
@@ -97,7 +97,7 @@ class MoonLiveScript {
             // One string because status IS the channel a module reports through, and a second
             // control for the number would be a field every non-scripted module carries for nothing.
             // The suffix is machine-read, so it stays a fixed shape rather than a sentence.
-            if (engine_.errorPos() > 0) {
+            if (engine_.hasErrorPos()) {
                 std::snprintf(statusBuf_, sizeof(statusBuf_), "%s @%u",
                               err ? err : "compile failed",
                               static_cast(engine_.errorPos()));
diff --git a/src/ui/app.js b/src/ui/app.js
index 8fa41031..f87e7bbe 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -212,6 +212,7 @@ function connectWs() {
             // already stored above, so updateValues() below still shows fresh values; the structural
             // render happens on the next full state once the interaction ends.
             if (userIsEditing()) { updateValues(); preview.setTargetFps(previewTargetFps(state)); return; }
+            restoreSelectedRoot();   // before the render that reads it: this may be the FIRST state
             renderCards();     // a full state may add/remove/reshape cards (structural resync): full render
             // The nav is built from the same tree, so a structural change (a module added or removed)
             // must rebuild it too, or the sidebar keeps entries the state no longer has. AFTER
@@ -343,13 +344,11 @@ async function init() {
             const snap = await resp.json();
             if (snap && Array.isArray(snap.modules)) {
                 state = snap;
-                const savedSel = lsRead(LS_SELECTED, null);
-                if (state.modules.length > 0) {
-                    const exists = savedSel && state.modules.some(m => m.name === savedSel);
-                    // Default to the first root AS LISTED, not as scheduled: otherwise a
-                    // device with no saved selection opens on a card that is not the one the
-                    // nav highlights at the top.
-                    selectedModule = exists ? savedSel : navRoots(state.modules)[0].name;
+                restoreSelectedRoot();
+                // Default to the first root AS LISTED, not as scheduled: otherwise a device with no
+                // saved selection opens on a card that is not the one the nav highlights at the top.
+                if (!selectedModule && state.modules.length > 0) {
+                    selectedModule = navRoots(state.modules)[0].name;
                 }
                 renderNav();
                 renderCards();
@@ -615,10 +614,17 @@ async function listSetField(moduleName, ctrlName, id, field, value) {
     }
 }
 
-async function addModule(type, parentName) {
-    if (!type) return;
+/// Create a module, optionally under a chosen name, and return the name it got.
+///
+/// `id` names the new module. The endpoint treats it as IDEMPOTENT (a module of that name already
+/// there is success, not a rename), and answers that case without a `name`: so a caller that wants
+/// a fresh module reads the absence of a name as "taken" and asks again with another. Returns null
+/// when nothing was created, for either reason.
+async function addModule(type, parentName, id) {
+    if (!type) return null;
     const body = {type: type};
     if (parentName) body.parent_id = parentName;
+    if (id) body.id = id;
     let name = null;
     try {
         const r = await fetch("/api/modules", {
@@ -626,8 +632,11 @@ async function addModule(type, parentName) {
             headers: {"Content-Type": "application/json"},
             body: JSON.stringify(body)
         });
-        name = (await r.json()).name;   // the created module's final name (post-disambiguation)
+        name = (await r.json()).name || null;   // the created module's final name; absent if taken
     } catch {}
+    // A name already in use: the caller decides whether to retry, and re-fetching state here would
+    // cost a round trip per attempt.
+    if (id && !name) return null;
     // Select the new module's tab BEFORE the re-render so renderCards shows it active (the tab strip
     // reads selectedTabs[parent]); then scroll it into view and focus its first control so a keyboard
     // user lands on it. Without this the view stays on the previously-active tab and the new module
@@ -638,6 +647,7 @@ async function addModule(type, parentName) {
     }
     await refetchState();
     if (name) focusModule(name);
+    return name;
 }
 
 // Bring a module's card into view and focus its first control (added via the "+" flow).
@@ -675,14 +685,24 @@ async function moveModuleTo(name, toIndex) {
 
 // swap a module for another type at the same position. The replacement starts
 // with its own default control values: a clean swap, not a value carry-over.
-async function replaceModule(name, newType) {
+/// Swap a module for another type, optionally renaming it.
+///
+/// `newName` is for a caller that knows what the slot now holds: replacing one script with another
+/// leaves a card labeled after the old script unless the name travels with it. Omitted, the device
+/// keeps a custom name and refreshes a default one, which is what a plain type swap wants.
+async function replaceModule(name, newType, newName) {
     if (!newType) return;
+    const body = {type: newType};
+    if (newName) body.name = newName;
     await fetch("/api/modules/" + encodeURIComponent(name) + "/replace", {
         method: "POST",
         headers: {"Content-Type": "application/json"},
-        body: JSON.stringify({type: newType})
+        body: JSON.stringify(body)
     });
-    refetchState();
+    // AWAITED, because a caller may address the slot straight afterwards: the replace can rename it
+    // (a module still carrying its old type's default name takes the new type's), and only the
+    // refreshed state says what it is called now.
+    await refetchState();
 }
 
 async function rebootDevice() {
@@ -869,6 +889,37 @@ function selectModule(name) {
     closeNavDrawer();
 }
 
+/// The module holding `name` as a child, or null at the top level.
+///
+/// Used after a replace to resolve a slot whose name was disambiguated: the parent bounds the
+/// search to the siblings the swap happened among.
+/// Restore the root the user was last on, once the tree is known.
+///
+/// Called from BOTH state arrivals, because either can be first: the WebSocket full state and the
+/// /api/state fetch race on load, and only the fetch used to consult this. When the socket won, the
+/// selection stayed null and renderCards fell back to the first root, so a refresh landed on Control
+/// instead of the Layer or File Manager the user left open. Intermittent exactly as a race is, and
+/// self-correcting after any nav click, which sets the selection in memory.
+///
+/// A saved name that is no longer in the tree is ignored, leaving the fallback to pick.
+function restoreSelectedRoot() {
+    if (selectedModule) return;                       // an explicit choice this session wins
+    if (!state || !Array.isArray(state.modules) || !state.modules.length) return;
+    const saved = lsRead(LS_SELECTED, null);
+    if (saved && state.modules.some(m => m.name === saved)) selectedModule = saved;
+}
+
+function findParentOf(name, modules) {
+    if (!modules) modules = state.modules;
+    for (const m of modules) {
+        const kids = m.children || [];
+        if (kids.some(k => k.name === name)) return m;
+        const found = findParentOf(name, kids);
+        if (found) return found;
+    }
+    return null;
+}
+
 function findModule(name, modules) {
     if (!modules) modules = state.modules;
     for (const m of modules) {
@@ -1405,6 +1456,11 @@ function mlEditorAdd(name, ed) {
 
 /// The editors on a module that are still on the page.
 ///
+/// The sweep runs from setStatusText, which a card only calls when it HAS a status: a module that
+/// never reports one leaves its detached editors in the map. They are inert (no timers, no DOM
+/// work: painting is driven by the editor's own events), so this is housekeeping rather than a
+/// leak, and the entries go when the module does.
+///
 /// renderCards rebuilds every card by clearing its host, which orphans an inline editor without
 /// ever calling dispose: registering on create with no counterpart leaked one editor per re-render,
 /// and each leaked one kept re-running the highlighter on detached DOM every time a status arrived.
@@ -1429,12 +1485,10 @@ function setStatusText(valEl, mod) {
     // Every editor on this module marks the line; each returns the same rewritten text, since they
     // hold the same file. Keeping the first answer rather than the last says that plainly: the
     // string does not depend on which editor supplied it.
+    // Every editor on this module marks the line and returns the SAME rewritten text, since they
+    // hold the same file: so mark them all, and take any one answer.
     let text = mod.status;
-    let first = true;
-    for (const ed of mlLiveEditors(mod.name)) {
-        const rewritten = ed.markError(mod.status);
-        if (first) { text = rewritten; first = false; }
-    }
+    for (const ed of mlLiveEditors(mod.name)) text = ed.markError(mod.status);
     // setText, not a bare assignment: this runs on every state push, and rewriting an unchanged
     // node throws away a selection the user may be holding on it.
     setText(valEl, text);
@@ -2696,6 +2750,14 @@ function createControl(moduleName, moduleType, ctrl) {
                 },
             });
             mlEditorAdd(moduleName, editor);
+            // Apply the status the card is ALREADY showing: registration only catches the next one,
+            // so a card rendered while its script is broken would report the error with no line
+            // marked until something recompiled. The modal does the same on open.
+            {
+                const row = document.querySelector(
+                    `[data-status-mid="${cssEscape(moduleName)}"] .status-value`);
+                if (row) editor.markError(row.textContent);
+            }
 
             // Re-read after the modal closes: it edits the same file through the same endpoints, so
             // whatever it saved is what this pane should now show.
@@ -4649,6 +4711,12 @@ function graphemes(s) {
 
 // All emoji for a type: role first, then dimensional (effects only), then each
 // curated tag emoji from tags(). Deduplicated, order preserved.
+// A script is marked; a compiled module is not. Two chips in the picker filter on this, and the
+// compiled one matches by ABSENCE, so adding it costs no tag on ninety existing modules.
+const SCRIPTED_EMOJI = "\u{1F4DD}";      // 📝 the module runs a MoonLive script
+// Not the gear: that is already the `generic` role's emoji, and reusing it drew the chip twice.
+const COMPILED_EMOJI = "\u{1F4E6}";      // 📦 chip only: never a tag any module carries
+
 function emojiTagsFor(t) {
     const out = [];
     const seen = new Set();
@@ -4664,34 +4732,183 @@ function emojiTagsFor(t) {
 //  - replace: pick a type to swap parentMod for, at the same position.
 // They differ only in the role filter and the commit action; the search box,
 // list, and keyboard nav are shared.
+/// The MoonLive module type that runs a script of this role.
+///
+/// Role, not extension: the picker knows what it is offering, and the catalog is already grouped
+/// the same way. Null for a role with no scripted form.
+function mlTypeForRole(role) {
+    return role === "effect" ? "MoonLiveEffect"
+         : role === "layout" ? "MoonLiveLayout"
+         : role === "modifier" ? "MoonLiveModifier" : null;
+}
+
+/// Every shipped script, as picker rows, for the roles a parent accepts.
+///
+/// One row per script rather than one "MoonLive" row: to a user, `dot` is a thing to add exactly
+/// as `DemoReel` is, and which of the two is compiled is a property, not a category. The scripted
+/// marker rides on the row so the merged list still says which is which, and `script` carries the
+/// file the row would load.
+async function mlScriptItems(roles) {
+    const cat = await mlFetchCatalog().catch(() => null);
+    if (!cat) return [];                       // no catalog: the picker still offers every type
+    // What the device already holds, so a row can say when picking it costs a download. One listing
+    // for every role: the scripts share a directory.
+    const local = new Set((await fmFetchDir(cat.dir).catch(() => [])).map(e => e.name));
+    const out = [];
+    for (const role of roles) {
+        if (!mlTypeForRole(role)) continue;
+        const g = cat[role + "s"] || {};       // "effect" -> catalog group "effects"
+        (g.names || []).forEach((n, i) => {
+            const tags = (g.tags && g.tags[i]) || "";
+            const isRemote = !local.has(n);
+            out.push({
+                name: n,
+                remote: isRemote,
+                // Without the extension: it is the file's business, not the reader's, and it keeps
+                // the row sorting next to the compiled modules rather than in a block of ".mle".
+                displayName: (isRemote ? "\u2601 " : "") + n.replace(/\.ml[elm]$/i, ""),
+                role,
+                // The scripted marker is what makes the row's kind visible, so it is added rather
+                // than assumed: a script whose own tags happen to omit it still reads correctly.
+                tags: tags.includes(SCRIPTED_EMOJI) ? tags : SCRIPTED_EMOJI + tags,
+                dim: (g.dim && g.dim[i]) || 0,
+                script: n,
+            });
+        });
+    }
+    return out;
+}
+
+/// Make sure a picked script is ON the device, downloading it if it is only in the catalog.
+///
+/// Creating or replacing a module before the file exists leaves a card reporting "script not found",
+/// so this runs first on both paths. Returns false when the download failed and the caller must not
+/// proceed; it has already told the user why.
+async function mlEnsureLocal(item) {
+    if (!item.remote) return true;
+    try {
+        await mlDownloadScript(item.script, item.role + "s");
+        return true;
+    } catch (e) {
+        alert("could not download " + item.script + ": " + (e && e.message ? e.message : e));
+        return false;
+    }
+}
+
+/// Add a module for a picked row, whether it is a compiled type or a script.
+///
+/// A script becomes a MoonLive module holding it, named after the script: the user picked `dot`, so
+/// the card says `dot`. `id` is how POST /api/modules names a module, and it is deliberately
+/// idempotent (an existing name is success, not a rename), so a collision is retried with a suffix
+/// rather than silently landing on the module already there.
+/// Point a card at a script, and make sure the card catches up.
+///
+/// The re-render is the point. A card is built BEFORE its script is set (created or replaced first,
+/// pointed at the file second), so its picker reads "(none)" and its editor is blank until something
+/// rebuilds it. A script that COMPILES hides this: defining controls changes the module's schema,
+/// which fires a full resync. A script that FAILS defines nothing, the schema signature is unchanged,
+/// and only the status text arrives, leaving a card that contradicts itself: an error about a script
+/// it claims not to have, correct again after a manual refresh.
+async function setCardScript(moduleName, script) {
+    await sendControl(moduleName, "script", script);
+    await refetchState();
+}
+
+async function addPickedType(item, parentName) {
+    if (!item.script) return addModule(item.name, parentName);
+    const type = mlTypeForRole(item.role);
+    if (!type) return;
+    if (!await mlEnsureLocal(item)) return;
+    // The cloud marker is a property of the row, not of the name the card takes.
+    const base = item.displayName.replace(/^\u2601\s*/, "");
+    for (let n = 1; n <= 20; n++) {
+        const id = n === 1 ? base : `${base}-${n}`;
+        const created = await addModule(type, parentName, id);
+        if (created) {
+            await setCardScript(created, item.script);
+            return created;
+        }
+    }
+    // Twenty siblings of one name, all taken. Unlikely, but silence would leave the user clicking
+    // create and watching nothing happen, which is the one outcome worse than an error.
+    alert(`could not add ${base}: twenty modules of that name already exist here`);
+}
+
 function openTypePicker(parentMod, anchorEl) {
     const roles = rolesAcceptedBy(parentMod);
     // One candidate = no choice to make, so don't stage a picker to ask a question with one answer:
     // "+" on Effects just adds a Layer. (Same filter openPicker uses, so the two can't disagree about
-    // what the candidates are.)
+    // what the candidates are.) Counted over the TYPES alone: the scripts arrive asynchronously, and
+    // a role with one type but many scripts is still a real choice, which the await below sees.
     const candidates = availableTypes.filter(t => roles.includes(t.role));
-    if (candidates.length === 1) {
-        addModule(candidates[0].name, parentMod.name);
-        return;
-    }
-    openPicker(anchorEl, {
-        roles,
-        actionLabel: "create",
-        commit: (type) => addModule(type, parentMod.name)
+    mlScriptItems(roles).then((scripts) => {
+        if (candidates.length === 1 && !scripts.length) {
+            addModule(candidates[0].name, parentMod.name);
+            return;
+        }
+        openPicker(anchorEl, {
+            items: [...candidates, ...scripts],
+            actionLabel: "create",
+            commit: (name, item) => addPickedType(item, parentMod.name),
+        });
     });
 }
 
 // Replace mode: filter to the target module's own role (effect ↔ effect), and
 // pre-select the module's CURRENT type so the cursor lands on it (not the first row).
 function openReplacePicker(targetMod, anchorEl) {
-    openPicker(anchorEl, {
-        roles: [targetMod.role],
-        actionLabel: "replace",
-        currentType: targetMod.type,
-        commit: (type) => replaceModule(targetMod.name, type)
+    const roles = [targetMod.role];
+    // Scripts here for the same reason they are in the add picker: swapping an effect for a script
+    // is the same question as adding one, and a list that offers scripts in one place and not the
+    // other makes the distinction visible again exactly where it should not be.
+    mlScriptItems(roles).then((scripts) => {
+        openPicker(anchorEl, {
+            items: [...availableTypes.filter(t => roles.includes(t.role)), ...scripts],
+            actionLabel: "replace",
+            currentType: targetMod.type,
+            commit: (name, item) => replacePickedType(targetMod, item),
+        });
     });
 }
 
+/// Replace a module with a picked row, whether it is a compiled type or a script.
+///
+/// A script replaces in place: the module becomes the MoonLive type for its role and then loads the
+/// file, so the slot keeps its position in the layer. It is also RENAMED, like the add path: a card
+/// is named after what it runs, so swapping what it runs renames it.
+async function replacePickedType(targetMod, item) {
+    if (!item) return;
+    // A replace ALWAYS renames: a card is named after what it runs, so swapping what it runs renames
+    // it. Simple and predictable, and it is why the device's replace takes a name at all: left to
+    // itself it preserves any name that is not the old type's default, which kept a card auto-named
+    // "MoonLive-3" labeled that after it became a Lissajous.
+    const picked = item.displayName.replace(/^\u2601\s*/, "");
+    if (!item.script) return replaceModule(targetMod.name, item.name, picked);
+    const type = mlTypeForRole(item.role);
+    if (!type) return;
+    if (!await mlEnsureLocal(item)) return;
+    // Named after the script, the same as adding one: a card running dot.mle is called `dot` however
+    // it got there, unless the user named it something of their own. The device disambiguates a
+    // collision, so the name asked for is not always the name given.
+    // WHERE the slot sits, captured BEFORE the replace. Afterwards the module answers to its new
+    // name and the old one is gone, so nothing can be found by it: the position is the only handle
+    // that survives. Looking the parent up afterwards found nothing, and the name asked for could
+    // match a DIFFERENT card when it was already taken, which sent the script to the wrong module.
+    const parent = findParentOf(targetMod.name);
+    const index = parent ? (parent.children || []).findIndex(k => k.name === targetMod.name) : -1;
+    const parentName = parent ? parent.name : null;
+
+    await replaceModule(targetMod.name, type, picked);
+
+    // The slot at that position, in the refreshed state. A replace swaps a child IN PLACE, so the
+    // index is stable and the parent keeps its own name; the device may have suffixed the name it
+    // was given ("dot-2") to keep it unique, which is exactly what the position answers.
+    const after = parentName ? findModule(parentName) : null;
+    const atIndex = after && index >= 0 && after.children ? after.children[index] : null;
+    const slot = atIndex ? atIndex.name : (findModule(picked) ? picked : null);
+    if (slot) await setCardScript(slot, item.script);
+}
+
 function openPicker(anchorEl, opts) {
     // Close any existing picker
     // Its MODAL, not just the inner block: removing only the picker would leave an open
@@ -4708,8 +4925,11 @@ function openPicker(anchorEl, opts) {
     // name so the list is scannable regardless of registration order (localeCompare:
     // case-insensitive, locale-aware).
     const source = opts.items || availableTypes.filter(t => opts.roles.includes(t.role));
-    const filtered = [...source]
-        .sort((a, b) => (a.displayName || a.name).localeCompare(b.displayName || b.name));
+    // Sorted on the NAME, not on any marker in front of it: a row prefixed with the cloud glyph is
+    // still that script alphabetically, and prefixed rows would otherwise collect in a block of
+    // their own instead of sitting where the reader looks for them.
+    const sortKey = (t) => (t.displayName || t.name).replace(/^[^\p{L}\p{N}]+/u, "");
+    const filtered = [...source].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
 
     const picker = document.createElement("div");
     picker.className = "type-picker";
@@ -4739,12 +4959,18 @@ function openPicker(anchorEl, opts) {
             if (!chipSeen.has(ch)) { chipSeen.add(ch); present.push(ch); }
         }
     }
+    // The compiled chip is not a tag anything carries, so it cannot be discovered from the rows the
+    // way every other chip is: it is offered when the list actually holds both kinds, which is the
+    // only situation where filtering by kind means anything.
+    if (chipSeen.has(SCRIPTED_EMOJI) && filtered.some(t => !emojiTagsFor(t).includes(SCRIPTED_EMOJI))) {
+        present.push(COMPILED_EMOJI);
+    }
     // Grouped rather than in first-seen order, so the row reads as the legend does: the scripted
     // marker, then what a module IS (role, then dimension), then where it came from, then what it
     // can do. A chip whose category is unknown falls in the last group rather than vanishing, so a
     // new emoji is visible before anyone remembers to classify it.
     const CHIP_GROUPS = [
-        ["\u{1F4DD}"],                                   // MoonLive: scripted, first
+        [SCRIPTED_EMOJI, COMPILED_EMOJI],                // what a row IS: scripted or compiled
         Object.values(ROLE_EMOJI),                       // type
         Object.values(DIM_EMOJI),                        // dimension
         ["\u{1F4AB}", "\u{1F319}", "\u{1F419}", "\u26A1\uFE0F"],   // origin
@@ -4797,6 +5023,7 @@ function openPicker(anchorEl, opts) {
     picker.appendChild(actions);
 
     let selectedType = null;
+    let selectedItem = null;      // the row itself: commit needs more than its name
 
     // Types matching the search box AND all active emoji chips. The query matches
     // against both the raw typeName ("RainbowEffect") and the displayName
@@ -4811,7 +5038,12 @@ function openPicker(anchorEl, opts) {
             }
             if (activeChips.size > 0) {
                 const has = new Set(emojiTagsFor(t));
-                for (const chip of activeChips) if (!has.has(chip)) return false;
+                for (const chip of activeChips) {
+                    // The compiled chip matches what carries NO scripted marker, since a compiled
+                    // module has no tag of its own: the two kind chips are each other's opposite.
+                    const ok = chip === COMPILED_EMOJI ? !has.has(SCRIPTED_EMOJI) : has.has(chip);
+                    if (!ok) return false;
+                }
             }
             return true;
         });
@@ -4843,15 +5075,17 @@ function openPicker(anchorEl, opts) {
                 list.querySelectorAll(".selected").forEach(x => x.classList.remove("selected"));
                 item.classList.add("selected");
                 selectedType = t.name;
+                selectedItem = t;
                 createBtn.disabled = false;
             });
             item.addEventListener("dblclick", () => {
-                opts.commit(t.name);
+                opts.commit(t.name, t);
                 closePicker();
             });
             list.appendChild(item);
         });
-        selectedType = matches.length > 0 ? matches[selIdx].name : null;
+        selectedItem = matches.length > 0 ? matches[selIdx] : null;
+        selectedType = selectedItem ? selectedItem.name : null;
         createBtn.disabled = !selectedType;
         // Scroll the pre-selected row into view (it may be below the fold for a long list).
         const selEl = list.querySelector(".type-picker-item.selected");
@@ -4868,19 +5102,21 @@ function openPicker(anchorEl, opts) {
             if (idx < items.length - 1) {
                 sel?.classList.remove("selected");
                 items[idx + 1].classList.add("selected");
-                selectedType = filteredAt(idx + 1)?.name;
+                selectedItem = filteredAt(idx + 1);
+                selectedType = selectedItem?.name;
             }
         } else if (e.key === "ArrowUp") {
             e.preventDefault();
             if (idx > 0) {
                 sel?.classList.remove("selected");
                 items[idx - 1].classList.add("selected");
-                selectedType = filteredAt(idx - 1)?.name;
+                selectedItem = filteredAt(idx - 1);
+                selectedType = selectedItem?.name;
             }
         } else if (e.key === "Enter") {
             e.preventDefault();
             if (selectedType) {
-                opts.commit(selectedType);
+                opts.commit(selectedType, selectedItem);
                 closePicker();
             }
         } else if (e.key === "Escape") {
@@ -4894,7 +5130,7 @@ function openPicker(anchorEl, opts) {
 
     createBtn.addEventListener("click", () => {
         if (selectedType) {
-            opts.commit(selectedType);
+            opts.commit(selectedType, selectedItem);
             closePicker();
         }
     });
@@ -6191,6 +6427,7 @@ function fmMountEditor(host, relPath, opts = {}) {
         '
' + '' + '' + + '
' + '
' + '
' + (statusEl ? '' : ' ') + @@ -6279,6 +6516,34 @@ function fmMountEditor(host, relPath, opts = {}) { }); taObserver.observe(stack); + // Resize by dragging the grip. + // + // Ours rather than the browser's `resize`, which cannot work here: the textarea is positioned + // over the whole stack including the corner the native gesture starts in, so the press lands on + // the text and the drag never begins. The handle is a real element above both layers, and + // setPointerCapture keeps the drag alive once the pointer leaves those few pixels. + const grip = wrap.querySelector(".fm-editor-grip"); + let dragFrom = 0, dragH = 0; + grip.addEventListener("pointerdown", (e) => { + dragFrom = e.clientY; + dragH = stack.getBoundingClientRect().height; + grip.setPointerCapture(e.pointerId); + e.preventDefault(); // no text selection while dragging + }); + grip.addEventListener("pointermove", (e) => { + if (!dragFrom) return; + // No floor here: the stack's own min-height clamps it, so one rule owns the minimum. + stack.style.height = `${dragH + (e.clientY - dragFrom)}px`; + }); + const endDrag = (e) => { + if (!dragFrom) return; + dragFrom = 0; + if (grip.hasPointerCapture(e.pointerId)) grip.releasePointerCapture(e.pointerId); + // The ResizeObserver above persists the new height; nothing to store here. + }; + grip.addEventListener("pointerup", endDrag); + grip.addEventListener("pointercancel", endDrag); + // Blur, Cmd+S and the Save button all call save(), and a blur fires when the button takes // focus: so without this guard one edit issues overlapping POSTs of the same file. `dirty` // cannot serve as the guard: it is only cleared after the await, so a second trigger passes @@ -6341,7 +6606,15 @@ function fmMountEditor(host, relPath, opts = {}) { loadAbort = ac; path = p; setDirty(false); - if (!path) { body.value = ""; body.readOnly = true; saveBtn.disabled = true; status.textContent = ""; return; } + if (!path) { + body.value = ""; body.readOnly = true; saveBtn.disabled = true; status.textContent = ""; + // The mark and the caret readout belong to text that is no longer there: left alone, an + // empty pane keeps a red band over nothing and a stale Ln/Col. + errorLine = -1; + paintHighlight(); + showCaret(); + return; + } const r = await fmLoadInto(body, path, size, ac.signal); if (r.aborted || ac !== loadAbort) return; // a newer load started: that one owns the pane body.readOnly = r.readOnly; diff --git a/src/ui/style.css b/src/ui/style.css index c73bfd5f..a5db8322 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1758,8 +1758,23 @@ body.cards-resizing { bottom landed on the stack instead of the text: the editor stopped responding partway down. */ .fm-editor-stack { position: relative; display: flex; flex: 1 1 auto; - min-height: 240px; overflow: hidden; resize: vertical; + min-height: 120px; overflow: hidden; +} +/* The resize handle, ours rather than the browser's `resize`. The native gesture cannot work here: + the textarea is positioned over the whole stack, corner included, so the press that would start + the drag lands on the text instead. This is a real element above both layers, so it both draws + the familiar corner strokes and receives the pointer. */ +.fm-editor-grip { + position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; + z-index: 3; cursor: ns-resize; touch-action: none; + background: + linear-gradient(135deg, transparent 42%, var(--fg-muted) 42%, var(--fg-muted) 56%, transparent 56%), + linear-gradient(135deg, transparent 70%, var(--fg-muted) 70%, var(--fg-muted) 84%, transparent 84%); + background-size: 12px 12px; background-position: right 3px bottom 3px; + background-repeat: no-repeat; + opacity: 0.75; } +.fm-editor-grip:hover { opacity: 1; } .fm-editor-body, .fm-editor-hl { margin: 0; padding: 12px 14px; border: none; font-family: ui-monospace, monospace; font-size: 0.85rem; line-height: 1.5; @@ -1847,8 +1862,11 @@ body.cards-resizing { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--bg-1); } -/* Shorter than the modal's: a card is one of several on screen, and the grip resizes it. */ -.control-fileedit .fm-editor-body { min-height: 140px; } +/* The starting height, which the grip then overrides per editor (stored in mm_textareaSizes). On + the STACK, not the textarea: the stack owns the height, so a floor on the textarea did nothing. + A card is one of several on screen, so it starts shorter than the modal. */ +.fm-editor-stack { height: 240px; } +.control-fileedit .fm-editor-stack { height: 200px; } .control-fileedit .fm-editor-foot { padding: 6px 10px; } /* Picker row ABOVE the editor, as one column. A .control-row is a flex row, so the two have to be diff --git a/src/ui/vendor/prism.js b/src/ui/vendor/prism.js index ba3f424e..138264c3 100644 --- a/src/ui/vendor/prism.js +++ b/src/ui/vendor/prism.js @@ -1,5 +1,27 @@ // Prism 1.29.0, vendored: core + clike + c + cpp, the minimum that highlights a MoonLive // script. Vendored rather than fetched from a CDN because a rig at a venue is on an isolated // network: the editor must look the same there as at a desk. 12 KB against app.js's 333 KB. -// Upstream: https://prismjs.com (MIT). Regenerate by concatenating those four components. +// Upstream: https://prismjs.com Regenerate by concatenating those four components. +// +// Prism is redistributed here under its own MIT license, reproduced in full because the code below +// is a copy of that work rather than a reference to it: +// +// MIT LICENSE +// +// Copyright (c) 2012 Lea Verou +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/};Prism.languages.c=Prism.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),Prism.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),Prism.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},Prism.languages.c.string],char:Prism.languages.c.char,comment:Prism.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:Prism.languages.c}}}}),Prism.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete Prism.languages.c.boolean;!function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n="\\b(?!)\\w+(?:\\s*\\.\\s*\\w+)*\\b".replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp("(\\b(?:class|concept|enum|struct|typename)\\s+)(?!)\\w+".replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp('(\\b(?:import|module)\\s+)(?:"(?:\\\\(?:\r\n|[^])|[^"\\\\\r\n])*"|<[^<>\r\n]*>|'+"(?:\\s*:\\s*)?|:\\s*".replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(Prism); \ No newline at end of file diff --git a/test/js/ui-picker-scripts.test.mjs b/test/js/ui-picker-scripts.test.mjs new file mode 100644 index 00000000..df0c52d3 --- /dev/null +++ b/test/js/ui-picker-scripts.test.mjs @@ -0,0 +1,171 @@ +// The module picker offers scripts and compiled modules as one list. +// +// To a user, adding `dot` and adding `DemoReel` are the same gesture: one is a MoonLive script and +// the other is compiled, which is a property of the row rather than a category of its own. So the +// picker merges them, sorts them together, and marks the scripted ones. These tests pin the three +// things that make that readable: the marker, the ordering, and the two filter chips. +// +// Read out of app.js rather than imported: the file is a browser script with no module boundary, +// and the alternative (a DOM harness driving the real picker) proved far slower to steer than the +// logic is to check directly. +// +// Run: `node --test test/js`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const src = readFileSync(new URL("../../src/ui/app.js", import.meta.url), "utf8"); + +/// A top-level function's source, by brace matching, `async` prefix included. +function fnSource(name) { + const at = src.indexOf(`function ${name}(`); + assert.notEqual(at, -1, `${name} not found in app.js`); + const from = src.startsWith("async ", at - 6) ? at - 6 : at; + const open = src.indexOf("{", at); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(from, i + 1); + } + assert.fail(`unbalanced braces in ${name}`); +} + +const SCRIPTED = "\u{1F4DD}"; +const COMPILED = "\u{1F4E6}"; // matches the source: NOT the gear, which is the generic role + +const CATALOG = { + dir: "/.moonlive", + effects: { names: ["balls.mle", "aim.mle"], tags: ["\u{1F4AB}", "\u{1F4AB}"], dim: [2, 2] }, + layouts: { names: ["grid.mll"], tags: [""], dim: [2] }, + modifiers: { names: [], tags: [], dim: [] }, +}; + +/// mlScriptItems, wired to a fixed catalog and a device holding only balls.mle. +function scriptItems(roles) { + const build = new Function( + "SCRIPTED_EMOJI", "COMPILED_EMOJI", "mlFetchCatalog", "fmFetchDir", + `${fnSource("mlTypeForRole")}\n${fnSource("mlScriptItems")}\nreturn mlScriptItems;`); + return build(SCRIPTED, COMPILED, + async () => CATALOG, + async () => [{ name: "balls.mle" }])(roles); +} + +test("a script becomes a picker row named after the file, without its extension", async () => { + const items = await scriptItems(["effect"]); + assert.deepEqual(items.map(i => i.script).sort(), ["aim.mle", "balls.mle"]); + const balls = items.find(i => i.script === "balls.mle"); + // The name a card would take, and the file it would load: the user picks "balls", not "balls.mle". + assert.equal(balls.displayName, "balls"); + assert.equal(balls.role, "effect"); +}); + +test("every script row carries the scripted marker, whatever its own tags say", async () => { + const items = await scriptItems(["effect"]); + for (const i of items) assert.ok(i.tags.includes(SCRIPTED), `${i.script} is unmarked`); +}); + +test("a script the device does not hold is marked as a download before it is chosen", async () => { + const items = await scriptItems(["effect"]); + const aim = items.find(i => i.script === "aim.mle"); // absent from the device + const balls = items.find(i => i.script === "balls.mle"); // present + assert.equal(aim.remote, true); + assert.equal(balls.remote, false); + assert.ok(aim.displayName.startsWith("☁"), "a remote row says so before it is picked"); +}); + +test("only the roles a parent accepts are offered", async () => { + const effects = await scriptItems(["effect"]); + assert.ok(effects.every(i => i.role === "effect")); + // A role with no scripted form contributes nothing rather than throwing. + assert.deepEqual(await scriptItems(["driver"]), []); +}); + +test("scripts and compiled modules sort as one alphabetical list, markers ignored", async () => { + // The picker's own sort key, which strips a leading marker so a prefixed row still sorts by its + // name: without this the cloud rows collect in a block instead of sitting where they are looked for. + const sortKey = (t) => (t.displayName || t.name).replace(/^[^\p{L}\p{N}]+/u, ""); + const compiled = [ + { name: "DemoReel", displayName: "DemoReel", role: "effect", tags: "" }, + { name: "SolidEffect", displayName: "Solid", role: "effect", tags: "" }, + ]; + const merged = [...compiled, ...(await scriptItems(["effect"]))] + .sort((a, b) => sortKey(a).localeCompare(sortKey(b))); + assert.deepEqual(merged.map(m => sortKey(m)), ["aim", "balls", "DemoReel", "Solid"]); +}); + +test("the two kind chips partition the list, the compiled one matching by absence", async () => { + const compiled = [{ name: "DemoReel", displayName: "DemoReel", role: "effect", tags: "" }]; + const merged = [...compiled, ...(await scriptItems(["effect"]))]; + // A compiled module carries no marker of its own, so its chip matches what LACKS the scripted + // one. That is what keeps ninety existing modules from needing a new tag. + const isScripted = (m) => (m.tags || "").includes(SCRIPTED); + const scripted = merged.filter(isScripted); + const notScripted = merged.filter(m => !isScripted(m)); + assert.equal(scripted.length, 2); + assert.equal(notScripted.length, 1); + assert.equal(scripted.length + notScripted.length, merged.length, "the chips partition the list"); +}); + +test("the picker's chip groups lead with the two kinds", () => { + // Order is the legend's: what a row IS, then role, then dimension, then origin. A reader scanning + // the chip row should meet scripted/compiled first, because that is the coarsest split. + const groups = src.slice(src.indexOf("const CHIP_GROUPS = [")); + const firstGroup = groups.slice(0, groups.indexOf("]", groups.indexOf("[", 20)) + 1); + assert.ok(firstGroup.includes("SCRIPTED_EMOJI") && firstGroup.includes("COMPILED_EMOJI"), + "the first chip group is the scripted/compiled pair"); +}); + +test("a replace always renames the card, whichever kind of row was picked", () => { + // A card is named after what it RUNS, so swapping what it runs renames it. Both branches of + // replacePickedType pass a name: the compiled one was the bug (a card auto-named "MoonLive-3" + // stayed "MoonLive-3" after becoming a Lissajous), because the device preserves any name that is + // not the old type's default and cannot tell a generated name from a chosen one. + const body = fnSource("replacePickedType"); + const compiled = body.slice(body.indexOf("if (!item.script)")); + assert.match(compiled.slice(0, 120), /replaceModule\([^)]*picked\)/, + "the compiled branch must pass the picked name"); + assert.match(body.slice(body.indexOf("mlTypeForRole")), /replaceModule\([^)]*picked\)/, + "the script branch must pass the picked name"); + // And the name is the row's, with the cloud marker stripped: that glyph says a download is + // coming, it is not part of what the card is called. + assert.match(body, /displayName\.replace\(\/\^\\u2601/, + "the cloud marker is stripped before the name is used"); +}); + +test("pointing a card at a script re-renders it, so a failing script still shows its source", () => { + // A card is built BEFORE its script is set: created or replaced first, pointed at the file + // second. Something has to rebuild it, or the picker keeps reading "(none)" over an empty editor. + // + // A script that COMPILES hides the gap, which is why this needs pinning: defining controls + // changes the module's schema, the device fires a full resync, and the card is rebuilt for free. + // A script that FAILS defines nothing, the schema signature is unchanged, so only the status text + // arrives and the card ends up reporting an error about a script it claims not to have. Worse, the + // editor holds no text, so the error's offset converts against nothing and reads "line 1, col 1". + const body = fnSource("setCardScript"); + assert.match(body, /sendControl\([^)]*"script"/, "it sets the script control"); + assert.match(body, /refetchState\(\)/, "and re-renders, which is the whole point"); + assert.ok(body.indexOf("sendControl") < body.indexOf("refetchState"), + "the re-render must come after the value it is meant to show"); + + // Both entry points go through it: a fix in one path only is the bug waiting to come back. + assert.match(fnSource("addPickedType"), /setCardScript\(/, "the add path uses it"); + assert.match(fnSource("replacePickedType"), /setCardScript\(/, "the replace path uses it"); +}); + +test("a replaced card is found by POSITION, so a name collision still reaches the right module", () => { + // The device keeps names unique, so replacing a card with a script whose name is already taken + // in that layer gives it a suffix ("dot-2"). Everything after the replace therefore has to find + // the slot WITHOUT its old name, which no longer exists, and without assuming it got the name it + // asked for: looking it up by the requested name matched the OTHER card and sent the script + // there, and looking the parent up by the old name found nothing at all, so the script was never + // applied. A replace swaps a child in place, so its index is the one handle that survives. + const body = fnSource("replacePickedType"); + const replaceAt = body.indexOf("await replaceModule("); + assert.ok(body.indexOf("findParentOf(targetMod.name)") < replaceAt, + "the parent must be captured BEFORE the replace, while the old name still resolves"); + assert.ok(body.indexOf("findIndex(") < replaceAt, + "and so must the index, for the same reason"); + assert.match(body.slice(replaceAt), /children\[index\]/, + "afterwards the slot is read at that index, not by a name that may not be its own"); +}); diff --git a/test/js/ui-selected-root.test.mjs b/test/js/ui-selected-root.test.mjs new file mode 100644 index 00000000..8d604396 --- /dev/null +++ b/test/js/ui-selected-root.test.mjs @@ -0,0 +1,84 @@ +// The root a user was last on survives a refresh, whichever state arrives first. +// +// Two payloads race on load: the WebSocket full state and the /api/state fetch. Both set `state` and +// both trigger the first render, so either can be the one that decides what is on screen. Only the +// fetch used to consult the saved selection, so when the socket won, the selection stayed null and +// renderCards fell back to the first root: a refresh with a Layer or the File Manager open landed on +// Control instead. Intermittent exactly as a race is, and it healed after any nav click, which sets +// the selection in memory and hides the bug for the rest of the session. +// +// So this pins the rule at its source: both arrival paths restore before the render that reads it. +// +// Run: `node --test test/js`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const src = readFileSync(new URL("../../src/ui/app.js", import.meta.url), "utf8"); + +/// A top-level function's source, by brace matching. +function fnSource(name) { + const at = src.indexOf(`function ${name}(`); + assert.notEqual(at, -1, `${name} not found in app.js`); + const open = src.indexOf("{", at); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1); + } + assert.fail(`unbalanced braces in ${name}`); +} + +/// restoreSelectedRoot, given a tree and what localStorage holds. +function restore({ modules, saved, already = null }) { + const build = new Function("state", "lsRead", "LS_SELECTED", "selectedModuleIn", ` + let selectedModule = selectedModuleIn; + ${fnSource("restoreSelectedRoot")} + restoreSelectedRoot(); + return selectedModule;`); + return build({ modules }, () => saved, "mm_selectedRoot", already); +} + +const TREE = [{ name: "Control" }, { name: "Layouts" }, { name: "File Manager" }, { name: "System" }]; + +test("the saved root is restored, so a refresh returns to where the user was", () => { + assert.equal(restore({ modules: TREE, saved: "File Manager" }), "File Manager"); + assert.equal(restore({ modules: TREE, saved: "Layouts" }), "Layouts"); +}); + +test("a choice made this session is not overwritten by what was saved earlier", () => { + // The user clicked System a moment ago; a later full state must not send them back. + assert.equal(restore({ modules: TREE, saved: "File Manager", already: "System" }), "System"); +}); + +test("a saved root that is no longer in the tree is ignored, leaving the fallback to choose", () => { + // A module deleted since the last visit, or a config from another device. + assert.equal(restore({ modules: TREE, saved: "Gone" }), null); + assert.equal(restore({ modules: TREE, saved: null }), null); +}); + +test("an empty or absent tree leaves the selection alone rather than guessing", () => { + assert.equal(restore({ modules: [], saved: "File Manager" }), null); +}); + +test("both state arrivals restore the selection before the render that reads it", () => { + // The actual bug: the WebSocket handler set `state` and rendered without consulting the saved + // root, so whichever payload won the race decided whether the user's tab survived. Checking the + // call sites, because a correct restoreSelectedRoot that only one path calls is the bug itself. + const wsAt = src.indexOf("if (!Array.isArray(data.modules)) return;"); + assert.notEqual(wsAt, -1, "the websocket full-state handler moved"); + // To the RENDER, not to the first setTargetFps: the mid-edit early-return above it mentions the + // same call, and slicing there cut the block off before the lines under test. + const wsBlock = src.slice(wsAt, src.indexOf("renderNav()", wsAt)); + const restoreAt = wsBlock.indexOf("restoreSelectedRoot()"); + const renderAt = wsBlock.indexOf("renderCards()"); + assert.notEqual(restoreAt, -1, "the websocket path must restore the saved root"); + assert.ok(restoreAt < renderAt, "restore must run BEFORE the render that reads the selection"); + + // And the fetch path, which is the one that always had it. + const httpAt = src.indexOf("const snap = await resp.json();"); + assert.notEqual(httpAt, -1, "the /api/state fetch moved"); + const httpBlock = src.slice(httpAt, src.indexOf("renderCards()", httpAt)); + assert.ok(httpBlock.includes("restoreSelectedRoot()"), "the fetch path must restore too"); +}); diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 7e9318d8..84619dfc 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -588,6 +588,27 @@ TEST_CASE("a burst of file writes costs one re-derive, not one per file") { CHECK(root->prepared == before + 1); } +TEST_CASE("a replaced module is named by the caller, then by its old custom name, then by its type") { + using H = mm::HttpServerModule; + + // A caller that knows what the slot now holds names it. This is what keeps a card swapped to a + // different MoonLive script from staying labeled after the old script: the UI asks for the new + // script's name, and it wins over both defaults. + CHECK(std::string(H::replacementName("dot", "balls", "MoonLive")) == "dot"); + CHECK(std::string(H::replacementName("dot", "MoonLive", "MoonLive")) == "dot"); + + // No request: a name the user or a scenario chose survives a type swap, so the slot keeps its + // identity and callers can still address it. + CHECK(std::string(H::replacementName(nullptr, "MOD", "Multiply")) == "MOD"); + CHECK(std::string(H::replacementName("", "MOD", "Multiply")) == "MOD"); + + // No request and no custom name: null means "leave it", so the fresh module keeps the default + // its OWN type gave it. Without this a Multiply replaced by a Checkerboard would read as a + // mislabeled "Multiply". + CHECK(H::replacementName(nullptr, "Multiply", "Multiply") == nullptr); + CHECK(H::replacementName("", "Multiply", "Multiply") == nullptr); +} + TEST_CASE("a file write with no scheduler is a no-op, not a crash") { // HttpServerModule is constructed before it is wired, and the Improv path builds one without a // tree at all. Degrade visibly, never crash (the robustness rule). diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 0363b1b3..ad5014a0 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -445,6 +445,41 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") { // The emitted loop tests and advances its OWN counter whatever name the condition and step clauses // write, so a mistyped name used to compile clean and run as though it said the right thing — a // wrong fixture with no diagnostic anywhere. Found by review. +TEST_CASE("a compile error reports the exact offset the editor turns into a line and column") { + uint8_t out[512]; + // The offset is the ONLY position anyone has: the editor slices the source up to it to find the + // failing line, and marks that line in the paint layer. It is therefore ZERO-based, counted in + // characters from the start of the whole source, while Lexer::col() is one-based; the conversion + // happens where the two conventions meet. An off-by-one here marks the wrong line whenever a + // failure lands on the first character of one. + const char* src = + "class T {\n" // line 1, offsets 0..9 + " byte b = 1;\n" // line 2, offsets 10..24 + " void tick() { fill(0, 0, 0; }\n" // line 3: the missing ')' is here + "}\n"; + auto r = moonlive::compileSource(src, kTable, kSys, out, sizeof(out)); + REQUIRE_FALSE(r.ok); + CHECK(std::string(r.error) == "expected ')'"); + + // What the light domain publishes, and what the editor reads back. + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(src, kTable, kSys)); + REQUIRE(eng.hasErrorPos()); + const size_t at = eng.errorPos(); + REQUIRE(at < std::strlen(src)); + + // The line and column a reader counts, derived the way the editor derives them. + const std::string upto(src, at); + const size_t line = std::count(upto.begin(), upto.end(), '\n') + 1; + const size_t nl = upto.rfind('\n'); + const size_t col = upto.size() - (nl == std::string::npos ? 0 : nl + 1) + 1; + INFO("reported offset " << at << " -> line " << line << ", col " << col); + CHECK(line == 3); + // The character AT the offset is where the parser stopped, on the line it belongs to. + CHECK(src[at] != '\n'); + CHECK(col > 1); +} + TEST_CASE("a for loop declares its counter, as every other variable in the language does") { uint8_t out[512]; // The rule the language already held everywhere else: a member carries its type and an From 9edc4762b36f92b5de33e7293f5bebfdf59a1195 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 1 Sep 2026 13:40:06 +0200 Subject: [PATCH 3/5] Include where the apply test uses it Fixes the three sanitizer jobs, which failed to compile. No behavior change. **Tests** - The replacement-name test introduced the first std::string in this file, and GCC does not supply transitively where clang does. Verified against CI's own toolchain (build_desktop.py --gcc --tests, g++-16): 1630 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- test/unit/core/unit_HttpServerModule_apply.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 84619dfc..13a69db3 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -8,6 +8,7 @@ #include "core/JsonSink.h" #include +#include // std::string: named explicitly, GCC does not pull it in transitively // Pins the transport-free apply-core that HttpServerModule exposes — applyAddModule // / applySetControl / applyClearChildren / applyOp. These are the operations the From fdfbe228c37a3313ff98f488d51973cb960bb586 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 1 Sep 2026 13:43:19 +0200 Subject: [PATCH 4/5] Run the tests when the code they compile changes The sanitizer jobs build the whole C++ suite, but the workflow only triggered on Python, JS and MoonLive paths. A C++ change could break them with no run at all, and the fix could not prove itself either. **Docs/CI** - src/**, test/unit/** and CMakeLists.txt join the trigger paths, so what runs matches what those jobs compile. Found when a one-line include fix for the failing sanitizer jobs pushed without starting a run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cdf6b74d..0cf47f8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,12 @@ on: # the "this is a subset of C++" claim needs re-checking. - 'moonlive/**' - 'src/light/moonlive/MoonLiveBuiltins_light.h' + # The sanitizer jobs below compile the WHOLE C++ suite, so they have to run when any of it + # changes. Without these, a C++ change could break them with no run at all, and the fix could + # not prove itself either: exactly what happened to a missing in a unit test. + - 'src/**' + - 'test/unit/**' + - 'CMakeLists.txt' - '.github/workflows/test.yml' push: branches: From cae200c42831583722071f01119623d1e77d2b8a Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 1 Sep 2026 13:53:50 +0200 Subject: [PATCH 5/5] Mark the failing line once the editor has the text to mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card built while its script is broken now shows the marked line straight away, on the right line. The test that guards the error position can finally catch the defect it was written for. **UI** - The initial error mark moved into the editor's load, and the status it should apply is passed in rather than read back from the DOM. Marking at construction could not work: createCard builds a DETACHED card, so the status row it looked for was not in the document, and it ran before the file arrived, so every offset resolved to line 1. - markError is a named function rather than a member of the returned object, so the load path and the API share one implementation. **Tests** - The offset test asserts the EXACT column and the character at it. `col > 1` was satisfied by an off-by-one in the one-based to zero-based conversion, so the test passed while the bug it exists for was live. Verified by reintroducing that off-by-one: the old assertion stayed green, the new one fails. **Reviews** - 🐇 The offset test could not detect the defect it was added for: fixed, and the fix proved against a deliberately reintroduced off-by-one. The suggested column literal was 30; the fixture reports 29, so the number came from the code. - 🐇 The initial mark queried a detached DOM and ran before the file loaded: both fixed by moving it into the editor. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/app.js | 58 +++++++++++++++-------- test/unit/core/unit_moonlive_compiler.cpp | 8 ++-- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/ui/app.js b/src/ui/app.js index f87e7bbe..266ad3ae 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2729,6 +2729,11 @@ function createControl(moduleName, moduleType, ctrl) { // module recompiles or reloads on its own. The browser sends nothing extra. const editor = fmMountEditor(pane, pathOf(ctrl.value), { sizeKey: key, + // The status this module is ALREADY reporting, so a card built while its script is + // broken shows the marked line straight away rather than waiting for a recompile. + // The EDITOR applies it once the file has loaded: marking at construction would + // convert the offset against an empty textarea and put every error on line 1. + initialStatus: (findModule(moduleName) || {}).status || "", saveButton: saveBtn, statusEl, // Editing a factory script FORKS it: the read came from the library directory, but @@ -6410,7 +6415,8 @@ function fmMountEditor(host, relPath, opts = {}) { // it: a factory script is read from the read-only library directory, and editing it must create // the user's own copy rather than overwrite what shipped. Defaults to writing back where it // read, which is what every other caller wants. - const { expectedSize, onSaved, onDispose, sizeKey, saveButton, statusEl, savePath } = opts; + const { expectedSize, onSaved, onDispose, sizeKey, saveButton, statusEl, savePath, + initialStatus } = opts; const wrap = document.createElement("div"); wrap.className = "fm-editor-pane"; // The footer carries Save and the status line, UNLESS the host supplies both: a card already has @@ -6479,6 +6485,31 @@ function fmMountEditor(host, relPath, opts = {}) { /// Move the paint under the text by TRANSFORM rather than by scrolling it: a scrollTop the /// element cannot reach (it has overflow:hidden) is silently clamped, which is what left the /// last lines of a file unreachable. + /// Mark the line a compile failed on, and return the status with a readable position. + /// + /// Converts the device's offset against the text this editor HOLDS, so it must not run before + /// the file has loaded: every position would resolve to line 1. + const markErrorAt = (statusText) => { + errorLine = -1; + let shown = statusText || ""; + // Two forms, because this is called with both: the raw device status "message @" on + // every update, and an ALREADY rewritten "message (line L, col C)" when a second editor + // opens on a module whose status was converted before it existed. + const at = /@(\d+)\s*$/.exec(shown); + const lc = /\(line (\d+), col \d+\)\s*$/.exec(shown); + if (at) { + const p = lineColAt(Number(at[1])); + errorLine = p.line - 1; + // The offset is replaced, not appended to: line and column is the only half a person can + // act on, and the editor marks the line anyway. + shown = shown.slice(0, at.index).trimEnd() + ` (line ${p.line}, col ${p.col})`; + } else if (lc) { + errorLine = Number(lc[1]) - 1; + } + paintHighlight(); + return shown; + }; + const syncHighlightScroll = () => { // The band follows VERTICALLY only: it spans the full width, so a horizontal shift would // just walk it off the box while the line it marks stays put. @@ -6622,6 +6653,10 @@ function fmMountEditor(host, relPath, opts = {}) { paintHighlight(); // the file just arrived: paint what it says showCaret(); status.textContent = r.message; + // A compile error the module was ALREADY reporting when this editor was built. Marked here + // rather than at construction, because markError converts an offset against the text: run + // before the file arrives and every position resolves to line 1. + if (initialStatus) { markErrorAt(initialStatus); initialStatus = null; } }; load(path, expectedSize); @@ -6640,26 +6675,7 @@ function fmMountEditor(host, relPath, opts = {}) { /// the only position anyone has: the parser records it and nothing else can reconstruct it. /// Passing "" or a status with no @ clears the mark, so a fixed script stops being flagged /// the moment it compiles. - markError: (statusText) => { - errorLine = -1; - let shown = statusText || ""; - // Two forms, because this is called with both: the raw device status "message @" - // on every update, and an ALREADY rewritten "message (line L, col C)" when a second - // editor opens on a module whose status was converted before it existed. - const at = /@(\d+)\s*$/.exec(shown); - const lc = /\(line (\d+), col \d+\)\s*$/.exec(shown); - if (at) { - const p = lineColAt(Number(at[1])); - errorLine = p.line - 1; - // The offset is replaced, not appended to: line and column is the only half a person - // can act on, and the editor marks the line anyway. - shown = shown.slice(0, at.index).trimEnd() + ` (line ${p.line}, col ${p.col})`; - } else if (lc) { - errorLine = Number(lc[1]) - 1; - } - paintHighlight(); - return shown; - }, + markError: markErrorAt, dispose: () => { taObserver.disconnect(); wrap.remove(); if (onDispose) onDispose(); }, }; } diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index ad5014a0..14a4f38a 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -474,10 +474,12 @@ TEST_CASE("a compile error reports the exact offset the editor turns into a line const size_t nl = upto.rfind('\n'); const size_t col = upto.size() - (nl == std::string::npos ? 0 : nl + 1) + 1; INFO("reported offset " << at << " -> line " << line << ", col " << col); + // The EXACT position, not merely somewhere on the right line: an off-by-one in the one-based + // to zero-based conversion keeps the line and still lands inside it, so a loose assertion + // passes while the editor marks a character that is not the one the parser choked on. CHECK(line == 3); - // The character AT the offset is where the parser stopped, on the line it belongs to. - CHECK(src[at] != '\n'); - CHECK(col > 1); + CHECK(col == 29); + CHECK(src[at] == ';'); // the ';' written where a ')' belongs } TEST_CASE("a for loop declares its counter, as every other variable in the language does") {