[object Object]

← 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

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 →