[object Object]

← 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

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 →