← back to Quadrille Showroom
Senior-first Guided Mode: big no-keyboard nav (Prev/Next/Auto Tour/All Designs), Explore toggle off by default, large title+instruction
2b0c269a536f47ef789d8cc24bbc85a2559690a1 · 2026-06-27 17:19:40 -0700 · Steve
Files touched
M public/js/showroom.jsM public/showroom.htmlA scripts/test-senior.js
Diff
commit 2b0c269a536f47ef789d8cc24bbc85a2559690a1
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 27 17:19:40 2026 -0700
Senior-first Guided Mode: big no-keyboard nav (Prev/Next/Auto Tour/All Designs), Explore toggle off by default, large title+instruction
---
public/js/showroom.js | 205 +++++++++++++++++++++++++++++++++++++++++++++++--
public/showroom.html | 164 +++++++++++++++++++++++++++++++++++++--
scripts/test-senior.js | 175 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 530 insertions(+), 14 deletions(-)
diff --git a/public/js/showroom.js b/public/js/showroom.js
index c8e3fec..9196d1f 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -58,6 +58,14 @@ let TEXTURE_POOL = [];
const keysDown = {};
const WALK_SPEED = 2.5; // meters per second
+// GUIDED MODE (DTD verdict A, 2026-06-27) — senior-first default. The boot
+// experience is GUIDED: camera flies to the middle board open, big bottom-centre
+// Previous/Next/Auto-Tour buttons drive everything, NO keyboard + NO 3D walking
+// required. Free-walk (WASD/orbit) is demoted behind an 'Explore' toggle that is
+// OFF by default. exploreMode gates all walking/orbit/proximity input.
+let exploreMode = (localStorage.getItem('qh_explore') === '1');
+let guidedReady = false; // set true once the middle board has been auto-focused on load
+
// ============================================================
// INIT
// ============================================================
@@ -98,6 +106,11 @@ function init() {
controls.maxDistance = 8.0;
controls.target.set(0, 1.2, -0.5);
controls.panSpeed = 0.5;
+ // GUIDED MODE default: orbit/pan/zoom OFF unless Explore is on. applyExploreMode()
+ // is the single source of truth for this; called again after init() below.
+ controls.enableRotate = exploreMode;
+ controls.enablePan = exploreMode;
+ controls.enableZoom = exploreMode;
updateLoadStatus('Building materials...', 10);
initMaterials();
@@ -128,11 +141,13 @@ function init() {
} else if (e.code === 'KeyB') {
e.preventDefault(); toggleProximity();
} else if (e.code === 'Escape') {
- if (peruseActive) stopPeruse();
- else if (focusedWing) unfocusWing();
+ const gridOpen = document.getElementById('grid-overlay');
+ if (gridOpen && gridOpen.classList.contains('open')) { closeAllDesignsGrid(); return; }
+ if (peruseActive) { stopPeruse(exploreMode ? false : true); if (!exploreMode) enterGuidedDefault(); }
+ else if (focusedWing) { unfocusWing(); if (!exploreMode) enterGuidedDefault(); } // guided mode never lands on an empty room
}
- // Manual walking interrupts an active peruse
- if (peruseActive && (e.code.indexOf('Arrow') === 0 || ['KeyW','KeyA','KeyS','KeyD'].indexOf(e.code) >= 0)) stopPeruse();
+ // Manual walking interrupts an active peruse (only when Explore is on)
+ if (exploreMode && peruseActive && (e.code.indexOf('Arrow') === 0 || ['KeyW','KeyA','KeyS','KeyD'].indexOf(e.code) >= 0)) stopPeruse();
});
window.addEventListener('keyup', e => { keysDown[e.code] = false; });
initHUD();
@@ -808,10 +823,14 @@ async function loadProducts() {
updateLoadStatus('Ready!', 100);
setTimeout(() => {
document.getElementById('loading-screen').classList.add('fade-out');
+ // GUIDED MODE default (DTD verdict A): unless the user has switched to Explore,
+ // fly to the middle board open so the big Previous/Next/Auto-Tour buttons drive
+ // everything from the first second — no keyboard, no walking.
+ setTimeout(() => { if (!exploreMode) enterGuidedDefault(); }, 700);
setTimeout(() => {
const tip = document.getElementById('welcome-tip');
if (tip) tip.classList.add('fade-out');
- }, 6000);
+ }, 9000);
}, 400);
populateVendorSidebar();
startTVSlideshow();
@@ -1466,6 +1485,7 @@ function unfocusWing(skipPanel) {
if (!skipPanel) revertWalls();
if (!skipPanel) document.getElementById('wing-detail').classList.add('hidden');
if (!skipPanel) hideNowViewing(); // tuck the navigator strip away on a true exit
+ if (!skipPanel) hideGuidedTitle(); // and the big top-centre title
// Unlock first so smoothCameraTo can move
unlockControls();
@@ -1546,6 +1566,8 @@ function showWingDetail(product) {
// Feed the NOW VIEWING media-player strip in lock-step with the detail card.
updateNowViewing(product);
+ // GUIDED MODE: large readable title + N-of-N up top, for senior legibility.
+ updateGuidedTitle(product);
}
// NOW VIEWING navigator — populate the museum media-player strip from the focused
@@ -1654,6 +1676,7 @@ function onCanvasMouseMove(event) {
// WASD WALKING — first-person navigation
// ============================================================
function updateWASD(dt) {
+ if (!exploreMode) return; // GUIDED MODE: keyboard walking is off unless Explore is on
if (controlsLocked || cameraAnim) return; // no walking when focused on wing or animating
let moveX = 0, moveZ = 0;
@@ -1714,7 +1737,7 @@ const _bookWP = new THREE.Vector3();
function updateBooks(dt) {
if (!wingBoards.length) return;
const backZ = -CONFIG.room.depth / 2 + 0.2;
- const interactive = proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
+ const interactive = exploreMode && proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
// Find the single wing nearest the camera, but only once we're close to the wall.
let nearest = null;
@@ -1910,6 +1933,157 @@ function setPeruseBtn(on) {
b.innerHTML = on ? '■ Stop Perusing' : '▷ Peruse Collection';
}
syncNowViewingPlay(); // keep the navigator strip's play glyph in lock-step
+ syncGuidedTourBtn(); // keep the big Auto-Tour button in lock-step
+}
+
+// ============================================================
+// GUIDED MODE — senior-first navigation (DTD verdict A, 2026-06-27)
+// ============================================================
+
+// Apply the Explore on/off state everywhere: orbit controls, the toggle button
+// label/look, and the bottom instruction. OFF (default) = guided, no keyboard.
+function applyExploreMode() {
+ if (controls && !controlsLocked) {
+ controls.enableRotate = exploreMode;
+ controls.enablePan = exploreMode;
+ controls.enableZoom = exploreMode;
+ }
+ const btn = document.getElementById('btn-explore');
+ if (btn) {
+ btn.classList.toggle('active', exploreMode);
+ btn.textContent = exploreMode ? 'Explore: On' : 'Explore: Off';
+ btn.title = exploreMode
+ ? 'Free walking is ON — use W A S D to walk and drag to look around. Tap to turn off.'
+ : 'Turn on free walking with the keyboard (W A S D). Off by default.';
+ }
+ const bm = document.getElementById('btn-bookmatch');
+ if (bm) bm.style.display = exploreMode ? '' : 'none'; // walk-up book-match is an explore-only affordance
+ // Body class drives which nav surface shows: guided (big bar + title) vs explore
+ // (the compact Now-Viewing strip + bottom HUD). They share screen real estate, so
+ // only one set is visible at a time. The big detail card stays in both.
+ document.body.classList.toggle('explore-mode', exploreMode);
+ document.body.classList.toggle('guided-mode', !exploreMode);
+ updateGuidedInstruction();
+}
+
+function toggleExplore() {
+ exploreMode = !exploreMode;
+ localStorage.setItem('qh_explore', exploreMode ? '1' : '0');
+ applyExploreMode();
+ if (exploreMode) {
+ // Stepping into Explore: release any guided focus so the user can walk freely.
+ if (peruseActive) stopPeruse(true);
+ if (focusedWing) unfocusWing();
+ const t = document.getElementById('info-text');
+ if (t) t.textContent = 'Explore mode — W A S D to walk, drag to look around. Click a board to view it.';
+ } else {
+ // Back to guided: refly to a board so the big buttons have something to drive.
+ enterGuidedDefault();
+ }
+}
+
+function updateGuidedInstruction() {
+ const el = document.getElementById('guided-instruction');
+ if (!el) return;
+ if (peruseActive) {
+ el.innerHTML = 'Watching all designs… tap <strong>Pause</strong> to stop, or <strong>Next</strong> to step ahead.';
+ } else if (exploreMode) {
+ el.innerHTML = 'Explore mode: use <strong>W A S D</strong> to walk, or use the big buttons below.';
+ } else {
+ el.innerHTML = 'Tap <strong>Next</strong> to see the next design — or <strong>Auto Tour</strong> to watch them all.';
+ }
+}
+
+// Large readable pattern title (top-centre) + N-of-N position. Driven from the
+// focused board so it stays in lock-step with Now Viewing + the detail card.
+function updateGuidedTitle(product) {
+ const wrap = document.getElementById('guided-title');
+ if (!wrap) return;
+ if (!product) { wrap.classList.add('hidden'); return; }
+ wrap.classList.remove('hidden');
+ const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
+ const nm = document.getElementById('gt-name');
+ if (nm) nm.textContent = pattern || 'Pattern';
+ const cw = product.color || colorway || product.collection || '';
+ const colEl = document.getElementById('gt-color');
+ const dot = document.getElementById('gt-dot');
+ if (colEl) colEl.textContent = cw;
+ const idx = (typeof windowOffset === 'number' ? windowOffset : 0) +
+ (focusedWing && focusedWing.userData ? focusedWing.userData.index : 0) + 1;
+ const total = windowTotal || products.length || 0;
+ const posEl = document.getElementById('gt-pos');
+ if (posEl) posEl.textContent = total ? ('Design ' + idx + ' of ' + total) : '';
+ if (dot) dot.style.display = (cw && total) ? '' : 'none';
+}
+
+function hideGuidedTitle() {
+ const wrap = document.getElementById('guided-title');
+ if (wrap) wrap.classList.add('hidden');
+}
+
+// Keep the big Auto-Tour button glyph/label + state in sync with Peruse.
+function syncGuidedTourBtn() {
+ const b = document.getElementById('g-tour');
+ if (!b) return;
+ b.classList.toggle('playing', !!peruseActive);
+ b.innerHTML = peruseActive ? '❚❚ Pause' : '▷ Auto Tour';
+ b.title = peruseActive ? 'Pause the tour' : 'Watch every design hands-free';
+ updateGuidedInstruction();
+}
+
+// On load (and when leaving Explore), fly to the MIDDLE board, open it, and present
+// its design — so the big Previous/Next/Auto-Tour buttons immediately have a hero.
+function enterGuidedDefault() {
+ if (!wingBoards.length) return;
+ const mid = Math.floor(wingBoards.length / 2);
+ restoreBoards();
+ focusOnWing(wingBoards[mid]);
+ guidedReady = true;
+}
+
+// ALL DESIGNS — big-thumbnail grid overlay. Builds from the live window products;
+// tapping a card closes the overlay and flies to that board. Familiar web-grid
+// browse path for users who find spatial navigation hard.
+function buildAllDesignsGrid() {
+ const host = document.getElementById('grid-items');
+ if (!host) return;
+ host.innerHTML = '';
+ const list = products.slice(0, windowSize);
+ list.forEach((p, i) => {
+ const card = document.createElement('div');
+ card.className = 'go-card';
+ const img = document.createElement('img');
+ img.className = 'go-img';
+ img.loading = 'lazy';
+ const src = p.tile || p.image || p.room;
+ if (src) img.src = src.charAt(0) === '/' ? src : ('/api/proxy/image?url=' + encodeURIComponent(src));
+ img.alt = (p.pattern_name || p.name || 'Design');
+ const cap = document.createElement('div'); cap.className = 'go-cap';
+ const { pattern, colorway } = splitPatternColor(p.pattern_name || p.name);
+ const pat = document.createElement('div'); pat.className = 'go-pat'; pat.textContent = pattern || 'Pattern';
+ const col = document.createElement('div'); col.className = 'go-col';
+ col.textContent = p.color_name || p.color || colorway || '';
+ cap.appendChild(pat); cap.appendChild(col);
+ card.appendChild(img); card.appendChild(cap);
+ card.addEventListener('click', () => {
+ closeAllDesignsGrid();
+ if (peruseActive) stopPeruse(true);
+ restoreBoards();
+ if (wingBoards[i]) focusOnWing(wingBoards[i]);
+ });
+ host.appendChild(card);
+ });
+}
+
+function openAllDesignsGrid() {
+ if (peruseActive) stopPeruse(true);
+ buildAllDesignsGrid();
+ const ov = document.getElementById('grid-overlay');
+ if (ov) ov.classList.add('open');
+}
+function closeAllDesignsGrid() {
+ const ov = document.getElementById('grid-overlay');
+ if (ov) ov.classList.remove('open');
}
// ============================================================
@@ -2017,6 +2191,25 @@ function initHUD() {
stopPeruse(); windowSize = parseInt(dens.value) || 50; await fetchWindow(0); rebuildWingWall();
});
+ // ---- GUIDED MODE wiring (senior-first big buttons) ----
+ const gPrev = document.getElementById('g-prev');
+ const gNext = document.getElementById('g-next');
+ const gTour = document.getElementById('g-tour');
+ const gGrid = document.getElementById('g-grid');
+ if (gPrev) gPrev.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(-1); });
+ if (gNext) gNext.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(1); });
+ if (gTour) gTour.addEventListener('click', () => togglePeruse());
+ if (gGrid) gGrid.addEventListener('click', () => openAllDesignsGrid());
+
+ const goClose = document.getElementById('go-close');
+ if (goClose) goClose.addEventListener('click', () => closeAllDesignsGrid());
+
+ const exp = document.getElementById('btn-explore');
+ if (exp) exp.addEventListener('click', () => toggleExplore());
+
+ // Reflect the persisted Explore state on the controls + toggle button now.
+ applyExploreMode();
+
// Mobile d-pad → synthesized WASD
document.querySelectorAll('#dpad button').forEach(btn => {
const code = { f:'KeyW', b:'KeyS', l:'KeyA', r:'KeyD' }[btn.dataset.dir];
diff --git a/public/showroom.html b/public/showroom.html
index f2f5ef0..0440a43 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -9,6 +9,128 @@
<style>
/* ---- Quadrille House additions (peruse, window controls, mobile d-pad) ---- */
:root { --qh-gold:#c9a96e; }
+
+ /* ============================================================
+ GUIDED MODE — senior-first primary navigation (DTD verdict A)
+ Big, high-contrast, word-labelled controls at bottom-centre so a
+ 65-year-old needs NO keyboard and NO 3D walking. Tap targets >=56px tall.
+ ============================================================ */
+ #guided-bar {
+ position:fixed; left:50%; bottom:22px; transform:translateX(-50%);
+ z-index:40; display:flex; align-items:center; gap:14px;
+ background:rgba(12,12,17,0.90); border:1.5px solid rgba(201,169,110,0.45);
+ border-radius:18px; padding:12px 16px; backdrop-filter:blur(14px);
+ box-shadow:0 18px 50px -12px rgba(0,0,0,0.75);
+ }
+ #guided-bar.hidden { display:none; }
+ .g-btn {
+ min-height:60px; min-width:140px; padding:0 24px;
+ display:inline-flex; align-items:center; justify-content:center; gap:8px;
+ font-size:20px; font-weight:700; letter-spacing:.5px; cursor:pointer;
+ border-radius:12px; border:2px solid rgba(201,169,110,0.55);
+ background:rgba(255,255,255,0.06); color:#f0e6cf;
+ transition:background .15s, transform .1s, border-color .15s;
+ -webkit-user-select:none; user-select:none;
+ }
+ .g-btn:hover { background:rgba(201,169,110,0.22); border-color:#c9a96e; }
+ .g-btn:active { transform:translateY(1px); }
+ .g-btn:disabled { opacity:.4; cursor:default; }
+ /* The big primary Auto-Tour button — brass fill so it's the obvious hero action */
+ #g-tour {
+ min-width:200px; font-size:21px;
+ background:linear-gradient(180deg,#d8bd84,#b9933f); color:#1a1206;
+ border-color:#b9933f;
+ }
+ #g-tour:hover { background:linear-gradient(180deg,#e3cb95,#c5a04a); }
+ #g-tour.playing { background:linear-gradient(180deg,#d06a5a,#a8392b); color:#fff; border-color:#a8392b; }
+ /* The 'All Designs' grid button */
+ #g-grid { min-width:130px; }
+ /* Always-on plain instruction strip above the guided bar */
+ #guided-instruction {
+ position:fixed; left:50%; bottom:96px; transform:translateX(-50%);
+ z-index:39; max-width:90vw; text-align:center;
+ font-size:18px; font-weight:600; letter-spacing:.3px; color:#f5efe2;
+ background:rgba(12,12,17,0.74); border:1px solid rgba(201,169,110,0.3);
+ padding:9px 22px; border-radius:24px; backdrop-filter:blur(8px);
+ text-shadow:0 1px 3px rgba(0,0,0,0.8);
+ }
+ #guided-instruction.hidden { display:none; }
+ /* Large readable pattern title shown top-centre in guided mode */
+ #guided-title {
+ position:fixed; left:50%; top:64px; transform:translateX(-50%);
+ z-index:38; text-align:center; pointer-events:none;
+ background:rgba(12,12,17,0.62); border:1px solid rgba(201,169,110,0.25);
+ border-radius:14px; padding:10px 28px; backdrop-filter:blur(8px);
+ box-shadow:0 8px 30px -10px rgba(0,0,0,0.6);
+ }
+ #guided-title.hidden { display:none; }
+ #guided-title .gt-name {
+ font-family:Georgia,'Times New Roman',serif; font-size:30px; font-weight:600;
+ color:#f5efdc; line-height:1.1; text-shadow:0 2px 6px rgba(0,0,0,0.7);
+ }
+ #guided-title .gt-sub {
+ font-size:16px; color:#d8cfba; margin-top:4px; letter-spacing:1px;
+ font-variant-numeric:tabular-nums;
+ }
+ /* Explore toggle — small, top-right, OFF by default (demotes WASD) */
+ #btn-explore {
+ font-size:12px; letter-spacing:1px; text-transform:uppercase;
+ padding:6px 14px; min-height:36px; border-radius:18px; cursor:pointer;
+ border:1px solid rgba(201,169,110,0.4); background:rgba(255,255,255,0.05);
+ color:#c9a96e; font-weight:600;
+ }
+ #btn-explore.active { background:rgba(201,169,110,0.85); color:#1a1206; border-color:#c9a96e; }
+
+ /* ALL DESIGNS — big-thumbnail grid overlay (even-simpler browse path) */
+ #grid-overlay {
+ position:fixed; inset:0; z-index:60; background:rgba(8,8,12,0.96);
+ display:none; flex-direction:column;
+ }
+ #grid-overlay.open { display:flex; }
+ .go-head {
+ display:flex; align-items:center; justify-content:space-between;
+ padding:18px 28px; border-bottom:1px solid rgba(201,169,110,0.2); flex-shrink:0;
+ }
+ .go-title { font-family:Georgia,serif; font-size:26px; color:#f0e6cf; letter-spacing:1px; }
+ .go-close {
+ min-height:52px; min-width:120px; font-size:18px; font-weight:700; cursor:pointer;
+ border-radius:12px; border:2px solid rgba(201,169,110,0.5);
+ background:rgba(201,169,110,0.12); color:#f0e6cf;
+ }
+ .go-close:hover { background:rgba(201,169,110,0.28); }
+ #grid-items {
+ flex:1; overflow-y:auto; padding:24px 28px;
+ display:grid; gap:20px;
+ grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));
+ align-content:start;
+ }
+ .go-card {
+ cursor:pointer; border-radius:12px; overflow:hidden; background:#15151c;
+ border:1.5px solid rgba(201,169,110,0.22); transition:transform .12s, border-color .15s;
+ }
+ .go-card:hover { transform:translateY(-3px); border-color:#c9a96e; }
+ .go-card .go-img { width:100%; aspect-ratio:1/1.25; object-fit:cover; display:block; background:#1c1c24; }
+ .go-card .go-cap { padding:10px 12px; }
+ .go-card .go-pat { font-family:Georgia,serif; font-size:17px; color:#f0e6cf; line-height:1.2;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+ .go-card .go-col { font-size:13px; color:#a7a299; margin-top:2px; letter-spacing:.5px; }
+
+ /* Which nav surface shows depends on mode. Guided (default) = big bar + title;
+ Explore = compact Now-Viewing strip + bottom HUD instruction. One at a time. */
+ body.guided-mode #now-viewing { display:none !important; }
+ body.guided-mode #bottom-bar #info-text { visibility:hidden; } /* keep bar height + FPS, drop the keyboard hint */
+ body.guided-mode #minimap { display:none; } /* spatial minimap is an explore aid */
+ body.explore-mode #guided-bar,
+ body.explore-mode #guided-instruction,
+ body.explore-mode #guided-title { display:none !important; }
+ /* On small screens the big guided bar wraps; let it */
+ @media (max-width:720px){
+ #guided-bar { flex-wrap:wrap; width:92vw; justify-content:center; }
+ .g-btn { min-width:120px; font-size:17px; }
+ #g-tour { min-width:160px; }
+ #guided-title .gt-name { font-size:23px; }
+ }
+
.loader-logo, .logo-mark { letter-spacing:.04em; }
#btn-peruse {
background:linear-gradient(180deg,#d8bd84,#b9933f); color:#1a1206; border:none;
@@ -55,7 +177,7 @@
}
</style>
</head>
-<body>
+<body class="guided-mode">
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
@@ -80,6 +202,7 @@
<button class="nav-btn active" data-section="overview" title="Full View">Overview</button>
</div>
<div class="top-right">
+ <button id="btn-explore" title="Turn on free walking with the keyboard (W A S D). Off by default.">Explore: Off</button>
<button id="btn-bookmatch" class="icon-btn active" title="Open board — wings open within 4 ft to present the full continuous design (B)">📖</button>
<button id="btn-walls" class="icon-btn active" title="Wallpaper the room walls on focus">▦</button>
<button id="btn-music" class="icon-btn" title="Listen to Music">♫</button>
@@ -176,16 +299,41 @@
<span id="info-text">WASD to walk up to a wing · within 4 ft it opens to the full continuous design · Click to inspect · P to Peruse</span>
<span id="fps-counter">60 FPS</span>
</div>
+
+ <!-- GUIDED MODE — large readable pattern title (top-centre) -->
+ <div id="guided-title">
+ <div class="gt-name" id="gt-name"> </div>
+ <div class="gt-sub"><span id="gt-color"></span><span id="gt-dot"> · </span><span id="gt-pos"></span></div>
+ </div>
+
+ <!-- GUIDED MODE — always-visible one-line plain instruction -->
+ <div id="guided-instruction">Tap <strong>Next</strong> to see the next design — or <strong>Auto Tour</strong> to watch them all.</div>
+
+ <!-- GUIDED MODE — big senior-first control bar (no keyboard, no walking) -->
+ <div id="guided-bar">
+ <button class="g-btn" id="g-prev" title="See the previous design">◀ Previous</button>
+ <button class="g-btn" id="g-tour" title="Watch every design hands-free">▷ Auto Tour</button>
+ <button class="g-btn" id="g-next" title="See the next design">Next ▶</button>
+ <button class="g-btn" id="g-grid" title="See all designs as big pictures">☷ All Designs</button>
+ </div>
+ </div>
+
+ <!-- ALL DESIGNS — big-thumbnail grid overlay (even-simpler browse path) -->
+ <div id="grid-overlay">
+ <div class="go-head">
+ <span class="go-title">All Designs — tap a picture to view it</span>
+ <button class="go-close" id="go-close">× Close</button>
+ </div>
+ <div id="grid-items"></div>
</div>
- <!-- Welcome Tooltip -->
+ <!-- Welcome Tooltip — senior-first: explains the big buttons, not the keyboard -->
<div id="welcome-tip">
- <div class="tip-title">SHOWROOM CONTROLS</div>
- <div class="tip-row"><kbd>W A S D</kbd> Walk around</div>
- <div class="tip-row"><kbd>P</kbd> Peruse the collection (auto-tour)</div>
- <div class="tip-row"><kbd>Walk up</kbd> Within 4 ft a wing opens — full continuous design</div>
- <div class="tip-row"><kbd>Click</kbd> Inspect a wing · <kbd>B</kbd> open board</div>
- <div class="tip-row"><kbd>Space</kbd> Stop & inspect · <kbd>Esc</kbd> back</div>
+ <div class="tip-title">HOW TO BROWSE</div>
+ <div class="tip-row"><kbd>Next ▶</kbd> See the next design</div>
+ <div class="tip-row"><kbd>◀ Previous</kbd> See the design before</div>
+ <div class="tip-row"><kbd>▷ Auto Tour</kbd> Watch them all by itself</div>
+ <div class="tip-row"><kbd>☷ All Designs</kbd> See every design as big pictures</div>
</div>
<canvas id="showroom-canvas"></canvas>
diff --git a/scripts/test-senior.js b/scripts/test-senior.js
new file mode 100644
index 0000000..25f5d1c
--- /dev/null
+++ b/scripts/test-senior.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * test-senior.js — SENIOR-STYLE walkthrough. Drives ONLY the big on-screen guided
+ * buttons (Previous / Next / Auto Tour / All Designs) with the MOUSE. NO keyboard,
+ * NO 3D walking — proves a 65-year-old can browse the whole collection hands-on-mouse.
+ *
+ * Verifies: guided mode boots focused on a board; Next/Previous fly board-to-board;
+ * Auto Tour runs hands-free and Pause stops it; All Designs grid opens + a card jumps
+ * to a board; the big buttons are >=56px tall; the China Seas spec card stays intact;
+ * FPS stays healthy; zero console errors.
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/test-senior.js
+ */
+const path = require('path');
+const fs = require('fs');
+const { chromium } = require('playwright');
+
+const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
+const OUT = path.join(__dirname, '..', 'recordings');
+const SHOTS = path.join(OUT, 'shots');
+fs.mkdirSync(SHOTS, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+(async () => {
+ const browser = await chromium.launch({ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
+ const ctx = await browser.newContext({
+ viewport: { width: 1600, height: 900 },
+ recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
+ });
+ const page = await ctx.newPage();
+ const errors = [], fpsLog = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ // Ensure a clean GUIDED default (no persisted Explore from a prior run).
+ await page.addInitScript(() => { try { localStorage.removeItem('qh_explore'); } catch(e){} });
+
+ console.log('→ loading', URL);
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => window._wingBoards && window._wingBoards.length > 0, { timeout: 25000 }).catch(() => {});
+ await sleep(3500); // loader fade + guided auto-focus on the middle board
+
+ const sampleFps = async (tag) => {
+ const fps = await page.evaluate(() => { const el = document.getElementById('fps-counter'); return el ? parseInt(el.textContent) : null; });
+ if (fps > 0) fpsLog.push({ tag, fps });
+ return fps;
+ };
+
+ // --- Assert guided mode booted on a focused board (big title visible) ---
+ const guided = await page.evaluate(() => {
+ const t = document.getElementById('guided-title');
+ const bar = document.getElementById('guided-bar');
+ const exploreOff = !document.body.classList.contains('explore-mode');
+ return {
+ titleVisible: t && !t.classList.contains('hidden'),
+ barVisible: !!bar && getComputedStyle(bar).display !== 'none',
+ exploreOff,
+ titleText: document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : null,
+ pos: document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : null,
+ };
+ });
+ console.log(' guided boot:', JSON.stringify(guided));
+ await page.screenshot({ path: path.join(SHOTS, 'sr-01-guided-boot.png') });
+ await sampleFps('boot');
+
+ // --- Measure the big-button tap-target heights (must be >= 56px) ---
+ const btnSizes = await page.evaluate(() => {
+ const ids = ['g-prev', 'g-tour', 'g-next', 'g-grid'];
+ const out = {};
+ ids.forEach(id => { const el = document.getElementById(id); out[id] = el ? Math.round(el.getBoundingClientRect().height) : 0; });
+ return out;
+ });
+ console.log(' button heights:', JSON.stringify(btnSizes));
+
+ // --- Browse FORWARD with the big "Next ▶" button only (mouse clicks) ---
+ console.log('→ NEXT × 6 (mouse only, no keyboard)');
+ const seen = [];
+ for (let i = 0; i < 6; i++) {
+ await page.click('#g-next');
+ await sleep(1500);
+ const name = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
+ const pos = await page.evaluate(() => document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : '');
+ seen.push(pos);
+ if (i === 2) await page.screenshot({ path: path.join(SHOTS, 'sr-02-after-next.png') });
+ await sampleFps('next');
+ }
+ console.log(' positions seen via Next:', JSON.stringify(seen));
+
+ // --- Browse BACKWARD with "◀ Previous" ---
+ console.log('→ PREVIOUS × 3 (mouse only)');
+ for (let i = 0; i < 3; i++) { await page.click('#g-prev'); await sleep(1400); await sampleFps('prev'); }
+ await page.screenshot({ path: path.join(SHOTS, 'sr-03-after-prev.png') });
+
+ // --- AUTO TOUR (hands-free) then PAUSE — all via the one big brass button ---
+ console.log('→ AUTO TOUR (hands-free) for ~14s, then PAUSE');
+ await page.click('#g-tour'); // start
+ await sleep(2000);
+ const touring = await page.evaluate(() => document.getElementById('g-tour').classList.contains('playing'));
+ await sleep(12000);
+ await page.screenshot({ path: path.join(SHOTS, 'sr-04-auto-tour.png') });
+ await sampleFps('tour');
+ await page.click('#g-tour'); // pause (same button)
+ await sleep(1200);
+ const paused = await page.evaluate(() => !document.getElementById('g-tour').classList.contains('playing'));
+ console.log(' auto-tour started:', touring, '| paused via same button:', paused);
+
+ // --- ALL DESIGNS grid — open, then tap a big picture to jump to it ---
+ console.log('→ ALL DESIGNS grid: open + tap a card');
+ await page.click('#g-grid');
+ await sleep(1200);
+ const cardCount = await page.evaluate(() => document.querySelectorAll('#grid-items .go-card').length);
+ await page.screenshot({ path: path.join(SHOTS, 'sr-05-all-designs.png') });
+ console.log(' grid cards:', cardCount);
+ // Tap the 4th card
+ await page.click('#grid-items .go-card:nth-child(4)').catch(() => {});
+ await sleep(1800);
+ const gridClosed = await page.evaluate(() => !document.getElementById('grid-overlay').classList.contains('open'));
+ const afterCardName = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
+ await page.screenshot({ path: path.join(SHOTS, 'sr-06-after-grid-pick.png') });
+ console.log(' grid closed after pick:', gridClosed, '| now showing:', afterCardName);
+
+ // --- Page across the WHOLE collection with Next, confirming the window advances ---
+ console.log('→ paging the full collection with Next (window should advance past 8)');
+ let maxPos = 0;
+ for (let i = 0; i < 12; i++) {
+ await page.click('#g-next');
+ await sleep(1100);
+ const idx = await page.evaluate(() => {
+ const el = document.getElementById('gt-pos'); if (!el) return 0;
+ const m = el.textContent.match(/Design\s+(\d+)/); return m ? parseInt(m[1]) : 0;
+ });
+ if (idx > maxPos) maxPos = idx;
+ }
+ console.log(' highest design index reached via Next:', maxPos);
+ await sampleFps('paging');
+
+ // --- Spec card integrity (China Seas) ---
+ const specText = await page.evaluate(() => {
+ const grid = document.getElementById('detail-spec-grid');
+ if (!grid) return null;
+ return [...grid.querySelectorAll('.wd-spec')].map(r => r.querySelector('dt').textContent + ': ' + r.querySelector('dd').textContent);
+ });
+ console.log(' SPEC SHEET:', JSON.stringify(specText));
+
+ const stats = await page.evaluate(() => (window._getSceneStats ? window._getSceneStats() : null));
+ const fpsVals = fpsLog.map(f => f.fps).filter(n => n > 0);
+ const report = {
+ when: new Date().toISOString(),
+ mode: 'senior-buttons-only',
+ guidedBoot: guided,
+ buttonHeights: btnSizes,
+ buttonsMeetTarget: Object.values(btnSizes).every(h => h >= 56),
+ autoTourWorked: !!touring && !!paused,
+ gridCards: cardCount,
+ gridPickJumped: gridClosed,
+ highestIndexViaNext: maxPos,
+ specText,
+ errors, errorCount: errors.length,
+ fpsMin: fpsVals.length ? Math.min(...fpsVals) : null,
+ fpsAvg: fpsVals.length ? Math.round(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length) : null,
+ fpsSamples: fpsLog,
+ sceneStats: stats,
+ };
+ fs.writeFileSync(path.join(OUT, 'test-senior-report.json'), JSON.stringify(report, null, 2));
+ console.log('\n=== SENIOR REPORT ===');
+ console.log('errors:', report.errorCount, '| fps min/avg:', report.fpsMin, '/', report.fpsAvg,
+ '| buttons>=56:', report.buttonsMeetTarget, '| autoTour:', report.autoTourWorked,
+ '| gridPick:', report.gridPickJumped, '| reachedIndex:', report.highestIndexViaNext);
+ if (errors.length) console.log('ERRORS:', errors.slice(0, 8).join(' || '));
+
+ await page.close();
+ const vid = await page.video().path().catch(() => null);
+ await ctx.close(); await browser.close();
+ if (vid && fs.existsSync(vid)) { fs.renameSync(vid, path.join(OUT, 'test-senior.webm')); console.log('✓ test-senior.webm'); }
+})().catch(e => { console.error('SENIOR TEST FAILED:', e.message); process.exit(1); });
← d7f73cb REVIEW.md: pass 6 — gap A blocked (no hi-res source, 1000px
·
back to Quadrille Showroom
·
Realism: subtle additive-blend bloom halo on ceiling cove st ed4ad46 →