[object Object]

← back to Quadrille Showroom

verify: At-the-Table behavioral test (nook hide/restore + seated set + sample-add) + verify-all runner

e0bed2f75ff7cdfdaa54f5291398a6641b65eb90 · 2026-07-01 10:36:58 -0700 · Steve

scripts/verify-table-view.mjs (npm run verify-table-view) drives window._viewmode.set('table'),
asserts the consultation nook hides (only centre table shows), the table+chair set and seated avatar
appear, addSample() grows the basket, then exits and asserts the nook is restored — 9/9 pass, 0 console
errors. Covers the newest committed feature (842a1dc). Added npm run verify-all to chain the 4 headless verifiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit e0bed2f75ff7cdfdaa54f5291398a6641b65eb90
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 10:36:58 2026 -0700

    verify: At-the-Table behavioral test (nook hide/restore + seated set + sample-add) + verify-all runner
    
    scripts/verify-table-view.mjs (npm run verify-table-view) drives window._viewmode.set('table'),
    asserts the consultation nook hides (only centre table shows), the table+chair set and seated avatar
    appear, addSample() grows the basket, then exits and asserts the nook is restored — 9/9 pass, 0 console
    errors. Covers the newest committed feature (842a1dc). Added npm run verify-all to chain the 4 headless verifiers.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 AGE-VIEWS-SPEC.md                      | 292 -------------------------
 package.json                           |   4 +-
 public/js/ageview.js                   | 375 ---------------------------------
 scripts/verify-ageview-3x.mjs          |  95 ---------
 scripts/verify-ageview-avatar.mjs      | 106 ----------
 scripts/verify-ageview-crossengine.mjs |  54 -----
 scripts/verify-ageview-envmorph.mjs    | 155 --------------
 scripts/verify-ageview-gate.mjs        |  32 ---
 scripts/verify-ageview-phase5.mjs      | 358 -------------------------------
 scripts/verify-table-view.mjs          |  57 +++++
 10 files changed, 60 insertions(+), 1468 deletions(-)

