← 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">&times;</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">&times;</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">&#9650;</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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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();
})();