[object Object]

← back to NationalPaperHangers

Add standalone AR Wall Measurement Kit at /measure (P0+P1)

313ab5527c49019a6382254effe9ed30129d9f23 · 2026-06-01 12:41:06 -0700 · SteveStudio2

New designer/DIY tool that reuses the /book RoomMeasure engine (WebXR AR +
gyro clinometer) on a bare page and adds a rolls/panels calculator.

- public/js/measure-kit.js — pure UMD calc brain (US/Euro rolls + mural panels,
  per-wall door/window deductions, repeat-aware waste). 14 unit tests, all pass.
- views/public/measure.ejs — device-aware hero CTA, multi-wall measure + manual
  entry, live result card with plain-English buy line + dual handoff (shop DW /
  find an installer). Everything client-side; no server storage.
- routes/public.js — GET /measure + sitemap entry. header.ejs — nav link.
- DATA_POLICY.md §11 — on-device-only measurement, no AR frames leave phone.
- package.json — measure-kit tests wired into npm test (now 43/43).

/book installer flow untouched. Local only — deploy is Steve-gated.

Files touched

Diff

commit 313ab5527c49019a6382254effe9ed30129d9f23
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon Jun 1 12:41:06 2026 -0700

    Add standalone AR Wall Measurement Kit at /measure (P0+P1)
    
    New designer/DIY tool that reuses the /book RoomMeasure engine (WebXR AR +
    gyro clinometer) on a bare page and adds a rolls/panels calculator.
    
    - public/js/measure-kit.js — pure UMD calc brain (US/Euro rolls + mural panels,
      per-wall door/window deductions, repeat-aware waste). 14 unit tests, all pass.
    - views/public/measure.ejs — device-aware hero CTA, multi-wall measure + manual
      entry, live result card with plain-English buy line + dual handoff (shop DW /
      find an installer). Everything client-side; no server storage.
    - routes/public.js — GET /measure + sitemap entry. header.ejs — nav link.
    - DATA_POLICY.md §11 — on-device-only measurement, no AR frames leave phone.
    - package.json — measure-kit tests wired into npm test (now 43/43).
    
    /book installer flow untouched. Local only — deploy is Steve-gated.
---
 AR_MEASURE_KIT_SPEC.md    | 111 +++++++++++++++++
 DATA_POLICY.md            |  20 ++-
 package.json              |   2 +-
 public/js/measure-kit.js  | 134 ++++++++++++++++++++
 routes/public.js          |  10 ++
 tests/measure-kit.test.js | 103 ++++++++++++++++
 views/partials/header.ejs |   1 +
 views/public/measure.ejs  | 309 ++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 688 insertions(+), 2 deletions(-)

