[object Object]

← back to Quadrille Showroom

Phase 5.5 /contrarian final pass: prove the AVATAR half + fix 2 weak non-senior CTAs

04948ab8ef602429792053ec1210900f10d512ce · 2026-06-29 14:50:46 -0700 · Steve

Contrarian (correctly) flagged the Phase-5 gate verified only the HUD half and proved
regression on a phantom hook. Closes both:

- scripts/verify-ageview-avatar.mjs — proves the avatar morph on the REAL _qh hooks
  (avatarAge getter + upperRigState): eye-height arc 1.40→1.62→1.61→1.58→1.48m,
  stoop monotonic rotX 0@28 → -0.070@65 → -0.209@85, regression OFF==age28 rotX=0,
  + look-down body screenshots at 12/28/45/65/85 (setSpin to PITCH_MIN). 0 errors.
- ageview.js BANDS: teen CTA #ff2fd6/near-black (APCA 47) -> #c01a9e/white (Lc 81);
  young CTA #a855f7/near-black (APCA 38, a real miss for the 21-yo band) -> #7c3aed/white
  (Lc 83). Same neon/violet identity, legible filled buttons.

Validation-loop re-run after edits: FPS floor 67 all 11 bands, senior 65/75/85 PASS,
regression PASS, cross-engine PASS, avatar PASS, 0 console errors.

Residual (honest, non-blocking): env-morph per-band is implemented + FPS-safe but
asserted by intensity values, not a measured rendered-luminance delta; adult(28)/
established(40) bands still ~90% identical (plan-gate fallback: merge or differentiate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 04948ab8ef602429792053ec1210900f10d512ce
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 14:50:46 2026 -0700

    Phase 5.5 /contrarian final pass: prove the AVATAR half + fix 2 weak non-senior CTAs
    
    Contrarian (correctly) flagged the Phase-5 gate verified only the HUD half and proved
    regression on a phantom hook. Closes both:
    
    - scripts/verify-ageview-avatar.mjs — proves the avatar morph on the REAL _qh hooks
      (avatarAge getter + upperRigState): eye-height arc 1.40→1.62→1.61→1.58→1.48m,
      stoop monotonic rotX 0@28 → -0.070@65 → -0.209@85, regression OFF==age28 rotX=0,
      + look-down body screenshots at 12/28/45/65/85 (setSpin to PITCH_MIN). 0 errors.
    - ageview.js BANDS: teen CTA #ff2fd6/near-black (APCA 47) -> #c01a9e/white (Lc 81);
      young CTA #a855f7/near-black (APCA 38, a real miss for the 21-yo band) -> #7c3aed/white
      (Lc 83). Same neon/violet identity, legible filled buttons.
    
    Validation-loop re-run after edits: FPS floor 67 all 11 bands, senior 65/75/85 PASS,
    regression PASS, cross-engine PASS, avatar PASS, 0 console errors.
    
    Residual (honest, non-blocking): env-morph per-band is implemented + FPS-safe but
    asserted by intensity values, not a measured rendered-luminance delta; adult(28)/
    established(40) bands still ~90% identical (plan-gate fallback: merge or differentiate).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/js/ageview.js              |   8 ++-
 scripts/verify-ageview-avatar.mjs | 106 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+), 2 deletions(-)

