← back to NationalPaperHangers
Harden the room-capture flow for mobile + touch
51b33df267a85ad59a69dc8183a2912a095bfed4 · 2026-05-18 17:00:10 -0700 · SteveStudio2
Mobile is the capture flow's primary surface (phone camera), so:
- @media (pointer:coarse) enlarges the box drag handles to 34px finger
targets; @media (max-width:560px) stacks the spec inputs, full-widths
the file buttons, and caps the photo at 58vh.
- wireBox now attaches pointermove/up to window instead of relying on
setPointerCapture — the drag survives the pointer leaving the small
handle, on mouse and touch alike.
e2e-room-capture gains a corner-drag assertion and a Phase 4 that loads
/book at 390px with touch — no horizontal overflow, finger-sized handles.
Suite 183 pass · 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/js/room-capture.jsM tests/e2e-room-capture.jsM views/public/book.ejs
Diff
commit 51b33df267a85ad59a69dc8183a2912a095bfed4
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 17:00:10 2026 -0700
Harden the room-capture flow for mobile + touch
Mobile is the capture flow's primary surface (phone camera), so:
- @media (pointer:coarse) enlarges the box drag handles to 34px finger
targets; @media (max-width:560px) stacks the spec inputs, full-widths
the file buttons, and caps the photo at 58vh.
- wireBox now attaches pointermove/up to window instead of relying on
setPointerCapture — the drag survives the pointer leaving the small
handle, on mouse and touch alike.
e2e-room-capture gains a corner-drag assertion and a Phase 4 that loads
/book at 390px with touch — no horizontal overflow, finger-sized handles.
Suite 183 pass · 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/js/room-capture.js | 22 +++++++++++-----------
tests/e2e-room-capture.js | 36 ++++++++++++++++++++++++++++++++++++
views/public/book.ejs | 15 +++++++++++++++
3 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/public/js/room-capture.js b/public/js/room-capture.js
index 86cd7d2..abadb66 100644
--- a/public/js/room-capture.js
+++ b/public/js/room-capture.js
@@ -123,18 +123,19 @@
layer.style.backgroundSize = tileWpct.toFixed(2) + '% ' + tileHpct.toFixed(2) + '%';
}
- // Corner-drag — axis-aligned rect, opposite corner stays anchored.
- // Pointer events cover mouse + touch uniformly; capture keeps the drag
- // alive when the finger leaves the handle.
+ // Corner-drag — axis-aligned rect, opposite corner stays anchored. The
+ // move/up listeners live on `window`, so the drag survives the pointer
+ // leaving the small handle (mouse and touch alike); `touch-action:none`
+ // on the photo + handles stops a touch-drag from scrolling the page.
function wireBox(r) {
var wrap = r.el.querySelector('.rc-photo-wrap');
r.el.querySelectorAll('.rc-handle').forEach(function (handle) {
var corner = handle.getAttribute('data-corner');
handle.addEventListener('pointerdown', function (e) {
e.preventDefault();
- handle.setPointerCapture(e.pointerId);
function move(ev) {
var rect = wrap.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
var x = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
var y = Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height));
var b = r.box, right = b.l + b.w, bottom = b.t + b.h, MIN = 0.08;
@@ -146,14 +147,13 @@
recompute(r);
}
function up() {
- handle.releasePointerCapture(e.pointerId);
- handle.removeEventListener('pointermove', move);
- handle.removeEventListener('pointerup', up);
- handle.removeEventListener('pointercancel', up);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ window.removeEventListener('pointercancel', up);
}
- handle.addEventListener('pointermove', move);
- handle.addEventListener('pointerup', up);
- handle.addEventListener('pointercancel', up);
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ window.addEventListener('pointercancel', up);
});
});
}
diff --git a/tests/e2e-room-capture.js b/tests/e2e-room-capture.js
index dccef3b..d39c6a4 100644
--- a/tests/e2e-room-capture.js
+++ b/tests/e2e-room-capture.js
@@ -76,6 +76,21 @@ function db() { if (!_db) _db = require('../lib/db'); return _db; }
const w2 = parseFloat(await page.locator('.rc-width').inputValue());
ok(w2 > w1, `taller ceiling widens derived width (${w1} → ${w2})`);
+ // Drag the bottom-right box corner inward — the wall re-measures.
+ await page.locator('.rc-photo-wrap').scrollIntoViewIfNeeded();
+ await page.waitForTimeout(120);
+ const wPreDrag = parseFloat(await page.locator('.rc-width').inputValue());
+ const hb = await page.locator('.rc-handle[data-corner="br"]').boundingBox();
+ const wb = await page.locator('.rc-photo-wrap').boundingBox();
+ await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(wb.x + wb.width * 0.45, wb.y + wb.height * 0.5, { steps: 8 });
+ await page.mouse.up();
+ await page.waitForTimeout(200);
+ const wPostDrag = parseFloat(await page.locator('.rc-width').inputValue());
+ ok(wPostDrag !== wPreDrag && wPostDrag > 0,
+ `dragging a box corner re-measures the wall (${wPreDrag} → ${wPostDrag})`);
+
await page.locator('.rc-room-label').fill('Dining room');
await page.locator('.rc-wallpaper summary').click();
@@ -146,6 +161,27 @@ function db() { if (!_db) _db = require('../lib/db'); return _db; }
} finally {
await db().query('DELETE FROM bookings WHERE id = $1', [row.id]);
}
+
+ // ── Phase 4 — mobile viewport (the capture flow's primary surface) ──
+ console.log('\n[phase 4] mobile viewport — 390px, touch');
+ const mctx = await browser.newContext({
+ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true
+ });
+ const mpage = await mctx.newPage();
+ try {
+ await mpage.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
+ const noOverflow = await mpage.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1
+ );
+ ok(noOverflow, 'no horizontal overflow at 390px');
+ await mpage.locator('.rc-photo-input').setInputFiles(FIXTURE);
+ await mpage.waitForSelector('.rc-stage:not([hidden])', { timeout: 8000 });
+ const hSize = await mpage.locator('.rc-handle[data-corner="br"]')
+ .evaluate(el => el.getBoundingClientRect().width);
+ ok(hSize >= 30, `drag handles finger-sized on touch (${Math.round(hSize)}px)`);
+ } finally {
+ await mctx.close();
+ }
} catch (err) {
console.error('\nFATAL:', err.message);
fail++;
diff --git a/views/public/book.ejs b/views/public/book.ejs
index 399e807..bf0b7b9 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -59,6 +59,21 @@
.rc-wp-note.is-error{color:#dc2626}
.rc-uploading{font-size:12px;color:var(--muted,#666);margin-top:8px}
.rc-uploading.is-error{color:#dc2626}
+ /* Touch devices — finger-sized drag handles (44px hit area via padding). */
+ @media (pointer: coarse) {
+ .rc-handle{width:34px;height:34px;margin:-17px}
+ }
+ /* Small screens — the capture flow is the primary surface (phone camera),
+ so stack the spec inputs, full-width the file buttons, cap photo height. */
+ @media (max-width: 560px) {
+ .room-capture{padding:12px}
+ .rc-room{padding:12px}
+ .rc-photo{max-height:58vh}
+ .rc-dims,.rc-wp-specs{flex-wrap:wrap}
+ .rc-dims label{flex:1 1 46%}
+ .rc-wp-specs label{flex:1 1 100%}
+ .rc-file-btn>span{display:block;text-align:center}
+ }
</style>
<section class="book-page">
← 5c5c9df e2e-room-capture: 14-assertion test for the camera quote flo
·
back to NationalPaperHangers
·
Render true half-drop in the on-wall wallpaper preview d69c431 →