diff --git a/AGE-VIEWS-SPEC.md b/AGE-VIEWS-SPEC.md
deleted file mode 100644
index 7fa0cbd..0000000
--- a/AGE-VIEWS-SPEC.md
+++ /dev/null
@@ -1,292 +0,0 @@
-# AGE-ADAPTIVE AVATAR SHOWROOM VIEWS — Authoritative Plan & Spec
-
-**Initiative:** "Age-Adaptive Avatar Showroom Views" — 10 first-person showroom views spanning ages 12 → 85.
-**Overseer:** vp-research-content (this doc is the overseer deliverable; build fans out under it).
-**Date:** 2026-06-29 · **Status:** PLAN — gated to /contrarian before any build. No engine edits this pass.
-**Constraints:** local only · $0 · no deploy · no customer-facing change · avatar from THREE primitives only.
-
----
-
-## 0. DTD verdict adopted — Verdict A (BOTH adapt)
-
-The running panel leans strongly **A: both (1) the AVATAR figure ages 12→85 AND (2) the UI/UX adapts per age.**
-**This overseer concurs with A**, and refines it:
-
-- The whole point of an *age-adaptive avatar showroom* is that a 12-year-old and an 85-year-old are
-  *embodied differently AND served differently*. Adapting only the avatar (A-without-UI) is a costume
-  change with no usability payoff; adapting only the UI (the existing age-adaptive-theme skill already
-  does that, decoupled from any avatar) ignores Steve's explicit ask to "create versions based on age...
-  for the avatar." A is the only reading that satisfies the literal commission.
-- **One sharpening of A:** the avatar and the UI must move on the *same age axis but not on identical
-  curves*. The avatar morphs *continuously* (proportions interpolate smoothly with age); the UI snaps to
-  the nearest of the validated age-adaptive-theme bands (typography research only validates discrete
-  bands, not a continuum — claiming a smoothly-interpolated WCAG value at age 58.3 would be the exact
-  "false APCA confidence" failure the validation-loop skill exists to prevent). So: **avatar = continuous
-  lerp; UI = banded snap.** Both driven by one `age` value.
-
-No dissent strong enough to override. Proceeding on A.
-
----
-
-## 1. THE 10 AGE BANDS (12 → 85)
-
-Bands chosen on **life-stage meaning** (how a person of that age relates to a luxury interiors showroom +
-how their vision/motor/cognition + taste profile differ), not arithmetic spacing. Each maps to exactly one
-age-adaptive-theme UI band (the skill's `bandFor()` covers 3–95; we use the 12–85 slice).
-
-| # | Age | Band name | Life stage / showroom relationship | UI band (age-adaptive-theme) |
-|---|-----|-----------|-------------------------------------|------------------------------|
-| 1 | **12** | **Tween** | Curious kid "designing my room"; play-mode, low stakes | `tween` (10–12) |
-| 2 | **16** | **Teen** | Self-expression, bold taste, fluent/fast UI, dense OK | `teen` (13–19) |
-| 3 | **21** | **Emerging Adult** | First apartment, budget-aware, mobile-native | `adult` (low end) |
-| 4 | **28** | **Young Pro** | First real design budget, decisive, modern dashboard | `adult` |
-| 5 | **35** | **Established** | Family home, refined taste, dense info wanted | `adult` (high end) |
-| 6 | **45** | **Connoisseur** | Peak buying power, calm refined pacing, serif | `mature` (45–59) |
-| 7 | **55** | **Tastemaker** | Mature eye, early presbyopia onset, larger type creeping in | `mature` (high end) |
-| 8 | **65** | **Senior** | ★ Senior-usability HARD bar begins — hi-contrast, large, big targets | `senior` (60–79) |
-| 9 | **75** | **Elder** | ★ Deeper senior bar — one decision at a time, max legibility | `senior` (high end) |
-| 10 | **85** | **Legacy** | ★ Hardest accessibility bar — yellow-on-black, 28px+, single action | `elder` (80+) |
-
-**The 65+ HARD bar** (bands 8–10) is non-negotiable: each MUST clear senior-usability —
-large type (≥20px body, ≥28px at 85), high contrast (APCA Lc ≥ 60 body / ≥ 75 for any
-call-to-action), big hit targets (≥48px at 65, ≥56px at 85). See §7 verification.
-
----
-
-## 2. PER-BAND AVATAR (first-person, all from THREE primitives — $0)
-
-First-person camera = the avatar's eyes. On look-straight the forward wing-bank view stays clean; on
-**look-down** the viewer sees their own torso / clasped hands / lap / legs / feet. So the avatar morph is
-felt primarily through **what your own body looks like when you glance down** + **eye height** + **posture
-sway**. All driven from the existing `buildAvatar()` primitive rig in `public/js/showroom.js` (~L989) — we
-only re-parameterize its existing constants (`AVATAR_EYE_Y`, `hipY`, `chestTopY`, `shoulderHalf`,
-`BODY_BACK`, arm/forearm/hand dims, leg length, foot size) + swap the 4 clothing materials. **No new
-geometry types, no GLTF, no model-gen.**
-
-### 2a. The four primitive levers that carry "age"
-
-1. **EYE HEIGHT (`AVATAR_EYE_Y`)** — the single most legible age cue. A 12-yr-old's eyes sit lower; growth
-   peaks ~late teens; a slight decline + stoop in the 80s lowers it again.
-2. **PROPORTIONS** — head-to-body ratio (kids = larger head fraction → at 12 the neck stub sits higher /
-   torso shorter), shoulder width (`shoulderHalf` narrow at 12 → broad at 28–45 → slightly narrowed at 85),
-   torso length, limb length. Pure scalar tweaks to existing constants.
-3. **POSTURE** — implemented as a small forward lean of the whole rig + a forward-tilt of the neck/upper
-   torso. Upright through midlife; a measured **kyphotic stoop** at 75–85 (rig leans forward ~6–12°, eye
-   drops, hands rest higher/closer). Posture is a rotation on the rig + neck group — no new meshes.
-4. **CLOTHING (material swap only)** — recolor the existing `sweater / jeans / skin / shoe` materials per
-   age. Skin roughness/tone shifts subtly older (cooler, higher roughness) at 65+. No new clothing meshes;
-   "age-appropriate" = palette + roughness on the 4 existing materials.
-
-### 2b. Per-band avatar parameters (relative to the current adult rig: EYE 1.62m, shoulderHalf 0.20)
-
-| # | Age | Eye Y (m) | Shoulder½ | Posture lean | Clothing palette (sweater / jeans / skin / shoe) |
-|---|-----|-----------|-----------|--------------|---------------------------------------------------|
-| 1 | 12 | **1.40** | 0.145 | upright, light bounce | bright hoodie (teal) / lighter jeans / youthful warm skin / canvas sneaker (white) |
-| 2 | 16 | 1.55 | 0.175 | upright, relaxed | graphic crew (charcoal+accent) / slim dark denim / warm skin / dark sneaker |
-| 3 | 21 | 1.60 | 0.195 | upright | casual knit (olive) / mid denim / warm skin / low boot |
-| 4 | 28 | **1.62** | 0.205 | upright (baseline) | charcoal sweater (current) / indigo denim / warm skin / dark leather (baseline rig) |
-| 5 | 35 | 1.62 | 0.215 | upright, settled | fine-gauge merino (slate) / dark trouser-denim / warm skin / leather derby |
-| 6 | 45 | 1.61 | 0.215 | upright, composed | cashmere (camel/greige) / wool trouser (charcoal) / mature skin / suede loafer |
-| 7 | 55 | 1.60 | 0.210 | very slight forward | quarter-zip (heather) / tailored trouser / cooler skin / leather loafer |
-| 8 | 65 | 1.58 | 0.205 | slight stoop ~4° | soft cardigan (navy) / relaxed trouser / cooler higher-roughness skin / comfort shoe |
-| 9 | 75 | 1.54 | 0.195 | stoop ~8°, hands higher | knit vest+shirt (warm grey) / easy trouser / elder skin / cushioned shoe |
-| 10 | 85 | **1.48** | 0.185 | stoop ~12°, hands clasped high/close | thick cardigan (oat) / soft trouser / elder skin (cool, matte) / orthopedic-style shoe |
-
-> Implementation note for the engine officer: these are **interpolation anchors**, not 10 hardcoded bodies.
-> `buildAvatar(age)` reads an `ageRigParams(age)` lerp function (anchors above) so the slider morphs the body
-> continuously. Hands/forearm rest position rises and pulls inward with stoop so the look-down framing stays
-> natural at every age. Contact shadow + grounding unchanged. All shadows/PBR inherited from the rig.
-
----
-
-## 3. PER-BAND UI/UX ADAPTATION (leverage age-adaptive-theme bands)
-
-The HUD chrome over the 3D canvas — masthead, board caption, spec panel, CTA, version rail, FPS toggle —
-re-themes per age by rewriting CSS vars exactly as the age-adaptive-theme skill prescribes. We consume that
-skill's `bands.json` + `bandFor()` + the `--btn-min-height` / `--font-line-height` baselines verbatim
-(do NOT re-derive APCA values — use the skill's validated tokens; that's the validation-loop lesson).
-
-| # | Age | Base body / heading | Palette | Density / spacing | Info-complexity (spec detail shown) | Copy tone | Hit target |
-|---|-----|---------------------|---------|-------------------|-------------------------------------|-----------|------------|
-| 1 | 12 | 15 / 22 px | amber bg + **indigo-700** | playful, roomy cards | name + 1 fun fact ("looks like candy stripes!") | friendly, simple | 44px |
-| 2 | 16 | 16 / 22 px | dark navy + magenta/cyan neon | dense, tight (scan-line accent) | full pattern + colorway + scale, power-user | confident, punchy | 44px |
-| 3 | 21 | 15 / 18 px | dark indigo (DW default) | modern dashboard | pattern, colorway, repeat, price-aware | clear, modern | 44px |
-| 4 | 28 | 15 / 18 px | dark indigo | modern dashboard, full Hermes panel | full spec table | professional | 44px |
-| 5 | 35 | 15 / 18 px | dark indigo | dense, all info | full spec + "pairs well with" | professional, assured | 44px |
-| 6 | 45 | 15 / 22 px | cream / burgundy serif | calmer pacing, 16px gap | full spec, refined hierarchy | refined editorial | 44px |
-| 7 | 55 | 16 / 24 px | cream / burgundy serif | calmer, larger | full spec, slightly larger labels | refined, unhurried | 48px |
-| 8 | **65** | **20 / 30 px** | **pure black-on-white** | single-column, 20px gap | **reduced** — pattern + colorway + 1 key spec; details behind a clear "More" | plain, direct | **48px** |
-| 9 | **75** | **22 / 32 px** | black-on-white | single-column, larger padding | **minimal** — name + colorway + one CTA | one idea per screen | **52px** |
-| 10 | **85** | **28 / 44 px** | **yellow-on-black** (cataract-scatter correct) | single action at a time | **essentials only** — name + the action verb | just the verb ("See it on the wall") | **56px** |
-
-**65+ HARD bar enforced here:** bands 8–10 use the skill's `senior`/`elder` tokens which are APCA-validated
-(black-on-white / yellow-on-black, Atkinson Hyperlegible, ≥20px body, ≥48px targets). FPS toggle, version
-rail, and any decorative chrome are **simplified or hidden** in these bands so the path is unobstructed.
-
----
-
-## 4. PER-BAND SHOWROOM ENVIRONMENT TWEAK (light touch)
-
-Environment morph is *subtle* — the room is still the v1 gallery wing-bank; only mood/lighting/calm shifts.
-This rides on the existing PBR/PMREM lighting rig; one or two scalar/colour tweaks per band, FPS-safe.
-
-| # | Age | Environment tweak |
-|---|-----|-------------------|
-| 1 | 12 | Brightest, most saturated key; warm playful bounce; faint extra board spacing so it feels open/fun |
-| 2 | 16 | Cooler, higher-contrast, slight neon rim accent on frames (matches teen UI) |
-| 3 | 21 | Neutral bright gallery (baseline) |
-| 4 | 28 | Neutral bright gallery (baseline) |
-| 5 | 35 | Neutral, slightly warmer key |
-| 6 | 45 | Warmer, softer key; gentle exposure lift; calmer |
-| 7 | 55 | Warmer still, softened shadows, reduced glare |
-| 8 | 65 | **Brighter overall ambient + reduced harsh speculars** (glare control for aging lens); calm, even light |
-| 9 | 75 | Even, high-ambient, very low glare; slowest any animation; maximal contrast on the focused board |
-| 10 | 85 | Calmest: high even ambient, no bloom/haze, focused board maximally lit vs a quiet surround; zero visual noise |
-
-> Senior bands (65+) deliberately *raise* ambient + *kill glare/bloom* — high contrast on content,
-> low contrast in the periphery. The opposite of the teen band's deep-contrast drama.
-
----
-
-## 5. IMPLEMENTATION DECISION — **RECOMMEND (b): ONE age-SLIDER on the core build**
-
-**Recommendation: build the 10 as a single live AGE SLIDER on the core (V1) build** that morphs the avatar
-(continuous) + UI (banded snap) + environment (banded) across 12→85 — **not** 10 standalone V22–V31 overlays.
-
-### Why (b) wins
-
-1. **Matches the source-of-truth models.** The avatar rig (`buildAvatar`) and the age-adaptive-theme skill
-   are *both* already slider/parameter shaped. age-adaptive-theme's entire reference implementation IS a
-   slider. Fighting that to freeze 10 static overlays throws away the morph that makes the feature
-   impressive — the *continuous transformation* is the demo.
-2. **One engine change, not ten files to keep in sync.** 10 overlays = 10 copies of avatar + UI + env code
-   that drift independently (we've seen this exact drift cost with the hero/proto fleet). One slider on V1 =
-   one `ageRigParams(age)` + one `applyAgeBand(age)` + one env tweak table. DRY, auditable, one place to fix.
-3. **The avatar lives ONLY in the core build.** The V12–V21 protos are camera-only first-person with NO
-   avatar rig. To do "age the avatar" as overlays we'd have to *port the whole primitive rig into 10 proto
-   files* — exactly the duplication (b) avoids. The avatar already exists in V1; the slider extends V1.
-4. **Senior-usability is testable once.** One slider → run the APCA + target-size checks at the 65/75/85
-   stops once, against one codebase. Ten overlays = ten audits.
-5. **Impressive + investor-legible.** "Drag one slider, watch the showroom-goer age from 12 to 85 — body,
-   eyes, type, contrast, calm all shift together" is a far stronger demo than clicking through 10 near-
-   identical static rooms.
-
-### Tradeoffs / what (b) costs (honest)
-
-- **(b) touches the core engine** (`showroom.js` `buildAvatar`, plus a new HUD-theming layer). That's the
-  one real cost: the engine is "owned elsewhere" per HIGH-END-VERSIONS-SPEC. **Mitigation:** the slider is
-  *additive and gated behind a new V-entry / mode flag* ("Age View"), default OFF; `buildAvatar()` keeps its
-  current zero-arg behavior (defaults to age 28 = today's exact rig), so V1 is byte-identical when the slider
-  isn't engaged. No regression to existing versions. The engine officer makes ONE careful, reviewed change.
-- **(a) 10 overlays** would be isolated + parallel-buildable (its only real advantage) — but at the price of
-  10× avatar-rig duplication, 10× drift, 10× audit, and a *worse* demo. The isolation advantage is moot here
-  because the thing being adapted (the avatar) doesn't exist in the overlay layer at all.
-- **Hybrid considered + rejected:** "slider on core + also snapshot 10 static overlays for the V-picker."
-  Rejected for this pass — it reintroduces the duplication/drift we're avoiding. If Steve later wants the 10
-  as discrete pickable chips, generate them as *thin presets that call the same slider engine at a fixed age*
-  (one line each), never as forked copies.
-
-**Net: (b) — one age slider on the core build, additive, default-off, age-28 == today's rig.**
-If /contrarian or Steve overrules toward (a), the §2/§3/§4 tables are overlay-ready as-is.
-
----
-
-## 6. FOUR-HORSEMEN DESIGN FRAMES NEEDED (orchestrator main-thread runs these)
-
-The MCP design tools (Figma / 21st / Paper / Canva) run in the orchestrator's main thread, not in this
-overseer thread. Requesting **exactly these frames** — 3 representative age bands spanning the range + the
-slider chrome itself:
-
-1. **HUD frame — Age 12 (Tween):** playful amber/indigo-700 masthead + board caption + "fun fact" line +
-   big friendly CTA. Show the roomy/playful density. *(Figma layout + Canva visual pass.)*
-2. **HUD frame — Age 45 (Connoisseur):** the refined serif cream/burgundy `mature` HUD — full spec table,
-   editorial hierarchy, calm pacing. The "peak buyer" frame. *(Figma + Canva.)*
-3. **HUD frame — Age 85 (Legacy):** the `elder` senior-usability HUD — yellow-on-black, 28/44px Atkinson
-   Hyperlegible, single action verb, 56px target, all decorative chrome stripped. **This is the frame the
-   senior-usability bar is judged against.** *(Figma + 21st live preview to confirm contrast renders.)*
-4. **The AGE SLIDER control itself** (the persistent chrome): age readout + band label + tagline + the
-   12→85 track, in a treatment that reads at every band (it must remain legible in the elder band too).
-   *(21st Dev component + Paper spec for behavior/states.)*
-5. **(Optional, if budget) A 3-up "morph strip"** — avatar+HUD thumbnails at 12 / 45 / 85 side by side — for
-   the pitch deck / VC debrief. *(Canva.)*
-
-> Deliberately only 3 HUD age-frames (12 / 45 / 85), not 10 — the other 7 bands interpolate/snap between
-> these anchors and don't need bespoke design comps. 3 anchors + slider chrome is the minimum that fully
-> specifies the visual range.
-
----
-
-## 7. PHASED BUILD PLAN + GATES + VERIFICATION
-
-Recommended execution = **single engine officer (vp-engineering), not an N-agent fan-out** — because (b) is
-*one coordinated change to one core file* (avatar rig + HUD theming on V1). Fan-out shines for N isolated
-files; this is the opposite shape. (If Steve overrules to (a) 10 overlays, THEN fan out 10 agents via
-abramstasks, one proto each, using §2/§3/§4 as the per-file spec.)
-
-### Phase 0 — Gate this plan (NOW)
-- /contrarian tears this spec apart → revise → only then build. **No engine edits until contrarian clears.**
-
-### Phase 1 — Design anchors (orchestrator)
-- Orchestrator runs the §6 four-horsemen frames (3 HUD anchors + slider chrome). Engine officer waits on
-  these so the HUD theming matches approved comps, not invented styling.
-
-### Phase 2 — Avatar age-morph (engine officer, vp-engineering)
-- Add `ageRigParams(age)` lerp (anchors from §2b) + extend `buildAvatar(age=28)` to consume it.
-  **Default zero-arg call == today's rig (age 28) byte-for-byte** (regression guard).
-- Continuous: eye height, proportions, posture lean, clothing material swap. Primitives only, $0.
-- Verify with the **a11y-check** + **avatar-dance**/screenshot at each anchor age that look-down shows a
-  plausible body and forward view stays clean.
-
-### Phase 3 — UI age-band theming (engine officer)
-- Drop in age-adaptive-theme's `bands.json` tokens + `bandFor()` + the `--btn-min-height`/`--font-line-height`
-  baselines onto the HUD chrome (masthead, caption, spec panel, CTA, rail). Banded snap, not interpolation.
-- Wire the §3 info-complexity shaping (`renderFor(band)` — fewer spec rows + simpler copy in 65+).
-
-### Phase 4 — Environment tweaks + the slider control (engine officer)
-- Add the §4 per-band light-touch env table (scalar/colour lerps on the existing PBR/PMREM rig, FPS-safe).
-- Add the persistent **Age slider** (matches the §6.4 approved chrome), default OFF behind an "Age View"
-  mode flag; persists `age` to `localStorage`.
-
-### Phase 5 — Verification (HARD gates)
-1. **FPS gate** — slider must hold ≥55fps at every age (HIGH-END-VERSIONS-SPEC budget). Prove with the FPS
-   readout; pause-on-hidden intact.
-2. **Senior-usability gate (65/75/85) — HARD, blocking:**
-   - **APCA contrast** via age-adaptive-theme's `lib/apca.js` (do NOT self-calculate): body text **Lc ≥ 60**,
-     any CTA **Lc ≥ 75**, at ages 65, 75, 85. Verify in `colour-contrast.com/apca` for the elder band.
-   - **Target size**: every interactive control **≥48px (65/75)** / **≥56px (85)** — measured, not asserted.
-   - **Type size**: body ≥20px (65) → ≥28px (85); headings ≥30px (65) → ≥44px (85).
-   - **Info-complexity**: 65+ shows reduced spec detail + single-action copy (no full spec table dumped on an
-     85-yr-old).
-   - Run **a11y-check** skill against the 65/75/85 stops.
-3. **Cross-engine render** — /3x (Chrome/Safari/Firefox) on the slider at the 3 anchor ages (12/45/85), 0
-   console errors, real China Seas textures intact, Back/picker postMessage still works.
-4. **validation-loop** — after applying any contrarian/a11y fixes, RE-RUN the same APCA + target + FPS checks
-   on the post-edit build before declaring done (the codified "don't claim done after one edit cycle" rule).
-5. **/contrarian final pass** on the built slider before it's called complete.
-
-### Gates summary
-- **/contrarian** — gates the PLAN (Phase 0) AND the final BUILD (Phase 5.5).
-- **HARD senior-usability + APCA gate** — blocking at 65/75/85; build is NOT done until these pass measured.
-- **Engine change is additive + default-off + age-28-identical** — the regression guard that lets us touch
-  "owned-elsewhere" core code safely.
-- **$0 throughout** — primitives + local APCA/a11y/FPS checks; the only paid step is the four-horsemen design
-  frames in the orchestrator (estimate: design-MCP calls, a few cents to low-$; orchestrator surfaces actual).
-
----
-
-## Appendix — file/asset touchpoints (for the build, not this pass)
-- `public/js/showroom.js` — `buildAvatar()` ~L989 → add `age` param + `ageRigParams(age)` lerp; `updateAvatarRig()` unchanged.
-- `public/js/versions.js` — add ONE "Age View" entry/mode flag (default off); `VERSION_ORDER` append.
-- HUD/CSS — consume `~/.claude/skills/age-adaptive-theme/bands.json` + `lib/apca.js` (reference, don't fork).
-- No new geometry, no GLTF, no model-gen, no stock images, no deploy.
-
----
-
-## CONTRARIAN GATE — REVISIONS APPLIED (2026-06-29, verdict APPROVE-PLAN)
-The /contrarian plan-gate approved this plan with the following BINDING fixes. They override any conflicting text above.
-
-1. **APCA-over-WebGL (HARD).** Every HUD panel/element at age **65+** MUST have a SOLID 100%-opaque background colour BEFORE the APCA check runs — NO semi-transparent chrome over the live WebGL scene at the senior bands. APCA is measured on the actually-rendered opaque hex pair, not assumed against a static-page background. (Without this the senior gate passes on a tool check but fails visually over the moving 3D scene — the single biggest hole.)
-2. **The age-slider control itself is gated at the elder band.** Its thumb/track/label must clear APCA Lc≥75 + ≥56px hit target + ≥28px label AT AGE 85 — it's persistent on-screen, so it's verified like any senior element in the Phase-5 85-band a11y check.
-3. **Avatar stoop → named `upperRig` sub-group.** The current rig has NO neck group (neck is a direct child of `rig`). Phase 2 build CREATES a named `upperRig` sub-group (torso + neck + shoulders + arms parented to it) so the kyphotic forward-lean at 75/85 is ONE coherent rotation. Invariant preserved: `buildAvatar()` zero-arg == today's age-28 rig byte-for-byte (`upperRig` at 0° rotation = unchanged).
-4. **Bands 21 & 28 differentiated** (were ~80% identical — a 10mm shoulder diff is not a band). 21 Emerging Adult = first-apartment / budget-conscious → energetic, higher-saturation, denser discovery, casual copy, relaxed posture. 28 Young Pro = first real design budget → refined-modern, calmer, spec-forward, professional copy, more upright posture + sharper clothing read. (If still indistinct at build time, MERGE them and add a band at **age 40** to fill the real 35→45 gap.)
-5. **Per-band env tweaks profiled individually.** Any per-band environment change that alters GEOMETRY/LAYOUT (e.g. "extra board spacing" at age 12) is NOT a free scalar — it must be FPS-profiled AT THAT BAND (the Phase-5 FPS≥55 gate runs at EACH band, not just slider-overall). Prefer cheap scalar/colour/light tweaks over layout rebuilds for the age signal.
diff --git a/package.json b/package.json
index 5a35a99..537a0d7 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,9 @@
     "verify-ageview-gate": "node scripts/verify-ageview-gate.mjs",
     "verify-sort-density-persist": "node scripts/verify-sort-density-persist.mjs",
     "verify-scene-render": "node scripts/verify-scene-render.mjs",
-    "verify-ageview-crossengine": "node scripts/verify-ageview-crossengine.mjs"
+    "verify-ageview-crossengine": "node scripts/verify-ageview-crossengine.mjs",
+    "verify-table-view": "node scripts/verify-table-view.mjs",
+    "verify-all": "npm run verify-ageview-gate && npm run verify-sort-density-persist && npm run verify-scene-render && npm run verify-table-view"
   },
   "license": "ISC",
   "description": "The Quadrille House — 3D Endless Wings Wallcovering Showroom (China Seas)",
diff --git a/public/js/ageview.js b/public/js/ageview.js
deleted file mode 100644
index 5c31394..0000000
--- a/public/js/ageview.js
+++ /dev/null
@@ -1,375 +0,0 @@
-/* ============================================================================
- * 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,
-    // 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.',
-    // BAND IDENTITY — energetic VIOLET, purple-dark, denser (gap 10). Distinct hue family from
-    // adult(blue)/established(teal) — they were ~90% identical until /5x band-differentiation 2026-06-29.
-    font:"system-ui, -apple-system, sans-serif", body:15, head:19, btn:44, gap:10, opaque:false, energetic:true,
-    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.',
-    // BAND IDENTITY — refined-modern DASHBOARD: cool blue-grey + dashboard-blue CTA #1d4ed8 (Lc 87),
-    // balanced density (gap 12). The calm professional baseline between energetic-young and dense-established.
-    font:"-apple-system, BlinkMacSystemFont, sans-serif", body:15, head:18, btn:44, gap:12, opaque:false,
-    bg:'#0a0c12', panel:'#161a26', fg:'#e6eef8', cta:'#1d4ed8', ctaFg:'#ffffff', border:'#252b3d', muted:'#9aa6bd' },
-  { max:40, id:'established', name:'Established', tag:'Family home — dense info, assured.',
-    // BAND IDENTITY — assured/dense: deeper warm-slate + TEAL CTA #155e75 (Lc 90), densest layout
-    // (gap 9) + a touch more heading hierarchy (head 19) for the "all-info, settled eye" reading.
-    font:"-apple-system, BlinkMacSystemFont, sans-serif", body:15, head:19, btn:44, gap:9, opaque:false,
-    bg:'#0b0e12', panel:'#16212a', fg:'#e9f0fa', cta:'#155e75', ctaFg:'#ffffff', border:'#244049', 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:22, head:34, 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:26, head:42, 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; }
-  /* The Age View bar shows a DARK glass panel for every NON-senior band (the
-     senior/elder/legacy bands override it to their own opaque panel). So the bar's
-     readout text must NOT inherit the band's --av-cta / --av-fg — several bands
-     (tween indigo, connoisseur/tastemaker dark-maroon over their OWN light panel)
-     carry DARK text colours that vanish on the dark glass (age#/band Lc≈0, found
-     2026-06-29). Bar text uses guaranteed-light readout vars; only when the bar is
-     itself opaque (senior bands) do we fall back to the band's real colours, which
-     are gated by the senior APCA check. */
-  body.ageview-on { --av-bar-num:#c9b6ff; --av-bar-fg:#f2f5fb; --av-bar-muted:#c2cad8; --av-bar-thumb:#ffffff; }
-  #av-age { font-weight:800; font-size:max(28px, var(--av-head)); line-height:1; color:var(--av-bar-num); }
-  #av-band { font-size:max(15px, var(--av-body)); font-weight:700; letter-spacing:.4px; margin-top:3px; color:var(--av-bar-fg); }
-  /* Senior bands: bar is opaque on the band's own panel → use the band's gated colours. */
-  body.age-opaque #av-age  { color:var(--av-cta); }
-  body.age-opaque #av-band { 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-bar-thumb); border:3px solid var(--av-cta); cursor:pointer; }
-  #av-slider::-moz-range-thumb { width:34px; height:34px; border-radius:50%; background:var(--av-bar-thumb); border:3px solid var(--av-cta); cursor:pointer; }
-  body.age-opaque #av-slider::-webkit-slider-thumb { background:var(--av-fg); }
-  body.age-opaque #av-slider::-moz-range-thumb { background:var(--av-fg); }
-  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-bar-muted); margin-top:2px; }
-  body.age-opaque #av-tag { color:var(--av-muted); }
-
-  /* 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;
-  }
-  /* 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,
-  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 = {};
-  // Type-size requirement is INTERPOLATED across the spec anchors 65→85 (body 20→28,
-  // head 30→44) and the check includes HEAD — matching scripts/verify-ageview-gate.mjs.
-  // (Prior version used coarse steps + skipped heads = a softer bar than the spec;
-  //  tightened 2026-07-01 after the contrarian pass.)
-  const reqBody = a => Math.ceil(20 + (Math.min(a, 85) - 65) / 20 * 8);
-  const reqHead = a => Math.ceil(30 + (Math.min(a, 85) - 65) / 20 * 14);
-  const reqBtn  = a => (a >= 85 ? 56 : 48);
-  [65, 75, 85].forEach(a => {
-    const b = bandFor(a);
-    const bodyLc = apcaLc(b.fg, b.panel);
-    const ctaLc  = apcaLc(b.ctaFg, b.cta);
-    const rb = reqBody(a), rh = reqHead(a), rk = reqBtn(a);
-    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,
-      reqBody: rb, reqHead: rh, reqBtn: rk,
-      pass: Math.abs(bodyLc) >= 60 && Math.abs(ctaLc) >= 75 && b.opaque &&
-            b.body >= rb && b.head >= rh && b.btn >= rk
-    };
-  });
-  // 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/scripts/verify-ageview-3x.mjs b/scripts/verify-ageview-3x.mjs
deleted file mode 100644
index 0be5c3a..0000000
--- a/scripts/verify-ageview-3x.mjs
+++ /dev/null
@@ -1,95 +0,0 @@
-/* ============================================================================
- * verify-ageview-3x.mjs — Phase 5.3 CROSS-ENGINE check for the AGE VIEW slider.
- *
- * Drives the slider at the 3 anchor ages (12 / 45 / 85) in WebKit (Safari) and
- * Firefox (Gecko) — Chrome/Blink is already covered by verify-ageview-phase5.mjs
- * on real GPU. Per anchor + engine asserts:
- *   - scene built (window._qh.wingBoards length > 0 → China Seas textures loaded)
- *   - Age View toggles ON and the band snaps (body.dataset.ageBand correct)
- *   - 0 console errors / 0 pageerrors
- *   - the picker postMessage bridge is wired (window.parent messaging path intact)
- *   - screenshot per engine/anchor
- *
- * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-ageview-3x.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', '3x');
-fs.mkdirSync(OUT, { recursive: true });
-const sleep = (ms) => new Promise(r => setTimeout(r, ms));
-const ANCHORS = [12, 45, 85];
-const EXPECT_BAND = { 12: 'tween', 45: 'connoisseur', 85: 'legacy' };
-
-async function runEngine(name, engine) {
-  const res = { engine: name, anchors: {}, errors: [], pass: false };
-  const browser = await engine.launch();
-  const ctx = await browser.newContext({
-    viewport: { width: 1440, height: 900 },
-    httpCredentials: { username: USER, password: PASS },
-  });
-  const page = await ctx.newPage();
-  page.on('console', m => { if (m.type() === 'error') res.errors.push(`[${name}] ${m.text()}`); });
-  page.on('pageerror', e => res.errors.push(`[${name}] PAGEERROR ${e.message}`));
-  await page.addInitScript(() => { try { localStorage.clear(); } catch (e) {} });
-
-  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 35000 });
-  await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._ageview, { timeout: 30000 }).catch(() => {});
-  await sleep(3500);
-
-  const boards = await page.evaluate(() => (window._qh && window._qh.wingBoards ? window._qh.wingBoards.length : 0));
-  const hasPostMsgBridge = await page.evaluate(() =>
-    typeof window.addEventListener === 'function' && (window.parent !== undefined));
-
-  await page.evaluate(() => window._ageview.setOn(true));
-  await sleep(500);
-
-  for (const age of ANCHORS) {
-    await page.evaluate(a => window._ageview.apply(a), age);
-    await sleep(800);
-    const snap = await page.evaluate(() => ({
-      band: document.body.dataset.ageBand,
-      on: window._ageview.on,
-      avAge: (document.getElementById('av-age') || {}).textContent,
-    }));
-    await page.screenshot({ path: path.join(OUT, `${name}-${age}.png`) });
-    res.anchors[age] = {
-      ...snap, boards,
-      bandOk: snap.band === EXPECT_BAND[age],
-      pass: snap.band === EXPECT_BAND[age] && snap.on === true && boards > 0,
-    };
-  }
-  res.bridge = hasPostMsgBridge;
-  res.pass = boards > 0 && hasPostMsgBridge && res.errors.length === 0 &&
-             Object.values(res.anchors).every(a => a.pass);
-  await browser.close();
-  return res;
-}
-
-(async () => {
-  const results = [];
-  for (const [nm, eng] of [['webkit', pw.webkit], ['firefox', pw.firefox]]) {
-    try { results.push(await runEngine(nm, eng)); }
-    catch (e) { results.push({ engine: nm, anchors: {}, errors: ['LAUNCH: ' + e.message.split('\n')[0]], pass: false, skipped: true }); }
-  }
-
-  console.log('\n================ PHASE 5.3 CROSS-ENGINE ================');
-  for (const r of results) {
-    console.log(`\n[${r.engine}] boards=${r.anchors[12]?.boards} bridge=${r.bridge} errors=${r.errors.length} → ${r.pass ? 'PASS' : 'FAIL'}`);
-    for (const age of ANCHORS) {
-      const a = r.anchors[age];
-      console.log(`   age ${String(age).padStart(2)}: band=${a.band} (expect ${EXPECT_BAND[age]}) on=${a.on} ${a.pass ? '✓' : '✗'}`);
-    }
-    if (r.errors.length) r.errors.slice(0, 8).forEach(e => console.log('   ERR', e));
-  }
-  const overall = results.every(r => r.pass);
-  fs.writeFileSync(path.join(OUT, '3x-result.json'), JSON.stringify(results, null, 2));
-  console.log('\n  OVERALL CROSS-ENGINE:', overall ? '✅ PASS' : '❌ FAIL');
-  process.exit(overall ? 0 : 1);
-})().catch(e => { console.error('3X CRASH:', e); process.exit(2); });
diff --git a/scripts/verify-ageview-avatar.mjs b/scripts/verify-ageview-avatar.mjs
deleted file mode 100644
index 868f766..0000000
--- a/scripts/verify-ageview-avatar.mjs
+++ /dev/null
@@ -1,106 +0,0 @@
-/* ============================================================================
- * 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); });
diff --git a/scripts/verify-ageview-crossengine.mjs b/scripts/verify-ageview-crossengine.mjs
deleted file mode 100644
index be1ec42..0000000
--- a/scripts/verify-ageview-crossengine.mjs
+++ /dev/null
@@ -1,54 +0,0 @@
-// verify-ageview-crossengine.mjs — Phase-5.3 cross-engine render gate for the Age View.
-// Drives Chromium / Firefox / WebKit against the live showroom, turns Age View on,
-// sets the REAL #av-slider to ages 12/45/85, and asserts per engine:
-//   • 0 console errors / pageerrors
-//   • window._ageview.seniorGate() all-pass (APCA + type/target on 65/75/85 + slider85)
-//   • China Seas wing wall intact (window._wingBoards.length > 0)
-// FPS (window._perf.fps) is REPORTED per engine/age but advisory — headless WebGL uses
-// software GL (swiftshader/llvmpipe), so it under-represents real-hardware FPS.
-import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
-const { chromium, firefox, webkit } = pw;
-const URL='http://127.0.0.1:7690/', CRED={username:'admin',password:'DWSecure2024!'};
-const AGES=[12,45,85];
-const engines=[['chromium',chromium],['firefox',firefox],['webkit',webkit]];
-let hardFails=0;
-for(const [name,launcher] of engines){
-  let browser;
-  try{ browser=await launcher.launch(); }
-  catch(e){ console.log(`\n### ${name}: SKIP (launch failed: ${e.message.split('\n')[0]})`); continue; }
-  const ctx=await browser.newContext({httpCredentials:CRED, viewport:{width:1440,height:900}});
-  const page=await ctx.newPage();
-  const errs=[];
-  page.on('console',m=>{ if(m.type()==='error') errs.push(m.text().slice(0,160)); });
-  page.on('pageerror',e=>errs.push('PAGEERROR '+String(e).slice(0,160)));
-  console.log(`\n### ${name}`);
-  try{
-    await page.goto(URL,{waitUntil:'domcontentloaded',timeout:30000});
-    await page.waitForFunction(()=>window._ageview && window._wingBoards && window._wingBoards.length>0,{timeout:60000});
-    await page.evaluate(()=>window._ageview.setOn(true));
-    for(const age of AGES){
-      await page.evaluate(a=>{ const s=document.getElementById('av-slider'); s.value=a; s.dispatchEvent(new Event('input',{bubbles:true})); }, age);
-      await page.waitForTimeout(1300); // let _perf + textures settle
-      const snap=await page.evaluate(()=>({
-        shownAge:document.getElementById('av-age')?.textContent,
-        band:document.getElementById('av-band')?.textContent,
-        fps:(window._perf&&window._perf.fps)||null,
-        wings:(window._wingBoards&&window._wingBoards.length)||0,
-        bodyClass:document.body.className
-      }));
-      const shot=`recordings/ageview-${name}-age${age}.png`;
-      await page.screenshot({path:shot});
-      console.log(`  age ${String(age).padStart(2)} → readout ${snap.shownAge} · band "${snap.band}" · wings ${snap.wings} · fps ${snap.fps} · shot ${shot}`);
-      if(snap.wings<1){ console.log(`    ✗ wing wall empty at age ${age}`); hardFails++; }
-    }
-    const gate=await page.evaluate(()=>window._ageview.seniorGate());
-    const gPass=[65,75,85].every(a=>gate[a].pass) && gate.slider85.pass;
-    console.log(`  seniorGate(): ${gPass?'✅ all pass':'❌ FAIL'} — 65:${gate[65].pass} 75:${gate[75].pass} 85:${gate[85].pass} slider85:${gate.slider85.pass}`);
-    if(!gPass) hardFails++;
-    if(errs.length){ console.log(`  ❌ ${errs.length} console error(s):`); errs.slice(0,6).forEach(e=>console.log('     · '+e)); hardFails++; }
-    else console.log('  ✅ 0 console errors');
-  }catch(e){ console.log(`  ❌ ${name} threw: ${String(e).split('\n')[0]}`); hardFails++; }
-  finally{ await browser.close(); }
-}
-console.log('\n'+(hardFails?`❌ cross-engine gate: ${hardFails} hard failure(s)`:'✅ cross-engine gate PASS (console-clean + seniorGate + wings intact, all engines)'));
-process.exit(hardFails?1:0);
diff --git a/scripts/verify-ageview-envmorph.mjs b/scripts/verify-ageview-envmorph.mjs
deleted file mode 100644
index 29d19c4..0000000
--- a/scripts/verify-ageview-envmorph.mjs
+++ /dev/null
@@ -1,155 +0,0 @@
-/* ============================================================================
- * verify-ageview-envmorph.mjs — MEASURED env-morph gate for AGE VIEW.
- *
- * AGE-VIEWS-SPEC says the room LIGHTING morphs per age band: senior bands raise
- * ambient (brighter room for the aging lens) and cut harsh speculars (less
- * glare). Until now that morph was only asserted by the INTENSITY SCALARS fed to
- * setEnvForAge() — never by the ACTUALLY-RENDERED pixels. A scalar can change
- * while the composited render does not (clamped light, wrong light handle, a
- * later pass overwriting it). This verifier closes that gap: it drives ages
- * 35 (neutral baseline) vs 85 (max senior) on a REAL GPU and measures the real
- * screen luminance of the rendered PLASTER WALL — proving:
- *
- *   (1) The morph actually CHANGES the render  — the 35 vs 85 wall pixels differ
- *       by a meaningful mean-luminance delta (not a no-op).
- *   (2) Senior is BRIGHTER                      — mean wall luminance(85) > (35).
- *   (3) Senior is LOWER-GLARE                   — the blown-out-highlight pixel
- *       fraction(85) ≤ (35) (harsh speculars cut for the aging lens).
- *   (4) Regression                              — Age View OFF restores the
- *       baseline render (≈ age-28 env, not the senior env).
- *
- * Wall sampling regions are pure plaster, away from the wing book, the paper
- * sculpture, and all HUD chrome (the Age View bar lives top-center).
- *
- * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-ageview-envmorph.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';
-import sharp from '/Users/stevestudio2/Projects/quadrille-showroom/node_modules/sharp/dist/index.mjs';
-
-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', 'envmorph');
-fs.mkdirSync(OUT, { recursive: true });
-const sleep = (ms) => new Promise(r => setTimeout(r, ms));
-
-// Pure-plaster wall regions (1600×900 viewport): upper-left + upper-right
-// corners — wall only, no wing book, no paper sculpture, no HUD bar.
-const WALL_REGIONS = [
-  { x: 20,   y: 200, width: 340, height: 220 },   // upper-left wall
-  { x: 1250, y: 200, width: 330, height: 180 },   // upper-right wall
-];
-
-// Per-pixel relative luminance (Rec.709) on 0..255 sRGB bytes.
-function lum(r, g, b) { return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
-
-// Decode a clipped PNG screenshot buffer → mean luminance + blown-highlight frac.
-async function analyze(pngBuf) {
-  const { data, info } = await sharp(pngBuf).raw().toBuffer({ resolveWithObject: true });
-  const ch = info.channels;              // 3 (rgb) or 4 (rgba)
-  let sum = 0, n = 0, hot = 0;
-  for (let i = 0; i < data.length; i += ch) {
-    const L = lum(data[i], data[i + 1], data[i + 2]);
-    sum += L; n++;
-    if (L >= 245) hot++;                 // near-white = specular blowout
-  }
-  return { meanLum: sum / n, hotFrac: hot / n, n };
-}
-
-// Capture all wall regions for the current env and aggregate.
-async function measureWalls(page, tag) {
-  let sum = 0, hot = 0, n = 0;
-  const per = [];
-  for (let i = 0; i < WALL_REGIONS.length; i++) {
-    const clip = WALL_REGIONS[i];
-    const buf = await page.screenshot({ clip });
-    fs.writeFileSync(path.join(OUT, `${tag}-region${i}.png`), buf);
-    const a = await analyze(buf);
-    per.push(a);
-    sum += a.meanLum * a.n; hot += a.hotFrac * a.n; n += a.n;
-  }
-  return { meanLum: sum / n, hotFrac: hot / n, per };
-}
-
-(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: 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));
-
-  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._ageview && typeof window._ageview.apply === 'function', { timeout: 20000 });
-  await page.waitForFunction(() => window._qh && typeof window._qh.setEnvForAge === 'function', { timeout: 15000 }).catch(() => {});
-  await sleep(3500);                       // loader fade + guided auto-focus
-
-  await page.evaluate(() => window._ageview.setOn(true));
-  await sleep(700);
-
-  // ---- Measure age 35 (neutral baseline) ----
-  await page.evaluate(() => window._ageview.apply(35));
-  await sleep(1300);
-  const m35 = await measureWalls(page, 'age35');
-  console.log(`  age 35  meanLum=${m35.meanLum.toFixed(2)}  hotFrac=${(m35.hotFrac * 100).toFixed(3)}%`);
-
-  // ---- Measure age 85 (max senior) ----
-  await page.evaluate(() => window._ageview.apply(85));
-  await sleep(1300);
-  const m85 = await measureWalls(page, 'age85');
-  console.log(`  age 85  meanLum=${m85.meanLum.toFixed(2)}  hotFrac=${(m85.hotFrac * 100).toFixed(3)}%`);
-
-  // ---- Regression: OFF restores baseline (≈ age-28 env, NOT senior) ----
-  await page.evaluate(() => window._ageview.setOn(false));
-  await sleep(1300);
-  const mOff = await measureWalls(page, 'off');
-  const offAge = await page.evaluate(() => window._qh && window._qh.avatarAge);
-  console.log(`  OFF     meanLum=${mOff.meanLum.toFixed(2)}  hotFrac=${(mOff.hotFrac * 100).toFixed(3)}%  avatarAge=${offAge}`);
-
-  // ---------------- Gates ----------------
-  const lumDelta = m85.meanLum - m35.meanLum;
-  const MIN_LUM_DELTA = 2.0;              // morph must move the render meaningfully
-
-  const checks = {
-    morphChangesRender: { v: lumDelta.toFixed(2),                 need: `|Δ|≥${MIN_LUM_DELTA}`, pass: Math.abs(lumDelta) >= MIN_LUM_DELTA },
-    seniorBrighter:     { v: lumDelta.toFixed(2),                 need: 'Δ>0',                  pass: lumDelta > 0 },
-    seniorLowerGlare:   { v: `${(m85.hotFrac*100).toFixed(3)}% ≤ ${(m35.hotFrac*100).toFixed(3)}%`, need: 'hot(85)≤hot(35)', pass: m85.hotFrac <= m35.hotFrac + 1e-6 },
-    // OFF must be CLOSER to baseline(35) than to senior(85) in luminance — proves
-    // resetEnv put us back on the neutral env, not stuck on the senior morph.
-    regressionEnv:      { v: `|off-35|=${Math.abs(mOff.meanLum-m35.meanLum).toFixed(2)} |off-85|=${Math.abs(mOff.meanLum-m85.meanLum).toFixed(2)}`,
-                          need: 'closer to baseline', pass: Math.abs(mOff.meanLum - m35.meanLum) <= Math.abs(mOff.meanLum - m85.meanLum) + 1e-6 },
-    regressionAge:      { v: String(offAge),                      need: '28',                   pass: offAge === 28 },
-    consoleErrs:        { v: String(errors.length),               need: '0',                    pass: errors.length === 0 },
-  };
-
-  console.log('\n================ ENV-MORPH MEASURED GATE ================');
-  let allPass = true;
-  for (const [k, c] of Object.entries(checks)) {
-    const ok = c.pass ? '✓' : '✗';
-    if (!c.pass) allPass = false;
-    console.log(`  ${ok} ${k.padEnd(20)} = ${String(c.v).padEnd(34)} (need ${c.need})`);
-  }
-  const report = { url: URL, m35, m85, mOff, offAge, lumDelta, checks, errors: errors.slice(0, 20), verdict: allPass ? 'PASS' : 'FAIL' };
-  fs.writeFileSync(path.join(OUT, 'envmorph-result.json'), JSON.stringify(report, null, 2));
-  console.log('  OVERALL: ' + (allPass ? '✅ PASS' : '❌ FAIL'));
-  console.log('  → ' + path.join(OUT, 'envmorph-result.json'));
-
-  await browser.close();
-  process.exit(allPass ? 0 : 1);
-})();
diff --git a/scripts/verify-ageview-gate.mjs b/scripts/verify-ageview-gate.mjs
deleted file mode 100644
index df2a89e..0000000
--- a/scripts/verify-ageview-gate.mjs
+++ /dev/null
@@ -1,32 +0,0 @@
-// verify-ageview-gate.mjs — Phase-5 HARD senior-usability gate for the Age View.
-// Measures APCA Lc (body≥60, CTA≥75), target-size, and type-size against the actual
-// BANDS tokens in public/js/ageview.js. Type-size requirement is interpolated across the
-// spec anchors 65→85 (evaluated at each band's oldest served age). Exit 1 on any fail.
-import fs from "fs";
-const src = fs.readFileSync("public/js/ageview.js","utf8");
-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(t,bg){const BlkThrs=0.022,BlkClmp=1.414,LoConThresh=0.1,Scale=1.14,LoOffset=0.027;let ty=sRGBtoY(hexToRgb(t)),by=sRGBtoY(hexToRgb(bg));if(ty<BlkThrs)ty+=Math.pow(BlkThrs-ty,BlkClmp);if(by<BlkThrs)by+=Math.pow(BlkThrs-by,BlkClmp);if(Math.abs(by-ty)<0.0005)return 0;if(by>ty){const S=(Math.pow(by,0.56)-Math.pow(ty,0.57))*Scale;return S<LoConThresh?0:Math.round((S-LoOffset)*100);}const S=(Math.pow(by,0.65)-Math.pow(ty,0.62))*Scale;return S>-LoConThresh?0:Math.round((S+LoOffset)*100);}
-const bi=src.indexOf("const BANDS"), be=src.indexOf("];",bi)+1;
-const BANDS=eval("("+src.slice(bi+src.slice(bi).indexOf("["),be)+")");
-// spec anchors 65→85 linear; below 65 use base floors
-const lerp=(a,x0,y0,x1,y1)=>Math.ceil(y0+(a-x0)/(x1-x0)*(y1-y0));
-const reqBodyPx=a=>a<65?12:lerp(Math.min(a,85),65,20,85,28);
-const reqHeadPx=a=>a<65?18:lerp(Math.min(a,85),65,30,85,44);
-const reqBtnPx =a=>a>=85?56:a>=65?48:44;
-console.log("age | opq | bodyLc | ctaLc | body(req) | head(req) | btn(req) | VERDICT");
-let fails=0;
-for(const b of BANDS){const a=b.max;
-  const bodyLc=Math.abs(apcaLc(b.fg,b.panel)), ctaLc=Math.abs(apcaLc(b.ctaFg,b.cta));
-  const rb=reqBodyPx(a), rh=reqHeadPx(a), rk=reqBtnPx(a);
-  const c=[];
-  if(bodyLc<60)c.push("BODY-APCA "+bodyLc);
-  if(ctaLc<75)c.push("CTA-APCA "+ctaLc);
-  if(a>=65&&!b.opaque)c.push("NOT-OPAQUE");
-  if(b.btn<rk)c.push(`BTN ${b.btn}<${rk}`);
-  if(b.body<rb)c.push(`BODY ${b.body}<${rb}`);
-  if(b.head<rh)c.push(`HEAD ${b.head}<${rh}`);
-  const ok=c.length===0; if(!ok)fails++;
-  console.log(`${String(a).padStart(3)} |  ${b.opaque?"Y":"·"}  |  ${String(bodyLc).padStart(4)}  |  ${String(ctaLc).padStart(4)} |  ${b.body}(${rb})  |  ${b.head}(${rh})  |  ${b.btn}(${rk}) | ${ok?"PASS":"FAIL → "+c.join(", ")}`);
-}
-if(fails){console.log("\n❌ "+fails+" band(s) FAIL the Phase-5 senior/APCA gate"); process.exit(1);} else {console.log("\n✅ ALL "+BANDS.length+" bands PASS the measured Phase-5 gate (APCA + target-size + type-size)");}
diff --git a/scripts/verify-ageview-phase5.mjs b/scripts/verify-ageview-phase5.mjs
deleted file mode 100644
index 4ba805c..0000000
--- a/scripts/verify-ageview-phase5.mjs
+++ /dev/null
@@ -1,358 +0,0 @@
-/* ============================================================================
- * 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 — measures STEADY-STATE render FPS by reading the live
-  // #fps-counter (a 1-second rolling window in showroom.js). The counter text
-  // only refreshes once per second, so the sampler:
-  //   (a) discards the in-flight window that STRADDLES the band-change morph
-  //       rebuild — that window carries a one-time relight/chrome-rebuild stall
-  //       that is NOT representative of steady-state render. We wait for the
-  //       counter text to ROLL OVER to a fresh window before collecting.
-  //   (b) records one sample per DISTINCT window (dedup on the raw text) so a
-  //       single stale window isn't counted 11× and a single fresh window once.
-  // The ≥55 floor is unchanged — we only ensure we measure steady state, which
-  // is exactly what "≥55fps at every band" means.
-  const readCounter = () => page.evaluate(() => {
-    const el = document.getElementById('fps-counter');
-    if (!el) return null;
-    return el.textContent || null;
-  });
-  async function sampleFps(ms = 2600) {
-    // (a) wait for the morph-straddling window to roll over (≤1.4s budget).
-    const before = await readCounter();
-    const rollT0 = Date.now();
-    while (Date.now() - rollT0 < 1400) {
-      await sleep(120);
-      if ((await readCounter()) !== before) break;   // fresh window started
-    }
-    // (b) collect one sample per distinct counter window.
-    const samples = [];
-    let lastText = null;
-    const t0 = Date.now();
-    while (Date.now() - t0 < ms) {
-      const txt = await readCounter();
-      if (txt && txt !== lastText) {
-        lastText = txt;
-        const n = parseInt(txt);
-        if (!isNaN(n) && n > 0) samples.push(n);
-      }
-      await sleep(150);
-    }
-    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 ----------
-  // Warm-up apply: the FIRST band measured right after setOn(true) otherwise eats
-  // the one-time Age-View activation cost (font load, style inject, first env morph),
-  // which is NOT steady-state render. Burn one throwaway band + settle before the
-  // measured loop so every band is sampled at steady state. Floor unchanged at 55.
-  await page.evaluate(() => window._ageview.apply(28));
-  await sleep(1600);
-  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(1200);                      // morph rebuild + relight + 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.1b Age View BAR legibility at EVERY band ----------
-  // The persistent Age View slider bar (age#, band name, tag) must stay readable at
-  // every band — not just the senior bands. Several bands carry dark text colours
-  // tuned for their OWN light panel that vanish on the bar's dark glass (tween,
-  // connoisseur, tastemaker had age#/band Lc≈0, found + fixed 2026-06-29). Measured
-  // APCA on the rendered text/bar hex pair; floor 45 for the age# (display weight,
-  // 28px+) and band/tag (the readout copy). Senior bands clear this trivially.
-  console.log('\n=== 5.1b Age View BAR legibility (every band, measured APCA) ===');
-  const BAR_FLOOR = 45;
-  report.barLegibility = {};
-  let barAllPass = true;
-  for (const age of ALL_AGES) {
-    await page.evaluate(a => window._ageview.apply(a), age);
-    await sleep(500);
-    const r = await page.evaluate(() => {
-      const lc = (txt, bg) => Math.abs(window._ageview.apcaLc(txt, bg));
-      const meas = (id) => {
-        const el = document.getElementById(id);
-        if (!el || !window.__visible(el)) return null;
-        const bg = window.__effectiveBg(el), fg = window.__textHex(el);
-        return { lc: (bg.opaque && fg) ? lc(fg, bg.hex) : null, fg, bg: bg.hex, px: Math.round(window.__fontPx(el)) };
-      };
-      return { band: document.body.dataset.ageBand, age: meas('av-age'), bandName: meas('av-band'), tag: meas('av-tag') };
-    });
-    const aLc = r.age?.lc ?? 0, bLc = r.bandName?.lc ?? 0, tLc = r.tag?.lc ?? 0;
-    const pass = aLc >= BAR_FLOOR && bLc >= BAR_FLOOR && tLc >= BAR_FLOOR;
-    if (!pass) barAllPass = false;
-    report.barLegibility[age] = { ...r, pass };
-    console.log(`  age ${String(age).padStart(2)} [${r.band}]  age#Lc=${aLc}  bandLc=${bLc}  tagLc=${tLc}  (${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, barPass: barAllPass, seniorPass, regPass, errPass, consoleErrors: errors.length,
-    overall: fpsPass && barAllPass && 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('  Bar legib.   :', barAllPass ? '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); });
diff --git a/scripts/verify-table-view.mjs b/scripts/verify-table-view.mjs
new file mode 100644
index 0000000..7a713d1
--- /dev/null
+++ b/scripts/verify-table-view.mjs
@@ -0,0 +1,57 @@
+// verify-table-view.mjs — behavioral test for the newest committed feature (commit 842a1dc):
+// "At the Table" view hides the consultation nook (only the centre table shows) and restores
+// it on exit; plus the seated set appears and samples can be added. Exit 1 on failure.
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+const CANDIDATES = ['playwright',
+  '/Users/stevestudio2/.claude/skills/hero-readability-auditor/node_modules/playwright/index.js',
+  '/Users/stevestudio2/.claude/skills/app-demo-video/node_modules/playwright/index.js'];
+let chromium=null; for (const c of CANDIDATES){ try{({chromium}=require(c)); if(chromium)break;}catch{} }
+if(!chromium){ console.error('playwright not found'); process.exit(2); }
+
+const URL = process.env.URL || 'http://127.0.0.1:7690';
+const b = await chromium.launch({ channel:'chrome' });
+const ctx = await b.newContext({ httpCredentials:{ username:'admin', password:'DWSecure2024!' } });
+const p = await ctx.newPage();
+const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE:'+e.message));
+await p.goto(URL,{ waitUntil:'load', timeout:20000 });
+await p.waitForFunction(()=>window._qhBootSettled===true && !!window._viewmode, { timeout:15000 }).catch(()=>{});
+await p.waitForTimeout(2000);
+
+const named = (name) => p.evaluate(n=>{ let f=null; window._scene && window._scene.traverse(o=>{ if(o.name===n) f=o; }); return f? f.visible : 'MISSING'; }, name);
+const nookVis = () => p.evaluate(()=> window._qhNookGroup ? window._qhNookGroup.visible : 'MISSING');
+const mode = () => p.evaluate(()=> window._viewmode ? window._viewmode.current : '?');
+
+let fail=0; const need=(c,m)=>{ if(!c){console.log('❌ '+m); fail++;} else console.log('✅ '+m); };
+
+// 0) baseline — nook visible before table view
+const nook0 = await nookVis();
+need(nook0===true, `nook visible before At-the-Table (got ${nook0})`);
+
+// 1) ENTER table view
+await p.evaluate(()=> window._viewmode.set('table'));
+await p.waitForTimeout(1500);
+need(await mode()==='table', 'mode == table after enter');
+need(await nookVis()===false, 'consultation nook HIDDEN in At-the-Table view');
+need(await named('tableSeatGroup')===true, 'table+chair set visible in table view');
+need(await named('seatedAvatarRig')===true, 'seated avatar visible in table view');
+
+// 2) add a sample to the table
+const before = await p.evaluate(()=> window._viewmode.sampleCount);
+await p.evaluate(()=> window._viewmode.addSample());
+await p.waitForTimeout(600);
+const after = await p.evaluate(()=> window._viewmode.sampleCount);
+need(after>before, `addSample() increments basket (${before} → ${after})`);
+
+// 3) EXIT table view → nook restored
+await p.evaluate(()=> window._viewmode.set('walk'));
+await p.waitForTimeout(1500);
+need(await nookVis()===true, 'consultation nook RESTORED on exit');
+need(await named('tableSeatGroup')===false || await named('tableSeatGroup')==='MISSING', 'table set hidden on exit');
+
+need(errs.length===0, `zero console errors (${errs.length})`);
+if(errs.length) console.log('  errors:', errs.slice(0,5));
+
+await b.close();
+console.log(fail? `\n❌ ${fail} table-view assertion(s) FAILED` : '\n✅ At-the-Table view: nook hides+restores, seated set shows, samples add — all correct');
+process.exit(fail?1:0);

← 4840661 5x follow-up: WebGL scene-correctness + FPS verifier (closes  ·  back to Quadrille Showroom  ·  Remove Age View / age-adaptive theming entirely (per Steve's e4d7eca →