diff --git a/AR_MEASURE_KIT_SPEC.md b/AR_MEASURE_KIT_SPEC.md
new file mode 100644
index 0000000..2024b01
--- /dev/null
+++ b/AR_MEASURE_KIT_SPEC.md
@@ -0,0 +1,111 @@
+# AR Wall Measurement Kit — Standalone Product Spec
+
+**Status:** P0 + P1 BUILT — local only, not deployed (2026-06-01).
+Surface **A** (`/measure` on NPH), **both** handoffs (shop DW + find installer),
+manual width fallback for non-WebXR devices. Deploy is Steve-gated.
+**Brand:** National Paper Hangers (nationalpaperhangers.com)
+**Audience:** Interior designers + DIYers (NOT just installers booking a job)
+**Foundation:** Reuse the already-shipped `public/js/room-measure.js` engine verbatim.
+
+---
+
+## 1. Why this is mostly *extraction*, not new R&D
+
+The measurement brain already exists and ships in `/book`:
+
+- `window.RoomMeasure` (public/js/room-measure.js)
+  - `.method()` → `'webxr' | 'gyro' | null`
+  - `.measure()` → `Promise<{ width_ft, height_ft, method }>`
+  - WebXR (Android Chrome): immersive-ar + hit-test, 3 taps → true-scale **width + height**.
+  - Gyro (iPhone Safari): DeviceOrientation clinometer → **height only**.
+
+The standalone product = a **new public route/surface** that drives `RoomMeasure`
+for a multi-wall, calculator-first workflow — none of the existing `/book`
+installer flow is modified.
+
+## 2. What the product adds on top of the engine
+
+| Capability | Status today | Standalone adds |
+|---|---|---|
+| Single wall W×H | ✅ `RoomMeasure.measure()` | reuse as-is |
+| Multi-wall room (sum sq ft) | ❌ | accumulate N measurements |
+| Doors/windows subtraction | ❌ | optional deductions per wall |
+| **Rolls / panels needed** | ❌ none | core calculator (see §4) |
+| iPhone width fallback | partial (height only) | manual width entry / photo-box |
+| Save / share / export | ❌ | shareable result link + PDF/email |
+| Order handoff | ❌ | deep-link to DW / wallco / "find an installer" |
+
+## 3. Surface options (DECISION — needs Steve)
+
+- **A. New route on NPH** — `/measure` (or `/tools/measure`) on the existing
+  pm2 app. Cheapest, inherits SSL/branding/analytics, lives under the NPH brand.
+  **(Recommended.)**
+- **B. Standalone microsite** — own domain (e.g. measure.* / a new domain),
+  own pm2 process. More "product," more infra + DNS + cert work.
+- **C. Embeddable widget** — single JS bundle other DW sites drop in. Most
+  reusable, most engineering.
+
+Default unless told otherwise: **A**, with the engine factored so B/C stay open.
+
+## 4. Roll / panel calculator (the real value-add)
+
+Inputs: total wall area (ft²), product format, pattern repeat, roll/panel
+coverage, waste factor.
+
+- **Standard roll (US "single roll" ≈ 28 ft² usable, sold in double rolls):**
+  `rolls = ceil( area_with_waste / usable_per_roll )`
+- **Euro roll (~21" × 33 ft ≈ 57 ft²):** same shape, different constant.
+- **Mural / panel goods:** `panels = ceil( wall_width / panel_width )` per wall.
+- **Waste factor:** default +15%; +20–25% when pattern `repeat > 0`
+  (drop-match eats more). Repeat is a user input; preset by product when the
+  calc is launched from a DW/wallco PDP.
+- Output: rolls/panels **per wall** and **per room**, plus a plain-English
+  "buy N double-rolls" line and a "always confirm with your installer" caveat.
+
+> Numbers above are the standard trade rules of thumb; final constants get a
+> quick sanity pass against DW's existing product data before shipping.
+
+## 5. UX flow (designer/DIY, mobile-first)
+
+1. Landing: "Measure any wall with your phone — no tape measure." Device-aware
+   CTA (`RoomMeasure.label()` already returns the right button text per method).
+2. Measure wall 1 → show W×H + ft². "Add another wall" or "Done."
+3. (Optional) subtract a door/window.
+4. Pick product format + repeat (or auto-filled from a DW/wallco link).
+5. Result card: total ft², rolls/panels, waste assumption, share/export.
+6. Handoff CTAs: shop the look (DW/wallco) · find an installer (NPH core).
+
+## 6. Compliance / standing-rules checklist
+
+- [ ] Camera/motion permission only on explicit user tap (engine already does this).
+- [ ] Privacy: AR frames never leave the device; only final numbers are stored
+      *if* the user saves. Update DATA_POLICY.md.
+- [ ] If results are emailed → CAN-SPAM footer + the existing George/mailer path.
+- [ ] Disclaimer: estimates only, confirm before ordering (liability).
+- [ ] Admin-facing save list (if any) → created date+time chip per standing rule.
+- [ ] Any product grid shown → sort + density controls per standing rule.
+- [ ] Deploy/DNS (option B) → Steve-gated, never autonomous.
+
+## 7. Phased build plan
+
+- **P0 — Extract & prove (no new infra):** ✅ DONE. `/measure` route + view;
+  `RoomMeasure` loads on the bare page; device-aware CTA; single-wall measure →
+  ft² readout. (Real-phone AR pass still recommended before any deploy.)
+- **P1 — Calculator:** ✅ DONE. Multi-wall accumulation, optional door/window
+  deductions, roll/panel calc (§4) in `public/js/measure-kit.js` (14 unit tests,
+  all pass), live result card with plain-English buy line + both handoff CTAs.
+- **P2 — Save/share/export:** shareable link, PDF/email, deductions.
+- **P3 — Order handoff:** DW/wallco deep-links, repeat auto-fill from PDP,
+  "find an installer" tie-in to NPH core.
+- **P4 — (optional) widget/microsite:** only if option B or C is chosen.
+
+Deploy of anything customer-facing is gated on explicit Steve approval.
+
+## 8. Open questions for Steve
+
+1. Surface: **A (NPH /measure)**, B (microsite), or C (embeddable widget)?
+2. Audience handoff: should results push toward **DW/wallco purchase**,
+   **NPH installer booking**, or **both**?
+3. iPhone width: acceptable to ask for manual width entry (since Apple ships no
+   WebXR), or invest in a photo-reference width estimate?
+4. Scope for *this* session: P0 prototype only, or P0+P1 (measure + calculator)?
diff --git a/DATA_POLICY.md b/DATA_POLICY.md
index f1925a6..8d8380f 100644
--- a/DATA_POLICY.md
+++ b/DATA_POLICY.md
@@ -108,7 +108,25 @@ This policy is informed by:
 - **GDPR / UK GDPR** — if a UK/EU studio is scraped, legitimate-interests basis may apply for directory inclusion, but transparency notice and right-to-object are honored within one month per ICO guidance. Default opt-in for any EU outbound.
 - **California §6155 / Bar restrictions** do NOT apply (this is a wallcovering installer directory, not a lawyer-referral service). Cited only to keep the policy adjacent to `feedback_lawyer_directory_compliance.md` precedent.
 
-## 11. Changes
+## 11. Measurement tools (/measure, /book)
+
+The AR Wall Measurement Kit (`/measure`) and the room-capture step of `/book`
+measure walls using the visitor's own device sensors (WebXR AR camera + hit-test
+on Android Chrome, DeviceOrientation clinometer on iOS Safari).
+
+- **On-device only.** Camera and motion access are requested **only** on an
+  explicit user tap, never on page load. AR camera frames are processed live in
+  the browser and **never transmitted, uploaded, or stored** — only the derived
+  numbers (width/height in feet) exist, and only in the page's memory.
+- **No server storage in the standalone /measure tool.** Wall dimensions,
+  deductions, product format, and the resulting roll/panel estimate stay entirely
+  client-side; nothing is POSTed to or persisted on our servers. (The `/book`
+  flow is different: there, dimensions a user chooses to attach to a quote brief
+  are stored as part of that booking — covered by §4.)
+- **Estimates only.** Quantity figures are trade rules-of-thumb and carry an
+  on-screen "confirm with your installer/manufacturer before ordering" caveat.
+
+## 12. Changes
 
 Material changes to this policy require a new version number and a date in section 1. Prior versions are kept in git history.
 
diff --git a/package.json b/package.json
index b6c7a1b..e0bbbde 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "dev": "nodemon server.js",
     "schema": "psql $PGDATABASE < db/schema.sql",
     "seed": "psql $PGDATABASE < db/seed.sql",
-    "test": "node --test tests/smoke.test.js tests/compliance.test.js",
+    "test": "node --test tests/smoke.test.js tests/compliance.test.js tests/measure-kit.test.js",
     "test:e2e": "node tests/run-e2e.js",
     "test:all": "npm test && npm run test:e2e",
     "dm-drafts": "node scripts/generate-ig-dm-drafts.js",
