diff --git a/STATUS.md b/STATUS.md
index 9f853d3..8d95cff 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -4,8 +4,9 @@ Single source of truth for "what's next." One milestone per PR/run. Autopilot:
pick the one task under **NEXT**, ship it, stop. Do **not** start anything under
**BLOCKED**.
-_Last updated: 2026-07-03 — Milestone 6 (Web Worker WASM inference) done;
-queue empty pending an M7 proposal._
+_Last updated: 2026-07-04 — Milestone 6 (Web Worker WASM inference) done; a
+user-directed polish item (boot loading bar) landed on top; queue empty pending
+an M7 proposal._
---
@@ -155,6 +156,26 @@ queue empty pending an M7 proposal._
max gap 1.57s** — pre-M6 the gap was the whole pass. Pre-change HEAD
(rollback) `c61d33b`.
+- **Polish — boot loading bar (user-directed, not a milestone).** The app used
+ to show a blank black screen until the ~194KB-gzipped entry bundle (Three.js)
+ downloaded, parsed, and rendered the first frame — no feedback. Added an
+ **inline, instant-paint boot loader**: an overlay + critical CSS live directly
+ in `index.html` (a `
` `
+
diff --git a/src/main.js b/src/main.js
index f3dc07d..3e840f0 100644
--- a/src/main.js
+++ b/src/main.js
@@ -440,6 +440,35 @@ function onResize() {
}
window.addEventListener('resize', onResize);
+// Boot loading bar: dismiss the inline #boot-loader overlay (see index.html)
+// once the app is actually up. It advances on its own CSS animation from the
+// first paint; here we snap the fill to 100% and fade the overlay out, so the
+// bar completes exactly when the app is ready rather than on a guessed timer.
+// One-shot, fired after the first rendered frame.
+let bootLoaderDismissed = false;
+function dismissBootLoader() {
+ const loader = document.getElementById('boot-loader');
+ if (!loader) return;
+ const fill = loader.querySelector('.boot-fill');
+ if (fill) {
+ // Stop the advance animation so the inline width wins, then let the width
+ // transition carry it from wherever it reached up to a full 100%.
+ fill.style.animation = 'none';
+ fill.style.width = '100%';
+ }
+ loader.classList.add('boot-done'); // opacity → 0, pointer-events off
+ // Remove after the fade so it never lingers over the UI or intercepts clicks.
+ // Only the overlay's OWN opacity transition should trigger removal — a
+ // transitionend from the fill's width tween bubbles up too and would cut the
+ // fade short. The timeout is a fallback for when the fade is a no-op (e.g.
+ // prefers-reduced-motion collapses its duration so transitionend may not fire).
+ const remove = () => loader.remove();
+ loader.addEventListener('transitionend', (e) => {
+ if (e.target === loader && e.propertyName === 'opacity') remove();
+ });
+ setTimeout(remove, 600);
+}
+
function animate() {
requestAnimationFrame(animate);
// M4: consume the newest completed depth frame, if one was posted by the
@@ -452,6 +481,11 @@ function animate() {
}
controls.update();
renderer.render(scene, camera);
+ // First frame is on screen — tear down the boot loader.
+ if (!bootLoaderDismissed) {
+ bootLoaderDismissed = true;
+ dismissBootLoader();
+ }
}
animate();
diff --git a/tests/smoke.spec.js b/tests/smoke.spec.js
index 87c2501..127e59f 100644
--- a/tests/smoke.spec.js
+++ b/tests/smoke.spec.js
@@ -644,6 +644,47 @@ test('milestone 4: camera-permission failure shows an error state and recovers',
expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]);
});
+// Boot loading bar. A loading indicator must be present in the INITIAL
+// server-delivered HTML — up on the first paint, before the app module (~194KB
+// gzipped of Three.js) has even downloaded, let alone run — and must be
+// dismissed once the app has actually booted (first frame rendered).
+//
+// The two assertions are non-tautological only together: (a) proves the loader
+// ships in the raw HTML (checked via a bare request, NOT the live DOM, so it
+// can't be a JS-injected node), and (b) proves main.js clears it. Add the HTML
+// but forget the dismissal → (a) passes, (b) fails (loader sits over the booted
+// app forever). Add neither → (a) fails. Only both together go green.
+test('boot loader: shipped in the initial HTML before JS, dismissed once the app boots', async ({
+ page,
+}) => {
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(`pageerror: ${e}`));
+ page.on('console', (m) => {
+ if (m.type() === 'error') errors.push(`console.error: ${m.text()}`);
+ });
+
+ // (a) The loader — an overlay carrying an ARIA progressbar — is in the exact
+ // bytes the server sends, so it renders before any module executes. Fetched
+ // raw (not via the live DOM) so a JS-injected element can't satisfy it.
+ const html = await (await page.request.get('/')).text();
+ expect(html, 'initial HTML must contain a #boot-loader overlay').toMatch(
+ /id=["']boot-loader["']/,
+ );
+ expect(html, 'the boot loader must expose an ARIA progressbar').toMatch(
+ /role=["']progressbar["']/,
+ );
+
+ // (b) Live: once the app has booted (the debug hook only exists after main.js
+ // ran end-to-end), the loader must be gone/hidden — main.js dismisses it.
+ await page.goto('/');
+ await page.waitForFunction(
+ () => !!window.__app && typeof window.__app.getPointCount === 'function',
+ );
+ await expect(page.locator('#boot-loader')).toBeHidden();
+
+ expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]);
+});
+
// Milestone 6: WASM inference moves into a Web Worker so the main thread stays
// responsive during a pass. The worker itself can't run in CI (it loads the
// real ~100MB model — the worker-path proof is a local real-model probe, like