diff --git a/public/js/ageview.js b/public/js/ageview.js
index 8835c6b..769a802 100644
--- a/public/js/ageview.js
+++ b/public/js/ageview.js
@@ -50,10 +50,14 @@ const BANDS = [
     bg:'#fef3c7', panel:'#ffffff', fg:'#1f2937', cta:'#4338ca', ctaFg:'#ffffff', border:'#fbbf24', muted:'#6b7280' },
   { max:16, id:'teen',   name:'Teen',           tag:'Bold taste, fast & dense — power-user.',
     font:"'Space Grotesk', system-ui, sans-serif", body:16, head:22, btn:44, gap:10, opaque:false,
-    bg:'#0a0014', panel:'#1a0030', fg:'#f0f0ff', cta:'#ff2fd6', ctaFg:'#0a0014', border:'#2a0050', muted:'#b9a8d6' },
+    // CTA was magenta #ff2fd6 + near-black text (APCA 47 — weak for a filled button). Deeper
+    // neon magenta + white text reads the same "neon" but clears a proper CTA bar (Lc 81). /contrarian 2026-06-29.
+    bg:'#0a0014', panel:'#1a0030', fg:'#f0f0ff', cta:'#c01a9e', ctaFg:'#ffffff', border:'#2a0050', muted:'#b9a8d6' },
   { max:21, id:'young',  name:'Emerging Adult',  tag:'First apartment — energetic & budget-aware.',
     font:"system-ui, -apple-system, sans-serif", body:15, head:19, btn:44, gap:12, opaque:false, energetic:true,
-    bg:'#120a1f', panel:'#1d1330', fg:'#ece4f8', cta:'#a855f7', ctaFg:'#0a0014', border:'#36244e', muted:'#b4a3c9' },
+    // CTA was light purple #a855f7 + near-black text (APCA 38 — a real legibility miss for the
+    // mobile-native 21-yo this band serves). Violet-700 + white text → Lc 83, same energy. /contrarian.
+    bg:'#120a1f', panel:'#1d1330', fg:'#ece4f8', cta:'#7c3aed', ctaFg:'#ffffff', border:'#36244e', muted:'#b4a3c9' },
   { max:34, id:'adult',  name:'Young Pro',       tag:'First real budget — refined, spec-forward.',
     font:"-apple-system, BlinkMacSystemFont, sans-serif", body:15, head:18, btn:44, gap:12, opaque:false,
     bg:'#0a0c12', panel:'#161a26', fg:'#e6eef8', cta:'#7c3aed', ctaFg:'#ffffff', border:'#252b3d', muted:'#9aa6bd' },