diff --git a/public/js/measure-kit.js b/public/js/measure-kit.js
new file mode 100644
index 0000000..68aec9a
--- /dev/null
+++ b/public/js/measure-kit.js
@@ -0,0 +1,134 @@
+/* measure-kit.js — the roll / panel calculator brain for the AR Wall
+ * Measurement Kit (/measure). Pure arithmetic, zero DOM, zero network — so it
+ * unit-tests in Node and loads verbatim in the browser. The measurement
+ * SENSORS live in room-measure.js (window.RoomMeasure); this file only turns
+ * wall dimensions into "how much wallpaper do I buy."
+ *
+ * Trade rules of thumb encoded here (see AR_MEASURE_KIT_SPEC.md §4):
+ *   US standard roll  — ~28 ft² usable per single roll, SOLD in double rolls.
+ *   Euro roll         — ~21" × 33 ft ≈ 57 ft² usable, sold single.
+ *   Mural / panel      — sized by wall WIDTH ÷ panel width, per wall.
+ *   Waste              — +15% plain; +20% when the pattern has a repeat
+ *                        (drop-match eats more), user-overridable.
+ *
+ * Numbers are estimates. Every result carries a "confirm with your installer
+ * before ordering" caveat — final constants get a sanity pass vs DW product
+ * data before this ever ships customer-facing.
+ */
+(function (root, factory) {
+  if (typeof module === 'object' && module.exports) module.exports = factory();
+  else root.MeasureKit = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+  'use strict';
+
+  // Roll formats keyed by id. `usablePerSingle` is ft² of hangable paper after
+  // trimming; `soldInDoubles` means the store unit is two singles bonded.
+  var ROLL_FORMATS = {
+    us_standard: { id: 'us_standard', label: 'US standard roll', usablePerSingle: 28, soldInDoubles: true },
+    euro: { id: 'euro', label: 'Euro roll (21" × 33 ft)', usablePerSingle: 57, soldInDoubles: false }
+  };
+
+  function round1(n) { return Math.round(n * 10) / 10; }
+
+  // Default waste fraction for a given pattern repeat (inches).
+  // Repeat > 0 → drop-match alignment wastes more paper.
+  function defaultWaste(repeatIn) {
+    return (Number(repeatIn) > 0) ? 0.20 : 0.15;
+  }
+
+  // Net area of one wall in ft²: width×height minus any door/window deductions.
+  // A wall = { width_ft, height_ft, deductions:[{w_ft,h_ft}] } — deductions
+  // optional. Negative/garbage inputs clamp to 0.
+  function wallArea(wall) {
+    var w = Math.max(0, Number(wall.width_ft) || 0);
+    var h = Math.max(0, Number(wall.height_ft) || 0);
+    var gross = w * h;
+    var deduct = 0;
+    (wall.deductions || []).forEach(function (d) {
+      var dw = Math.max(0, Number(d.w_ft) || 0);
+      var dh = Math.max(0, Number(d.h_ft) || 0);
+      deduct += dw * dh;
+    });
+    var net = Math.max(0, gross - deduct);
+    return { gross: round1(gross), deduct: round1(deduct), net: round1(net) };
+  }
+
+  // Sum net area across N walls (ft²).
+  function totalArea(walls) {
+    return round1((walls || []).reduce(function (sum, wll) {
+      return sum + wallArea(wll).net;
+    }, 0));
+  }
+
+  // Roll-goods calculation from a total net area.
+  // opts: { formatId, repeatIn, waste } — waste overrides defaultWaste when a
+  // finite number ≥ 0 is passed.
+  function rollsForArea(netArea, opts) {
+    opts = opts || {};
+    var fmt = ROLL_FORMATS[opts.formatId] || ROLL_FORMATS.us_standard;
+    var waste = (typeof opts.waste === 'number' && isFinite(opts.waste) && opts.waste >= 0)
+      ? opts.waste : defaultWaste(opts.repeatIn);
+    var areaWithWaste = Math.max(0, Number(netArea) || 0) * (1 + waste);
+    var singles = Math.ceil(areaWithWaste / fmt.usablePerSingle);
+    if (!isFinite(singles) || singles < 0) singles = 0;
+    var result = {
+      format: fmt.label,
+      formatId: fmt.id,
+      wastePct: Math.round(waste * 100),
+      areaWithWaste: round1(areaWithWaste),
+      singleRolls: singles,
+      soldInDoubles: fmt.soldInDoubles
+    };
+    // Stores sell US paper as double rolls — two bonded singles.
+    if (fmt.soldInDoubles) result.doubleRolls = Math.ceil(singles / 2);
+    return result;
+  }
+
+  // Mural / panel goods: panels needed to span each wall's WIDTH. panelWidthIn
+  // is the printed panel width in inches (default 36"). Returns per-wall +
+  // total panel counts; height is the caller's responsibility (a mural panel
+  // must be ordered tall enough to cover the wall — we flag walls taller than
+  // panelMaxHeightFt if given).
+  function panelsForWalls(walls, opts) {
+    opts = opts || {};
+    var panelWidthFt = Math.max(0.5, (Number(opts.panelWidthIn) || 36) / 12);
+    var maxH = Number(opts.panelMaxHeightFt) || 0;
+    var per = (walls || []).map(function (wll, i) {
+      var w = Math.max(0, Number(wll.width_ft) || 0);
+      var h = Math.max(0, Number(wll.height_ft) || 0);
+      var panels = w > 0 ? Math.ceil(w / panelWidthFt) : 0;
+      return {
+        wallIndex: i,
+        panels: panels,
+        tooTall: (maxH > 0 && h > maxH)
+      };
+    });
+    return {
+      panelWidthIn: Math.round(panelWidthFt * 12),
+      perWall: per,
+      totalPanels: per.reduce(function (s, p) { return s + p.panels; }, 0)
+    };
+  }
+
+  // Plain-English buy line for a roll result.
+  function buyLine(rollResult) {
+    if (!rollResult || !rollResult.singleRolls) return 'Add a wall measurement to size your order.';
+    if (rollResult.soldInDoubles) {
+      var d = rollResult.doubleRolls;
+      return 'Buy ' + d + ' double roll' + (d === 1 ? '' : 's') +
+             ' (' + rollResult.singleRolls + ' single-roll equivalent) of ' + rollResult.format + '.';
+    }
+    var s = rollResult.singleRolls;
+    return 'Buy ' + s + ' roll' + (s === 1 ? '' : 's') + ' of ' + rollResult.format + '.';
+  }
+
+  return {
+    ROLL_FORMATS: ROLL_FORMATS,
+    defaultWaste: defaultWaste,
+    wallArea: wallArea,
+    totalArea: totalArea,
+    rollsForArea: rollsForArea,
+    panelsForWalls: panelsForWalls,
+    buyLine: buyLine
+  };
+});
diff --git a/routes/public.js b/routes/public.js
index 0e4abb9..ed2cb70 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -736,6 +736,7 @@ router.get('/sitemap.xml', async (req, res, next) => {
       `<url><loc>${base}/map</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`,
       `<url><loc>${base}/papers</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`,
       `<url><loc>${base}/watch</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>`,
+      `<url><loc>${base}/measure</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`,
       `<url><loc>${base}/for-installers</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>`,
       `<url><loc>${base}/about</loc><changefreq>monthly</changefreq><priority>0.5</priority></url>`,
       ...rows.map(r =>
@@ -1035,6 +1036,15 @@ router.get('/for-installers', (req, res) => {
   res.render('public/for-installers', { title: 'For installers · National Paper Hangers' });
 });
 
+// AR Wall Measurement Kit — phone-sensor wall measure + roll/panel calculator
+// for designers & DIYers. Reuses the /book RoomMeasure engine; no server data.
+router.get('/measure', (req, res) => {
+  res.render('public/measure', {
+    title: 'Measure your walls — AR Wall Measurement Kit · National Paper Hangers',
+    metaDescription: 'Measure any wall with your phone — no tape measure — then get an instant rolls-or-panels estimate for your wallpaper project. Free tool from National Paper Hangers.'
+  });
+});
+
 router.get('/privacy', (req, res) => {
   res.render('public/legal', { title: 'Privacy · National Paper Hangers', kind: 'privacy' });
 });
diff --git a/tests/measure-kit.test.js b/tests/measure-kit.test.js
new file mode 100644
index 0000000..43242ac
--- /dev/null
+++ b/tests/measure-kit.test.js
@@ -0,0 +1,103 @@
+'use strict';
+// Unit tests for the AR Measurement Kit roll/panel calculator (/measure).
+// Pure arithmetic — no server, no DOM. Run: node --test tests/measure-kit.test.js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const MK = require('../public/js/measure-kit');
+
+test('wallArea: gross with no deductions', () => {
+  const a = MK.wallArea({ width_ft: 10, height_ft: 8 });
+  assert.equal(a.gross, 80);
+  assert.equal(a.deduct, 0);
+  assert.equal(a.net, 80);
+});
+
+test('wallArea: subtracts a door + window', () => {
+  const a = MK.wallArea({
+    width_ft: 12, height_ft: 9,
+    deductions: [{ w_ft: 3, h_ft: 7 }, { w_ft: 4, h_ft: 3 }]
+  });
+  assert.equal(a.gross, 108);
+  assert.equal(a.deduct, 33); // 21 + 12
+  assert.equal(a.net, 75);
+});
+
+test('wallArea: clamps garbage/negative input to 0', () => {
+  const a = MK.wallArea({ width_ft: -5, height_ft: 'x' });
+  assert.equal(a.gross, 0);
+  assert.equal(a.net, 0);
+});
+
+test('totalArea: sums multiple walls net', () => {
+  const total = MK.totalArea([
+    { width_ft: 10, height_ft: 8 },               // 80
+    { width_ft: 12, height_ft: 8, deductions: [{ w_ft: 3, h_ft: 7 }] } // 96-21=75
+  ]);
+  assert.equal(total, 155);
+});
+
+test('defaultWaste: 15% plain, 20% with a repeat', () => {
+  assert.equal(MK.defaultWaste(0), 0.15);
+  assert.equal(MK.defaultWaste(18), 0.20);
+});
+
+test('rollsForArea: US standard sells double rolls', () => {
+  // 155 ft² × 1.15 = 178.25 → /28 = 6.36 → 7 singles → 4 doubles
+  const r = MK.rollsForArea(155, { formatId: 'us_standard', repeatIn: 0 });
+  assert.equal(r.wastePct, 15);
+  assert.equal(r.singleRolls, 7);
+  assert.equal(r.doubleRolls, 4);
+  assert.equal(r.soldInDoubles, true);
+});
+
+test('rollsForArea: repeat bumps default waste to 20%', () => {
+  // 155 × 1.20 = 186 → /28 = 6.64 → 7 singles
+  const r = MK.rollsForArea(155, { formatId: 'us_standard', repeatIn: 21 });
+  assert.equal(r.wastePct, 20);
+  assert.equal(r.singleRolls, 7);
+});
+
+test('rollsForArea: explicit waste override wins over repeat default', () => {
+  const r = MK.rollsForArea(100, { formatId: 'us_standard', repeatIn: 21, waste: 0.10 });
+  assert.equal(r.wastePct, 10);
+});
+
+test('rollsForArea: euro roll is sold single (no doubles)', () => {
+  // 155 × 1.15 = 178.25 → /57 = 3.13 → 4 singles
+  const r = MK.rollsForArea(155, { formatId: 'euro', repeatIn: 0 });
+  assert.equal(r.singleRolls, 4);
+  assert.equal(r.soldInDoubles, false);
+  assert.equal(r.doubleRolls, undefined);
+});
+
+test('rollsForArea: zero area → zero rolls', () => {
+  const r = MK.rollsForArea(0, { formatId: 'us_standard' });
+  assert.equal(r.singleRolls, 0);
+  assert.equal(r.doubleRolls, 0);
+});
+
+test('panelsForWalls: panels per wall by width, default 36"', () => {
+  // wall 1: 10 ft / 3 ft = 3.33 → 4 panels; wall 2: 6 ft / 3 = 2 panels
+  const p = MK.panelsForWalls([
+    { width_ft: 10, height_ft: 9 },
+    { width_ft: 6, height_ft: 9 }
+  ]);
+  assert.equal(p.panelWidthIn, 36);
+  assert.equal(p.perWall[0].panels, 4);
+  assert.equal(p.perWall[1].panels, 2);
+  assert.equal(p.totalPanels, 6);
+});
+
+test('panelsForWalls: flags walls taller than panel max height', () => {
+  const p = MK.panelsForWalls([{ width_ft: 8, height_ft: 12 }], { panelMaxHeightFt: 10 });
+  assert.equal(p.perWall[0].tooTall, true);
+});
+
+test('buyLine: double-roll phrasing', () => {
+  const r = MK.rollsForArea(155, { formatId: 'us_standard', repeatIn: 0 });
+  assert.match(MK.buyLine(r), /Buy 4 double rolls \(7 single-roll equivalent\)/);
+});
+
+test('buyLine: empty result prompts to measure', () => {
+  assert.match(MK.buyLine(MK.rollsForArea(0, {})), /Add a wall measurement/);
+});
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 46adfcd..3304581 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -9,6 +9,7 @@
       <a href="/find" class="<%= _navPath === '/find' ? 'is-current' : '' %>">Find an installer</a>
       <a href="/map" class="<%= _navPath === '/map' ? 'is-current' : '' %>">Map</a>
       <a href="/papers" class="<%= _navPath.startsWith('/papers') ? 'is-current' : '' %>">Papers</a>
+      <a href="/measure" class="<%= _navPath === '/measure' ? 'is-current' : '' %>">Measure</a>
       <a href="/watch" class="<%= _navPath === '/watch' ? 'is-current' : '' %>">Watch</a>
       <a href="/for-installers" class="<%= _navPath === '/for-installers' ? 'is-current' : '' %>">For installers</a>
       <a href="/about" class="<%= _navPath === '/about' ? 'is-current' : '' %>">About</a>
diff --git a/views/public/measure.ejs b/views/public/measure.ejs
new file mode 100644
index 0000000..f99bfc4
--- /dev/null
+++ b/views/public/measure.ejs
@@ -0,0 +1,309 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+<style>
+  /* ── rmx-* overlay styling — shared with /book's room-measure engine.
+        Copied (not linked) so /book is never touched by this page. ───── */
+  .rmx-overlay{position:fixed;inset:0;z-index:99999;display:flex;flex-direction:column;justify-content:flex-end;pointer-events:none}
+  .rmx-card{pointer-events:auto;background:rgba(12,12,12,0.94);color:#fff;border-radius:16px 16px 0 0;padding:18px 20px 22px;box-shadow:0 -8px 30px rgba(0,0,0,0.4);max-width:560px;width:100%;margin:0 auto}
+  .rmx-title{font-size:15px;font-weight:700;margin:0 0 8px}
+  .rmx-body{font-size:13px;line-height:1.5}
+  .rmx-instr{margin:0 0 10px}
+  .rmx-step{font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#f5c451;font-weight:700;margin:0 0 4px}
+  .rmx-status{margin:8px 0;font-size:12px;font-weight:600}
+  .rmx-hit-ok{color:#4ade80}
+  .rmx-hit-none{color:#fbbf24}
+  .rmx-readout{font-size:20px;font-weight:700;margin:8px 0;font-variant-numeric:tabular-nums}
+  .rmx-field{display:block;margin:8px 0}
+  .rmx-field input{display:block;width:100%;margin-top:4px;padding:10px;border-radius:8px;border:1px solid #555;background:#1a1a1a;color:#fff;font-size:16px}
+  .rmx-crosshair{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);font-size:54px;line-height:1;color:#f5c451;text-shadow:0 0 6px rgba(0,0,0,0.9);pointer-events:none}
+  .rmx-actions{display:flex;gap:10px;margin-top:16px}
+  .rmx-actions button{flex:1;padding:13px;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer;border:0}
+  .rmx-cancel{background:transparent;color:#bbb;border:1px solid #555}
+  .rmx-primary{background:#f5c451;color:#0a0a0a}
+  .rmx-primary:disabled{opacity:0.45;cursor:not-allowed}
+
+  /* ── measure-kit page ─────────────────────────────────────────────── */
+  .mk-hero{max-width:720px;margin:0 auto;padding:8px 0 4px;text-align:center}
+  .mk-hero .kicker{font-size:12px;letter-spacing:0.14em;text-transform:uppercase;color:var(--muted,#888);margin:0 0 8px}
+  .mk-hero h1{font-family:'Cormorant Garamond',Georgia,serif;font-weight:500;font-size:clamp(30px,6vw,46px);line-height:1.05;margin:0 0 12px}
+  .mk-hero .lede{font-size:16px;line-height:1.55;color:var(--muted,#555);max-width:560px;margin:0 auto 18px}
+  .mk-cta{display:inline-flex;align-items:center;gap:8px;background:var(--ink,#0a0a0a);color:#fff;border:0;padding:14px 26px;border-radius:999px;font-size:15px;font-weight:600;cursor:pointer}
+  .mk-cta:hover{opacity:0.92}
+  .mk-cta.is-gold{background:#f5c451;color:#0a0a0a}
+  .mk-method-note{font-size:12px;color:var(--muted,#888);margin:10px 0 0}
+
+  .mk-wrap{max-width:720px;margin:26px auto 0}
+  .mk-walls{display:flex;flex-direction:column;gap:12px;margin:0 0 16px}
+  .mk-wall{border:1px solid var(--border,#d4d2c8);border-radius:12px;padding:14px 16px;background:var(--card-bg,#fff)}
+  .mk-wall-head{display:flex;align-items:center;gap:8px;margin:0 0 10px}
+  .mk-wall-head strong{flex:1;font-size:14px}
+  .mk-wall-area{font-size:13px;color:#16a34a;font-weight:600;font-variant-numeric:tabular-nums}
+  .mk-remove{border:0;background:transparent;color:var(--muted,#999);font-size:18px;line-height:1;cursor:pointer;padding:2px 6px}
+  .mk-remove:hover{color:#dc2626}
+  .mk-dims{display:flex;gap:10px;flex-wrap:wrap}
+  .mk-field{flex:1;min-width:120px;font-size:12px;color:var(--muted,#666)}
+  .mk-field input,.mk-field select{display:block;width:100%;margin-top:4px;padding:10px;border-radius:8px;border:1px solid var(--border,#ccc);background:var(--bg,#fff);color:var(--ink,#111);font-size:16px}
+  .mk-row-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}
+  .mk-mini{font-size:12px;font-weight:600;border:1px solid var(--border,#ccc);background:transparent;color:var(--ink,#222);padding:7px 12px;border-radius:8px;cursor:pointer}
+  .mk-mini.is-primary{background:#f5c451;border-color:#f5c451}
+  .mk-deductions{margin-top:10px;border-top:1px dashed var(--border,#e3e1d8);padding-top:10px}
+  .mk-deduct{display:flex;gap:8px;align-items:flex-end;margin:0 0 8px}
+  .mk-deduct .mk-field{min-width:0}
+  .mk-add-wall{display:block;width:100%;border:2px dashed var(--border,#d4d2c8);background:transparent;color:var(--ink,#333);padding:13px;border-radius:12px;font-size:14px;font-weight:600;cursor:pointer;margin:0 0 18px}
+  .mk-add-wall:hover{border-color:#9aa39a}
+
+  .mk-calc{border:1px solid var(--border,#d4d2c8);border-radius:12px;padding:16px 18px;background:var(--bg-alt,#f8f7f2);margin:0 0 18px}
+  .mk-calc h2{font-size:16px;margin:0 0 12px}
+  .mk-calc-grid{display:flex;gap:10px;flex-wrap:wrap}
+
+  .mk-result{border:1px solid var(--ink,#0a0a0a);border-radius:14px;padding:20px 22px;background:var(--card-bg,#fff)}
+  .mk-result .total{font-size:13px;color:var(--muted,#666);margin:0 0 4px}
+  .mk-result .total b{font-size:22px;color:var(--ink,#0a0a0a);font-variant-numeric:tabular-nums}
+  .mk-buy{font-size:18px;font-weight:600;margin:10px 0;line-height:1.4}
+  .mk-perwall{font-size:12px;color:var(--muted,#666);margin:0 0 8px;line-height:1.6}
+  .mk-flag{font-size:12px;color:#b45309;background:#fef3c7;border-radius:8px;padding:8px 10px;margin:8px 0}
+  .mk-handoff{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
+  .mk-handoff a{flex:1;min-width:180px;text-align:center;text-decoration:none;padding:13px 16px;border-radius:10px;font-size:14px;font-weight:600}
+  .mk-shop{background:#f5c451;color:#0a0a0a}
+  .mk-installer{background:var(--ink,#0a0a0a);color:#fff}
+  .mk-disclaimer{font-size:11px;color:var(--muted,#888);line-height:1.5;margin:14px 0 0;border-top:1px solid var(--border,#eee);padding-top:12px}
+  .mk-privacy{font-size:12px;color:var(--muted,#888);text-align:center;max-width:560px;margin:18px auto 0;line-height:1.5}
+</style>
+
+<section class="mk-hero">
+  <p class="kicker">National Paper Hangers · Tools</p>
+  <h1>Measure any wall with your phone.</h1>
+  <p class="lede">No tape measure, no ladder. Point your phone at the wall, place three taps, and get true-scale width and height — then we'll tell you exactly how much wallpaper to buy.</p>
+  <button type="button" class="mk-cta is-gold" id="mk-hero-cta">📐 Measure your first wall</button>
+  <p class="mk-method-note" id="mk-method-note"></p>
+</section>
+
+<div class="mk-wrap">
+  <div class="mk-walls" id="mk-walls"></div>
+  <button type="button" class="mk-add-wall" id="mk-add-wall">+ Add another wall</button>
+
+  <div class="mk-calc">
+    <h2>How much wallpaper?</h2>
+    <div class="mk-calc-grid">
+      <label class="mk-field">Product format
+        <select id="mk-format">
+          <option value="us_standard">US standard roll (sold in double rolls)</option>
+          <option value="euro">Euro roll (21" × 33 ft)</option>
+          <option value="panels">Mural / panel goods</option>
+        </select>
+      </label>
+      <label class="mk-field" id="mk-repeat-field">Pattern repeat (in)
+        <input type="number" id="mk-repeat" min="0" max="120" step="0.5" value="0" inputmode="decimal">
+      </label>
+      <label class="mk-field" id="mk-waste-field">Waste factor (%)
+        <input type="number" id="mk-waste" min="0" max="60" step="1" placeholder="auto" inputmode="numeric">
+      </label>
+      <label class="mk-field mk-panel-only" id="mk-panelw-field" style="display:none">Panel width (in)
+        <input type="number" id="mk-panelw" min="6" max="60" step="1" value="36" inputmode="numeric">
+      </label>
+    </div>
+  </div>
+
+  <div class="mk-result" id="mk-result">
+    <p class="total">Total wall area: <b id="mk-total">0 ft²</b></p>
+    <p class="mk-buy" id="mk-buy">Add a wall measurement to size your order.</p>
+    <p class="mk-perwall" id="mk-perwall"></p>
+    <div id="mk-flags"></div>
+    <div class="mk-handoff">
+      <a class="mk-shop" href="https://designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Shop the look — Designer Wallcoverings</a>
+      <a class="mk-installer" href="/find">Find an installer near you</a>
+    </div>
+    <p class="mk-disclaimer">These figures are estimates based on standard trade rules of thumb and the dimensions entered. Pattern match, wall condition, and trim losses vary — <strong>always confirm quantities with your installer or the manufacturer before ordering.</strong> National Paper Hangers is a referral and lead-coordination service and does not warrant installation work.</p>
+  </div>
+
+  <p class="mk-privacy">🔒 Privacy: measurement happens entirely on your device. Camera and motion access are only requested when you tap to measure, AR camera frames never leave your phone, and nothing you enter here is sent to or stored on our servers.</p>
+</div>
+
+<script src="/js/room-measure.js" defer></script>
+<script src="/js/measure-kit.js" defer></script>
+<script>
+(function () {
+  'use strict';
+  var walls = [];                 // [{ width_ft, height_ft, method, deductions:[] }]
+  var nextId = 1;
+
+  var $ = function (id) { return document.getElementById(id); };
+  var wallsEl = $('mk-walls');
+
+  function num(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
+  function fmtFt(n) { return (Math.round(n * 10) / 10) + ' ft'; }
+
+  // ── render the wall list ────────────────────────────────────────────
+  function renderWalls() {
+    wallsEl.innerHTML = '';
+    walls.forEach(function (wall, i) {
+      var area = window.MeasureKit ? window.MeasureKit.wallArea(wall) : { net: 0 };
+      var card = document.createElement('div');
+      card.className = 'mk-wall';
+      var deductHtml = (wall.deductions || []).map(function (d, di) {
+        return '<div class="mk-deduct">' +
+          '<label class="mk-field">Door/window W (ft)<input type="number" min="0" step="0.5" inputmode="decimal" data-k="dw" data-w="' + i + '" data-d="' + di + '" value="' + (d.w_ft || '') + '"></label>' +
+          '<label class="mk-field">H (ft)<input type="number" min="0" step="0.5" inputmode="decimal" data-k="dh" data-w="' + i + '" data-d="' + di + '" value="' + (d.h_ft || '') + '"></label>' +
+          '<button type="button" class="mk-remove" data-rmdeduct="' + i + '" data-d="' + di + '" aria-label="Remove deduction">×</button>' +
+        '</div>';
+      }).join('');
+
+      card.innerHTML =
+        '<div class="mk-wall-head">' +
+          '<strong>Wall ' + (i + 1) + '</strong>' +
+          '<span class="mk-wall-area">' + area.net + ' ft²</span>' +
+          '<button type="button" class="mk-remove" data-rmwall="' + i + '" aria-label="Remove wall">×</button>' +
+        '</div>' +
+        '<div class="mk-dims">' +
+          '<label class="mk-field">Width (ft)<input type="number" min="0" step="0.5" inputmode="decimal" data-k="w" data-w="' + i + '" value="' + (wall.width_ft || '') + '"></label>' +
+          '<label class="mk-field">Height (ft)<input type="number" min="0" step="0.5" inputmode="decimal" data-k="h" data-w="' + i + '" value="' + (wall.height_ft || '') + '"></label>' +
+        '</div>' +
+        '<div class="mk-row-actions">' +
+          '<button type="button" class="mk-mini is-primary" data-measure="' + i + '" id="mk-measure-' + i + '">📐 Measure with phone</button>' +
+          '<button type="button" class="mk-mini" data-adddeduct="' + i + '">+ Subtract a door/window</button>' +
+        '</div>' +
+        (deductHtml ? '<div class="mk-deductions">' + deductHtml + '</div>' : '');
+      wallsEl.appendChild(card);
+    });
+    // device-aware measure button labels
+    if (window.RoomMeasure) {
+      window.RoomMeasure.ready.then(function () {
+        var lbl = window.RoomMeasure.label();
+        walls.forEach(function (_, i) {
+          var b = $('mk-measure-' + i);
+          if (b) {
+            if (lbl) { b.textContent = lbl; b.disabled = false; }
+            else { b.textContent = '📐 No phone sensors — type dimensions'; b.disabled = true; }
+          }
+        });
+      });
+    }
+    recompute();
+  }
+
+  // ── calculator ──────────────────────────────────────────────────────
+  function recompute() {
+    if (!window.MeasureKit) return;
+    var MK = window.MeasureKit;
+    var total = MK.totalArea(walls);
+    $('mk-total').textContent = total + ' ft²';
+
+    var format = $('mk-format').value;
+    var repeat = num($('mk-repeat').value);
+    var wasteRaw = $('mk-waste').value.trim();
+    var waste = wasteRaw === '' ? undefined : (num(wasteRaw) / 100);
+
+    // auto-fill the waste placeholder so the assumption is visible
+    $('mk-waste').placeholder = Math.round(MK.defaultWaste(repeat) * 100) + ' (auto)';
+
+    var buyEl = $('mk-buy'), perEl = $('mk-perwall'), flagsEl = $('mk-flags');
+    flagsEl.innerHTML = '';
+
+    if (format === 'panels') {
+      $('mk-panelw-field').style.display = '';
+      $('mk-repeat-field').style.display = 'none';
+      $('mk-waste-field').style.display = 'none';
+      var pres = MK.panelsForWalls(walls, { panelWidthIn: num($('mk-panelw').value) || 36 });
+      buyEl.textContent = total > 0
+        ? 'Buy ' + pres.totalPanels + ' panel' + (pres.totalPanels === 1 ? '' : 's') +
+          ' (' + pres.panelWidthIn + '" wide) to span all walls.'
+        : 'Add a wall measurement to size your order.';
+      perEl.innerHTML = pres.perWall.map(function (p) {
+        return 'Wall ' + (p.wallIndex + 1) + ': ' + p.panels + ' panel' + (p.panels === 1 ? '' : 's');
+      }).join(' · ');
+      flagsEl.innerHTML = '<div class="mk-flag">Mural panels must also be ordered tall enough to cover each wall’s height — check the panel length against your tallest wall.</div>';
+    } else {
+      $('mk-panelw-field').style.display = 'none';
+      $('mk-repeat-field').style.display = '';
+      $('mk-waste-field').style.display = '';
+      var r = MK.rollsForArea(total, { formatId: format, repeatIn: repeat, waste: waste });
+      buyEl.textContent = MK.buyLine(r);
+      perEl.innerHTML = total > 0
+        ? 'Based on ' + total + ' ft² + ' + r.wastePct + '% waste = ' + r.areaWithWaste + ' ft² of paper.'
+        : '';
+    }
+  }
+
+  // ── measure via RoomMeasure engine ──────────────────────────────────
+  function measureInto(i) {
+    if (!window.RoomMeasure) return;
+    window.RoomMeasure.measure().then(function (m) {
+      if (m.width_ft != null) walls[i].width_ft = m.width_ft;
+      if (m.height_ft != null) walls[i].height_ft = m.height_ft;
+      walls[i].method = m.method;
+      renderWalls();
+      // gyro gives height only — nudge the user to fill width
+      if (m.width_ft == null && m.method === 'gyro') {
+        var wb = wallsEl.querySelector('input[data-k="w"][data-w="' + i + '"]');
+        if (wb) { wb.focus(); }
+      }
+    }).catch(function (err) {
+      if (err && err.message && err.message !== 'cancelled') {
+        alert(err.message);
+      }
+    });
+  }
+
+  function addWall() { walls.push({ width_ft: null, height_ft: null, deductions: [] }); renderWalls(); }
+
+  // ── events (delegated) ──────────────────────────────────────────────
+  wallsEl.addEventListener('input', function (e) {
+    var t = e.target, k = t.getAttribute('data-k');
+    if (!k) return;
+    var wi = +t.getAttribute('data-w');
+    if (k === 'w') walls[wi].width_ft = num(t.value);
+    else if (k === 'h') walls[wi].height_ft = num(t.value);
+    else if (k === 'dw' || k === 'dh') {
+      var di = +t.getAttribute('data-d');
+      walls[wi].deductions[di][k === 'dw' ? 'w_ft' : 'h_ft'] = num(t.value);
+    }
+    // update just the area chips + result without nuking focus
+    var chips = wallsEl.querySelectorAll('.mk-wall-area');
+    walls.forEach(function (wall, idx) {
+      if (chips[idx]) chips[idx].textContent = window.MeasureKit.wallArea(wall).net + ' ft²';
+    });
+    recompute();
+  });
+
+  wallsEl.addEventListener('click', function (e) {
+    var t = e.target;
+    if (t.hasAttribute('data-measure')) measureInto(+t.getAttribute('data-measure'));
+    else if (t.hasAttribute('data-rmwall')) { walls.splice(+t.getAttribute('data-rmwall'), 1); renderWalls(); }
+    else if (t.hasAttribute('data-adddeduct')) { walls[+t.getAttribute('data-adddeduct')].deductions.push({ w_ft: null, h_ft: null }); renderWalls(); }
+    else if (t.hasAttribute('data-rmdeduct')) {
+      walls[+t.getAttribute('data-rmdeduct')].deductions.splice(+t.getAttribute('data-d'), 1); renderWalls();
+    }
+  });
+
+  $('mk-add-wall').addEventListener('click', addWall);
+  $('mk-hero-cta').addEventListener('click', function () {
+    if (!walls.length) addWall();
+    // if a phone method is available, jump straight into measuring wall 1
+    if (window.RoomMeasure) {
+      window.RoomMeasure.ready.then(function () {
+        if (window.RoomMeasure.method()) measureInto(0);
+        else { var b = wallsEl.querySelector('input[data-k="w"]'); if (b) b.focus(); }
+      });
+    }
+  });
+  ['mk-format', 'mk-repeat', 'mk-waste', 'mk-panelw'].forEach(function (id) {
+    $(id).addEventListener('input', recompute);
+    $(id).addEventListener('change', recompute);
+  });
+
+  // method note in the hero
+  if (window.RoomMeasure) {
+    window.RoomMeasure.ready.then(function () {
+      var m = window.RoomMeasure.method();
+      $('mk-method-note').textContent =
+        m === 'webxr' ? 'Your device supports full AR — measure width AND height.'
+        : m === 'gyro' ? 'Your device measures wall HEIGHT with its motion sensor; type the width in.'
+        : 'This device has no measurement sensors — type your dimensions below (or open this page on your phone).';
+    });
+  }
+
+  addWall();   // start with one wall ready
+})();
+</script>
+
+<%- include('../partials/footer') %>

← b18f55a Add per-site favicon (kills /favicon.ico 404)  ·  back to NationalPaperHangers  ·  Add P3 order-handoff to /measure: PDP deep-link prefill + ho 8c10bac →