From 1cf2672bf8cfcb5f718bea04ca6a6cba08c7a5f3 Mon Sep 17 00:00:00 2001 From: DecodedXR Date: Wed, 8 Jul 2026 11:33:45 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20M7=20mobile-responsive=20UI=20?= =?UTF-8?q?=E2=80=94=20bottom-docked=20panel,=2044px=20tap=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone-width viewport the desktop chrome broke down: the fixed top-left #controls panel (~236px) collided with the centered #app-title on a 390px screen, and the buttons were far below finger size — the user's "unusable on iPhone" report. Pure-CSS fix, all inside a new @media (max-width: 640px) block in src/style.css (desktop layout above the breakpoint is untouched, and no JS changed at all): - #controls docks to the bottom edge, full width, with safe-area padding; sliders stretch; buttons and the file input meet the 44px minimum tap target. - #build-credit moves up under the top-center title block, since the docked panel now owns the bottom edge. Test harness: a new `mobile-chromium` Playwright project (390x844, DPR 3, hasTouch, isMobile, same SwiftShader args) runs the new tests/mobile.spec.js: boot-loader dismissal, pairwise no-intersection among the fixed chrome, every control fully in-viewport, tap targets >= 44px, and a real touch drag (CDP Input.dispatchTouchEvent — trusted pointerType:'touch' events; synthetic dispatchEvent would hit OrbitControls' unguarded setPointerCapture and throw NotFoundError) orbits the camera. RED pre-fix on the overlap assert with exactly the reported geometry. Autopilot: STATUS.md M7 (Mobile-responsive UI) Co-Authored-By: Claude Fable 5 --- playwright.config.js | 18 +++++ src/style.css | 61 +++++++++++++++++ tests/mobile.spec.js | 154 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 tests/mobile.spec.js diff --git a/playwright.config.js b/playwright.config.js index 9259d7e..9a3cc86 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -32,11 +32,29 @@ export default defineConfig({ projects: [ { name: 'chromium', + // The mobile spec runs only under the phone-emulation project below. + testIgnore: /mobile\.spec\.js/, use: { ...devices['Desktop Chrome'], launchOptions: { args: webglArgs }, }, }, + // Milestone 7: phone emulation for the mobile-responsive chrome. Chromium + // only (CI runners provision Chromium only — do NOT add a WebKit project); + // iPhone-class viewport + touch + DPR 3 so the narrow-viewport breakpoint + // and coarse-pointer tap targets are exercised for real. + { + name: 'mobile-chromium', + testMatch: /mobile\.spec\.js/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + launchOptions: { args: webglArgs }, + }, + }, ], // `npm test` builds first, then this serves the production build for the smoke // test to hit — the same bytes that would ship. diff --git a/src/style.css b/src/style.css index 879cc3d..a21a021 100644 --- a/src/style.css +++ b/src/style.css @@ -165,3 +165,64 @@ canvas { max-width: 210px; color: #e8c46a; } + +/* Milestone 7: mobile-responsive chrome. On a phone-width viewport the desktop + layout breaks down — the fixed top-left #controls panel collides with the + centered #app-title, and the controls are far below finger size. Below the + breakpoint the panel docks to the BOTTOM edge, full width, with 44px-minimum + tap targets (Apple HIG / WCAG), and the build credit moves up under the + title since the panel now owns the bottom edge. Everything lives inside this + media query so the desktop layout above 640px is byte-for-byte unaffected. */ +@media (max-width: 640px) { + #controls { + top: auto; + left: 0; + right: 0; + bottom: 0; + box-sizing: border-box; + border-radius: 10px 10px 0 0; + border-left: none; + border-right: none; + border-bottom: none; + /* Keep the panel's own bottom padding clear of the iPhone home-bar. */ + padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); + } + + /* Finger-sized rows; sliders stretch to use the full panel width. */ + #controls .ctrl-row { + min-height: 44px; + } + + #controls input[type='range'] { + flex: 1; + width: auto; + } + + #controls button { + min-height: 44px; + padding: 10px 16px; + } + + #controls input[type='file'] { + max-width: none; + width: 100%; + min-height: 44px; + } + + #controls input[type='file']::file-selector-button { + padding: 12px 14px; + } + + #status, + #device-note { + max-width: none; + } + + /* The bottom edge belongs to the docked panel now — tuck the build credit + directly under the top-center title block instead (title bottom lands + around y=43; 56px leaves a clear gap). */ + #build-credit { + bottom: auto; + top: 56px; + } +} diff --git a/tests/mobile.spec.js b/tests/mobile.spec.js new file mode 100644 index 0000000..21a98ef --- /dev/null +++ b/tests/mobile.spec.js @@ -0,0 +1,154 @@ +import { test, expect } from '@playwright/test'; + +// Milestone 7: mobile-responsive UI. These tests run ONLY under the +// `mobile-chromium` project (390×844, DPR 3, hasTouch — see +// playwright.config.js); the desktop project ignores this file, and the +// desktop suite pins that layout unchanged above the breakpoint. +// +// The user report driving this milestone: on an iPhone the fixed top-left +// #controls panel collides with the centered #app-title and the controls are +// too small to tap. So the contract asserted here is geometric — the three +// pieces of fixed chrome never intersect, every control sits fully inside the +// viewport, tap targets meet the 44px minimum — plus the two behaviors a +// phone actually needs: the boot loader still dismisses, and a touch drag +// orbits the camera. + +// Two axis-aligned boxes intersect iff they overlap on both axes. +function intersects(a, b) { + return ( + a.x < b.x + b.width && + b.x < a.x + a.width && + a.y < b.y + b.height && + b.y < a.y + a.height + ); +} + +function insideViewport(box, viewport) { + return ( + box.x >= 0 && + box.y >= 0 && + box.x + box.width <= viewport.width && + box.y + box.height <= viewport.height + ); +} + +function collectErrors(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()}`); + }); + return errors; +} + +test('mobile: boot completes and the fixed chrome is overlap-free, on-screen, and tappable', async ({ + page, +}) => { + const errors = collectErrors(page); + await page.goto('/'); + await page.waitForFunction(() => window.__app?.getPointCount() === 128 * 128); + + // The boot loader must still dismiss on a phone — main.js removes the + // overlay after the first rendered frame. + await expect(page.locator('#boot-loader')).toHaveCount(0); + + const viewport = page.viewportSize(); + + // (a) No pairwise intersection among the three pieces of fixed chrome — + // this is the iPhone collision from the user report. + const chrome = {}; + for (const id of ['controls', 'app-title', 'build-credit']) { + const el = page.locator(`#${id}`); + await expect(el, `#${id} must be visible on mobile`).toBeVisible(); + chrome[id] = await el.boundingBox(); + } + const ids = Object.keys(chrome); + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + expect( + intersects(chrome[ids[i]], chrome[ids[j]]), + `#${ids[i]} ${JSON.stringify(chrome[ids[i]])} must not overlap #${ + ids[j] + } ${JSON.stringify(chrome[ids[j]])}`, + ).toBe(false); + } + } + + // (b) The panel and every control in it sit fully inside the viewport — + // nothing to scroll to, nothing clipped off-screen (body overflow is + // hidden, so off-viewport chrome is simply unreachable on a phone). + const controlSelectors = [ + '#controls', + '#webcam-toggle', + '#photo-input', + '#controls input[type="range"] >> nth=0', + '#controls input[type="range"] >> nth=1', + '#controls input[type="range"] >> nth=2', + ]; + for (const sel of controlSelectors) { + const box = await page.locator(sel).boundingBox(); + expect(box, `${sel} must render a box`).not.toBeNull(); + expect( + insideViewport(box, viewport), + `${sel} ${JSON.stringify(box)} must sit fully inside the ${viewport.width}×${ + viewport.height + } viewport`, + ).toBe(true); + } + + // (c) Coarse-pointer tap targets: the buttons and the file input must meet + // the 44px minimum (Apple HIG / WCAG AAA) — the "too small to tap" half of + // the user report. + for (const sel of ['#webcam-toggle', '#photo-input']) { + const box = await page.locator(sel).boundingBox(); + expect( + box.height, + `${sel} must be at least 44px tall to tap (got ${box.height})`, + ).toBeGreaterThanOrEqual(44); + } + + expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]); +}); + +// Touch orbit: a one-finger drag on the canvas must rotate the camera. +// OrbitControls listens to pointer events, but its pointerdown handler calls +// setPointerCapture(event.pointerId) unguarded (verified in the installed +// three 0.185 source) — a synthetic dispatchEvent(new PointerEvent(...)) has +// no active pointer behind it, so Chromium throws NotFoundError and orbit +// never engages. CDP Input.dispatchTouchEvent drives the REAL input pipeline: +// the browser itself synthesizes trusted pointerdown/move/up events with +// pointerType 'touch' and a live active pointer, exactly what a finger sends. +test('mobile: a touch drag on the canvas orbits the camera', async ({ page }) => { + const errors = collectErrors(page); + await page.goto('/'); + await page.waitForFunction(() => window.__app?.getPointCount() === 128 * 128); + await expect(page.locator('#boot-loader')).toHaveCount(0); + + const before = await page.evaluate(() => + window.__app.camera.quaternion.toArray(), + ); + + // Drag across the upper-middle of the screen — canvas territory, clear of + // the title (top) and the docked controls panel (bottom). + const client = await page.context().newCDPSession(page); + await client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: 195, y: 300 }], + }); + for (let i = 1; i <= 8; i++) { + await client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: 195 + i * 15, y: 300 + i * 6 }], + }); + } + await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + + // The rAF loop applies the rotation via controls.update(); wait until the + // camera orientation has actually moved off its starting quaternion. + await page.waitForFunction((start) => { + const q = window.__app.camera.quaternion.toArray(); + return q.some((v, i) => Math.abs(v - start[i]) > 1e-4); + }, before); + + expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]); +}); From 82e078445490f4586ef7b29c4c8454d4d94f8111 Mon Sep 17 00:00:00 2001 From: DecodedXR Date: Wed, 8 Jul 2026 11:48:30 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20cap=20mobile=20panel=20height=20in?= =?UTF-8?q?=20landscape=20=E2=80=94=20scroll=20internally=20(verifier=20fi?= =?UTF-8?q?nding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 640x360 / 568x320 the bottom-docked #controls panel (~336px tall) sat inside the same <=640px breakpoint, overlapped #app-title/#build-credit, and with a grown status line extended above the viewport top where body{overflow:hidden} made rows unreachable. Cap it at calc(100dvh - 88px) (vh fallback) with internal overflow-y scrolling. New landscape test pins no-overlap + in-viewport + scroll-reachability; RED pre-fix on the verifier geometry. Autopilot: STATUS.md M7 (Mobile-responsive UI) Co-Authored-By: Claude Fable 5 --- src/style.css | 11 ++++++++ tests/mobile.spec.js | 64 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/style.css b/src/style.css index a21a021..3dd6cef 100644 --- a/src/style.css +++ b/src/style.css @@ -186,6 +186,17 @@ canvas { border-bottom: none; /* Keep the panel's own bottom padding clear of the iPhone home-bar. */ padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); + /* Landscape phones are ≤640px wide too but only ~320–360px TALL — the + docked panel is taller than that. Cap it below the top chrome band + (title + build credit end around y=67) and scroll internally, so it + never collides with the title or grows past the viewport top, where + body{overflow:hidden} would make clipped rows unreachable. dvh tracks + the real visible height under mobile browser toolbars; the vh line is + the fallback for engines without it. */ + max-height: calc(100vh - 88px); + max-height: calc(100dvh - 88px); + overflow-y: auto; + overscroll-behavior: contain; } /* Finger-sized rows; sliders stretch to use the full panel width. */ diff --git a/tests/mobile.spec.js b/tests/mobile.spec.js index 21a98ef..2fff5ba 100644 --- a/tests/mobile.spec.js +++ b/tests/mobile.spec.js @@ -110,6 +110,70 @@ test('mobile: boot completes and the fixed chrome is overlap-free, on-screen, an expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]); }); +// Landscape phones sit inside the same ≤640px breakpoint but are only +// ~320–360px TALL — and the bottom-docked panel is taller than that, so +// without a height cap it collides with the top chrome and extends above the +// viewport, where body{overflow:hidden} makes the clipped rows unreachable +// (verifier finding: at 640×360 the uncapped panel overlapped #app-title AND +// #build-credit, and with a grown status line its top edge went negative). +// Contract here: the panel stays clear of the top chrome and fully inside the +// viewport, and anything clipped is reachable by scrolling the panel itself. +test('mobile landscape: panel caps its height below the top chrome and scrolls internally', async ({ + page, +}) => { + const errors = collectErrors(page); + await page.setViewportSize({ width: 640, height: 360 }); + await page.goto('/'); + await page.waitForFunction(() => window.__app?.getPointCount() === 128 * 128); + await expect(page.locator('#boot-loader')).toHaveCount(0); + + // Grow the panel through its real growth path — a long status error line + // (mobile drops the desktop max-width, so this wraps to multiple rows). + await page.evaluate(() => { + document.getElementById('status').textContent = + 'Webcam failed: NotReadableError: Could not start video source — the ' + + 'device is already in use by another application.'; + }); + + const viewport = page.viewportSize(); + const chrome = {}; + for (const id of ['controls', 'app-title', 'build-credit']) { + const el = page.locator(`#${id}`); + await expect(el, `#${id} must be visible in landscape`).toBeVisible(); + chrome[id] = await el.boundingBox(); + } + const ids = Object.keys(chrome); + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + expect( + intersects(chrome[ids[i]], chrome[ids[j]]), + `#${ids[i]} ${JSON.stringify(chrome[ids[i]])} must not overlap #${ + ids[j] + } ${JSON.stringify(chrome[ids[j]])} in landscape`, + ).toBe(false); + } + } + expect( + insideViewport(chrome['controls'], viewport), + `#controls ${JSON.stringify(chrome['controls'])} must sit fully inside the ${ + viewport.width + }×${viewport.height} viewport`, + ).toBe(true); + + // The grown panel cannot fit 44px rows in ~280px of height, so it must be + // scrollable — and scrolling must actually move (reach the clipped rows). + const scroll = await page.evaluate(() => { + const el = document.getElementById('controls'); + const scrollable = el.scrollHeight > el.clientHeight + 1; + el.scrollTop = el.scrollHeight; + return { scrollable, moved: el.scrollTop > 0 }; + }); + expect(scroll.scrollable, 'clipped panel must overflow internally').toBe(true); + expect(scroll.moved, 'panel must scroll to reach clipped controls').toBe(true); + + expect(errors, `unexpected page errors:\n${errors.join('\n')}`).toEqual([]); +}); + // Touch orbit: a one-finger drag on the canvas must rotate the camera. // OrbitControls listens to pointer events, but its pointerdown handler calls // setPointerCapture(event.pointerId) unguarded (verified in the installed From 20024fb4e5073ced352360194e82c818fea30551 Mon Sep 17 00:00:00 2001 From: DecodedXR Date: Wed, 8 Jul 2026 11:50:49 -0500 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20M7=20mobile-re?= =?UTF-8?q?sponsive=20UI=20done;=20M8=20promoted=20to=20NEXT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the 2026-07-08 queue rebuild (M7-M10 from user direction) and the M7 completion record into one atomic update. Rollback HEAD for M7: 58d3b9a. Autopilot: STATUS.md M7 (Mobile-responsive UI) Co-Authored-By: Claude Fable 5 --- STATUS.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/STATUS.md b/STATUS.md index 1c3e65b..f5b150a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -4,10 +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-04 — Milestone 6 (Web Worker WASM inference) done; four -user-directed polish items (boot loading bar, a black+green "coding" aesthetic, -a persistent title + author credit, then a bottom "Built with Claude Fable 5" -build credit) landed on top; queue empty pending an M7 proposal._ +_Last updated: 2026-07-08 — Milestone 7 (mobile-responsive UI) done; M8 +model-download progress is NEXT, then M9 STL+PLY export and M10 expanded +depth range. (Queue rebuilt from user direction earlier the same day.)_ --- @@ -228,6 +227,36 @@ build credit) landed on top; queue empty pending an M7 proposal._ `#build-credit`). Verified visually on a real build. Pre-change HEAD (rollback) `94b4084`. +- **Milestone 7 — Mobile-responsive UI.** Fixes the user's "unusable on + iPhone" report (2026-07-08): on a ~390px screen the fixed top-left + `#controls` panel (~236px) collided with the centered `#app-title`, and the + controls were far below finger size. Pure-CSS fix — one + `@media (max-width: 640px)` block in `src/style.css`, **no JS and no markup + changes** (`src/main.js`, the render loop, and the decoupling machinery are + byte-identical; desktop above the breakpoint is untouched since every new + rule lives inside the query). Mobile layout: `#controls` docks to the + bottom edge full-width (safe-area padding for the home bar), sliders + stretch, buttons + file input meet the 44px tap minimum; `#build-credit` + moves up under the top-center title (the panel owns the bottom edge). + **Landscape phones** (≤640px wide but only ~320–360px tall — verifier + finding, a regression risk the portrait-only design missed) are handled by + capping the panel at `calc(100dvh − 88px)` (vh fallback) with internal + `overflow-y` scrolling, so it never reaches the top chrome or exceeds the + viewport. Test harness: a new **`mobile-chromium` Playwright project** + (Chromium, 390×844, DPR 3, `hasTouch`, `isMobile` — no WebKit, CI provisions + Chromium only) runs the new `tests/mobile.spec.js`: boot-loader dismissal; + pairwise non-intersection of `#controls`/`#app-title`/`#build-credit`; + every control fully in-viewport; tap targets ≥ 44px; the landscape + cap/scroll contract at 640×360 with a grown status line; and touch orbit — + driven via **CDP `Input.dispatchTouchEvent`** (trusted `pointerType:'touch'` + pointer events; a synthetic `dispatchEvent` would hit OrbitControls 0.185's + unguarded `setPointerCapture` and throw `NotFoundError` — verified from the + installed source) asserting the camera quaternion moves. Proven + non-tautological twice (portrait overlap RED on the pre-M7 CSS with exactly + the reported geometry; landscape RED pre-cap on the verifier's geometry). + Verified visually on a real build (mobile + desktop screenshots; desktop + byte-identical). Pre-change HEAD (rollback) `58d3b9a`. + **Carry-over facts from M3/M4 (do not re-derive):** - Depth output contract: `depth` RawImage, Uint8, 1 channel, input W×H, 0–255 @@ -243,14 +272,54 @@ build credit) landed on top; queue empty pending an M7 proposal._ ## NEXT (the one actionable task) -(Nothing queued — propose an M7 candidate with the human before the next -autopilot run.) +- **Milestone 8 — Model-download progress.** The first photo upload / webcam + start silently downloads the Depth Anything weights (tens of MB) behind a + static status line. Show real progress — a percentage or bar in `#status`. + transformers.js takes a `progress_callback` option at pipeline construction; + **confirm the exact event shape from the installed 4.2.0 source before + coding (working rule 1) — do not assume.** Expect per-file events needing + aggregation into one number. M6 wrinkle: on the WASM path the pipeline is + built inside `src/depth-worker.js`, so progress events must be relayed over + the worker bridge via postMessage; on the WebGPU (main-thread) path they're + direct — both call sites feed the same UI. Weights are cached after first + load, so the bar should appear only while bytes actually move. Smoke: fake + progress events → visible advancing %; completion returns `#status` to the + normal flow. --- ## BLOCKED — do NOT start until the prior milestone has merged -(Nothing queued.) +- **Milestone 9 — Export: "Save as STL" + "Save as PLY" buttons.** (Format + question resolved with the user 2026-07-08: **both**.) One export module, + two buttons in `#controls`, fully client-side (build an ArrayBuffer → Blob → + anchor download; hand-roll both writers — no new dependencies, both formats + are simple). **Binary STL** — for 3D printing: triangulate the current + 128×128 grid as a heightfield (fixed grid topology, two triangles per cell, + ~32.3k tris); STL has no color, that's inherent to the format. **Binary + little-endian PLY** — the actual colored point cloud for + MeshLab/CloudCompare/Blender: positions plus per-point `aColor` as uchar + RGB. Both exporters must bake in the Points object's world scale (M3 + preserves photo aspect via object scale, so raw attribute positions are not + world coords). Export snapshots whatever is currently displayed — works + pre-upload too (the green random cloud); no special webcam guard needed, + buffers only swap between frames. Smoke: apply a synthetic ramp, click each + button, capture the download (Playwright download event), parse the bytes → + assert header/counts and a few known vertex values; PLY colors match + `aColor`. + +- **Milestone 10 — Expanded depth range** (user: "make the potential depth + more expansive"). Displacement is hard-coded — `z = d/255 − 0.5`, a total + span of 1.0 world unit — so relief always reads shallow. Add a fourth + "depth" slider to `#controls` (alongside pointSize/glow/falloff) scaling + the span, e.g. ×0.25–×4, default ×1. Cache the last applied + `{depth, canvas}` so moving the slider re-applies **live** to the on-screen + cloud, not just the next pass; during a webcam session the next consumed + frame picks it up anyway — do not touch the M4 drop-never-queue machinery. + Sign contract (bright = near) and the default look at ×1 unchanged. Smoke: + synthetic ramp at two slider values → z extent scales proportionally; + existing M3 sign asserts stay green. (If M9 landed first, exports + automatically reflect the scaled Z — no extra work.) ---