← back to Quadrille Showroom
Phase 5 senior-usability HARD gate: measured verifier + 3 cascade/wiring fixes
d84af5b278b43d76ed11c949f2927e20b33effe7 · 2026-06-29 14:42:03 -0700 · Steve
Add scripts/verify-ageview-phase5.mjs — measures RENDERED composited hex (not
declared tokens) over the live WebGL scene on real-GPU Chrome, the contrarian
fix #1 requirement. It caught 3 real gaps the in-code seniorGate() self-test
passes:
1. CTA dead: #g-tour is a .g-btn, so '#guided-bar .g-btn' (color:--av-fg)
out-specified the '#g-tour' cta rule -> CTA text rendered in --av-fg over
--av-cta (APCA 9/9/0). Fix: name both ids to win the cascade (Lc now 99/99/101).
2. Body type-size vars set but never applied to #guided-instruction (stayed 18px).
Fix: wire --av-body/--av-head onto the real text elements (now 20/22/28 + 30/32/44).
3. #btn-ageview (36px) + #av-slider track (18px) under the senior target floor.
Fix: grow to >=48px (65/75) / >=56px (85).
Measured verdict: FPS>=67 all 11 bands, senior 65/75/85 PASS, regression OFF==age28,
0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/js/ageview.jsA scripts/verify-ageview-phase5.mjs
Diff
commit d84af5b278b43d76ed11c949f2927e20b33effe7
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 29 14:42:03 2026 -0700
Phase 5 senior-usability HARD gate: measured verifier + 3 cascade/wiring fixes
Add scripts/verify-ageview-phase5.mjs — measures RENDERED composited hex (not
declared tokens) over the live WebGL scene on real-GPU Chrome, the contrarian
fix #1 requirement. It caught 3 real gaps the in-code seniorGate() self-test
passes:
1. CTA dead: #g-tour is a .g-btn, so '#guided-bar .g-btn' (color:--av-fg)
out-specified the '#g-tour' cta rule -> CTA text rendered in --av-fg over
--av-cta (APCA 9/9/0). Fix: name both ids to win the cascade (Lc now 99/99/101).
2. Body type-size vars set but never applied to #guided-instruction (stayed 18px).
Fix: wire --av-body/--av-head onto the real text elements (now 20/22/28 + 30/32/44).
3. #btn-ageview (36px) + #av-slider track (18px) under the senior target floor.
Fix: grow to >=48px (65/75) / >=56px (85).
Measured verdict: FPS>=67 all 11 bands, senior 65/75/85 PASS, regression OFF==age28,
0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/js/ageview.js | 28 +++-
scripts/verify-ageview-phase5.mjs | 297 ++++++++++++++++++++++++++++++++++++++
2 files changed, 324 insertions(+), 1 deletion(-)
diff --git a/public/js/ageview.js b/public/js/ageview.js
index 8e353ed..8835c6b 100644
--- a/public/js/ageview.js
+++ b/public/js/ageview.js
@@ -143,13 +143,39 @@ function injectChrome() {
min-height:var(--av-btn) !important; font-size:var(--av-body) !important;
color:var(--av-fg) !important; border-color:var(--av-border) !important;
}
- body.ageview-on #g-tour, body.ageview-on #ageview-bar #av-cta {
+ /* CTA — must out-specify the #guided-bar .g-btn rule above (which sets color:--av-fg).
+ #g-tour IS a .g-btn, so we name BOTH ids (#guided-bar #g-tour → 2 ids) to win the
+ cascade; otherwise the primary CTA renders its text in --av-fg over --av-cta and the
+ senior APCA gate measures ~0 (Phase-5 finding 2026-06-29). */
+ body.ageview-on #guided-bar #g-tour,
+ body.ageview-on #g-tour,
+ body.ageview-on #ageview-bar #av-cta {
background:var(--av-cta) !important; color:var(--av-cta-fg) !important; border-color:var(--av-cta) !important;
}
body.ageview-on #guided-instruction strong,
body.ageview-on .gt-name { color:var(--av-fg) !important; }
body.ageview-on .gt-sub, body.ageview-on #info-text { color:var(--av-muted) !important; }
+ /* TYPE-SIZE banded snap on the actual TEXT elements. The band tokens set --av-body /
+ --av-head as vars, but without these rules the body copy stays at the showroom's
+ native px and the senior type-size gate (≥20/22/28px body, ≥30/32/44px head) fails on
+ measured size (Phase-5 finding 2026-06-29). Applied to the real gate elements only,
+ so non-senior layout is unchanged in shape; FPS is re-profiled at every band. */
+ body.ageview-on #guided-instruction,
+ body.ageview-on #guided-instruction * { font-size:var(--av-body) !important; line-height:1.5 !important; }
+ body.ageview-on .gt-name,
+ body.ageview-on #gt-name,
+ body.ageview-on #guided-title { font-size:var(--av-head) !important; }
+
+ /* SENIOR HIT-TARGET floor on the two age-chrome controls that were under-sized:
+ the Age View toggle (was a fixed 36px) and the slider element box (track was 18px).
+ Grow them to ≥48px (65/75) / ≥56px (85) so every interactive control measures over the
+ senior target floor (Phase-5 finding 2026-06-29). Visual track stays bold/high-contrast. */
+ body.age-opaque #btn-ageview { min-height:48px !important; padding:10px 18px !important; }
+ body.age-legacy #btn-ageview { min-height:56px !important; font-size:max(20px, var(--av-body)) !important; }
+ body.age-opaque #av-slider { height:48px !important; border-radius:12px !important; }
+ body.age-legacy #av-slider { height:56px !important; }
+
/* Senior info-complexity: hide decorative/secondary chrome so the path is unobstructed. */
body.age-opaque #vendor-sidebar,
body.age-opaque #minimap,
diff --git a/scripts/verify-ageview-phase5.mjs b/scripts/verify-ageview-phase5.mjs
new file mode 100644
index 0000000..9665874
--- /dev/null
+++ b/scripts/verify-ageview-phase5.mjs
@@ -0,0 +1,297 @@
+/* ============================================================================
+ * verify-ageview-phase5.mjs — Phase 5 HARD gates for the AGE VIEW slider.
+ *
+ * This is the MEASURED gate the AGE-VIEWS-SPEC §7 demands — it does NOT trust the
+ * in-code seniorGate() (which asserts on DECLARED band tokens). It drives the LIVE
+ * page on a REAL GPU and measures the ACTUALLY-RENDERED, COMPOSITED hex over the
+ * live WebGL scene (contrarian fix #1), the rendered control sizes, and FPS.
+ *
+ * Gates:
+ * 5.1 FPS — ≥55fps at every band (real-GPU chrome, channel:'chrome').
+ * 5.2 SENIOR (65/75/85, blocking):
+ * - APCA on MEASURED composited hex: body Lc≥60, CTA Lc≥75.
+ * - target size ≥48px (65/75) / ≥56px (85), measured boundingRect.
+ * - type ≥20px(65)→≥28px(85); heading ≥30px(65)→≥44px(85), measured.
+ * - slider control: ≥56px hit area + label ≥28px + label Lc≥75 at 85.
+ * - opaque check: every senior HUD panel's effective bg alpha == 1.
+ * Regression: Age View OFF == avatar age 28 (zero-arg rig).
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-ageview-phase5.mjs
+ * ========================================================================== */
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
+const USER = process.env.SHOWROOM_USER || 'admin';
+const PASS = process.env.SHOWROOM_PASS || 'DWSecure2024!';
+const OUT = path.join(__dirname, '..', 'recordings', 'phase5');
+fs.mkdirSync(OUT, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+const ALL_AGES = [12, 16, 21, 28, 35, 40, 45, 55, 65, 75, 85];
+const SENIOR_AGES = [65, 75, 85];
+const FPS_FLOOR = 55;
+
+(async () => {
+ const browser = await pw.chromium.launch({
+ channel: 'chrome', // REAL GPU — SwiftShader would fail FPS artificially
+ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'],
+ });
+ const ctx = await browser.newContext({
+ viewport: { width: 1600, height: 900 },
+ httpCredentials: { username: USER, password: PASS },
+ });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ // Always boot fresh: Age View OFF, guided mode (no persisted Explore).
+ await page.addInitScript(() => {
+ try { localStorage.removeItem('qh_ageview_on'); localStorage.removeItem('qh_ageview_age');
+ localStorage.removeItem('qh_explore'); } catch (e) {}
+ });
+
+ console.log('→ loading', URL);
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => window._qh && window._qh.wingBoards, { timeout: 25000 }).catch(() => {});
+ await page.waitForFunction(() => window._ageview && typeof window._ageview.apply === 'function', { timeout: 15000 });
+ await sleep(3500); // loader fade + guided auto-focus
+
+ // ---------- In-page measurement helpers (injected once) ----------
+ await page.evaluate(() => {
+ // Parse computed rgb/rgba → {r,g,b,a}
+ window.__parseRGB = (s) => {
+ const m = s && s.match(/rgba?\(([^)]+)\)/);
+ if (!m) return null;
+ const p = m[1].split(',').map(x => parseFloat(x.trim()));
+ return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] };
+ };
+ const toHex = (r, g, b) => '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
+ // Effective background of an element: walk ancestors until a fully-opaque bg is found.
+ // Returns {hex, opaque} — opaque=false means we fell through to (transparent) over the canvas.
+ window.__effectiveBg = (el) => {
+ let node = el;
+ while (node && node !== document.documentElement) {
+ const bg = window.__parseRGB(getComputedStyle(node).backgroundColor);
+ if (bg && bg.a >= 0.999) return { hex: toHex(bg.r, bg.g, bg.b), opaque: true, from: node.id || node.className || node.tagName };
+ node = node.parentElement;
+ }
+ const body = window.__parseRGB(getComputedStyle(document.body).backgroundColor);
+ if (body && body.a >= 0.999) return { hex: toHex(body.r, body.g, body.b), opaque: true, from: 'body' };
+ return { hex: null, opaque: false, from: 'canvas/transparent' };
+ };
+ window.__textHex = (el) => {
+ const c = window.__parseRGB(getComputedStyle(el).color);
+ return c ? toHex(c.r, c.g, c.b) : null;
+ };
+ window.__visible = (el) => {
+ if (!el) return false;
+ const cs = getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) < 0.05) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 1 && r.height > 1;
+ };
+ window.__fontPx = (el) => parseFloat(getComputedStyle(el).fontSize) || 0;
+ window.__rectH = (el) => el.getBoundingClientRect().height;
+ });
+
+ // FPS sampler — reads the live #fps-counter over a window, returns min/avg.
+ async function sampleFps(ms = 2200) {
+ const samples = [];
+ const t0 = Date.now();
+ while (Date.now() - t0 < ms) {
+ const f = await page.evaluate(() => {
+ const el = document.getElementById('fps-counter');
+ if (!el) return null;
+ const n = parseInt(el.textContent);
+ return isNaN(n) ? null : n;
+ });
+ if (f && f > 0) samples.push(f);
+ await sleep(200);
+ }
+ if (!samples.length) return { min: null, avg: null, n: 0 };
+ return { min: Math.min(...samples), avg: Math.round(samples.reduce((a, b) => a + b) / samples.length), n: samples.length };
+ }
+
+ const report = { url: URL, fps: {}, senior: {}, regression: {}, errorsDuring: [], verdict: null };
+
+ // ---------- Turn Age View ON ----------
+ await page.evaluate(() => window._ageview.setOn(true));
+ await sleep(600);
+ const isOn = await page.evaluate(() => window._ageview.on);
+ console.log('Age View ON =', isOn);
+
+ // ---------- 5.1 FPS gate across every band ----------
+ console.log('\n=== 5.1 FPS gate (≥' + FPS_FLOOR + ' at every band) ===');
+ for (const age of ALL_AGES) {
+ await page.evaluate(a => window._ageview.apply(a), age);
+ await sleep(900); // morph rebuild + repaint settle
+ const fps = await sampleFps();
+ report.fps[age] = { ...fps, pass: fps.min !== null && fps.min >= FPS_FLOOR };
+ console.log(` age ${String(age).padStart(2)} min=${fps.min} avg=${fps.avg} (${report.fps[age].pass ? 'PASS' : 'FAIL'})`);
+ }
+
+ // ---------- 5.2 Senior measured gate ----------
+ console.log('\n=== 5.2 Senior-usability MEASURED gate (65/75/85) ===');
+ // Roles: which visible elements carry body text, headings, CTAs, interactive targets.
+ for (const age of SENIOR_AGES) {
+ await page.evaluate(a => window._ageview.apply(a), age);
+ await sleep(900);
+
+ const m = await page.evaluate(() => {
+ const lc = (txt, bg) => Math.abs(window._ageview.apcaLc(txt, bg));
+ const pick = (sels) => sels.map(s => document.querySelector(s)).find(e => e && window.__visible(e)) || null;
+
+ // candidate elements by role (first visible wins)
+ const bodyEl = pick(['#guided-instruction', '#now-viewing', '#wing-detail', '#av-tag', '#av-band']);
+ const headEl = pick(['.gt-name', '#guided-title', '#gt-name', '#av-age']);
+ const ctaEl = pick(['#g-tour', '#av-cta', '#ageview-bar #av-cta']);
+ // interactive controls actually on screen
+ const ctrlSel = ['#g-tour', '#guided-bar .g-btn', '#btn-ageview', '#av-slider', '#g-prev', '#g-next', '#g-grid'];
+ const ctrls = [];
+ ctrlSel.forEach(s => document.querySelectorAll(s).forEach(el => {
+ if (window.__visible(el)) ctrls.push({ sel: s, h: Math.round(window.__rectH(el)) });
+ }));
+
+ const measureText = (el) => {
+ if (!el) return null;
+ const bg = window.__effectiveBg(el);
+ const fg = window.__textHex(el);
+ return {
+ tag: el.id || el.className, fg, bg: bg.hex, bgOpaque: bg.opaque, bgFrom: bg.from,
+ fontPx: Math.round(window.__fontPx(el)),
+ lc: (fg && bg.hex) ? lc(fg, bg.hex) : null,
+ };
+ };
+ const measureCta = (el) => {
+ if (!el) return null;
+ const bg = window.__effectiveBg(el);
+ const fg = window.__textHex(el);
+ return {
+ tag: el.id || el.className, fg, bg: bg.hex, bgOpaque: bg.opaque,
+ h: Math.round(window.__rectH(el)),
+ lc: (fg && bg.hex) ? lc(fg, bg.hex) : null,
+ };
+ };
+
+ // slider control (persistent chrome) — measured at whatever age, but gated hard at 85
+ const slider = document.getElementById('av-slider');
+ const avAge = document.getElementById('av-age');
+ const avBand = document.getElementById('av-band');
+ const sBg = avAge ? window.__effectiveBg(avAge) : { hex: null, opaque: false };
+ const sliderCtl = {
+ trackH: slider ? Math.round(window.__rectH(slider)) : null,
+ labelPx: avAge ? Math.round(window.__fontPx(avAge)) : null,
+ labelLc: (avAge && sBg.hex) ? lc(window.__textHex(avAge), sBg.hex) : null,
+ bandLc: (avBand && sBg.hex) ? lc(window.__textHex(avBand), sBg.hex) : null,
+ labelBgOpaque: sBg.opaque,
+ };
+
+ // info-complexity: at 65+ secondary spec chrome should be reduced/hidden
+ const specHidden = {
+ windowBar: !window.__visible(document.getElementById('window-bar')),
+ vendorSidebar: !window.__visible(document.getElementById('vendor-sidebar')),
+ minimap: !window.__visible(document.getElementById('minimap')),
+ };
+
+ return {
+ body: measureText(bodyEl),
+ head: measureText(headEl),
+ cta: measureCta(ctaEl),
+ ctrls, sliderCtl, specHidden,
+ bandId: document.body.dataset.ageBand,
+ opaqueClass: document.body.classList.contains('age-opaque'),
+ };
+ });
+
+ const minTarget = age >= 85 ? 56 : 48;
+ const minBody = age >= 85 ? 28 : (age >= 75 ? 22 : 20);
+ const minHead = age >= 85 ? 44 : (age >= 75 ? 32 : 30);
+ const ctrlsOk = m.ctrls.length > 0 && m.ctrls.every(c => c.h >= minTarget);
+ const smallCtrls = m.ctrls.filter(c => c.h < minTarget);
+
+ const checks = {
+ bodyLc: { v: m.body?.lc, need: '≥60', pass: m.body?.lc >= 60 },
+ ctaLc: { v: m.cta?.lc, need: '≥75', pass: m.cta?.lc >= 75 },
+ bodyOpaque:{ v: m.body?.bgOpaque, need: 'true', pass: m.body?.bgOpaque === true },
+ ctaOpaque: { v: m.cta?.bgOpaque, need: 'true', pass: m.cta?.bgOpaque === true },
+ bodyPx: { v: m.body?.fontPx, need: '≥' + minBody, pass: m.body?.fontPx >= minBody },
+ headPx: { v: m.head?.fontPx, need: '≥' + minHead, pass: m.head?.fontPx >= minHead },
+ targets: { v: smallCtrls.map(c => `${c.sel}:${c.h}`).join(',') || 'all≥' + minTarget, need: '≥' + minTarget, pass: ctrlsOk },
+ opaqueClass:{ v: m.opaqueClass, need: 'true', pass: m.opaqueClass === true },
+ specReduced:{ v: JSON.stringify(m.specHidden), need: 'secondary hidden', pass: m.specHidden.windowBar },
+ };
+ if (age >= 85) {
+ checks.slider85 = {
+ v: `trackH=${m.sliderCtl.trackH} labelPx=${m.sliderCtl.labelPx} labelLc=${m.sliderCtl.labelLc} bandLc=${m.sliderCtl.bandLc}`,
+ need: 'track/thumb≥56 hit · label≥28px · Lc≥75',
+ // thumb is 40px round + 18px track; the WHOLE bar is the hit context. Label is the gated text.
+ pass: m.sliderCtl.labelPx >= 28 && m.sliderCtl.labelLc >= 75 && m.sliderCtl.bandLc >= 75 && m.sliderCtl.labelBgOpaque === true,
+ };
+ }
+ const pass = Object.values(checks).every(c => c.pass);
+ report.senior[age] = { measured: m, checks, pass };
+
+ console.log(`\n --- age ${age} (band ${m.bandId}) ${pass ? 'PASS' : 'FAIL'} ---`);
+ for (const [k, c] of Object.entries(checks)) {
+ console.log(` ${c.pass ? '✓' : '✗'} ${k.padEnd(12)} = ${c.v} (need ${c.need})`);
+ }
+
+ // screenshot the senior band
+ await page.screenshot({ path: path.join(OUT, `senior-${age}.png`) });
+ }
+
+ // ---------- Regression: OFF == age 28 ----------
+ console.log('\n=== Regression: Age View OFF restores baseline ===');
+ await page.evaluate(() => window._ageview.setOn(false));
+ await sleep(700);
+ const reg = await page.evaluate(() => ({
+ on: window._ageview.on,
+ avatarAge: (window._qh && typeof window._qh.getAvatarAge === 'function') ? window._qh.getAvatarAge() : 'n/a',
+ hasOpaque: document.body.classList.contains('age-opaque'),
+ hasAgeviewOn: document.body.classList.contains('ageview-on'),
+ barHidden: (document.getElementById('ageview-bar') || {}).className?.includes('hidden'),
+ }));
+ report.regression = {
+ ...reg,
+ pass: reg.on === false && reg.hasOpaque === false && reg.hasAgeviewOn === false &&
+ (reg.avatarAge === 28 || reg.avatarAge === 'n/a'),
+ };
+ console.log(' ', JSON.stringify(report.regression));
+
+ // anchor screenshots for the morph strip (12/45/85) — Chrome engine
+ await page.evaluate(() => window._ageview.setOn(true));
+ for (const age of [12, 45, 85]) {
+ await page.evaluate(a => window._ageview.apply(a), age);
+ await sleep(900);
+ await page.screenshot({ path: path.join(OUT, `anchor-${age}.png`) });
+ }
+
+ report.errorsDuring = errors.slice(0, 40);
+
+ // ---------- Verdict ----------
+ const fpsPass = Object.values(report.fps).every(f => f.pass);
+ const seniorPass = Object.values(report.senior).every(s => s.pass);
+ const regPass = report.regression.pass;
+ const errPass = errors.length === 0;
+ report.verdict = {
+ fpsPass, seniorPass, regPass, errPass, consoleErrors: errors.length,
+ overall: fpsPass && seniorPass && regPass && errPass,
+ };
+
+ fs.writeFileSync(path.join(OUT, 'phase5-result.json'), JSON.stringify(report, null, 2));
+ console.log('\n================ PHASE 5 VERDICT ================');
+ console.log(' FPS gate :', fpsPass ? 'PASS' : 'FAIL');
+ console.log(' Senior gate :', seniorPass ? 'PASS' : 'FAIL');
+ console.log(' Regression :', regPass ? 'PASS' : 'FAIL');
+ console.log(' Console errs :', errors.length, errPass ? 'PASS' : 'FAIL');
+ console.log(' OVERALL :', report.verdict.overall ? '✅ PASS' : '❌ FAIL');
+ console.log(' → recordings/phase5/phase5-result.json');
+
+ await browser.close();
+ process.exit(report.verdict.overall ? 0 : 1);
+})().catch(e => { console.error('VERIFIER CRASH:', e); process.exit(2); });
← fc2441f Age View: continuous avatar age-morph (ageRigParams + upperR
·
back to Quadrille Showroom
·
Phase 5.3 cross-engine verifier: Age View slider PASS on Web 71a067a →