diff --git a/AI-CONTEXT.md b/AI-CONTEXT.md
index 9631a25..7d6de11 100644
--- a/AI-CONTEXT.md
+++ b/AI-CONTEXT.md
@@ -38,7 +38,7 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
| `model_cache.py` | 935 | 3-tier cache: LoRA files → incremental states → patched models. Async preloading |
| `manifest_utils.py` | 316 | Load/save/merge manifest. `save_manifest()` reloads from disk first to preserve concurrent user edits (favorites, votes, metrics) |
| `metadata_packer.py` | 561 | PNG metadata embedding for CivitAI. `pack_metadata_into_image()` accepts `workflow_data` kwarg |
-| `run_api.py` | 144 | Pure helpers behind `POST /config_tester/run` and `GET /config_tester/run_status` |
+| `run_api.py` | 196 | Pure helpers behind `POST /config_tester/run`, `GET /config_tester/run_status` and `GET /config_tester/manifest` (`load_manifest_for_fetch`) |
| `image_metrics.py`, `metrics_runner.py` | 668, 167 | Optional per-image metrics at save time; background job that backfills them for old sessions |
| `workflow_sweep.py`, `workflow_sweep_runner.py`, `workflow_sweep_collect.py` | 1106, 1027, 281 | Workflow Sweep: pure expansion core, in-process queue feeder (never blocks the executor), `USCGSweepCollect` / `USCGSweepCollectVideo` output nodes |
| `web/conf_builder/conf-builder-main.js` | 779 | Node registration. Default state and its migration blocks define ALL fields including upscaling/cooldown |
@@ -144,7 +144,7 @@ Images saved as WebP at `output/benchmarks/{session}/images/img_{id}.webp`
- **Python files:** Restart ComfyUI
- **`web/` JS files:** Refresh browser (Ctrl+F5)
-- **`resources/` files:** Must regenerate dashboard HTML — re-run sampler or call `/config_tester/get_session_html`
+- **`resources/` files:** Reload the dashboard (F5 on `/config_tester/dashboard`, or the node's RELOAD button). The page is a lazy shell: data comes from `GET /config_tester/manifest` (ETag/304, gzip); only code is inlined
- **New state fields:** Add migration in `conf-builder-main.js` `onConfigure`
## Common Tasks
diff --git a/ProjectStructure.md b/ProjectStructure.md
index 6a66179..a0a3515 100644
--- a/ProjectStructure.md
+++ b/ProjectStructure.md
@@ -242,7 +242,8 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
| POST | `/config_tester/clear_comparisons` | `__init__.py` | `{session_name}` | Delete all votes of a session |
| POST | `/config_tester/save_manifest` | `__init__.py` | `{session_name, manifest}` | Full merge save (keeps server-side `meta` and `comparisons`) |
| POST | `/config_tester/get_session_html` | `__init__.py` | `{session_name, node_id}` | Generate the dashboard HTML on demand (empty manifest for an unknown session, not a 404) |
-| GET | `/config_tester/dashboard` | `__init__.py` | `?session=` (optional) | Dashboard SPA as a top-level page (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`) |
+| GET | `/config_tester/dashboard` | `__init__.py` | `?session=` (optional) | Dashboard SPA as a top-level page (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`). A lazy shell (no manifest inlined); gzip + content ETag (304 on `If-None-Match`) |
+| GET | `/config_tester/manifest` | `__init__.py` | `?session=` | A session's manifest as compact JSON for the SPA (lazy loading). Takes a session NAME, never a path. Weak `ETag` (mtime+size) + `If-None-Match` -> 304, gzip when accepted, `Cache-Control: no-cache`; 400 empty name, 403 outside benchmarks, 404 unknown, 503 + `Retry-After` for a torn read (`save_manifest` is not atomic; 3 retries first). Logic in `run_api.load_manifest_for_fetch` |
| POST | `/config_tester/run` | `__init__.py` | `{configs_json or config_name, session_name?, overwrite_existing?, force? (default true), front?, client_id?}` | Headless run: builds the Generator+Dashboard prompt and enqueues it in-process like `/prompt`. Helpers in `run_api.py` |
| GET | `/config_tester/run_status` | `__init__.py` | `?prompt_id=` | `pending`, `running`, `success` or `error` + session / dashboard_url / item_count |
| POST | `/config_tester/export_favorites` | `__init__.py` | `{session_name, pack_metadata?, organize_by_prompt?, organize_by_lora?, export_prompt_txt?, copy_manifest? (default true), pack_workflow?, pack_nodes_workflow?, workflow_data?, nodes_workflows?}` | Export favorited images with options (metadata, workflow, organize) |
@@ -329,10 +330,10 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
* **`html_generator.py`** (57 lines)
- * `get_html_template(title, manifest_data, node_id)` — reads all `/resources/` files, inlines into `template.html`
+ * `get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False)` — reads all `/resources/` files, inlines them into `template.html`. By default the page is a **lazy shell**: `fullManifest` is empty and flagged `__uscg_lazy__`, `manifest_data` is ignored, and the SPA fetches `GET /config_tester/manifest?session=
` itself (`inline_manifest=True` restores the old inlined data; used by tests)
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_wfparams.js` → `logic_dragdrop.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE inlined script: top-level names must be unique across them)
* Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`
- * Zero external HTTP requests — everything inlined
+ * Zero external HTTP requests for code (JS/CSS inlined); the manifest comes from `/config_tester/manifest`
---
@@ -438,7 +439,7 @@ The distribution code (job queue, worker thread, `/uscg-distribution/*` routes,
### **4. The Frontend: Dashboard SPA (`resources/`)**
-The Dashboard is a standalone single-page app rendered inside an iframe. All files are inlined by `html_generator.py`.
+The Dashboard is a standalone single-page app rendered inside an iframe. All code files are inlined by `html_generator.py`; the manifest data is fetched lazily from `GET /config_tester/manifest` (boot in `logic_pipeline.js` `uscgBootLoad`, `fetchManifest`/`loadSession` in `logic_utils.js`, `reload_manifest` + `applyManifestReplace` in `logic_events.js`).
* **`template.html`** (662 lines)
* HTML skeleton with settings panel (cog icon), export options, analytics
@@ -825,7 +826,7 @@ __init__.py
3. **onNodeCreated Must Be Synchronous** — In `conf-builder-main.js` line 51, `onNodeCreated` MUST remain synchronous. Async initialization runs inside a fire-and-forget `(async () => { ... })()` IIFE. Making `onNodeCreated` itself async breaks widget registration.
-4. **Resources Are Inlined** — `html_generator.py` reads all `/resources/` files and bakes them into one HTML string. Zero external HTTP requests. Changes to dashboard JS/CSS require re-generating the HTML (re-run the sampler node or call `/config_tester/get_session_html`).
+4. **Resources Are Inlined, Data Is Not** — `html_generator.py` reads all `/resources/` files and bakes them into one HTML string (~500 KB; the same string rides ComfyUI's `executed` websocket message and history, so it must not grow with the session). The manifest is NOT in it: the page is a lazy shell and `loadSession()` fetches `/config_tester/manifest` (compact, gzip, ETag). Changes to dashboard JS/CSS: F5 on `/config_tester/dashboard` or the node's RELOAD button rebuild the page from `resources/` on every request; a node that is already showing a page keeps the old JS until RELOAD. The Generator's `executed` message carries a shell, so `web/dashboard.js` turns it into a `reload_manifest` postMessage (conditional re-fetch) instead of parsing data out of the HTML.
5. **Manifest Merge on Save** — `manifest_utils.py` `save_manifest()` calls `merge_manifest_user_changes()` which reloads from disk before saving. This preserves user favorites/rejected/notes that changed concurrently while generation was running. Also, `flush_batch_with_vae()` (image_generation.py line 657) does its own disk sync.
@@ -904,7 +905,7 @@ __init__.py
- **Python backend files:** Restart ComfyUI
- **`web/` JS files:** Refresh browser (Ctrl+F5) — served via `/extensions/` with cache-busting
-- **`resources/` JS/CSS/HTML files:** Dashboard HTML must be regenerated — re-run the sampler node or call `/config_tester/get_session_html`
+- **`resources/` JS/CSS/HTML files:** Reload the dashboard (F5 on `/config_tester/dashboard`, or the node's RELOAD button); a page that is already open keeps its old JS
- **Config Builder state fields:** Also update migration checks in `conf-builder-main.js` `onConfigure`
- **New config fields:** `state_to_configs_json()` in Python is the only converter (the JS preview calls `/configbuilder/preview`); then follow the checklist in gotcha 1
diff --git a/README.md b/README.md
index eb0bdbe..ddaf971 100644
--- a/README.md
+++ b/README.md
@@ -988,6 +988,7 @@ http://:8188/config_tester/dashboard?session=
- It opens its own ComfyUI websocket, so new images and the ETA bar update live while a run is going. After your phone sleeps, it reloads the session when you come back, without duplicating cards. Reverse proxies that do not upgrade websockets (some Tailscale Serve / nginx setups) lose live updates; the **R** key and the resume reload still work.
- Touch screens get 44px buttons, and the lightbox gets a bottom bar (Prev / Favorite / Reject / Revise / Next). The in-node view is unchanged apart from the extra header button.
- **GENERATE NEW** in the lightbox queues through the run API below, into the session you are viewing. "Pack workflow" in export needs the ComfyUI tab and is disabled here.
+- **Lazy loading:** the page itself is only the dashboard code (~500 KB, ~115 KB gzipped); the image data comes from `GET /config_tester/manifest?session=` (compact JSON, gzip, `ETag`). A 1000-image session is about 160 KB over the wire instead of 3.7 MB of page plus 3.5 MB of `manifest.json`, and a phone that wakes up and finds nothing new pays one 304. Both the page and the manifest answer `If-None-Match`, so a reload with nothing changed is nearly free. `--enable-compress-response-body` is not needed.
- Like ComfyUI itself, the page has no authentication. Only expose it on a network you trust.
---
@@ -1525,6 +1526,7 @@ Bot pattern: `POST /run`, post `dashboard_url` to the channel, poll `run_status`
|---|---|---|
| POST | `/configbuilder/preview` | Body `{state}` (the Builder state); returns `{configs_json}` from the same function the node uses (what the JSON Preview panel shows) |
| GET | `/config_tester/dashboard` | The Dashboard as a standalone page, optional `?session=` |
+| GET | `/config_tester/manifest` | A session's manifest as compact JSON `?session=` (gzip, ETag / 304); what the Dashboard fetches |
| POST | `/config_tester/save_comparisons` | Append / remove Pairwise Review votes `{session_name, append, remove}` |
| POST | `/config_tester/clear_comparisons` | Delete every vote of a session `{session_name}` |
| POST | `/config_tester/compute_metrics` | Start the background metrics job for a session `{session_name, overwrite?}`; returns `{job_id, total_images}` |
@@ -1712,7 +1714,7 @@ Tests live in `tests/` and must not contain network or process-spawning code (no
2. **One config-to-JSON implementation**: `UltimateConfigBuilder.state_to_configs_json` in `config_builder_node.py` is the single source of truth. The Builder preview POSTs the state to `/configbuilder/preview`, which runs the same function; there is no JS converter any more.
-3. **Dashboard runs in an iframe** — The dashboard HTML is set via `iframe.srcdoc`. All JS/CSS is inlined (no external requests). Communication between the parent ComfyUI page and the iframe uses `postMessage()`. The `dashboard.js` file bridges server events to iframe messages.
+3. **Dashboard runs in an iframe** — The dashboard HTML is set via `iframe.srcdoc`. All JS/CSS is inlined (no external code requests); the manifest is fetched from `/config_tester/manifest` (the page is a lazy shell with no item data). Communication between the parent ComfyUI page and the iframe uses `postMessage()`. The `dashboard.js` file bridges server events to iframe messages.
4. **Dashboard auto-loads on node creation** — `web/dashboard.js` calls `forceLoadSession()` with a 500ms delay when a dashboard node is created. The `get_session_html` API endpoint returns a full HTML template with empty manifest for non-existent sessions (NOT a 404).
@@ -1782,7 +1784,7 @@ __init__.py
### Testing Changes
-After modifying backend Python files, restart ComfyUI. After modifying `web/` JS files, refresh the browser (Ctrl+F5). After modifying `resources/` JS/CSS/HTML files, the dashboard HTML must be regenerated — either re-run the sampler node or call the `/config_tester/get_session_html` endpoint.
+After modifying backend Python files, restart ComfyUI. After modifying `web/` JS files, refresh the browser (Ctrl+F5). After modifying `resources/` JS/CSS/HTML files, reload the dashboard (F5 on `/config_tester/dashboard`, or the node's RELOAD button); the page is rebuilt from `resources/` on every request and fetches its data from `/config_tester/manifest`.
---
diff --git a/Roadmap.md b/Roadmap.md
index 16b6d9d..fd49f7a 100644
--- a/Roadmap.md
+++ b/Roadmap.md
@@ -166,7 +166,9 @@ STARTED (11/4/26): the Pairwise Review scoreboard (per-axis win % + ratings) cov
* **Milestone 3:** Hot-reload in dev — JS changes visible on browser refresh without re-running sampler
Biggest architectural leverage: eliminates the #1 developer friction point.
-STARTED (11/4/26): `/config_tester/dashboard` serves the SPA as a standalone page with its own websocket; the manifest is still inlined by html_generator (Milestones 1-3 open).
+STARTED (11/4/26): `/config_tester/dashboard` serves the SPA as a standalone page with its own websocket.
+DONE (11/4/26) Milestone 1: the page is a lazy shell (`html_generator`, `__uscg_lazy__`); the SPA fetches `GET /config_tester/manifest?session=` (compact JSON, gzip, ETag/304; the route is named `manifest`, not `get_manifest`). The node's `executed` message no longer carries the manifest (`web/dashboard.js` sends `reload_manifest`). Milestone 3 comes free for F5 / RELOAD (the page is rebuilt from `resources/` on every request); a node already showing a page keeps its old JS until RELOAD.
+Milestone 2 is blocked as written: ComfyUI's frontend imports every `web/**/*.js` as an extension module, so the `logic_*.js` files in `WEB_DIRECTORY` would run inside the ComfyUI page. Sketch of the cheap alternative: fixed path-less routes `GET /config_tester/dashboard_bundle_js?v=` / `..._css?v=` serving the concatenated files with ETag, `Cache-Control: public, max-age=31536000, immutable` and gzip, referenced from the shell with `"}, {"id": 1, "note": ""}]}
+ with open(base / "sess1" / "manifest.json", "w", encoding="utf-8") as f:
+ json.dump(manifest, f, indent=4, ensure_ascii=False)
+ (tmp_path / "secret").mkdir()
+ (tmp_path / "secret" / "manifest.json").write_text('{"items": []}')
+ return str(base), manifest
+
+
+@pytest.mark.parametrize("raw", ["", " ", None, "..", "../", "//", "<>"])
+def test_empty_after_sanitising_is_400(bench, raw):
+ assert fetch(bench[0], raw)[0] == 400
+
+
+@pytest.mark.parametrize("raw", ["../secret", "..\\secret", "a/../../secret", "", _shell(standalone=True))
+ js = max(scripts, key=len)
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "page.js")
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(js)
+ out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
+ assert out.returncode == 0, out.stderr.decode()
+
+
+def test_inline_manifest_flag_keeps_the_old_behaviour():
+ page = _page()
+ assert "__uscg_lazy__" not in page.split("/*__JSON_START__*/")[1].split("/*__JSON_END__*/")[0]
+ assert '"id": 1' in page
+
+
+# ---- dashboard_node: session name extraction from the generated page ---------------------------
+def _load_dashboard_node():
+ import sys
+ import types
+ sys.modules.setdefault("folder_paths", types.SimpleNamespace(get_output_directory=lambda: "."))
+ pkg = sys.modules.get(_PKG) or types.ModuleType(_PKG)
+ pkg.__path__ = [_ROOT]
+ sys.modules[_PKG] = pkg
+ sys.modules[f"{_PKG}.html_generator"] = html_generator
+ spec = importlib.util.spec_from_file_location(f"{_PKG}.dashboard_node_real", os.path.join(_ROOT, "dashboard_node.py"))
+ mod = importlib.util.module_from_spec(spec)
+ mod.__package__ = _PKG
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.mark.parametrize("title", ["my_session", "Sexy-IL-v2"])
+def test_dashboard_node_extracts_session_name_and_sets_node_id(title):
+ node = _load_dashboard_node()
+ page = html_generator.get_html_template(title, {"items": []}, "gen-node")
+ out = node.SamplerConfigDashboardViewer().view("ignored", "55", dashboard_html=page)["ui"]
+ assert out["update_session_name"] == [title]
+ assert 'const TARGET_NODE_ID = "55";' in out["html"][0]
+ assert 'const TARGET_NODE_ID = "gen-node";' not in out["html"][0]
+
+
+def test_dashboard_node_landing_page_has_no_session_name():
+ node = _load_dashboard_node()
+ page = html_generator.get_html_template("", {"items": []}, "gen-node")
+ out = node.SamplerConfigDashboardViewer().view("ignored", "55", dashboard_html=page)["ui"]
+ assert out["update_session_name"] == []
diff --git a/web/dashboard.js b/web/dashboard.js
index 2dc002b..e817ccc 100644
--- a/web/dashboard.js
+++ b/web/dashboard.js
@@ -430,6 +430,15 @@ app.registerExtension({
try {
const newManifest = JSON.parse(match[1]);
+ // Lazy shell (no item data inlined): never post its empty manifest as
+ // data; tell the dashboard to re-fetch (it sends If-None-Match, so an
+ // unchanged manifest costs one 304).
+ if (newManifest.__uscg_lazy__ && node.iframe?.contentWindow) {
+ node.iframe.contentWindow.postMessage({ type: 'reload_manifest' }, '*');
+ console.log('[UltimateGrid] 🔄 Manifest re-fetch requested');
+ return;
+ }
+
// Send incremental update instead of full reload
if (node.iframe?.contentWindow) {
node.iframe.contentWindow.postMessage({