← back to Quadrille Showroom
public/js/versions.js
937 lines
/* ============================================================================
* versions.js — The Quadrille House: the "10 VERSIONS" system.
*
* Self-contained module loaded AFTER showroom.js + viewmodes.js. Talks to the
* showroom ONLY through the stable window._qh API (and window._viewmode). Adds:
* 1. a persistent VERSION PICKER rail (V1…V10) — each version is a distinct
* showroom LOOK with its own build() that (re)stages the scene.
* 2. a NUMBERED-ELEMENT OVERLAY — clickable circled-number pins (①②③…) on
* each element of the active version, with the element label.
* 3. a "Chosen Elements" tray — clicking a pin adds {V_.N label} to a tray
* Steve uses to compose the final site (copy-as-text export).
*
* Architecture (DTD verdict A, 2026-06-28): a bolt-on like viewmodes.js. It
* NEVER edits the monolith; it composes existing environments / view-modes /
* themes + a focused scene-reconfig for V1/V2. The LOCKED features (senior
* guided Next/Back/Auto-Tour, collapsed popup, 27×27 specs, photoreal surfaces,
* on-demand shadows, FPS≥50) all keep working because every version is built on
* top of focusOnWing / enterGuidedDefault / peruse / the view-mode engine.
*
* State persisted to localStorage: last version (qh_version), chosen elements
* (qh_chosen), overlay on/off (qh_overlay), tray open (qh_tray_open).
* ========================================================================== */
(function () {
'use strict';
function boot() {
const QH = window._qh;
if (!QH || !QH.wingBoards || !QH.wingBoards.length) { return setTimeout(boot, 250); }
const THREE = QH.THREE;
const VM = () => window._viewmode; // view-mode engine (lazy — may load a beat later)
// ---- helpers that drive the showroom through the stable API ----------------
function setTheme(key) { const vm = VM(); if (vm && vm.theme) try { vm.theme(key); } catch (e) {} }
function setMode(key) { const vm = VM(); if (vm && vm.set) try { vm.set(key); } catch (e) {} }
function setWingDeg(deg) {
const ang = document.getElementById('angle-range');
if (ang) { ang.value = deg; ang.dispatchEvent(new Event('input')); }
}
function setReveal(v) {
const r = document.getElementById('reveal-range');
if (r) { r.value = v; r.dispatchEvent(new Event('input')); }
}
function setBoards(n) {
const d = document.getElementById('density-range');
if (d) { d.value = n; d.dispatchEvent(new Event('input')); }
}
function focusMiddle() {
const wb = QH.wingBoards; if (!wb.length) return;
const mid = Math.floor(wb.length / 2);
QH.restoreBoards();
if (!QH.focusedWing || QH.focusedWing.userData.index !== mid) QH.focusOnWing(wb[mid]);
}
// ===========================================================================
// V2 CONVEYOR controller — user dead-centre + stationary; the RACK rotates so each
// product rotates INTO the centre. ARROW KEYS / Next/Prev step it one board at a time
// (Steve's dry-cleaner-conveyor spec). Self-contained: yaws the wall group about its
// arc centre; intercepts the guided buttons + arrow keys WHILE V2 is active only.
// ===========================================================================
let conveyor = null; // { wg, stepRad, target, current, raf, keyHandler, clickL, clickR }
function findWallGroupV() {
let g = null;
const WH = QH.CONFIG.wing.height;
QH.scene.traverse(o => {
if (!g && o.isGroup && o.children && o.children.some(ch =>
ch.geometry && ch.geometry.type === 'BoxGeometry' && Math.abs(ch.position.y - (WH + 0.10)) < 0.06)) g = o;
});
return g;
}
function startConveyor() {
stopConveyor();
const wg = findWallGroupV();
QH.restoreBoards();
QH.revertWalls();
if (QH.focusedWing) QH.unfocusWing(true);
// Park the camera dead-centre, stationary, looking at the back-wall arc centre.
const ROOM = QH.CONFIG.room;
const backZ = -ROOM.depth / 2 + 0.2;
const boardCY = QH.CONFIG.wing.height / 2 + 0.04;
const pos = new THREE.Vector3(0, boardCY, ROOM.depth / 2 - 1.5);
const tgt = new THREE.Vector3(0, boardCY, backZ);
QH.setControlsLocked(false);
QH.smoothCameraTo(pos, tgt, () => { QH.lockControls(pos.clone(), tgt.clone()); }, 48);
if (!wg) return;
const n = Math.max(1, QH.wingBoards.length);
conveyor = {
wg, n,
stepRad: (Math.PI * 0.9) / n, // arc spans ~0.9π; one board-step of yaw
target: wg.rotation.y || 0,
current: wg.rotation.y || 0,
raf: null
};
// step on arrow keys + guided Next/Prev while V2 is active
conveyor.keyHandler = (e) => {
if (currentVersion !== 'V2') return;
if (e.code === 'ArrowRight') { e.preventDefault(); stepConveyor(1); }
else if (e.code === 'ArrowLeft') { e.preventDefault(); stepConveyor(-1); }
};
window.addEventListener('keydown', conveyor.keyHandler, true);
conveyor.clickN = (e) => { if (currentVersion === 'V2') { e.stopImmediatePropagation(); e.preventDefault(); stepConveyor(1); } };
conveyor.clickP = (e) => { if (currentVersion === 'V2') { e.stopImmediatePropagation(); e.preventDefault(); stepConveyor(-1); } };
const gN = document.getElementById('g-next'), gP = document.getElementById('g-prev');
if (gN) gN.addEventListener('click', conveyor.clickN, true);
if (gP) gP.addEventListener('click', conveyor.clickP, true);
runConveyorAnim();
}
function stepConveyor(dir) {
if (!conveyor) return;
conveyor.target += dir * conveyor.stepRad;
runConveyorAnim();
}
function runConveyorAnim() {
if (!conveyor) return;
if (conveyor.raf) return; // already animating
const tick = () => {
if (!conveyor) return;
const d = conveyor.target - conveyor.current;
if (Math.abs(d) < 0.0015) {
conveyor.current = conveyor.target;
conveyor.wg.rotation.y = conveyor.current;
conveyor.raf = null;
if (QH.requestShadowUpdate) QH.requestShadowUpdate(3);
return;
}
conveyor.current += d * 0.14; // ease toward target
conveyor.wg.rotation.y = conveyor.current;
if (QH.requestShadowUpdate) QH.requestShadowUpdate(1);
conveyor.raf = requestAnimationFrame(tick);
};
conveyor.raf = requestAnimationFrame(tick);
}
function stopConveyor() {
if (!conveyor) return;
if (conveyor.raf) cancelAnimationFrame(conveyor.raf);
if (conveyor.keyHandler) window.removeEventListener('keydown', conveyor.keyHandler, true);
const gN = document.getElementById('g-next'), gP = document.getElementById('g-prev');
if (gN && conveyor.clickN) gN.removeEventListener('click', conveyor.clickN, true);
if (gP && conveyor.clickP) gP.removeEventListener('click', conveyor.clickP, true);
if (conveyor.wg) conveyor.wg.rotation.y = 0; // reset so other versions see a clean rack
conveyor = null;
if (QH.requestShadowUpdate) QH.requestShadowUpdate(3);
}
// ===========================================================================
// VERSION REGISTRY — each: { name, tag, scaffold?, build(), elements:[…] }
// elements[i] = { n, label, anchor } where anchor is:
// { board: <index> } → pin tracks wing board i
// { named: '<name>' } → pin tracks a named scene anchor (railCenter…)
// { ui: '#selector' } → pin tracks a HUD element's centre (sliders etc.)
// build() (re)stages the look; it MUST be idempotent + reversible (compose
// existing modes/themes — no direct mesh creation, so nothing leaks between
// versions). The numbered elements are the SELECT-AND-CHOOSE menu Steve composes.
// ===========================================================================
const VERSIONS = {
V1: {
name: 'PJ Wingboard Arc Rack', tag: 'arc',
build() {
// The canonical Phillip-Jeffries Dallas look (pj-dallas-showroom.png):
// packed sliver boards on the arc, one flipped open in the centre, warm
// gallery light, the consultation nook + olive in the foreground. This is an
// ESTABLISHING shot (not an isolated single board) so the whole arc + nook read
// like the reference photo. The senior Next/Back still opens a board face-on.
// SLICE-1 CANONICAL: the viewer is FIXED at room centre, front-facing the 50-wing
// arc bank; the MIDDLE board rests open-at-angle (no detail panel, nothing selected);
// arrow keys yaw-spin in place (clamped). The engine owns all of this — V1 just
// restores the scene and hands off to the fixed-centre rest pose. Do NOT override
// the 50-board / REVEAL defaults and do NOT park+lock the camera anymore.
setTheme('warm');
setWingDeg(8);
QH.revertWalls();
QH.enterFixedCentre(); // fixed-centre spin pose + resting-open middle (no lock, no panel)
if (QH.requestShadowUpdate) QH.requestShadowUpdate(12);
},
elements: [
{ n: 1, label: 'Arc / bay rack wrapping the corner', anchor: { named: 'railCenter' } },
{ n: 2, label: 'Sliver-packed wingboards (edge of each shows)', anchor: { board: 2 } },
{ n: 3, label: 'Dark-bronze board frames', anchor: { board: 1 } },
{ n: 4, label: 'Flip-to-open hero (single vertical hinge)', anchor: { board: -1 } }, // -1 = focused board
{ n: 5, label: 'Consultation nook (table + bouclé chairs + plant)', anchor: { named: 'nook' } },
{ n: 6, label: 'Brand signage on the wall', anchor: { named: 'signWall' } },
{ n: 7, label: 'REVEAL slider (how much of each board shows)', anchor: { ui: '#reveal-range' } }
]
},
V2: {
name: 'Dry-Clean Conveyor Rack', tag: 'conveyor',
build() {
// User dead-centre + STATIONARY; the RACK rotates each product into the centre
// like a dry-cleaner's conveyor. ARROW KEYS / Next rotate the rack one board at
// a time — each product rotates into the centre to present (Steve's spec). Built
// on a dedicated step controller (NOT the auto-spin carousel) so it's user-driven.
setTheme('cool');
setBoards(10);
setReveal(70);
setWingDeg(0);
startConveyor();
},
exit() { stopConveyor(); },
elements: [
{ n: 1, label: 'Rotating conveyor loop', anchor: { named: 'railCenter' } },
{ n: 2, label: 'User dead-centre + stationary', anchor: { named: 'floor' } },
{ n: 3, label: 'ARROW KEYS / Next rotate the rack', anchor: { ui: '#g-next' } },
{ n: 4, label: 'Each product rotates into the centre', anchor: { board: -1 } },
{ n: 5, label: 'Reveal slider', anchor: { ui: '#reveal-range' } }
]
},
// ── V3–V5 are CAMERA/THEME PREVIEWS of the V1 arc-rack scene, not yet their own
// mechanic (contrarian 2026-06-28: don't label a top-down rack "Fan-on-Table"
// when there's no fan — the chip name + legend must describe what the scene
// ACTUALLY shows). Each carries a "(preview)" marker + an "intended" note so
// nothing promises a mechanic the scene doesn't deliver. Real-mechanic build
// is the next pass; the framework makes it a per-preset swap like V6–V10.
V3: {
name: 'Card-Wall — gallery preview', tag: 'cards', scaffold: true,
build() {
// PREVIEW: a flat, even-lit gallery survey of the whole rail — the closest
// read to the intended dense swatch-card grid until the instanced card wall
// is built. Pulls back to show every board at once.
setTheme('bright');
setBoards(12);
setReveal(80);
setWingDeg(0);
setMode('gallery');
},
elements: [
{ n: 1, label: 'Flat gallery survey of the whole rail', anchor: { named: 'railCenter' } },
{ n: 2, label: 'Every board shown at once (dense grid)', anchor: { board: 0 } },
{ n: 3, label: 'Even white-box gallery light', anchor: { board: 4 } },
{ n: 4, label: 'Intended: small swatch cards on hooks', anchor: { named: 'rightWall' } },
{ n: 5, label: 'Intended: tap card → enlarge + spec', anchor: { board: -1 } }
]
},
V4: {
name: 'Table — top-down preview', tag: 'fan', scaffold: true,
build() {
// PREVIEW: a top-down plan over the consultation table — the closest read to
// the intended radial fan of swatches laid on the table (the fan mesh is the
// next-pass build; this is the camera, not the pinwheel yet).
setTheme('warm');
setMode('gallery'); // (top-down mode retired 2026-06-30 — gallery is the closest overview)
},
elements: [
{ n: 1, label: 'Top-down plan over the consultation table', anchor: { named: 'nook' } },
{ n: 2, label: 'Warm overhead gallery light', anchor: { named: 'nook' } },
{ n: 3, label: 'Intended: radial fan of swatches on table', anchor: { named: 'floor' } },
{ n: 4, label: 'Intended: spin / drag the fan to browse', anchor: { ui: '#g-next' } }
]
},
V5: {
name: 'Closet — orbit preview', tag: 'closet', scaffold: true,
build() {
// PREVIEW: an orbit around a single board in warm boutique light — the closest
// read to the intended right-corner rotating closet carousel.
setTheme('boutique');
setWingDeg(20);
focusMiddle();
setMode('carousel'); // (orbit mode retired 2026-06-30 — carousel is the closest rotating preview)
},
elements: [
{ n: 1, label: 'Orbit around the hero board', anchor: { board: -1 } },
{ n: 2, label: 'Warm boutique millwork light', anchor: { named: 'rightWall' } },
{ n: 3, label: 'Intended: rotating two-tier closet rack', anchor: { board: 0 } },
{ n: 4, label: 'Intended: right-corner closet', anchor: { named: 'rightWall' } },
{ n: 5, label: 'Intended: spin to browse', anchor: { ui: '#g-next' } }
]
},
// ── V6–V10 are REAL standalone shopping MECHANICS (the contrarian-passed
// protos), opened FULL-SCREEN in an overlay iframe over the 3D canvas.
// Each has overlay:'<route>' instead of a 3D build(); the picker's
// openOverlay() handles them. Their numbered elements live in the proto's
// own legend (proto-chrome.js) AND here so the Chosen tray stays in sync.
V6: {
name: 'Color River', tag: 'colorriver', overlay: '/proto/v6-colorriver.html',
elements: [
{ n: 1, label: 'All 883 swatches flow into one continuous hue gradient' },
{ n: 2, label: 'Color buckets (red→orange→yellow→green→blue→magenta)' },
{ n: 3, label: 'Scrub the river to jump to a color family' },
{ n: 4, label: 'Click a swatch → enlarge + spec' },
{ n: 5, label: 'Shop-by-color header + live count' }
]
},
V7: {
name: 'Mood-Board Table', tag: 'moodboard', overlay: '/proto/v7-moodboard.html',
elements: [
{ n: 1, label: 'Drag swatches onto the design table' },
{ n: 2, label: 'Build a palette from the dropped swatches' },
{ n: 3, label: '"Pairs-well-with" suggestions by color family' },
{ n: 4, label: 'Swatch tray / collection rail to drag from' },
{ n: 5, label: 'Live palette read-out (the chosen colors)' }
]
},
V8: {
name: 'Swipe Tower', tag: 'swipe', overlay: '/proto/v8-swipe.html',
elements: [
{ n: 1, label: 'One big swatch at a time, centre stage' },
{ n: 2, label: 'Swipe / button KEEP to save to favorites' },
{ n: 3, label: 'Swipe / button SKIP to pass' },
{ n: 4, label: 'Favorites stack builds as you go' },
{ n: 5, label: 'Card spec (pattern · colorway · SKU)' }
]
},
V9: {
name: 'Walk-In Room', tag: 'walkin', overlay: '/proto/v9-walkin.html',
elements: [
{ n: 1, label: 'Stand INSIDE a 1:1 room fully papered in the design' },
{ n: 2, label: 'Look around (drag) from the centre of the room' },
{ n: 3, label: 'Switch the wallpaper to any design' },
{ n: 4, label: 'Now-viewing pattern + colorway read-out' },
{ n: 5, label: 'Real room scale (window, floor, ceiling)' }
]
},
V10: {
name: 'Concierge Nook', tag: 'concierge', overlay: '/proto/v10-concierge.html',
elements: [
{ n: 1, label: 'Consultation-table POV (sit at the nook)' },
{ n: 2, label: 'Concierge assist surface recommends designs' },
{ n: 3, label: 'Boards are pulled TO you on request' },
{ n: 4, label: 'Back-wall rail of the collection' },
{ n: 5, label: 'Selected board presented at the table' }
]
},
V11: {
name: 'Sample Table', tag: 'sampletable', overlay: '/proto/sample-table.html',
elements: [
{ n: 1, label: 'Sit at the consultation table (POV)' },
{ n: 2, label: '“+ Add Sample” drops the square swatch onto the table' },
{ n: 3, label: 'Samples spread & overlap like a real appointment' },
{ n: 4, label: 'Drag to rearrange / lift to remove a sample' },
{ n: 5, label: '“Request these samples” memo basket' }
]
},
// ── V12+ are HIGH-END GRAPHICS showrooms (PBR/PMREM/ACES), full-screen proto
// overlays that re-skin the room/lighting while keeping the wing-bank +
// swivel + clean-open-face interaction. Registered as they land.
V12: {
name: 'Gallery Noir', tag: 'gallerynoir', overlay: '/proto/v12-gallery-noir.html',
elements: [
{ n: 1, label: 'Black-box gallery — each board its own warm spotlight' },
{ n: 2, label: 'Featured board dead-centre, full pattern face under the light' },
{ n: 3, label: 'Neighbours rake into chiaroscuro darkness as slivers' },
{ n: 4, label: 'Polished dark-concrete floor with contact reflection' },
{ n: 5, label: 'Click a sliver → it rides to centre + lights up' }
]
},
V16: {
name: 'Art-Deco Salon', tag: 'artdecobrass', overlay: '/proto/v16-artdeco-brass.html',
elements: [
{ n: 1, label: '1920s Art-Deco salon — brass-framed sample boards' },
{ n: 2, label: 'Polished marble floor (clearcoat reflections)' },
{ n: 3, label: 'Brass pilasters + deco sunburst lintel framing' },
{ n: 4, label: 'Centred board presents a clean full pattern face' },
{ n: 5, label: 'Warm salon key + spot light, env-mapped reflective brass' }
]
},
V21: {
name: 'Garcia Room', tag: 'garciaroom', overlay: '/proto/v21-garcia-room.html',
elements: [
{ n: 1, label: 'Psychedelic liquid-light colour wash on the walls (slow hue cycle)' },
{ n: 2, label: 'Tie-dye PMREM environment + warm bloom glow' },
{ n: 3, label: 'Dancing-bear + lightning-bolt framed accents' },
{ n: 4, label: 'China Seas wing bank with a clean focused face' },
{ n: 5, label: '“Garcia Room” rainbow wordmark — the Dead-tribute lounge' }
]
},
V13: {
name: 'Atrium Daylight', tag: 'atriumdaylight', overlay: '/proto/v13-atrium-daylight.html',
elements: [
{ n: 1, label: 'Glass atrium — luminous sky-gradient daylight environment' },
{ n: 2, label: 'Long soft sun-shaft shadows rake across pale limestone' },
{ n: 3, label: 'Soft global illumination — airy and bright' },
{ n: 4, label: 'Champagne-framed boards, centred board full-face' },
{ n: 5, label: 'Floor seams recede to a sky-lit horizon' }
]
},
V14: {
name: 'Velvet Lounge', tag: 'velvetlounge', overlay: '/proto/v14-velvet-lounge.html',
elements: [
{ n: 1, label: 'Jewel-box lounge — emerald / sapphire / oxblood velvet walls' },
{ n: 2, label: 'Brass sconces with warm flame-glow pools' },
{ n: 3, label: 'Intimate low-key warm key + sapphire fill' },
{ n: 4, label: 'Brass-framed boards, centred board presented full-face' },
{ n: 5, label: 'Plush velvet sheen + candle-flicker ambience' }
]
},
V15: {
name: 'Brutalist Concrete', tag: 'brutalist', overlay: '/proto/v15-brutalist-concrete.html',
elements: [
{ n: 1, label: 'Board-formed concrete walls (form lines + tie holes)' },
{ n: 2, label: 'One hard directional key — crisp dramatic shadows' },
{ n: 3, label: 'Strong ambient occlusion in the recesses' },
{ n: 4, label: 'Monochrome grey/charcoal — the patterns are the only colour' },
{ n: 5, label: 'Blackened-steel framed boards on a concrete plinth' }
]
},
V17: {
name: 'Japanese Tatami', tag: 'tatami', overlay: '/proto/v17-tatami.html',
elements: [
{ n: 1, label: 'Tea-house — glowing shoji screen walls (paper translucency)' },
{ n: 2, label: 'Woven tatami-mat floor, warm post-and-beam wood' },
{ n: 3, label: 'Soft diffuse north light, calm minimal palette' },
{ n: 4, label: 'Light-wood swivel boards, centred board full-face' },
{ n: 5, label: 'Wabi-sabi serenity' }
]
},
V18: {
name: 'Infinity Mirror', tag: 'infinitymirror', overlay: '/proto/v18-infinity-mirror.html',
elements: [
{ n: 1, label: 'Mirrored floor + side walls — the bank repeats into infinity' },
{ n: 2, label: 'Thin brushed-metal swivel boards' },
{ n: 3, label: 'Tasteful cool-white / amber neon edge accents' },
{ n: 4, label: 'Single CubeCamera reflection probe (FPS-cheap mirror)' },
{ n: 5, label: 'Centred board full-face amid the reflected depth' }
]
},
V19: {
name: 'Conservatory Garden', tag: 'conservatory', overlay: '/proto/v19-conservatory.html',
elements: [
{ n: 1, label: 'Victorian palm-house — wrought-iron-and-glass conservatory' },
{ n: 2, label: 'Lush potted palms framing the bank' },
{ n: 3, label: 'Dappled leaf-shadow gobo light across boards + stone floor' },
{ n: 4, label: 'Hazy jungle treeline beyond the glazing' },
{ n: 5, label: 'Iron-framed boards, centred board full-face' }
]
},
V20: {
name: 'Penthouse Dusk', tag: 'penthousedusk', overlay: '/proto/v20-penthouse-dusk.html',
elements: [
{ n: 1, label: 'High-rise penthouse — floor-to-ceiling dusk-gradient glass' },
{ n: 2, label: 'Soft city-bokeh lights beyond the windows' },
{ n: 3, label: 'Warm interior key, dark polished stone floor with reflections' },
{ n: 4, label: 'Sophisticated metropolitan evening mood' },
{ n: 5, label: 'Bronze-framed boards, centred board full-face' }
]
}
};
const VERSION_ORDER = ['V1','V2','V3','V4','V5','V6','V7','V8','V9','V10','V11','V12','V13','V14','V15','V16','V17','V18','V19','V20','V21'];
// ===========================================================================
// GLOBALLY-UNIQUE ELEMENT CODES (Slice-3 / chunk H)
// ---------------------------------------------------------------------------
// Steve's rule: every numbered element gets a GLOBALLY-UNIQUE build code that
// is unique across ALL 11 builds, base 1000, carrying the build code + the
// element's version. Canonical example: element 1 → `1000QHV2` =
// `${1000 + globalSeq}` + `${BUILD_CODE}` + `${version}`.
//
// BUILD_CODE is ONE swappable constant. Steve typed "build code CH" but his
// concrete worked example is `1000QH…`, so we use 'QH' per the literal example
// and isolate it here — flip to 'CH' is a single-char change if Steve confirms.
const BUILD_CODE = 'QH';
//
// assignElementCodes() runs ONCE at registry load: it iterates VERSION_ORDER
// (deterministic, stable order) → each version's `elements` by ASCENDING `n`,
// handing out global sequence 1000, 1001, 1002 … and stamping `elt.code` on
// every element. Because VERSION_ORDER and each elements[] array are fixed
// literals, the same element ALWAYS gets the same code on every page load,
// independent of click order or which version is opened.
// code = `${1000 + globalSeq}${BUILD_CODE}${version}` e.g. seq 0, V2 → 1000QHV2
function assignElementCodes() {
let globalSeq = 0;
VERSION_ORDER.forEach(vk => {
const v = VERSIONS[vk];
if (!v || !Array.isArray(v.elements)) return;
// Ascending-n is the friendly index order; elements are authored in n-order
// already, but sort defensively so a future out-of-order edit can't shuffle codes.
v.elements.slice().sort((a, b) => a.n - b.n).forEach(elt => {
elt.code = '' + (1000 + globalSeq) + BUILD_CODE + vk;
globalSeq++;
});
});
}
assignElementCodes();
// Look up an element's code from its version + n (for migrating old chosen rows
// and for cross-checks). Returns null if the element no longer exists.
function codeFor(version, n) {
const v = VERSIONS[version];
if (!v || !Array.isArray(v.elements)) return null;
const elt = v.elements.find(e => e.n === n);
return elt ? elt.code : null;
}
// ===========================================================================
// STATE
// ===========================================================================
let currentVersion = null;
let lastThreeDVersion = 'V1'; // last in-engine version (so Back from a mechanic restores a real scene)
let overlayOn = false;
let chosen = loadChosen(); // [{ code:'1003QHV1', version:'V1', n:4, label:'…' }]
// IDENTITY (Slice-3 / chunk H): a chosen element is keyed by its globally-unique
// `code` (e.g. 1003QHV1), NOT the old version-local 'V1.4' key (which collided
// across versions). loadChosen() tolerates OLD localStorage rows that only carried
// `key` — it backfills `code` from {version,n} where the element still exists, and
// keeps any it can't resolve so an old pick stays displayable instead of crashing.
function loadChosen() {
let raw;
try { raw = JSON.parse(localStorage.getItem('qh_chosen') || '[]'); } catch (e) { return []; }
if (!Array.isArray(raw)) return [];
return raw.map(c => {
if (!c || typeof c !== 'object') return null;
if (c.code) return c; // already migrated
// Old shape: derive version/n from {version,n} or a 'V1.4' key, then backfill code.
let version = c.version, n = c.n;
if ((version == null || n == null) && typeof c.key === 'string' && c.key.indexOf('.') > 0) {
const parts = c.key.split('.');
version = version != null ? version : parts[0];
n = n != null ? n : parseInt(parts[1], 10);
}
const code = (version != null && n != null) ? (codeFor(version, n) || c.key || ('?' + (c.key||''))) : (c.key || '?');
return { code, version: version != null ? version : null, n: n != null ? n : null, label: c.label || '' };
}).filter(Boolean);
}
function saveChosen() {
try { localStorage.setItem('qh_chosen', JSON.stringify(chosen)); } catch (e) {}
}
// ===========================================================================
// VERSION SWITCH — the single switcher. Persists choice; rebuilds the look.
// ===========================================================================
function setVersion(key, fromInit) {
const v = VERSIONS[key]; if (!v) return;
// Clean up the previous version's controller (e.g. the conveyor key/frame hooks).
const prev = VERSIONS[currentVersion];
if (prev && prev.exit && currentVersion !== key) { try { prev.exit(); } catch (e) {} }
currentVersion = key;
// rail active state (do this for both 3D and overlay versions)
document.querySelectorAll('#ver-rail .ver-chip').forEach(c =>
c.classList.toggle('active', c.dataset.version === key));
if (v.overlay) {
// REAL standalone mechanic — open it full-screen over the 3D canvas.
// The 3D scene is left untouched underneath; closing returns to the picker.
if (overlayOn) setOverlay(false); // 3D pin overlay is meaningless over an iframe
openOverlay(key, v);
if (!fromInit) { try { localStorage.setItem('qh_version', key); } catch (e) {} }
return;
}
// 3D in-engine version — make sure any open mechanic overlay is closed first.
closeOverlay();
lastThreeDVersion = key; // remember so Back from a mechanic restores this scene
try { v.build(); } catch (e) { console.warn('version build failed', key, e); }
if (QH.requestShadowUpdate) QH.requestShadowUpdate(20);
if (!fromInit) { try { localStorage.setItem('qh_version', key); } catch (e) {} }
// rebuild the pin overlay for the new version (its elements differ)
if (overlayOn) buildPins();
}
// ===========================================================================
// FULL-SCREEN MECHANIC OVERLAY — V6–V10 run as standalone pages in an <iframe>
// over the 3D canvas. A big senior "‹ Back to Showroom" button (rendered by the
// proto's own proto-chrome.js) postMessage's back here to close + restore the
// picker. The numbered-element legend (also in proto-chrome.js) postMessage's
// each chosen element back so the Chosen Elements tray stays in sync.
// ===========================================================================
let overlayFrameKey = null;
function ensureOverlayDom() {
let host = document.getElementById('mechanic-overlay');
if (host) return host;
host = document.createElement('div');
host.id = 'mechanic-overlay';
host.innerHTML =
'<div id="mech-loading"><span class="mech-spin"></span>Opening experience…</div>' +
'<iframe id="mech-frame" title="Showroom experience" allow="fullscreen"></iframe>';
document.body.appendChild(host);
const frame = host.querySelector('#mech-frame');
frame.addEventListener('load', () => {
const ld = document.getElementById('mech-loading');
if (ld) ld.style.display = 'none';
});
return host;
}
function openOverlay(key, v) {
const host = ensureOverlayDom();
const frame = host.querySelector('#mech-frame');
const ld = host.querySelector('#mech-loading');
if (ld) ld.style.display = 'flex';
// Only (re)load if switching to a different mechanic — avoids a reload flash
// when re-selecting the same one.
if (overlayFrameKey !== key) {
frame.src = v.overlay;
overlayFrameKey = key;
}
host.classList.add('open');
document.body.classList.add('mechanic-open');
}
function closeOverlay() {
const host = document.getElementById('mechanic-overlay');
if (host) host.classList.remove('open');
document.body.classList.remove('mechanic-open');
// keep the iframe loaded (overlayFrameKey stays) so re-opening is instant;
// the src is reused unless a different mechanic is picked.
}
// postMessage bridge: protos → picker (back / choose / ready) and picker → proto (sync-chosen)
window.addEventListener('message', (ev) => {
const d = ev.data; if (!d || !d.__qhProto) return;
if (d.type === 'back') {
closeOverlay();
// Return to the last 3D version the user was on (or V1) so the picker shows
// a live scene behind it again. Default V1 (senior establishing shot).
const back3d = lastThreeDVersion || 'V1';
setVersion(back3d);
} else if (d.type === 'choose') {
// Protos still send {version,n}; resolve to the global code (overlay mechanics'
// legends key locally by n, but the Chosen tray + copy use the durable code).
const code = codeFor(d.version, d.n) || (d.version + '.' + d.n);
const exists = isChosen(code);
if (d.chosen && !exists) { chosen.push({ code, version: d.version, n: d.n, label: d.label }); bumpCount(); }
else if (!d.chosen && exists) { const i = chosen.findIndex(c => c.code === code); if (i >= 0) chosen.splice(i, 1); }
saveChosen(); renderTray();
} else if (d.type === 'ready') {
// sync the current chosen set for THIS version into the proto's legend. The proto
// legend keys by the local 'V_.n' id, so map our chosen rows back to that shape.
const frame = document.getElementById('mech-frame');
if (frame && frame.contentWindow) {
const keys = chosen.filter(c => c.version === d.version).map(c => c.version + '.' + c.n);
try { frame.contentWindow.postMessage({ __qhPicker: true, type: 'sync-chosen', keys }, '*'); } catch (e) {}
}
}
});
// ===========================================================================
// NUMBERED-ELEMENT OVERLAY — circled-number pins projected onto each element.
// ===========================================================================
let pinLayer = null;
let activePins = []; // [{ el, anchor, n, label, code }]
function anchorWorld(anchor) {
if (!anchor) return null;
if (anchor.named) return QH.namedAnchor(anchor.named);
if (typeof anchor.board === 'number') {
let i = anchor.board;
if (i === -1) { // the currently-focused board (the "hero")
i = QH.focusedWing ? QH.focusedWing.userData.index : Math.floor(QH.wingBoards.length / 2);
}
i = Math.max(0, Math.min(QH.wingBoards.length - 1, i));
return QH.boardWorldPos(i);
}
return null; // UI-anchored pins handled separately (no world projection)
}
function buildPins() {
clearPins();
const v = VERSIONS[currentVersion]; if (!v) return;
if (!pinLayer) { pinLayer = document.getElementById('pin-layer'); }
if (!pinLayer) return;
v.elements.forEach(elt => {
// IDENTITY = the globally-unique code (1003QHV1…), NOT the old version-local
// 'V1.4' key. The friendly circled number `n` stays on the badge; the code
// rides in a sub-label so every visible pin shows its durable cross-build ID.
const code = elt.code || (currentVersion + '.' + elt.n); // codeFor-equivalent (already assigned)
const pin = document.createElement('div');
pin.className = 'el-pin';
pin.dataset.code = code;
pin.innerHTML =
'<span class="pin-badge">' + elt.n + '</span>' +
'<span class="pin-code" title="Globally-unique element code">' + escapeHtml(code) + '</span>' +
'<span class="pin-label">' + escapeHtml(elt.label) + '</span>';
if (isChosen(code)) pin.classList.add('chosen');
pin.addEventListener('click', (e) => { e.stopPropagation(); toggleChoice(code, currentVersion, elt.n, elt.label, pin); });
pinLayer.appendChild(pin);
activePins.push({ el: pin, anchor: elt.anchor, n: elt.n, label: elt.label, code });
});
positionPins(); // place immediately
}
function clearPins() {
activePins.forEach(p => p.el.remove());
activePins = [];
}
// Per-frame: reproject every pin's world anchor to screen (or read a UI element's
// box for ui-anchored pins). Installed as QH.overlayHook only while the overlay is on.
function positionPins() {
if (!overlayOn || !activePins.length) return;
const w = window.innerWidth, h = window.innerHeight;
activePins.forEach(p => {
let x, y, vis = true;
if (p.anchor && p.anchor.ui) {
const tgt = document.querySelector(p.anchor.ui);
if (tgt) { const r = tgt.getBoundingClientRect(); x = r.left + r.width / 2; y = r.top + r.height / 2; vis = r.width > 0; }
else { vis = false; }
} else {
const wpt = anchorWorld(p.anchor);
if (!wpt) { vis = false; }
else {
const s = QH.projectToScreen(wpt);
x = s.x; y = s.y; vis = s.onScreen;
}
}
if (vis && x >= -40 && x <= w + 40 && y >= -40 && y <= h + 40) {
p.el.style.display = 'flex';
p.el.style.left = Math.round(x) + 'px';
p.el.style.top = Math.round(y) + 'px';
} else {
p.el.style.display = 'none';
}
});
}
function setOverlay(on) {
overlayOn = (on == null) ? !overlayOn : on;
const layer = document.getElementById('pin-layer');
if (layer) layer.style.display = overlayOn ? 'block' : 'none';
const btn = document.getElementById('vr-overlay-btn');
if (btn) { btn.classList.toggle('active', overlayOn); btn.innerHTML = overlayOn ? '① Numbers ON' : '① Numbers'; }
if (overlayOn) { buildPins(); QH.overlayHook = positionPins; }
else { clearPins(); QH.overlayHook = null; }
try { localStorage.setItem('qh_overlay', overlayOn ? '1' : '0'); } catch (e) {}
}
// ===========================================================================
// CHOSEN ELEMENTS TRAY
// ===========================================================================
// Chosen-element identity is the globally-unique `code` (1003QHV1…).
function isChosen(code) { return chosen.some(c => c.code === code); }
function toggleChoice(code, version, n, label, pinEl) {
const i = chosen.findIndex(c => c.code === code);
if (i >= 0) { chosen.splice(i, 1); if (pinEl) pinEl.classList.remove('chosen'); }
else { chosen.push({ code, version, n, label }); if (pinEl) pinEl.classList.add('chosen'); bumpCount(); }
saveChosen(); renderTray();
}
function removeChoice(code) {
const i = chosen.findIndex(c => c.code === code);
if (i >= 0) chosen.splice(i, 1);
saveChosen(); renderTray();
const pin = document.querySelector('#pin-layer .el-pin[data-code="' + cssEsc(code) + '"]');
if (pin) pin.classList.remove('chosen');
}
function bumpCount() {
const c = document.getElementById('ct-count'); if (!c) return;
c.classList.add('bump'); setTimeout(() => c.classList.remove('bump'), 180);
}
function chosenText() {
// Sort by version order then element number for a tidy composition spec. Each row
// leads with the unambiguous GLOBAL CODE (1003QHV1) so a composed spec references
// the durable cross-build ID, not the old "V1.4" version-local key.
const sorted = chosen.slice().sort((a, b) =>
(VERSION_ORDER.indexOf(a.version) - VERSION_ORDER.indexOf(b.version)) || ((a.n || 0) - (b.n || 0)));
return sorted.map(c => (c.code || ('V' + c.version + '.' + c.n)) + ' ' + c.label).join('\n');
}
function renderTray() {
const tray = document.getElementById('chosen-tray'); if (!tray) return;
tray.classList.add('show'); // tray is always present once initialized
const items = document.getElementById('chosen-items');
const empty = document.getElementById('chosen-empty');
const count = document.getElementById('ct-count');
if (count) count.textContent = chosen.length;
if (!items) return;
items.innerHTML = '';
if (!chosen.length) {
if (empty) empty.style.display = 'block';
items.style.display = 'none';
return;
}
if (empty) empty.style.display = 'none';
items.style.display = 'flex';
const sorted = chosen.slice().sort((a, b) =>
(VERSION_ORDER.indexOf(a.version) - VERSION_ORDER.indexOf(b.version)) || ((a.n || 0) - (b.n || 0)));
sorted.forEach(c => {
const code = c.code || ('V' + c.version + '.' + c.n);
const row = document.createElement('div');
row.className = 'ct-chip';
row.innerHTML =
'<span class="ctc-tag" title="Globally-unique element code">' + escapeHtml(code) + '</span>' +
'<span class="ctc-name">' + escapeHtml(c.label) + '</span>' +
'<button class="ctc-x" title="Remove">×</button>';
row.querySelector('.ctc-x').addEventListener('click', () => removeChoice(code));
items.appendChild(row);
});
}
// ===========================================================================
// DOM — version rail, pin layer, tray. Built once; vanilla CSS (ART-DIRECTION).
// ===========================================================================
function buildDom() {
// pin layer (above canvas, below chrome)
if (!document.getElementById('pin-layer')) {
const pl = document.createElement('div'); pl.id = 'pin-layer'; pl.style.display = 'none';
document.body.appendChild(pl);
}
// VERSION RAIL
const rail = document.createElement('div'); rail.id = 'ver-rail';
let chips = '';
VERSION_ORDER.forEach(k => {
const v = VERSIONS[k];
const num = k.replace('V', '');
chips +=
'<button class="ver-chip' + (v.scaffold ? ' scaffold' : '') + '" data-version="' + k + '" title="' + escapeHtml(v.name) + (v.scaffold ? ' (scaffold)' : '') + '">' +
'<span class="vc-num">V' + num + '</span>' +
'<span class="vc-name">' + escapeHtml(v.name) + '</span>' +
'</button>';
});
rail.innerHTML =
'<div id="ver-rail-head">' +
'<span class="vr-eyebrow">Versions</span>' +
'<button id="ver-rail-collapse" title="Hide the version rail">×</button>' +
'</div>' +
chips +
'<div id="ver-rail-tools">' +
'<button id="vr-overlay-btn" class="vr-tool" title="Show numbered element pins to select & choose">① Numbers</button>' +
'</div>';
document.body.appendChild(rail);
// pull-out tab when collapsed
const tab = document.createElement('div'); tab.id = 'ver-rail-tab'; tab.textContent = 'Versions';
document.body.appendChild(tab);
// CHOSEN TRAY
const tray = document.createElement('div'); tray.id = 'chosen-tray';
tray.innerHTML =
'<div id="chosen-tray-head">' +
'<span class="ct-title">Chosen Elements <span id="ct-count" class="ct-count">0</span></span>' +
'<span class="ct-chev">▲</span>' +
'</div>' +
'<div id="chosen-tray-body">' +
'<div id="chosen-empty">Turn on <strong>① Numbers</strong> and tap a pin to pick the elements you like. They collect here so you can compose the final site (e.g. V1.4 + V5.2).</div>' +
'<div id="chosen-items"></div>' +
'<div id="chosen-actions">' +
'<button class="ct-act primary" id="ct-copy">Copy as text</button>' +
'<button class="ct-act" id="ct-clear">Clear all</button>' +
'</div>' +
'</div>';
document.body.appendChild(tray);
// ---- wire events ----
rail.querySelectorAll('.ver-chip').forEach(c =>
c.addEventListener('click', () => setVersion(c.dataset.version)));
document.getElementById('vr-overlay-btn').addEventListener('click', () => setOverlay());
document.getElementById('ver-rail-collapse').addEventListener('click', () => {
rail.classList.add('collapsed');
try { localStorage.setItem('qh_ver_rail', 'collapsed'); } catch (e) {}
});
tab.addEventListener('click', () => {
rail.classList.remove('collapsed');
try { localStorage.setItem('qh_ver_rail', 'open'); } catch (e) {}
});
if (localStorage.getItem('qh_ver_rail') === 'collapsed') rail.classList.add('collapsed');
// tray: header toggles open/closed; copy + clear
document.getElementById('chosen-tray-head').addEventListener('click', () => {
tray.classList.toggle('open');
try { localStorage.setItem('qh_tray_open', tray.classList.contains('open') ? '1' : '0'); } catch (e) {}
});
if (localStorage.getItem('qh_tray_open') === '1') tray.classList.add('open');
document.getElementById('ct-copy').addEventListener('click', (e) => {
e.stopPropagation();
const txt = chosenText();
const btn = e.currentTarget;
const done = () => { const o = btn.textContent; btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = o, 1200); };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => fallbackCopy(txt, done));
else fallbackCopy(txt, done);
});
document.getElementById('ct-clear').addEventListener('click', (e) => {
e.stopPropagation();
chosen = []; saveChosen(); renderTray();
document.querySelectorAll('#pin-layer .el-pin.chosen').forEach(p => p.classList.remove('chosen'));
});
tray.classList.add('show');
renderTray();
}
function fallbackCopy(txt, done) {
try {
const ta = document.createElement('textarea'); ta.value = txt;
ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);
ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done && done();
} catch (e) {}
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[m])); }
function cssEsc(s) { return String(s).replace(/(["\\.])/g, '\\$1'); }
// ===========================================================================
// INIT — build DOM, restore choices, set the boot version.
// Boot rule (LOCKED): keep it simple — default to V1 (or V6) so the senior
// guided experience is intact on load. Never boot a free-camera scaffold.
// ===========================================================================
buildDom();
// Boot default = the senior guided 3D showroom (V1 establishing arc). NEVER
// auto-open a full-screen mechanic (V6–V10) or a free-camera scaffold on reload
// — a returning senior would land inside an experience they didn't choose, or
// mid-spin. Only the in-engine PRESENTATION versions are senior-safe to restore;
// the picker is how you switch INTO the mechanics. (DTD verdict A.)
const safeBoot = { V1: 1 }; // V2-V5 are camera scaffolds; V6-V10 are mechanic overlays
const saved = localStorage.getItem('qh_version');
const bootKey = (saved && VERSIONS[saved] && safeBoot[saved]) ? saved : 'V1';
// Defer the build so BOTH the showroom's guided auto-focus (~700ms) AND viewmodes.js's
// boot ('hero', ~1200ms) have settled — then the version build OWNS the final scene
// state (otherwise the guided focus re-isolates the boards under V1's establishing shot).
setTimeout(() => setVersion(bootKey, true), 1800);
// Overlay restore (default OFF — the locked simple boot).
if (localStorage.getItem('qh_overlay') === '1') setTimeout(() => setOverlay(true), 1400);
// Expose for the test harness + cross-module use.
window._versions = {
set: setVersion,
overlay: setOverlay,
get current() { return currentVersion; },
get overlayOn() { return overlayOn; },
get chosen() { return chosen.slice(); },
chosenText,
// chunk-H surface: the global-code machinery, for the proof harness to assert
// uniqueness + stability and for any consumer needing a code lookup.
BUILD_CODE,
codeFor,
assignElementCodes,
// Flat list of every {version,n,code,label} across all builds — convenient for
// the uniqueness assertion.
allCodes() {
const out = [];
VERSION_ORDER.forEach(vk => {
const v = VERSIONS[vk];
if (v && Array.isArray(v.elements)) v.elements.forEach(e => out.push({ version: vk, n: e.n, code: e.code, label: e.label }));
});
return out;
},
VERSIONS, VERSION_ORDER
};
}
boot();
})();