diff --git a/scripts/verify-ageview-avatar.mjs b/scripts/verify-ageview-avatar.mjs
new file mode 100644
index 0000000..868f766
--- /dev/null
+++ b/scripts/verify-ageview-avatar.mjs
@@ -0,0 +1,106 @@
+/* ============================================================================
+ * verify-ageview-avatar.mjs — Phase 5 AVATAR proof (the contrarian-named blind spot).
+ *
+ * The HUD gate (verify-ageview-phase5.mjs) measured the chrome. This proves the OTHER
+ * half — the actual avatar morph — that nothing else verified:
+ *   - look-down body screenshots at 12/28/45/65/85 (setSpin to PITCH_MIN ~ -1.05)
+ *   - eye-height monotonicity sanity: ageRigParams(age).eyeY rises 12→~28 then falls →85
+ *   - STOOP proof: _qh.upperRigState().rotX ≈ 0 at 28, and increasingly forward at 75/85
+ *   - REGRESSION proof on the REAL hook: _qh.avatarAge === 28 after setOn(false)
+ *     (the phase5 gate used a phantom getAvatarAge() and got "n/a")
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-ageview-avatar.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', 'avatar');
+fs.mkdirSync(OUT, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const AGES = [12, 28, 45, 65, 85];
+
+(async () => {
+  const browser = await pw.chromium.launch({
+    channel: 'chrome',
+    args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'],
+  });
+  const ctx = await browser.newContext({
+    viewport: { width: 1280, 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));
+  await page.addInitScript(() => { try { localStorage.clear(); } catch (e) {} });
+
+  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+  await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._ageview, { timeout: 25000 });
+  await sleep(3500);
+
+  const PITCH_MIN = await page.evaluate(() => window._qh.PITCH_MIN ?? -1.05);
+  const report = { ages: {}, regression: {}, stoopMonotonic: false, eyeArc: false, errors: [] };
+
+  await page.evaluate(() => window._ageview.setOn(true));
+  await sleep(400);
+
+  // Look-down body screenshots + per-age rig facts
+  for (const age of AGES) {
+    await page.evaluate(a => window._ageview.apply(a), age);
+    await sleep(700);
+    await page.evaluate(p => window._qh.setSpin(0, p), PITCH_MIN);   // look straight down at own body
+    await sleep(700);
+    const facts = await page.evaluate(() => {
+      const rp = window._qh.ageRigParams(window._qh.avatarAge);
+      const ur = window._qh.upperRigState();
+      return { avatarAge: window._qh.avatarAge, eyeY: +rp.eyeY.toFixed(3), leanRad: +rp.leanRad.toFixed(4),
+               shoulderHalf: +rp.shoulderHalf.toFixed(3), upperRigPresent: !!(ur && ur.present),
+               upperRigRotX: ur ? +ur.rotX.toFixed(4) : null, upperRigChildren: ur ? ur.childCount : null };
+    });
+    await page.screenshot({ path: path.join(OUT, `lookdown-${age}.png`) });
+    report.ages[age] = facts;
+    console.log(`  age ${String(age).padStart(2)}  eyeY=${facts.eyeY}  shoulder½=${facts.shoulderHalf}  upperRig.rotX=${facts.upperRigRotX}  children=${facts.upperRigChildren}`);
+  }
+
+  // Reset look + REGRESSION on the REAL hook
+  await page.evaluate(() => window._qh.setSpin(0, 0));
+  await page.evaluate(() => window._ageview.setOn(false));
+  await sleep(700);
+  const offAge = await page.evaluate(() => ({ avatarAge: window._qh.avatarAge, upper: window._qh.upperRigState() }));
+  report.regression = {
+    avatarAgeAfterOff: offAge.avatarAge,
+    upperRigRotXAfterOff: offAge.upper ? +offAge.upper.rotX.toFixed(4) : null,
+    pass: offAge.avatarAge === 28 && Math.abs(offAge.upper ? offAge.upper.rotX : 1) < 1e-6,
+  };
+
+  // Eye-height arc: rises to peak around 21-28, declines into 85
+  const e = report.ages;
+  report.eyeArc = e[12].eyeY < e[28].eyeY && e[28].eyeY >= e[45].eyeY && e[45].eyeY > e[65].eyeY && e[65].eyeY > e[85].eyeY;
+  // Stoop monotonic: 28 ~ 0, 65 < 75-ish < 85 (more forward lean = larger |leanRad| / rotX)
+  report.stoopMonotonic = Math.abs(e[28].upperRigRotX) < 1e-6 && Math.abs(e[65].upperRigRotX) > 0 &&
+                          Math.abs(e[85].upperRigRotX) > Math.abs(e[65].upperRigRotX);
+  report.errors = errors.slice(0, 20);
+
+  const pass = report.regression.pass && report.eyeArc && report.stoopMonotonic && errors.length === 0;
+  fs.writeFileSync(path.join(OUT, 'avatar-result.json'), JSON.stringify(report, null, 2));
+
+  console.log('\n================ AVATAR PROOF ================');
+  console.log('  eye-height arc (12<28>=45>65>85):', report.eyeArc ? 'PASS' : 'FAIL',
+              `[${AGES.map(a => e[a].eyeY).join(' → ')}]`);
+  console.log('  stoop monotonic (28≈0 <65 <85)  :', report.stoopMonotonic ? 'PASS' : 'FAIL',
+              `[rotX 28=${e[28].upperRigRotX} 65=${e[65].upperRigRotX} 85=${e[85].upperRigRotX}]`);
+  console.log('  regression OFF==age28 (REAL hook):', report.regression.pass ? 'PASS' : 'FAIL',
+              `[age=${report.regression.avatarAgeAfterOff} rotX=${report.regression.upperRigRotXAfterOff}]`);
+  console.log('  console errors                   :', errors.length);
+  console.log('  look-down screenshots → recordings/phase5/avatar/lookdown-*.png');
+  console.log('  OVERALL AVATAR                   :', pass ? '✅ PASS' : '❌ FAIL');
+
+  await browser.close();
+  process.exit(pass ? 0 : 1);
+})().catch(e => { console.error('AVATAR CRASH:', e); process.exit(2); });

← 71a067a Phase 5.3 cross-engine verifier: Age View slider PASS on Web  ·  back to Quadrille Showroom  ·  Default view = walk the avatar + single centered mirrored bo fa8ee90 →