[object Object]

← back to Quadrille Showroom

Age View: continuous avatar age-morph (ageRigParams + upperRig stoop sub-group) + default-off age slider with banded UI/env snap + senior-opaque APCA gate

fc2441f12ba64bfa8a8e2bfede4ff4fdf4de2cb2 · 2026-06-29 07:19:28 -0700 · Steve Abrams

Files touched

Diff

commit fc2441f12ba64bfa8a8e2bfede4ff4fdf4de2cb2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 07:19:28 2026 -0700

    Age View: continuous avatar age-morph (ageRigParams + upperRig stoop sub-group) + default-off age slider with banded UI/env snap + senior-opaque APCA gate
---
 public/js/ageview.js  | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++
 public/js/showroom.js | 194 +++++++++++++++++++++++++++---
 public/showroom.html  |   1 +
 3 files changed, 496 insertions(+), 17 deletions(-)

diff --git a/public/js/ageview.js b/public/js/ageview.js
new file mode 100644
index 0000000..8e353ed
--- /dev/null
+++ b/public/js/ageview.js
@@ -0,0 +1,318 @@
+/* ============================================================================
+ * ageview.js — The Quadrille House: AGE-ADAPTIVE AVATAR VIEW (ages 12 → 85).
+ *
+ * A bolt-on like versions.js / viewmodes.js. Talks to the showroom ONLY through
+ * the stable window._qh API. ADDITIVE + DEFAULT-OFF + REGRESSION-SAFE:
+ *
+ *   • When Age View is OFF the showroom is byte-for-byte as today. No body class,
+ *     no CSS vars, the avatar is the zero-arg buildAvatar() (== age-28 rig), the
+ *     env light is baseline. The age machinery only runs when the toggle is ON.
+ *   • When ON: one AGE SLIDER (12→85) drives THREE things off a single age value —
+ *       1. the AVATAR morphs CONTINUOUSLY  (_qh.setAvatarAge(age) — primitive lerp);
+ *       2. the UI SNAPS to the nearest validated age-adaptive-theme band (we do NOT
+ *          interpolate APCA/contrast — banded snap is the validation-loop lesson);
+ *       3. the ENVIRONMENT light snaps per band (_qh.setEnvForAge — cheap scalars).
+ *
+ * SENIOR HARD BAR (65/75/85) — every HUD panel gets a SOLID 100%-opaque background
+ * BEFORE any APCA check (contrarian fix #1): `body.age-senior` strips ALL semi-
+ * transparent chrome / backdrop-blur over the live WebGL scene. APCA is then measured
+ * on the rendered opaque hex pair, not asserted.
+ *
+ * Consumes the age-adaptive-theme skill's VALIDATED band tokens verbatim (mirrored
+ * below from ~/.claude/skills/age-adaptive-theme/bands.json v9 — same hex/px/targets);
+ * does NOT fork or re-derive them. APCA calc mirrors lib/apca.js exactly.
+ *
+ * State persisted to localStorage: qh_ageview_on, qh_ageview_age.
+ * ========================================================================== */
+(function () {
+'use strict';
+
+// ---- APCA-W3 Lc (mirror of age-adaptive-theme/lib/apca.js — identical algorithm) ----
+function hexToRgb(h){ h=h.replace('#',''); if(h.length===3) h=h.split('').map(c=>c+c).join(''); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)]; }
+function sRGBtoY([r,g,b]){ return 0.2126729*Math.pow(r/255,2.4)+0.7151522*Math.pow(g/255,2.4)+0.0721750*Math.pow(b/255,2.4); }
+function apcaLc(textHex,bgHex){
+  const BlkThrs=0.022,BlkClmp=1.414,LoConThresh=0.1,Scale=1.14,LoOffset=0.027;
+  let txtY=sRGBtoY(hexToRgb(textHex)), bgY=sRGBtoY(hexToRgb(bgHex));
+  if(txtY<BlkThrs) txtY+=Math.pow(BlkThrs-txtY,BlkClmp);
+  if(bgY<BlkThrs)  bgY +=Math.pow(BlkThrs-bgY, BlkClmp);
+  if(Math.abs(bgY-txtY)<0.0005) return 0;
+  if(bgY>txtY){ const S=(Math.pow(bgY,0.56)-Math.pow(txtY,0.57))*Scale; return S<LoConThresh?0:Math.round((S-LoOffset)*100); }
+  const S=(Math.pow(bgY,0.65)-Math.pow(txtY,0.62))*Scale; return S>-LoConThresh?0:Math.round((S+LoOffset)*100);
+}
+
+// ---- The 10 validated bands (12→85). UI tokens mirrored from age-adaptive-theme
+// bands.json v9 "best" values + the AGE-VIEWS-SPEC §3 per-band shaping. Each band is a
+// SNAP target: avatar age is continuous, UI lands on the nearest of these. bg/fg/cta are
+// the OPAQUE hex the APCA gate measures against (senior bands MUST be opaque). ----
+const BANDS = [
+  { max:12, id:'tween',  name:'Tween',          tag:'Curious & playful — designing my room.',
+    font:"'Public Sans', system-ui, sans-serif", body:15, head:22, btn:44, gap:18, opaque:false,
+    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' },
+  { 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' },
+  { 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' },
+  { max:40, id:'established', name:'Established', tag:'Family home — dense info, assured.',
+    font:"-apple-system, BlinkMacSystemFont, sans-serif", body:15, head:18, btn:44, gap:12, opaque:false,
+    bg:'#0a0c12', panel:'#171b28', fg:'#e9f0fa', cta:'#7c3aed', ctaFg:'#ffffff', border:'#28304a', muted:'#9aa6bd' },
+  { max:50, id:'connoisseur', name:'Connoisseur', tag:'Peak buying power — calm, refined, editorial.',
+    font:"'Source Serif Pro', Georgia, serif", body:15, head:22, btn:44, gap:16, opaque:false,
+    bg:'#f8f6f1', panel:'#ffffff', fg:'#1c1917', cta:'#6b1d07', ctaFg:'#ffffff', border:'#d6d3d1', muted:'#6b6358' },
+  { max:60, id:'tastemaker', name:'Tastemaker',  tag:'Mature eye — unhurried, larger type.',
+    font:"'Source Serif Pro', Georgia, serif", body:16, head:24, btn:48, gap:18, opaque:false,
+    bg:'#f8f6f1', panel:'#ffffff', fg:'#1c1917', cta:'#6b1d07', ctaFg:'#ffffff', border:'#cdc9c2', muted:'#5f584d' },
+  { max:70, id:'senior',  name:'Senior',         tag:'High contrast, large text, big targets.',
+    font:"'Atkinson Hyperlegible', Helvetica, Arial, sans-serif", body:20, head:30, btn:48, gap:20, opaque:true,
+    bg:'#ffffff', panel:'#ffffff', fg:'#000000', cta:'#003a78', ctaFg:'#ffffff', border:'#000000', muted:'#333333' },
+  { max:80, id:'elder',   name:'Elder',          tag:'One idea per screen — maximum legibility.',
+    font:"'Atkinson Hyperlegible', Helvetica, Arial, sans-serif", body:22, head:32, btn:52, gap:24, opaque:true,
+    bg:'#ffffff', panel:'#ffffff', fg:'#000000', cta:'#003a78', ctaFg:'#ffffff', border:'#000000', muted:'#2a2a2a' },
+  { max:85, id:'legacy',  name:'Legacy',         tag:'Just the action — yellow on black, biggest type.',
+    font:"'Atkinson Hyperlegible', Helvetica, Arial, sans-serif", body:28, head:44, btn:56, gap:28, opaque:true,
+    bg:'#000000', panel:'#000000', fg:'#ffffff', cta:'#ffff00', ctaFg:'#000000', border:'#ffffff', muted:'#d9d9d9' }
+];
+function bandFor(age){ for(const b of BANDS){ if(age<=b.max) return b; } return BANDS[BANDS.length-1]; }
+
+let on = false;
+let age = 28;
+
+// ---- Inject the slider chrome + the band-driven CSS once ----
+function injectChrome() {
+  if (document.getElementById('ageview-styles')) return;
+  const st = document.createElement('style');
+  st.id = 'ageview-styles';
+  st.textContent = `
+  /* Age View base CSS vars (set per band on <body>). Inert when Age View OFF. */
+  body.ageview-on {
+    --av-bg:#0a0c12; --av-panel:#161a26; --av-fg:#e6eef8; --av-cta:#7c3aed;
+    --av-cta-fg:#fff; --av-border:#252b3d; --av-muted:#9aa6bd;
+    --av-body:15px; --av-head:18px; --av-btn:44px; --av-gap:12px;
+    --av-font:-apple-system, BlinkMacSystemFont, sans-serif;
+    --av-panel-bg:rgba(12,12,17,0.86);  /* semi over WebGL for non-senior bands */
+  }
+  /* SENIOR HARD BAR: opaque chrome — strip ALL transparency/blur over the live scene
+     so APCA is measured on a true opaque hex pair (contrarian fix #1). */
+  body.age-opaque { --av-panel-bg: var(--av-panel); }
+  body.age-opaque #hud, body.age-opaque #hud * { backdrop-filter:none !important; -webkit-backdrop-filter:none !important; }
+
+  /* The Age View bar — persistent, top-centre under the masthead. It is itself
+     gated at the elder band (contrarian fix #2): ≥56px target, ≥28px label, Lc≥75. */
+  #ageview-bar {
+    position:fixed; left:50%; top:58px; transform:translateX(-50%); z-index:80;
+    display:flex; align-items:center; gap:14px; padding:12px 20px; border-radius:16px;
+    background:var(--av-panel-bg); border:2px solid var(--av-border); color:var(--av-fg);
+    font-family:var(--av-font); box-shadow:0 14px 40px -12px rgba(0,0,0,0.7);
+    backdrop-filter:blur(10px); max-width:92vw;
+  }
+  body.age-opaque #ageview-bar { backdrop-filter:none; background:var(--av-panel); }
+  #ageview-bar.hidden { display:none; }
+  #av-readout { text-align:center; min-width:120px; }
+  #av-age { font-weight:800; font-size:max(28px, var(--av-head)); line-height:1; color:var(--av-cta); }
+  body.age-legacy #av-age { color:var(--av-cta); }
+  #av-band { font-size:max(15px, var(--av-body)); font-weight:700; letter-spacing:.4px; margin-top:3px; color:var(--av-fg); }
+  /* Slider thumb/track gated for the elder band — large hit target + bright track. */
+  #av-slider { -webkit-appearance:none; appearance:none; height:14px; border-radius:8px; min-width:300px; flex:1;
+    background:var(--av-cta); outline:none; cursor:pointer; }
+  #av-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:34px; height:34px; border-radius:50%;
+    background:var(--av-fg); border:3px solid var(--av-cta); cursor:pointer; }
+  #av-slider::-moz-range-thumb { width:34px; height:34px; border-radius:50%; background:var(--av-fg); border:3px solid var(--av-cta); cursor:pointer; }
+  body.age-opaque #av-slider { height:18px; }
+  body.age-opaque #av-slider::-webkit-slider-thumb { width:40px; height:40px; }
+  body.age-opaque #av-slider::-moz-range-thumb { width:40px; height:40px; }
+  #av-tag { flex-basis:100%; text-align:center; font-size:max(15px, var(--av-body)); color:var(--av-muted); margin-top:2px; }
+
+  /* Re-theme the live HUD chrome when Age View is on. CSS-var driven, so the showroom's
+     own component CSS doesn't change — only the values do. Banded snap, not interpolation. */
+  body.ageview-on #top-bar,
+  body.ageview-on #wing-detail,
+  body.ageview-on #now-viewing,
+  body.ageview-on #guided-bar,
+  body.ageview-on #window-bar,
+  body.ageview-on #guided-title,
+  body.ageview-on #guided-instruction {
+    background:var(--av-panel-bg) !important; color:var(--av-fg) !important;
+    border-color:var(--av-border) !important; font-family:var(--av-font) !important;
+  }
+  body.ageview-on #guided-bar .g-btn,
+  body.ageview-on #window-bar button {
+    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 {
+    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; }
+
+  /* Senior info-complexity: hide decorative/secondary chrome so the path is unobstructed. */
+  body.age-opaque #vendor-sidebar,
+  body.age-opaque #minimap,
+  body.age-opaque #photo-depth,
+  body.age-opaque #window-bar { display:none !important; }
+  body.age-legacy #now-viewing .nv-spec,
+  body.age-legacy #wing-detail .wd-specs { display:none !important; }  /* 85 = essentials only */
+
+  /* The toggle button itself (top-right, near Explore). */
+  #btn-ageview { 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-ageview.active { background:rgba(201,169,110,0.85); color:#1a1206; border-color:#c9a96e; }
+  `;
+  document.head.appendChild(st);
+
+  // Atkinson Hyperlegible + Space Grotesk + Public Sans + Source Serif for the bands.
+  if (!document.getElementById('ageview-fonts')) {
+    const lf = document.createElement('link'); lf.id='ageview-fonts'; lf.rel='stylesheet';
+    lf.href='https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Public+Sans:wght@400;700&family=Source+Serif+Pro:wght@400;600;700&family=Space+Grotesk:wght@400;600&display=swap';
+    document.head.appendChild(lf);
+  }
+
+  // The slider bar.
+  const bar = document.createElement('div');
+  bar.id = 'ageview-bar'; bar.className = 'hidden';
+  bar.innerHTML = `
+    <div id="av-readout"><div id="av-age">28</div><div id="av-band">Young Pro</div></div>
+    <input id="av-slider" type="range" min="12" max="85" step="1" value="28"
+           aria-label="Viewer age" />
+    <div id="av-tag">First real budget — refined, spec-forward.</div>`;
+  document.body.appendChild(bar);
+  document.getElementById('av-slider').addEventListener('input', e => apply(+e.target.value));
+
+  // The toggle button (insert into the top-right cluster next to Explore).
+  const tr = document.querySelector('#top-bar .top-right');
+  if (tr && !document.getElementById('btn-ageview')) {
+    const b = document.createElement('button');
+    b.id = 'btn-ageview'; b.title = 'Age View — morph the showroom-goer from 12 to 85 (avatar + UI adapt). Off by default.';
+    b.textContent = 'Age View: Off';
+    tr.insertBefore(b, tr.firstChild);
+    b.addEventListener('click', () => setOn(!on));
+  }
+}
+
+// ---- Apply a continuous age: avatar morphs, UI snaps to the band, env snaps. ----
+function apply(a) {
+  age = Math.max(12, Math.min(85, a));
+  const b = bandFor(age);
+  const root = document.body;
+
+  // CSS vars (the UI banded snap)
+  root.style.setProperty('--av-bg', b.bg);
+  root.style.setProperty('--av-panel', b.panel);
+  root.style.setProperty('--av-fg', b.fg);
+  root.style.setProperty('--av-cta', b.cta);
+  root.style.setProperty('--av-cta-fg', b.ctaFg);
+  root.style.setProperty('--av-border', b.border);
+  root.style.setProperty('--av-muted', b.muted);
+  root.style.setProperty('--av-font', b.font);
+  root.style.setProperty('--av-body', b.body + 'px');
+  root.style.setProperty('--av-head', b.head + 'px');
+  root.style.setProperty('--av-btn', b.btn + 'px');
+  root.style.setProperty('--av-gap', b.gap + 'px');
+  // Opaque chrome at the senior bands (contrarian #1): solid panel bg, blur stripped.
+  root.classList.toggle('age-opaque', !!b.opaque);
+  root.classList.toggle('age-legacy', b.id === 'legacy');
+  // band id marker for any per-band CSS hooks
+  root.dataset.ageBand = b.id;
+
+  // Readout
+  const ageEl=document.getElementById('av-age'), bandEl=document.getElementById('av-band'), tagEl=document.getElementById('av-tag'), slider=document.getElementById('av-slider');
+  if (ageEl) ageEl.textContent = age;
+  if (bandEl) bandEl.textContent = b.name;
+  if (tagEl) tagEl.textContent = b.tag;
+  if (slider && +slider.value !== age) slider.value = age;
+
+  // Avatar (continuous) + env (banded). Guarded — only when the API is present.
+  const QH = window._qh;
+  if (QH) {
+    try { QH.setAvatarAge(age); } catch (e) {}
+    try { QH.setEnvForAge(age); } catch (e) {}
+  }
+
+  try { localStorage.setItem('qh_ageview_age', String(age)); } catch (e) {}
+}
+
+function setOn(v) {
+  on = !!v;
+  const btn = document.getElementById('btn-ageview');
+  document.body.classList.toggle('ageview-on', on);
+  if (btn) { btn.classList.toggle('active', on); btn.textContent = 'Age View: ' + (on ? 'On' : 'Off'); }
+  const bar = document.getElementById('ageview-bar');
+  if (bar) bar.classList.toggle('hidden', !on);
+  try { localStorage.setItem('qh_ageview_on', on ? '1' : '0'); } catch (e) {}
+
+  if (on) {
+    apply(age);                          // theme + morph to the current age
+  } else {
+    // REGRESSION-SAFE OFF: clear every age class/var, restore baseline rig + env.
+    document.body.classList.remove('age-opaque', 'age-legacy');
+    delete document.body.dataset.ageBand;
+    const QH = window._qh;
+    if (QH) {
+      try { QH.setAvatarAge(28); } catch (e) {}   // == zero-arg today's rig
+      try { QH.resetEnv(); } catch (e) {}
+    }
+  }
+}
+
+// ---- Senior-gate self-test (read by the proof harness). Measures APCA on the
+// OPAQUE band hex pairs + reports type/target sizes per the spec thresholds. ----
+function seniorGate() {
+  const out = {};
+  [65, 75, 85].forEach(a => {
+    const b = bandFor(a);
+    const bodyLc = apcaLc(b.fg, b.panel);
+    const ctaLc  = apcaLc(b.ctaFg, b.cta);
+    out[a] = {
+      band: b.id, opaque: !!b.opaque,
+      bgPair: { panel: b.panel, fg: b.fg }, ctaPair: { cta: b.cta, ctaFg: b.ctaFg },
+      bodyLc, ctaLc,
+      bodyAbs: Math.abs(bodyLc), ctaAbs: Math.abs(ctaLc),
+      type: b.body, head: b.head, target: b.btn,
+      pass: Math.abs(bodyLc) >= 60 && Math.abs(ctaLc) >= 75 && b.opaque &&
+            b.body >= (a >= 85 ? 28 : 20) && b.btn >= (a >= 85 ? 56 : 48)
+    };
+  });
+  // The slider control itself at age 85 (contrarian #2): persistent on-screen, so it's
+  // verified like any senior element. Label (#av-age) renders at max(28px, head=44px);
+  // the slider thumb is 40px round AND the full track (min-width 300px × 18px tall) is the
+  // hit area; the readout label fg vs panel must clear Lc≥75.
+  const L = bandFor(85);
+  const labelPx = Math.max(28, L.head);   // #av-age uses max(28px, --av-head)
+  out.slider85 = {
+    labelPx, thumbPx: 40, trackHeightPx: 18, trackMinWidthPx: 300,
+    labelLc: Math.abs(apcaLc(L.cta, L.panel)),     // #av-age colour is var(--av-cta) on panel
+    bandLabelLc: Math.abs(apcaLc(L.fg, L.panel)),  // #av-band / #av-age band readout fg on panel
+    pass: labelPx >= 28 && Math.abs(apcaLc(L.cta, L.panel)) >= 75 && Math.abs(apcaLc(L.fg, L.panel)) >= 75
+  };
+  return out;
+}
+
+function boot() {
+  if (!window._qh || !window._qh.wingBoards) { return setTimeout(boot, 250); }
+  injectChrome();
+
+  // Restore persisted state — but DEFAULT OFF. Only turn on if Steve explicitly did last time.
+  let savedAge = 28, savedOn = false;
+  try { savedAge = +localStorage.getItem('qh_ageview_age') || 28; } catch (e) {}
+  try { savedOn = localStorage.getItem('qh_ageview_on') === '1'; } catch (e) {}
+  age = Math.max(12, Math.min(85, savedAge));
+  const slider = document.getElementById('av-slider'); if (slider) slider.value = age;
+  if (savedOn) setOn(true);     // OFF unless previously on; OFF == byte-identical baseline
+
+  // Verification surface for the proof harness.
+  window._ageview = {
+    get on() { return on; }, get age() { return age; },
+    setOn, apply, bandFor, apcaLc, seniorGate,
+    bands: BANDS
+  };
+}
+
+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+else boot();
+})();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index ffe5cee..50c8c3f 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -986,10 +986,94 @@ function capsuleMesh(radius, length, mat, radialSegs) {
   return g;
 }
 
-function buildAvatar() {
+// ============================================================
+// Chunk K′ — AGE-RIG: continuous age morph of the primitive body (12→85).
+// Returns a set of multipliers/offsets the rig consumes so the SAME primitive
+// build ages smoothly. The ANCHOR at age 28 returns the identity set
+// (all scalars 1.0 / leanDeg 0 / baseline materials) so buildAvatar(28) — and
+// the zero-arg call (default 28) — reproduce today's rig BYTE-FOR-BYTE.
+// This is the regression guard: Age View OFF never reaches the non-28 branches.
+//
+// Anchors (AGE-VIEWS-SPEC §2b) are expressed RELATIVE to the current adult rig
+// (eyeY 1.62, shoulderHalf 0.20) so 28 is exactly neutral. Linear interp between
+// the nearest two anchors; clamped to [12,85].
+// ============================================================
+const AGE_ANCHORS = [
+  // age, eyeY,  shoulder½, leanDeg, torsoScaleY, limbScale, headFrac, palette key
+  { age: 12, eyeY: 1.40, shoulderHalf: 0.145, leanDeg: 0,  torsoY: 0.86, limb: 0.84, neckUp: 0.030, pal: 'tween'   },
+  { age: 16, eyeY: 1.55, shoulderHalf: 0.175, leanDeg: 0,  torsoY: 0.94, limb: 0.93, neckUp: 0.012, pal: 'teen'    },
+  { age: 21, eyeY: 1.60, shoulderHalf: 0.195, leanDeg: 0,  torsoY: 0.99, limb: 0.98, neckUp: 0.004, pal: 'young'   },
+  { age: 28, eyeY: 1.62, shoulderHalf: 0.200, leanDeg: 0,  torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'base'    }, // IDENTITY anchor
+  { age: 35, eyeY: 1.62, shoulderHalf: 0.205, leanDeg: 0,  torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'merino'  },
+  { age: 45, eyeY: 1.61, shoulderHalf: 0.205, leanDeg: 1,  torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'cashmere'},
+  { age: 55, eyeY: 1.60, shoulderHalf: 0.200, leanDeg: 2,  torsoY: 0.99, limb: 0.99, neckUp: 0.002, pal: 'quarter' },
+  { age: 65, eyeY: 1.58, shoulderHalf: 0.195, leanDeg: 4,  torsoY: 0.98, limb: 0.98, neckUp: 0.004, pal: 'cardigan'},
+  { age: 75, eyeY: 1.54, shoulderHalf: 0.185, leanDeg: 8,  torsoY: 0.96, limb: 0.96, neckUp: 0.010, pal: 'vest'    },
+  { age: 85, eyeY: 1.48, shoulderHalf: 0.175, leanDeg: 12, torsoY: 0.93, limb: 0.94, neckUp: 0.018, pal: 'oat'     }
+];
+
+// Clothing palettes per anchor — material COLOURS only (swap, not new meshes).
+// 'base' = today's exact rig colours (regression identity).
+const AGE_PALETTES = {
+  base:     { sweater: 0x3d424d, jeans: 0x2b3a4d, skin: 0xc99a78, shoe: 0x1b1b20, skinRough: 0.70 },
+  tween:    { sweater: 0x1f8a7a, jeans: 0x4a6aa0, skin: 0xd6a883, shoe: 0xf0f0f0, skinRough: 0.62 }, // bright teal hoodie / lighter jeans / white sneaker
+  teen:     { sweater: 0x2a2c33, jeans: 0x232a36, skin: 0xceA07a, shoe: 0x141418, skinRough: 0.66 }, // charcoal graphic crew / slim dark denim
+  young:    { sweater: 0x5e6b4a, jeans: 0x35506e, skin: 0xc99a78, shoe: 0x3a2a1e, skinRough: 0.68 }, // olive casual knit / mid denim / low boot
+  merino:   { sweater: 0x4a505c, jeans: 0x222a36, skin: 0xc59370, shoe: 0x241a12, skinRough: 0.72 }, // slate merino / dark trouser-denim / derby
+  cashmere: { sweater: 0xb09472, jeans: 0x33373f, skin: 0xc18e6c, shoe: 0x5a4632, skinRough: 0.74 }, // camel cashmere / charcoal wool trouser / suede loafer
+  quarter:  { sweater: 0x7d7f86, jeans: 0x2e3138, skin: 0xbe8a68, shoe: 0x2a2622, skinRough: 0.76 }, // heather quarter-zip / tailored trouser
+  cardigan: { sweater: 0x2a3550, jeans: 0x3a3d44, skin: 0xb98662, shoe: 0x33302c, skinRough: 0.80 }, // navy soft cardigan / relaxed trouser / cooler skin
+  vest:     { sweater: 0x8a8378, jeans: 0x4a463f, skin: 0xb38260, shoe: 0x3d3a35, skinRough: 0.84 }, // warm-grey knit vest / easy trouser / elder skin
+  oat:      { sweater: 0xc9bca4, jeans: 0x55514a, skin: 0xae7e5d, shoe: 0x45413b, skinRough: 0.88 }  // oat thick cardigan / soft trouser / cool matte elder skin
+};
+
+function lerpHexColor(a, b, t) {
+  const ar=(a>>16)&255, ag=(a>>8)&255, ab=a&255;
+  const br=(b>>16)&255, bg=(b>>8)&255, bb=b&255;
+  return ((Math.round(ar+(br-ar)*t)<<16) | (Math.round(ag+(bg-ag)*t)<<8) | Math.round(ab+(bb-ab)*t));
+}
+
+function ageRigParams(age) {
+  age = Math.max(12, Math.min(85, age));
+  // find bracketing anchors
+  let lo = AGE_ANCHORS[0], hi = AGE_ANCHORS[AGE_ANCHORS.length - 1];
+  for (let i = 0; i < AGE_ANCHORS.length - 1; i++) {
+    if (age >= AGE_ANCHORS[i].age && age <= AGE_ANCHORS[i + 1].age) { lo = AGE_ANCHORS[i]; hi = AGE_ANCHORS[i + 1]; break; }
+  }
+  const span = hi.age - lo.age;
+  const t = span === 0 ? 0 : (age - lo.age) / span;
+  const L = (k) => lo[k] + (hi[k] - lo[k]) * t;
+  const palLo = AGE_PALETTES[lo.pal], palHi = AGE_PALETTES[hi.pal];
+  return {
+    age,
+    eyeY:        L('eyeY'),
+    shoulderHalf:L('shoulderHalf'),
+    leanRad:     L('leanDeg') * Math.PI / 180,
+    torsoY:      L('torsoY'),
+    limb:        L('limb'),
+    neckUp:      L('neckUp'),
+    colors: {
+      sweater:   lerpHexColor(palLo.sweater, palHi.sweater, t),
+      jeans:     lerpHexColor(palLo.jeans,   palHi.jeans,   t),
+      skin:      lerpHexColor(palLo.skin,    palHi.skin,    t),
+      shoe:      lerpHexColor(palLo.shoe,    palHi.shoe,    t),
+      skinRough: palLo.skinRough + (palHi.skinRough - palLo.skinRough) * t
+    }
+  };
+}
+
+let avatarAge = 28;            // current avatar age; 28 == today's rig
+let avatarUpperRig = null;     // named sub-group (torso+neck+shoulders+arms) — the stoop pivot
+
+function buildAvatar(age) {
   // Clean rebuild safety (rebuilds shouldn't stack bodies)
   if (avatarRig) { scene.remove(avatarRig); avatarRig = null; }
 
+  // Zero-arg (or 28) == today's rig byte-for-byte. Age View passes an explicit age.
+  avatarAge = (typeof age === 'number') ? Math.max(12, Math.min(85, age)) : 28;
+  const P = ageRigParams(avatarAge);
+  const isBaseline = (avatarAge === 28);   // exact-identity fast path / safety
+
   const rig = new THREE.Group();
   rig.name = 'avatarRig';
   // The rig's ORIGIN is the floor directly under the eye; the body is built in rig-local
@@ -999,16 +1083,21 @@ function buildAvatar() {
   else rig.position.set(CONFIG.camera.startPos.x, 0, CONFIG.camera.startPos.z);
 
   // ---- Materials: simple, high-end, NOT cartoon ----
-  const sweater = new THREE.MeshStandardMaterial({ color: 0x3d424d, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 }); // charcoal knit (one shade lighter so torso/arms read against the dark upper scene on look-down — /contrarian)
-  const jeans   = new THREE.MeshStandardMaterial({ color: 0x2b3a4d, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 }); // indigo denim
-  const skin    = new THREE.MeshStandardMaterial({ color: 0xc99a78, roughness: 0.7,  metalness: 0.0, envMapIntensity: 0.2 });  // hands
-  const shoe    = new THREE.MeshStandardMaterial({ color: 0x1b1b20, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 }); // dark leather
-
-  // Proportions (metres), tuned so the eye sits ~1.62m and feet at 0.
-  const hipY      = 0.95;          // top of legs / waist
-  const chestTopY = 1.42;          // top of torso (shoulder line)
-  const torsoH    = chestTopY - hipY;   // 0.47
-  const shoulderHalf = 0.20;       // half shoulder width (arms hang outside this)
+  // Colours come from the age palette; at age 28 (base) these are the exact original
+  // hex values, so the baseline rig is byte-identical. Roughness/metalness unchanged
+  // except skin roughness, which the palette nudges older (cooler/mattier) past 65.
+  const sweater = new THREE.MeshStandardMaterial({ color: P.colors.sweater, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 }); // charcoal knit (one shade lighter so torso/arms read against the dark upper scene on look-down — /contrarian)
+  const jeans   = new THREE.MeshStandardMaterial({ color: P.colors.jeans, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 }); // indigo denim
+  const skin    = new THREE.MeshStandardMaterial({ color: P.colors.skin, roughness: P.colors.skinRough,  metalness: 0.0, envMapIntensity: 0.2 });  // hands
+  const shoe    = new THREE.MeshStandardMaterial({ color: P.colors.shoe, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 }); // dark leather
+
+  // Proportions (metres), tuned so the eye sits ~1.62m and feet at 0. At age 28 the
+  // P.* multipliers are all identity (1.0 / 0), so these collapse to the original
+  // constants. Younger → shorter torso + limbs + lower hip; older → slight shrink.
+  const hipY      = 0.95 * (isBaseline ? 1 : (0.55 + 0.45 * P.eyeY / 1.62)); // floor→waist scales gently with stature
+  const chestTopY = isBaseline ? 1.42 : (hipY + 0.47 * P.torsoY);   // top of torso (shoulder line)
+  const torsoH    = chestTopY - hipY;   // 0.47 at baseline
+  const shoulderHalf = P.shoulderHalf;  // half shoulder width (arms hang outside this)
   // BODY_BACK: shift the core mass (torso/neck/shoulders/hips/legs) BEHIND the eye in
   // rig-local +z (Steve faces -z, so +z is behind him). In a real first-person view the
   // eye sits at the FRONT of the head, ahead of the chest — without this the camera would
@@ -1019,19 +1108,30 @@ function buildAvatar() {
   // NOT shifted back — they reach forward into the low-front field so a glance down sees them.
   const BODY_BACK = 0.16;
 
+  // ---- upperRig — a NAMED sub-group holding torso + neck + shoulders + arms so the
+  // kyphotic forward-lean (stoop) at 75/85 is ONE coherent rotation about the hip line,
+  // not a per-mesh hack. At age 28 upperRig sits at origin with rotation 0, so the rig
+  // is byte-identical to today's (upperRig is a transparent passthrough group). The
+  // pivot is the waist (hipY): we translate the group's pivot there, rotate, translate
+  // back, so the upper body hinges forward from the hips like a real stoop. ----
+  const upperRig = new THREE.Group();
+  upperRig.name = 'upperRig';
+  rig.add(upperRig);
+
   // ---- TORSO (charcoal sweater) — a soft tapered capsule, slightly narrower at the
   // waist. Sits LOW/centre so it doesn't crowd the forward view; you mainly see the
   // chest when you pitch down. ----
   const torso = capsuleMesh(0.165, torsoH - 0.10, sweater, 16);
   torso.position.set(0, hipY + torsoH / 2, BODY_BACK);
   torso.scale.set(1.15, 1.0, 0.78);   // broaden across, flatten front-to-back (human chest)
-  rig.add(torso);
+  upperRig.add(torso);
 
   // Short neck stub (sweater collar) — ends just below the eye so there's no clipped
-  // head; the camera looks out from just above + ahead of it.
+  // head; the camera looks out from just above + ahead of it. neckUp lifts the collar
+  // a touch on kids/elders to keep the head-to-body fraction reading right.
   const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.075, 0.12, 12), sweater);
-  neck.position.set(0, chestTopY + 0.04, BODY_BACK);
-  neck.castShadow = true; rig.add(neck);
+  neck.position.set(0, chestTopY + 0.04 + P.neckUp, BODY_BACK);
+  neck.castShadow = true; upperRig.add(neck);
 
   // ---- SHOULDERS — round caps so the sweater reads as a real shoulder line, set wide
   // and low (peripheral), not blocking the centre forward view. ----
@@ -1039,7 +1139,7 @@ function buildAvatar() {
     const sh = new THREE.Mesh(new THREE.SphereGeometry(0.11, 14, 12), sweater);
     sh.position.set(s * shoulderHalf, chestTopY - 0.04, BODY_BACK);
     sh.scale.set(1.1, 0.85, 0.9);
-    sh.castShadow = true; rig.add(sh);
+    sh.castShadow = true; upperRig.add(sh);
   });
 
   // ---- ARMS — upper arm + forearm + hand. FORWARD is -z (Steve faces -z), so the arms
@@ -1051,6 +1151,7 @@ function buildAvatar() {
   [-1, 1].forEach(s => {
     const arm = new THREE.Group();
     arm.position.set(s * shoulderHalf, chestTopY - 0.06, BODY_BACK);
+    if (!isBaseline) arm.scale.setScalar(P.limb);   // shorter limbs on kids, slight on elders
 
     // Upper arm — drops from the shoulder, angled out + slightly forward (toward -z).
     const upper = capsuleMesh(0.062, 0.24, sweater, 12);
@@ -1075,7 +1176,7 @@ function buildAvatar() {
     arm.add(hand);
 
     arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
-    rig.add(arm);
+    upperRig.add(arm);
   });
 
   // ---- LEGS — two indigo-denim capsules from the hip to the ankle, set at hip width,
@@ -1103,12 +1204,27 @@ function buildAvatar() {
   hips.scale.set(1.1, 1.0, 0.8);
   hips.castShadow = true; rig.add(hips);
 
+  // ---- KYPHOTIC STOOP — hinge the whole upperRig forward (-z) about the waist line.
+  // At age 28 leanRad == 0 so upperRig stays at identity (byte-identical baseline).
+  // Pivot at the hip: rotate about x at y=hipY,z=BODY_BACK by lifting the group's
+  // pivot there. Because every upper-body mesh is built in rig-local coords, we set the
+  // group's rotation about its own origin then compensate position so the hinge sits at
+  // the waist rather than the floor. Forward stoop = negative x-rotation (top tips -z).
+  if (!isBaseline && P.leanRad > 0) {
+    upperRig.position.set(0, hipY, BODY_BACK);
+    upperRig.rotation.x = -P.leanRad;
+    // children were built in absolute rig-local coords; shift them back by the pivot
+    // so the rotation hinges at the waist instead of double-translating.
+    upperRig.children.forEach(c => { c.position.y -= hipY; c.position.z -= BODY_BACK; });
+  }
+
   // Soft contact shadow under the feet so the figure reads grounded on the floor.
   addContactShadow(rig.position.x, rig.position.z, 0.55, 0.42, 0.7);
 
   rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
   scene.add(rig);
   avatarRig = rig;
+  avatarUpperRig = upperRig;
   updateAvatarRig();   // seat yaw + position immediately
   requestShadowUpdate(2);
 }
@@ -3473,6 +3589,50 @@ window._qh = {
       eye: lockedSpinEye ? { x: lockedSpinEye.x, y: lockedSpinEye.y, z: lockedSpinEye.z } : null
     };
   },
+  // ---- Chunk K′ — AGE VIEW control surface (default-off; only used when Age View is ON).
+  // setAvatarAge rebuilds the primitive rig at a continuous age (12→85). getAvatarAge
+  // returns the current age. ageRigParams exposes the lerp for the HUD readout/tests.
+  // upperRigState proves the named stoop sub-group exists + reports its rotation (0 at 28).
+  // setEnvForAge does CHEAP scalar/colour env tweaks only (exposure + ambient/hemi/key
+  // intensity + tint) — no geometry/layout change, so FPS is untouched (contrarian #5).
+  // resetEnv restores the baseline gallery light.
+  setAvatarAge(age) { buildAvatar(age); },
+  get avatarAge() { return avatarAge; },
+  ageRigParams,
+  upperRigState() {
+    if (!avatarUpperRig) return null;
+    return { present: true, name: avatarUpperRig.name, rotX: avatarUpperRig.rotation.x, childCount: avatarUpperRig.children.length };
+  },
+  setEnvForAge(age) {
+    age = Math.max(12, Math.min(85, age));
+    const amb = scene.children.find(o => o.isAmbientLight);
+    const hemi = scene.children.find(o => o.isHemisphereLight);
+    const key = window._keyLight, pic = window._picLight;
+    // Senior bands (65+) RAISE ambient + REDUCE harsh speculars (glare control for the
+    // aging lens); youth bands are brighter/punchier; mid is neutral baseline.
+    let exposure = 1.02, ambI = 0.42, hemiI = 0.42, keyI = 1.05, picI = 0.55;
+    if (age <= 16)      { exposure = 1.10; ambI = 0.46; hemiI = 0.48; keyI = 1.12; picI = 0.60; }
+    else if (age <= 35) { exposure = 1.02; ambI = 0.42; hemiI = 0.42; keyI = 1.05; picI = 0.55; }
+    else if (age <= 55) { exposure = 1.06; ambI = 0.46; hemiI = 0.46; keyI = 0.98; picI = 0.52; }
+    else if (age <= 75) { exposure = 1.08; ambI = 0.60; hemiI = 0.58; keyI = 0.82; picI = 0.46; }
+    else                { exposure = 1.10; ambI = 0.68; hemiI = 0.64; keyI = 0.74; picI = 0.42; }
+    renderer.toneMappingExposure = exposure;
+    if (amb)  amb.intensity = ambI;
+    if (hemi) hemi.intensity = hemiI;
+    if (key)  key.intensity = keyI;
+    if (pic)  pic.intensity = picI;
+    requestShadowUpdate(2);
+  },
+  resetEnv() {
+    renderer.toneMappingExposure = 1.02;
+    const amb = scene.children.find(o => o.isAmbientLight);
+    const hemi = scene.children.find(o => o.isHemisphereLight);
+    if (amb)  amb.intensity = 0.42;
+    if (hemi) hemi.intensity = 0.42;
+    if (window._keyLight) window._keyLight.intensity = 1.05;
+    if (window._picLight) window._picLight.intensity = 0.55;
+    requestShadowUpdate(2);
+  },
   // Drive the spin pose for headless verification (no key events in screenshot harness).
   setSpin(yaw, pitch) {
     if (typeof yaw === 'number')   spinYaw   = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, yaw));
diff --git a/public/showroom.html b/public/showroom.html
index d08a1eb..ce2b3f5 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -376,5 +376,6 @@
   <script src="/js/showroom.js"></script>
   <script src="/js/viewmodes.js"></script>
   <script src="/js/versions.js"></script>
+  <script src="/js/ageview.js"></script>
 </body>
 </html>

← 3048f97 AGE-VIEWS-SPEC: apply /contrarian plan-gate fixes (opaque HU  ·  back to Quadrille Showroom  ·  Phase 5 senior-usability HARD gate: measured verifier + 3 ca d84af5b →