From d5b0f5b7de5bc7471ecb5c87324a391b0de2c9da Mon Sep 17 00:00:00 2001 From: DecodedXR Date: Sat, 4 Jul 2026 10:48:41 -0600 Subject: [PATCH] feat: instant-paint boot loading bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app showed a blank black screen until the ~194KB-gzipped entry bundle (Three.js) downloaded, parsed, and rendered the first frame — no feedback. Add an inline boot loader: an overlay + critical CSS live directly in index.html (a +
+
Live Depth Field
+
+
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