Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._

---

Expand Down Expand Up @@ -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 `<head>` `<style>` + `#boot-loader` markup Vite passes
through untouched into the built HTML, ahead of the module `<script>`), so it
paints on the FIRST paint, before any JS downloads. The fill advances toward
92% on a pure-CSS animation; `src/main.js` snaps it to 100% and fades the
overlay out on the **first rendered frame** (a one-shot after the first
`renderer.render` in `animate()`, which runs synchronously at module eval —
before `window.__app` is set, so the overlay is `pointer-events:none`/removed
before any UI interaction). This is a **perceived-load** win (First Contentful
Paint: blank → instant) — the JS payload is unchanged (Three.js is required for
the first frame; the depth model already lazy-loads, no model bytes at boot).
Smoke test asserts the loader ships in the raw server HTML (fetched via
`page.request.get('/')`, so a JS-injected node can't satisfy it) AND is gone
once the app boots — non-tautological only together (loader-in-HTML but no
dismissal was verified RED on the second assertion). Verified visually on a
real build (frozen-loader screenshot). Pre-change HEAD (rollback) `789d6ae`.

**Carry-over facts from M3/M4 (do not re-derive):**

- Depth output contract: `depth` RawImage, Uint8, 1 channel, input W×H, 0–255
Expand Down
73 changes: 73 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,81 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Live Depth Field</title>
<link rel="stylesheet" href="/src/style.css" />
<!--
Boot loading bar. Inlined here (not in a stylesheet, not built by JS) so it
paints on the FIRST paint — the instant the page opens, before the ~194KB
app module downloads or runs. src/main.js fades it out on the first
rendered frame. The CSS fill advances toward 92% on its own, then main.js
snaps it to 100% exactly when the app is actually ready — honest feedback,
zero JS to start it.
-->
<style>
#boot-loader {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: #000;
transition: opacity 0.35s ease;
}
#boot-loader.boot-done {
opacity: 0;
pointer-events: none;
}
#boot-loader .boot-title {
font: 600 13px/1 system-ui, sans-serif;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #8fb7ee;
}
#boot-loader .boot-track {
width: 220px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
#boot-loader .boot-fill {
width: 0;
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #5a8fd6, #8ec7ff);
box-shadow: 0 0 12px rgba(142, 199, 255, 0.7);
animation: boot-advance 2.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
transition: width 0.3s ease;
}
@keyframes boot-advance {
from {
width: 0;
}
to {
width: 92%;
}
}
@media (prefers-reduced-motion: reduce) {
#boot-loader .boot-fill {
animation-duration: 0.01ms;
}
#boot-loader {
transition-duration: 0.01ms;
}
}
</style>
</head>
<body>
<div
id="boot-loader"
role="progressbar"
aria-label="Loading Live Depth Field"
aria-live="polite"
>
<div class="boot-title">Live Depth Field</div>
<div class="boot-track"><div class="boot-fill"></div></div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
Expand Down
34 changes: 34 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand Down
41 changes: 41 additions & 0 deletions tests/smoke.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading