RELEASE
planning/2.8.5/plans/U7_STRIP_DEVCONSOLE_PLAN.md
sha256 1a5544e9e6e54af9 · 20813 bytes ·
original held in the private archive
# U7 — Strip the Dev-Only Diagnostic Shell from the Production Build
**Stage:** Protocol 8 stage 1 (Opus — diagnose + plan). **PLAN ONLY. No code, no commit.**
**Branch:** `dev`, clean at `6381771`. **Snapshot as of 2026-07-17** — code always wins where this drifts.
**Unit:** Health-batch U7 (the bundle-size headline: stop shipping the 204 KB dev console to prod).
---
## 1. The one-paragraph summary (owner, on a phone)
The Diagnostic Shell (`js/dev/test-console.js`, **204 KB**) is a dev/debug tool that ships to **every** production visitor even though they can never open it (the unlock minigame isn't built until 2.9.0). It's the single biggest served JS file — **~13% of the boot JavaScript** — pure dead weight, worst on mobile. The fix: **cut it out of the production build only**, at deploy time, while dev and staging keep it fully working. The repo source tree is left almost entirely untouched, so **all existing tests still pass** (they read the source tree, which still has the file). The only source change is a **one-line safety guard** so the app doesn't trip over the missing file on prod. Net: prod boot JS drops by ~204 KB with zero behavior change for users.
---
## 2. Every reference to the Diagnostic Shell (the full map)
I grepped the whole repo. Here is every place the shell is named or wired, grouped by what the plan does to it.
### 2a. The THREE hard-consistency references (must move together or PWA/offline breaks)
These three describe the **same served file** and must always agree. Break one and the service-worker precache (which is all-or-nothing) fails → prod install fails → black screen. This is the SW-precache-mismatch trap.
| # | File / line | Reference | Plan action |
|---|---|---|---|
| 1 | `js/dev/test-console.js` (the file, 209,316 bytes) | the shell itself | **Removed from the prod artifact** (`_site/`) at build time |
| 2 | `index.html:4736` | `<script src="js/dev/test-console.js"></script>` | **Stripped from the prod `index.html`** at build time |
| 3 | `sw.js:62` | `'./js/dev/test-console.js',` (precache entry) | **Stripped from the prod `sw.js`** ASSETS list at build time |
### 2b. The graceful-absence reference (the ONE repo source change)
| # | File / line | Reference | Plan action |
|---|---|---|---|
| 4 | `js/ui/ui-core.js:1412` | `initTestConsole();` — the `window.onload` caller | **Wrap in a `typeof` guard** in the repo (see §4). This is the only source edit; it is safe on all environments. |
### 2c. Optional secondary strip — the shell-only stylesheet (~29 KB more)
The shell has a dedicated stylesheet that is also dead on prod. Stripping it is the **same three-point pattern** as the JS and doubles down on the saving. Recommended but separable — the owner can ship JS-only first.
| # | File / line | Reference | Plan action (optional) |
|---|---|---|---|
| 5 | `css/20-diagnostic-shell.css` (29,262 bytes) | shell-only styles | Removed from prod `_site/` at build time |
| 6 | `index.html:36` | `<link rel="stylesheet" href="css/20-diagnostic-shell.css" />` | Stripped from prod `index.html` |
| 7 | `sw.js:20` | `'./css/20-diagnostic-shell.css',` | Stripped from prod `sw.js` ASSETS |
> ⚠ Do **not** touch `css/15-overseer.css` — that's the Director Uplink presence (Protocol UI-10), a real prod feature, not the shell.
### 2d. References that STAY as-is (repo source untouched → tests keep passing)
Because the strip happens at **build time only**, the repo keeps all of these, and every test that asserts their presence keeps passing:
- **`index.html`** inert markup: `<template id="testConsoleTemplate">`, `#testConsoleMount`, the OCR/visual-parse test inputs, and ~6 explanatory comments (lines 191–211, 272–274, 421–423, 500–502, 4729–4730, 4833–4835). Inert on prod already (never cloned into the DOM unless the shell mounts). Left in place — surgically removing HTML is risky and saves little; the win is the 204 KB JS.
- **`js/ui/ui-core.js:1674`** `_isStagingEnv()` — the env gate the shell reuses. Stays (also drives the changelog viewer).
- **`js/core/state.js:96,110`** MetaStore keys `robco_dsh_fab_pos`, `robco_dsh_minigame_unlocked` (owner `test-console.js`). Stay — harmless device prefs with no reader on prod; `robco_dsh_minigame_unlocked` is the 2.9.0 unlock seam.
- **`js/core/runtime.js:244,257`** `forceState()` + registry snapshot (consumed by the shell). Stay — unused on prod, no cost.
- **`js/services/cloud.js:174`** `_featureFlags` (shell exercises fallback). Stays.
- **`js/ui/ui-core-chassis.js`, `js/ui/ui-core-cmd.js`** — comment-only references. Stay.
- **`eslint.config.mjs:196`** `initTestConsole: 'readonly'` global. Stays (the file is still linted in the repo; the guard in §4 uses `typeof`, which this keeps clean).
- **`repomix.config.json`** — includes `js/**` by glob; the file stays in the repo, so **no change needed**.
- **`scripts/check-boot-chain.js:280`** references `20-diagnostic-shell.css` in a list; reads the repo, unchanged.
- **`scripts/cf-staging-build.mjs`** — the Cloudflare staging build copies the whole `js/` dir and does **not** strip → **staging keeps the shell**. No change required (I recommend adding one comment noting the deliberate asymmetry, so a future reader doesn't "helpfully" mirror the prod strip into staging).
### 2e. Tests that assert the shell is present (all read the repo tree → all keep passing)
Every one of these reads the **source tree**, which still contains the file, tag, and precache entry. Build-time strip means **none of them need changing** and none go red:
- **`tests/robco-diagnostics.js` Suite 149** — 149.1 (file exists on disk), 149.2 (`sw.js` precaches it), 149.3 (`index.html` static tag ordered after `ui-core.js`, before `api.js`), 149.5 (`window.onload` calls `initTestConsole()` after `initAmbientRuntime()`), 149.6 (gates on `_devConsoleUnlocked()` first), 149.9 (save-boundary).
- **Suite 161** (replay hatch), **Suite 205** (OCR wiring + sw precache + repomix), **Suite 206** (visual-parse wiring) — all read repo source.
- **`tests/test.html:180`** loads `../js/dev/test-console.js` and asserts `initTestConsole()` fail-safes to hidden. Runtime mirror; reads repo tree; unchanged.
- **Docs** (`CLAUDE.md`, `ARCHITECTURE.md`, `README.md`, `QUEUE.md`, `RULES.md`) name `js/dev/test-console.js`; Protocol 45 / Suite 220 checks the file exists on disk — it does, so **220 stays green**.
**Consequence: the whole 235-suite / 3383-test gate passes with zero test edits for the strip itself.** New tests are added only for the guard + the strip-consistency check (§4, §7).
---
## 3. The mechanism — chosen, with the alternatives it beats
**Chosen: build-time strip inside the EXISTING production deploy job (`deploy.yml`), plus a one-line feature-detect guard in `ui-core.js`.**
The production build is a single GitHub Actions step — `deploy.yml` → **"Stage app files"** — which copies the served files into `_site/` and publishes to GitHub Pages. It is `workflow_call`-only, invoked exclusively by `release.yml` on a version release (Protocol 43). That step is the one and only place prod bytes are assembled, so it is the correct, Protocol-22-compliant hook (no parallel build invented). Cloudflare staging uses a **separate** build (`cf-staging-build.mjs`) that keeps the shell — the dev/prod asymmetry falls out naturally.
**Why not the alternatives:**
- **Load-time gating (inject the `<script>` dynamically only when `_isStagingEnv()`):** does **not** save payload. The file would still sit in `_site/` and still be pulled by the SW precache on install — prod users download 204 KB they never run. To actually save the bytes you must remove it from the served set anyway, so this adds complexity for no benefit. Rejected.
- **A build-time env marker the app reads at runtime:** same problem — the file still ships. Rejected.
- **Deleting the file from the repo / making it dev-branch-only:** breaks Suites 149/161/205/206 + `test.html` + Protocol 45, destroys the shell for the owner's staging use, and loses CI coverage of the shell. Rejected — this is exactly what build-time strip avoids.
**Exactly which files the build stage changes (prod `_site/` copies only — never the repo):**
1. `deploy.yml` "Stage app files" step gains a strip stage that operates on `_site/` **after** the copy:
- remove `_site/js/dev/test-console.js` (the whole `_site/js/dev/` dir, since it holds only this file);
- delete the `<script src="js/dev/test-console.js"></script>` line from `_site/index.html`;
- delete the `'./js/dev/test-console.js',` line from `_site/sw.js`;
- *(optional CSS)* remove `_site/css/20-diagnostic-shell.css` and its `<link>` line in `_site/index.html` + its `sw.js` ASSETS line;
- **self-consistency assertion** (fails the deploy if any reference survives): `grep` must find **no** `test-console` reference remaining in `_site/index.html` or `_site/sw.js`. This is the SW-precache-mismatch trap turned into a hard build gate.
2. **Recommended refinement (testability):** put the strip logic in a small committed Node script — `scripts/prod-strip-devshell.mjs` — that takes the staged dir, performs the removals, and runs the self-consistency assertion. `deploy.yml` calls the script (one line); a **new gate suite** calls the same script against a temp staged copy and asserts it removes exactly the intended references and leaves a self-consistent artifact (§7). This makes the strip a **single source of truth** (Protocol 22) that is **gate-guarded** (Protocol 36b) — a future refactor that renames the file or tag can't silently break the prod strip, because the test would go red. Inline `sed`/`rm` in the YAML is the acceptable fallback if the owner prefers no new script, but then the strip logic is only guarded by the in-build `grep` assertion, not by the gate.
**Repo source change (the only one):** `js/ui/ui-core.js:1412` — see §4.
---
## 4. The graceful-absence contract (Protocol 33 — never throw, never black-screen)
**The trap:** `ui-core.js:1412` currently calls a bare `initTestConsole();`. On a prod build where the file is stripped, `window.initTestConsole` is undefined → the bare call throws `ReferenceError` inside `window.onload` → **boot dies, black screen.** This is the single must-fix for prod safety.
**The contract — exactly one behavior at exactly one caller.** `initTestConsole` is called from **one** place (confirmed by grep: only `ui-core.js:1412` invokes it; `test-console.js:5358` defines it). The fix:
```
// before
initTestConsole();
// after
if (typeof initTestConsole === 'function') initTestConsole();
```
**Why this is safe on every environment:**
- **Prod (stripped):** `typeof` is `'undefined'` → the call is skipped → no throw, boot continues. The shell simply never exists (exactly the intended prod state).
- **Dev / staging (file present):** `typeof` is `'function'` → called normally → shell behaves as today. `initTestConsole()` itself still early-returns unless `_devConsoleUnlocked()` is true, so the existing fail-safe-to-hidden logic is untouched.
- **`typeof` on an undeclared identifier is legal JS** (never throws), and `eslint.config.mjs` already declares `initTestConsole` as a global, so lint stays clean.
**Test-assertion safety:** Suite 149.5's regex `/initAmbientRuntime\(\);[\s\S]*?initTestConsole\(\);/` still matches — the new line still contains the substring `initTestConsole();`. No test breaks; a small regression test is added to lock the guard (§7).
**No other caller needs handling.** The MetaStore keys, `forceState`, `_featureFlags`, and the inert `<template>` are all passive data/markup with no prod reader — their presence is inert, their absence is not required.
---
## 5. Verification plan (how the build proves each promise)
**(a) Prod build omits the 204 KB and still boots + works.**
- After the deploy job's strip stage, assert (in-build `grep`, fails the job on any hit): `_site/js/dev/test-console.js` absent; no `test-console.js` string in `_site/index.html` or `_site/sw.js`.
- Locally, dry-run the strip: run `scripts/prod-strip-devshell.mjs` against a copy of the served files, then serve `_site/` and load it in the browser harness (Protocol 10): assert it reaches READY, a core board renders, and a native tool (THREAT / LOOT calc or a stat edit) works — with **no console error** and specifically no `ReferenceError: initTestConsole`. Confirm `document.getElementById('testConsolePanel')` is null.
- Payload check: `js/**` boot JS before = 1,585,367 bytes; after strip = 1,585,367 − 209,316 = **1,376,051 bytes** served. With the optional CSS strip, additionally −29,262 bytes of CSS.
**(b) Staging / dev still has the shell working.**
- `cf-staging-build.mjs` is unchanged (copies whole `js/`), so `dist-staging/js/dev/test-console.js` still exists and `_isStagingEnv()` is true on `*.pages.dev` / the injected `robco-env` marker. Load the staging build (or localhost), unlock path already true → assert the shell panel mounts and a trigger fires. This is already covered behaviorally by `test.html` + Suites 149/161/205/206 against the dev tree.
**(c) Offline / PWA still works in prod (SW precache consistent).**
- The strip removes the file **and** its `sw.js` ASSETS entry in the same stage, so `cache.addAll(ASSETS)` on the stripped `sw.js` references only files that exist → install succeeds (no 404, no all-or-nothing failure). Verify by registering the stripped `sw.js` against the stripped `_site/` in the harness and asserting `install` completes and the app loads offline after the network is cut (reuse the U1 offline harness).
- The release's normal `CACHE_NAME` bump (forced anyway by the `ui-core.js` guard being a served-file change — Protocol 1) means cached prod users get the new, shorter asset set and the old `test-console.js` cache entry is purged on `activate`. No orphaned reference can be served, because a client runs entirely from one `CACHE_NAME` snapshot at a time (atomic).
**(d) CI tests exercising the shell still pass.**
- CI (`ci.yml` → `npm run gate`) runs against the **full dev tree** where the file is present. Suites 149/161/205/206 + `test.html` all pass unchanged. The strip only exists inside `deploy.yml`, which CI does not run as part of the test gate. Confirm the full 235-suite / 3383-test gate is green plus the two new tests (§7), counts synced per Protocol 2a.
**Payload before/after (headline):** boot JS **1.51 MB → ~1.31 MB** served to prod users (−209,316 bytes, ~13.2% of boot JS). With optional CSS: an additional −29,262 bytes.
---
## 6. Risk / regression notes
- **SW-precache-mismatch trap (highest risk).** If the file is removed but a `sw.js` (or `index.html`) reference survives, prod install fails all-or-nothing → black screen. **Mitigation:** the strip removes all references in one atomic stage + an in-build `grep` assertion fails the deploy before publish + (recommended) a gate test on the strip script. Three refs (file, tag, precache) must always agree — this is the count that must stay consistent.
- **Unlock-hook-throws trap.** Covered by §4's `typeof` guard. Without it, prod black-screens on boot. This is the reason the plan touches repo source at all.
- **CACHE_NAME discipline.** The release carrying U7 must bump `CACHE_NAME` (Protocol 1). It will, because the `ui-core.js` guard edits a served file. Note it explicitly in the release so a cached user actually receives the smaller build.
- **Staging-vs-prod marker interaction.** The strip is keyed to the **build path** (only `deploy.yml` strips), not to the runtime `robco-env` marker — so there is no risk of the runtime env detection and the payload strip disagreeing. Staging keeps the shell because its build (`cf-staging-build.mjs`) never strips, independent of any marker. Keep these two decisions separate: env marker = *what the running app shows*; build strip = *what bytes prod ships*.
- **Test-count sync (Protocol 2a).** Adding the guard regression test + the strip-consistency suite changes the count; sync every location in the same commit. The strip itself changes **zero** existing tests.
- **Optional-CSS scope creep.** If the owner wants JS-only first, ship exactly the three JS references; the CSS trio is a clean follow-up using the identical mechanism. Don't half-strip (JS file gone but CSS `<link>` dangling is harmless, but a dangling CSS precache entry is **not** — it would fail SW install). Whatever is stripped must strip all three of its own references.
- **`js/dev/` empty-dir.** After removing the only file, `rm -rf _site/js/dev` is cleaner than leaving an empty dir; either is harmless.
---
## 7. New tests owed (escape-ratchet — Protocol 13 / 36b / 42), for stage 2
1. **Guard regression test** (Node runner): assert `ui-core.js`'s `window.onload` invokes `initTestConsole` behind a `typeof … === 'function'` guard (not a bare call) — locks the prod-safety fix so a future refactor can't reintroduce the black-screen.
2. **Strip-consistency test** (Node runner, recommended path): call `scripts/prod-strip-devshell.mjs` against a temp staged copy of the served files and assert (a) `js/dev/test-console.js` removed, (b) no `test-console.js` reference remains in the stripped `index.html`/`sw.js`, (c) every remaining `sw.js` ASSETS entry maps to a file that exists in the staged dir (no all-or-nothing landmine). If the owner keeps inline `sed`, this test is not possible against a script — in that case, add a test that re-derives the expected stripped output from the repo files and asserts the three references are the complete set removed.
---
## 8. Definition of Done (Protocol 26)
- [ ] `deploy.yml` (or `scripts/prod-strip-devshell.mjs` it calls) removes the shell JS + its `<script>` tag + its `sw.js` precache entry from `_site/`, with an in-build assertion that no reference survives.
- [ ] `js/ui/ui-core.js:1412` wraps the call in a `typeof` guard; prod boots with the file absent and never throws.
- [ ] Dev/staging build still mounts and runs the shell (unchanged `cf-staging-build.mjs`).
- [ ] Stripped prod artifact: reaches READY, core board renders, a native tool works offline, `install` succeeds, no `test-console` reference anywhere in the served set.
- [ ] Full gate green (235 suites / 3383 tests) + the two new tests; counts synced everywhere (Protocol 2a).
- [ ] `CACHE_NAME` bumped on the release (Protocol 1); `CHANGELOG.md`/docs updated (Protocol 2) — the changelog line is user-plain ("trimmed the production download; no visible change").
- [ ] Measured before/after payload recorded.
---
## Report (as required)
- **Exact payload saving:** prod boot JS drops from **1,585,367 → 1,376,051 bytes** — **−209,316 bytes (~204 KB, 13.2% of boot JS)**. Optional stylesheet strip adds **−29,262 bytes** (`css/20-diagnostic-shell.css`), for **~238 KB** total if both ship.
- **Chosen mechanism (one paragraph):** Strip the dev console **at build time inside the existing production deploy job** (`.github/workflows/deploy.yml`'s "Stage app files" step, ideally via a small committed `scripts/prod-strip-devshell.mjs` it calls), which removes `js/dev/test-console.js` and its two references (the `index.html` `<script>` tag and the `sw.js` precache entry) from the `_site/` artifact only — never the repo, and never Cloudflare staging (which keeps the shell). Because the repo source tree is left intact, all 235 suites / 3383 tests keep passing unchanged; the strip runs only at release, guarded by an in-build "no surviving reference" assertion and (recommended) a gate test on the strip script. The one repo source edit is a `typeof` feature-detect guard at the single `initTestConsole()` caller so prod never throws on the missing file.
- **Count of references that must stay consistent:** **3** hard-consistency references for the JS (the file, the `index.html` `<script>` tag, the `sw.js` precache entry) — all must move together or the all-or-nothing SW precache fails. **+1** graceful-absence caller guard. **+3** more if the optional CSS is also stripped (file, `<link>`, precache entry).
- **Graceful-absence contract:** the sole `initTestConsole()` caller (`ui-core.js:1412`) becomes `if (typeof initTestConsole === 'function') initTestConsole();`. On prod the identifier is undefined → the call is skipped, no throw, boot continues, no shell. On dev/staging the function exists → runs exactly as today (and still self-gates to hidden via `_devConsoleUnlocked()`). `typeof` on an undeclared name never throws and lint stays clean because the global is already declared. No other reference needs absence handling — they are all passive data or inert markup with no prod reader.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).