/* global React */
/* ==========================================================
   Hero — "Slice."
   ==========================================================
   One vertical scroll, one cinematic sequence. Pin = 480vh;
   `p` is normalized scroll progress across that pin.

   Key insight: the 3D scene lives inside `.xp-prism` (translate +
   rotate). Score + tiny mark sit in `.xp-hero-rails` — fixed band in
   the viewport (no prism shift); CSS places the band ~10% from the
   top (+ safe-area).

   Before the pin: normal document scroll. First a full viewport
   "I am SkillsCake", then a short black watch-line strip, then the
   pinned 3D pin. No sticky/crossfade before the pin.

   STAGES — see `HERO_SCROLL` (frozen timeline object). Summary:
     settle      p below ~0.114 — flat sheet + rails (no fade-in)
     rotate      p ~0.114→0.234
     stack-hold  stackEnd→pickStart
     pick        pickStart→pickEnd
     populated   pickEnd→flattenStart
     flatten     flattenStart→flattenEnd  (`collapseSpanP` in `HERO_SCROLL`)
     spotBlobFade … → revealTextEnd
     revealText  revealTextStart→revealTextEnd
     untilt      untiltStart→untiltEnd  (`untiltSpanP` after collapse)
   ----------------------------------------------------------
   Components:
     Hero()         scroll + renders prism
     PageStack      16 alt-skill sheets behind the top page
     SpotField      blue/pink highlights — one flat rect per spot
                    that grows from pick-at-depth to full tile on
                    reveal

   The wireframe box (StackWireframe) has been removed — the 12 edge
   lines are gone; during pick the raw page stack shows through and
   the spots float over the words.
   ========================================================== */

const { useEffect: useEffectH, useRef: useRefH, useState: useStateH, useMemo: useMemoH } = React;

// ---- tiny math helpers ------------------------------------
const ease    = (t) => t < 0 ? 0 : t > 1 ? 1 : t * t * (3 - 2 * t);
const clamp   = (v, min, max) => Math.max(min, Math.min(max, v));
const clamp01 = (v) => clamp(v, 0, 1);
const lerp    = (a, b, t) => a + (b - a) * t;
const range   = (p, a, b) => clamp01((p - a) / (b - a));

// ---- geometry constants -----------------------------------
const PW = 360;                                // paper width  (prism-local px)
const PH = 460;                                // paper height (prism-local px)

// Text block inside the prism. Mirrors .xp-paper-body `inset: 38px 22px 22px 22px`
// in styles.css — the spots need to sit inside THIS box, not the full PW×PH
// prism, or they appear floating in the margins outside the actual words.
const TEXT_INSET_TOP    = 38;
const TEXT_INSET_BOTTOM = 22;
const TEXT_INSET_LEFT   = 22;
const TEXT_INSET_RIGHT  = 22;
const TEXT_W = PW - TEXT_INSET_LEFT - TEXT_INSET_RIGHT;               // 316
const TEXT_H = PH - TEXT_INSET_TOP  - TEXT_INSET_BOTTOM;              // 400
// Text block center relative to prism center (which sits at PW/2, PH/2).
// X: insets are equal → 0. Y: (top - bottom) / 2 = (38 - 22) / 2 = 8 px down.
const TEXT_OFFSET_X = (TEXT_INSET_LEFT - TEXT_INSET_RIGHT) / 2;        // 0
const TEXT_OFFSET_Y = (TEXT_INSET_TOP  - TEXT_INSET_BOTTOM) / 2;       // 8

// Stack depth = same as before so wireframe/ribbons stay aligned.
// 16 alternative-skill layers sit behind the top sheet at even
// depth intervals: z = -LAYER_SPACING × i  for i ∈ 1..16.
// Tighter spacing than before (14.25 px instead of 28.5) so the
// deck reads as dense pages rather than shelved books.
const STACK_DEPTH   = 228;
const LAYER_SPACING = STACK_DEPTH / 16;  // 14.25 px between consecutive pages
const LAYER_COUNT   = 16;

// Alternative skills — each a full SKILL.md draft (~52 lines). Data
// lives in window.ALT_SKILLS (see skills.js). We read it lazily so
// the script loads regardless of source order.

// ---- PAGE_SPOTS — shared highlight data -------------------
// Every blue-or-pink spot has a single (xPct, yPct, wPct, hPct)
// rectangle that tiles the top page when collapsed, and a single
// depthPct in [0..1] that positions it inside the cube during the
// pick stage. At pick, spots render small at their depth (reads
// like highlights floating through the stack). At flatten+reveal,
// they grow to fill their tile and z compresses to 0, so the
// entire top page ends up paved with solid blue/pink rectangles.
//
// The row layout is the earlier 12-row collage grid. To make the
// pick stage read as populated well before the final tiling, each
// row's columns are SUBDIVIDED into TWO half-width spots: the left
// half keeps the original color + depth, the right half keeps the
// color but offsets the depth by 0.37 (so the two halves don't
// land on the same plane). That doubles the count from 31 → 62.
// Every row's widths still sum to 100, and every (xPct, yPct,
// wPct, hPct) rectangle still tiles perfectly at reveal.
const PAGE_SPOTS = (() => {
  // row: { yPct, hPct, cols: [{ wPct, color, d }] } where d = depthPct.
  const baseRows = [
    { yPct: 0,   hPct: 7,  cols: [ { wPct: 28, color: 'blue', d: 0.08 }, { wPct: 42, color: 'pink', d: 0.62 }, { wPct: 30, color: 'blue', d: 0.22 } ] },
    { yPct: 7,   hPct: 9,  cols: [ { wPct: 46, color: 'pink', d: 0.88 }, { wPct: 54, color: 'blue', d: 0.34 } ] },
    { yPct: 16,  hPct: 8,  cols: [ { wPct: 20, color: 'blue', d: 0.52 }, { wPct: 34, color: 'pink', d: 0.18 }, { wPct: 46, color: 'pink', d: 0.76 } ] },
    { yPct: 24,  hPct: 9,  cols: [ { wPct: 62, color: 'pink', d: 0.40 }, { wPct: 38, color: 'blue', d: 0.12 } ] },
    { yPct: 33,  hPct: 7,  cols: [ { wPct: 30, color: 'pink', d: 0.70 }, { wPct: 30, color: 'blue', d: 0.48 }, { wPct: 40, color: 'pink', d: 0.28 } ] },
    { yPct: 40,  hPct: 10, cols: [ { wPct: 52, color: 'blue', d: 0.84 }, { wPct: 48, color: 'pink', d: 0.58 } ] },
    { yPct: 50,  hPct: 8,  cols: [ { wPct: 18, color: 'pink', d: 0.14 }, { wPct: 54, color: 'blue', d: 0.66 }, { wPct: 28, color: 'pink', d: 0.36 } ] },
    { yPct: 58,  hPct: 9,  cols: [ { wPct: 44, color: 'pink', d: 0.92 }, { wPct: 24, color: 'blue', d: 0.24 }, { wPct: 32, color: 'pink', d: 0.80 } ] },
    { yPct: 67,  hPct: 7,  cols: [ { wPct: 60, color: 'blue', d: 0.44 }, { wPct: 40, color: 'pink', d: 0.56 } ] },
    { yPct: 74,  hPct: 9,  cols: [ { wPct: 28, color: 'pink', d: 0.10 }, { wPct: 48, color: 'blue', d: 0.72 }, { wPct: 24, color: 'blue', d: 0.32 } ] },
    { yPct: 83,  hPct: 8,  cols: [ { wPct: 52, color: 'blue', d: 0.86 }, { wPct: 48, color: 'pink', d: 0.50 } ] },
    { yPct: 91,  hPct: 9,  cols: [ { wPct: 22, color: 'pink', d: 0.20 }, { wPct: 38, color: 'blue', d: 0.78 }, { wPct: 40, color: 'pink', d: 0.42 } ] },
  ];

  const spots = [];
  for (const row of baseRows) {
    let x = 0;
    for (const c of row.cols) {
      const halfW = c.wPct / 2;
      // Left half — original color + depth.
      spots.push({
        xPct: x, yPct: row.yPct,
        wPct: halfW, hPct: row.hPct,
        color: c.color,
        depthPct: c.d,
      });
      // Right half — same color, depth offset by 0.37 (wraps around
      // the [0..1] range). The offset is deliberately non-simple so
      // paired halves never stack at the same z, and the color is
      // kept so the overall pink/blue balance matches the base rows.
      spots.push({
        xPct: x + halfW, yPct: row.yPct,
        wPct: halfW, hPct: row.hPct,
        color: c.color,
        depthPct: (c.d + 0.37) % 1,
      });
      x += c.wPct;
    }
  }
  return spots;
})();

// Pick-stage size (prism-local px). A spot at pick renders at this
// fixed size regardless of its final tile dimensions. Small enough
// that ~31 of them scattered through the cube read as highlights
// rather than solid mass.
const PICK_SPOT_W = 22;
const PICK_SPOT_H = 11;

const COL = { pink: 'var(--pink)', blue: 'var(--blue)' };

// Firefox fallback renders the page plane as a 2D matrix. Child offsets are
// still expressed in local plane coordinates, so this is the inverse-mapped
// local vector that lands on Chrome's projected -Z direction on screen.
const FF_DEPTH_X = -1.4368;
const FF_DEPTH_Y =  1.8808;

const SCORE_START = 0.55;
const SCORE_END = 0.9;

/**
 * Scroll timeline for the hero (`p` ∈ [0, 1] = pin progress).
 *
 * **Public API:** read-only fields on `HERO_SCROLL` — `Hero()` uses these only.
 * Do not duplicate magic numbers elsewhere.
 *
 * **To retime:** edit the `// --- knobs` section inside the IIFE. Everything
 * after (pick/populated/flatten/reveal/untilt/spot fades) is derived from those knobs.
 *
 * Spots never form a static “wall”: blob fade + `spotSizeTEnd` are derived here.
 */
const HERO_SCROLL = (() => {
  // --- knobs (scroll `p` budgets — adjust these, not magic in Hero()) -----
  const stackStart = 0.234;
  const stackEnd = 0.334;
  const stackHoldSpanP = 0.074;
  const pickStart = stackEnd + stackHoldSpanP;
  const pickSpanP = 0.075;
  const populatedSpanP = 0.08;
  const pickEnd = pickStart + pickSpanP;
  const populatedEnd = pickEnd + populatedSpanP;
  const flattenStart = populatedEnd;
  /** Depth collapse (stack → flat) in pin progress `p`. */
  const collapseSpanP = 0.1446;
  /** Prism unrotate after collapse, in `p`. */
  const untiltSpanP = 0.158;
  /** Final sheet line fade-in window after `flattenEnd`, in `p`. */
  const postCollapseTextHoldP = 0.04;
  /** Spot blob fade starts this many `p` before `flattenEnd`. */
  const spotBlobLeadBeforeFlattenEndP = 0.015;
  /** Where `sizeT` peaks along the collapse span (0…1). */
  const spotSizeTAlongCollapse = 0.898;

  const flattenEnd = flattenStart + collapseSpanP;
  const revealTextStart = flattenEnd;
  const revealTextEnd = flattenEnd + postCollapseTextHoldP;
  const spotBlobFadeStart = flattenEnd - spotBlobLeadBeforeFlattenEndP;
  const spotBlobFadeEnd = revealTextEnd;
  const spotSizeTEnd = flattenStart + collapseSpanP * spotSizeTAlongCollapse;

  const untiltStart = flattenEnd;
  const untiltEnd = untiltStart + untiltSpanP;

  return Object.freeze({
    stackStart,
    stackEnd,
    pickStart,
    pickEnd,
    populatedEnd,
    flattenStart,
    flattenEnd,
    untiltStart,
    untiltEnd,
    revealTextStart,
    revealTextEnd,
    spotBlobFadeStart,
    spotBlobFadeEnd,
    spotSizeTEnd,
  });
})();

// ============================================================
// Hero
// ============================================================
function Hero() {
  const pinRef = useRefH(null);
  const [p, setP] = useStateH(0);
  const [viewport, setViewport] = useStateH(() => ({
    w: window.innerWidth,
    h: window.innerHeight,
  }));
  const isFirefox = /firefox/i.test(navigator.userAgent);

  const skill = useMemoH(() => (window.PICK_SKILL ? window.PICK_SKILL() : window.SKILLS?.[0]), []);

  useEffectH(() => {
    let raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const pin = pinRef.current;
        if (pin) {
          const rect = pin.getBoundingClientRect();
          const pinTotal = pin.offsetHeight - window.innerHeight;
          const y = Math.min(Math.max(-rect.top, 0), pinTotal);
          setP(pinTotal > 0 ? y / pinTotal : 0);
        } else {
          setP(0);
        }
      });
    };
    onScroll();
    const onResize = () => {
      setViewport({ w: window.innerWidth, h: window.innerHeight });
      onScroll();
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
    };
  }, []);

  const S = HERO_SCROLL; // scroll timeline; all `p` windows below use `S.*` only

  // ---- stage progress windows (all wired from `HERO_SCROLL`) ----
  // Flat sheet + rails from p=0; rotation starts at pRotate window (settle first).
  const pRotate   = ease(range(p, 0.114, 0.234));
  const pStack    = ease(range(p, S.stackStart, S.stackEnd));
  const pPick     = ease(range(p, S.pickStart, S.pickEnd));
  const pFlatten  = ease(range(p, S.flattenStart, S.flattenEnd));
  const pUntilt   = ease(range(p, S.untiltStart, S.untiltEnd));
  const pFinalText = ease(range(p, S.revealTextStart, S.revealTextEnd));

  const flattenSpan = S.flattenEnd - S.flattenStart;
  const pTextFade = ease(range(p, S.flattenStart, S.flattenStart + flattenSpan / 6));

  const closingOp    = ease(range(p, 0.98, 1.00));

  // Spots: dissolve out while final copy reads in (never a static tiled wall).
  const spotBlobFade = ease(range(p, S.spotBlobFadeStart, S.spotBlobFadeEnd));
  const spotPhase = Math.max(pPick, pFlatten, pUntilt);
  const spotStrength = spotPhase * (1 - spotBlobFade) * (1 - closingOp);
  const spotSizeT = ease(range(p, S.flattenStart, S.spotSizeTEnd));

  // ---- prism transform ----
  // rotY/rotZ are relatively large so that when the stack is deep, the depth
  // axis projects clearly onto screen (avoids extreme foreshortening of Z).
  const rotX = lerp(0, 62, pRotate) * (1 - pUntilt);
  const rotY = lerp(0, 34, pRotate) * (1 - pUntilt);
  const rotZ = lerp(0, 14, pRotate) * (1 - pUntilt);
  const flatTilt = pRotate * (1 - pUntilt);
  // Firefox's CSS 3D compositor flips the visual read of this scene. For FF,
  // use the exact 2D affine plane produced by the Chrome peak rotation:
  // rotateX(62) -> rotateY(34) -> rotateZ(14), projected to x/y.
  const ffA = lerp(1, 0.80438, flatTilt);
  const ffB = lerp(0, 0.20056, flatTilt);
  const ffC = lerp(0, 0.36550, flatTilt);
  const ffD = lerp(1, 0.57497, flatTilt);
  const firefoxPrismTransform = `matrix(${ffA}, ${ffB}, ${ffC}, ${ffD}, 0, 0)`;
  const chromePrismTransform = `rotateZ(${rotZ}deg) rotateY(${rotY}deg) rotateX(${rotX}deg)`;
  const centerAmount = pStack * (1 - pUntilt);
  const shiftY = -128 * centerAmount;
  const shiftX = 18  * centerAmount;

  // Firefox desktop often has a much shorter content viewport than the
  // Cursor/mobile previews. Keep the tall/mobile composition untouched, but
  // shrink the full 3D object in short landscape windows so the deck doesn't
  // loom into a near-field perspective blowout.
  const shortLandscape = viewport.w > viewport.h * 1.12 && viewport.h < 720;
  const heightScale = shortLandscape ? clamp((viewport.h - 120) / 610, 0.72, 1) : 1;
  const narrowScale = viewport.w < 370 ? clamp((viewport.w - 28) / PW, 0.82, 1) : 1;
  const sceneScale = Math.min(heightScale, narrowScale) * (isFirefox && shortLandscape ? 0.9 : 1);

  // Depth compression: 1 = full stack depth, 0 = flat
  const zCompress = 1 - pFlatten;

  // ---- sheet opacity: stack pages stay visible through pick; quick-fade
  // at the start of flatten; clear for final text window.
  const sheetAlpha = (1 - pTextFade) * (1 - pFinalText);

  // Top sheet: visible from first frame of pin (no arrival fade).
  const clampedTopAlpha = 1;

  // ---- text on top sheet ----
  const draftTextOp = (1 - pTextFade);
  const finalTextOp = pFinalText;

  // Score ramps during pick; lands at SCORE_END after pick completes.
  const score01 = p < S.pickStart ? SCORE_START : lerp(SCORE_START, SCORE_END, pPick);
  const scoreStr = (Math.round(score01 * 100) / 100).toFixed(2);

  const metaScreenOp = (1 - closingOp);
  const markRailOp = metaScreenOp;
  const scoreRailOp = metaScreenOp;

  return (
    <>
      <section className="xp-hero-identity" aria-labelledby="xp-hero-identity-title">
        <div className="xp-hero-identity-inner xp-hero-identity-inner--tight">
          <h1 id="xp-hero-identity-title" className="xp-hero-title">
            I am <span className="accent">SkillsCake.</span>
          </h1>
          <div className="xp-hero-hint" style={{ marginTop: '1.6rem' }}>Scroll</div>
        </div>
      </section>

      <section className="xp-hero-watch-line" aria-labelledby="xp-hero-scroll-title">
        <h1 id="xp-hero-scroll-title" className="xp-hero-title xp-hero-title--watch-line">
          watch me upgrade your <span className="accent">skill.</span>
        </h1>
      </section>

      <div ref={pinRef} style={{ position: 'relative', height: '480vh', background: '#000' }}>
      <div style={{
        position: 'sticky', top: 0,
        height: '100vh', overflow: 'hidden',
        background: '#000',
      }}>
        {/* Closing */}
        <div className="xp-hero-closing-caption" style={{ opacity: closingOp }}>
          — <span className="accent">SkillsCake.</span>
        </div>

        {/* Fixed viewport rails — do not follow prism shiftY/shiftX; stay above paper (z-index). */}
        <div className="xp-hero-rails">
          <div className="xp-hero-rails-inner">
            <div
              className="xp-paper-meta xp-paper-meta--mark"
              style={{ opacity: markRailOp }}
              aria-label="SkillsCake"
            >
              <img
                className="xp-hero-rail-mark"
                src="design-system/assets/logo-skillscake-s-dot.svg"
                alt=""
                width={34}
                height={34}
                decoding="async"
              />
            </div>
            <div
              className="xp-paper-meta xp-paper-meta--right"
              style={{ opacity: scoreRailOp }}
              aria-label={`Skill score ${scoreStr}`}
            >
              <div className="xp-paper-meta-chip">
                <span className="xp-paper-meta-chip-label">score</span>
                <span className="xp-paper-meta-chip-value">{scoreStr}</span>
              </div>
            </div>
          </div>
        </div>

        <div className={`xp-prism-stage${isFirefox ? ' xp-prism-stage--firefox' : ''}`}>
          <div
            className={`xp-prism-frame${isFirefox ? ' xp-prism-frame--firefox' : ''}`}
            style={{
              transform: `translate(-50%, -50%) translate3d(${shiftX}px, ${shiftY}px, 0) scale(${sceneScale})`,
            }}
          >
            <div
              className="xp-prism"
              style={{
                transform: isFirefox ? firefoxPrismTransform : chromePrismTransform,
              }}
            >
              {/* TOP "SHEET" — transparent text container; no panel, no glow. */}
              <div
                className="xp-sheet xp-sheet-top"
                style={{
                  opacity: clampedTopAlpha,
                  transform: 'translate3d(-50%,-50%,0)',
                }}
              >
                <div className="xp-paper-body" style={{ opacity: draftTextOp }}>
                  {skill.draft.map((L, i) => (
                    <div key={i} className="xp-paper-line">{L || '\u00A0'}</div>
                  ))}
                </div>
                <div className="xp-paper-body xp-paper-body-over" style={{ opacity: finalTextOp }}>
                  {skill.final.map((L, i) => (
                    <div key={i} className="xp-paper-line">{L || '\u00A0'}</div>
                  ))}
                </div>
              </div>

              {/* PAGE STACK — 8 alternative-skill sheets growing behind the
                  top sheet. Each layer sits at z = -LAYER_SPACING × i and fades
                  in as pStack sweeps past its depth plane. Gated on pStack so
                  layers don't sit under a flat top sheet during hold/rotate. */}
              {pStack > 0.001 && sheetAlpha > 0.01 && (
                <PageStack
                  depth={STACK_DEPTH * pStack * zCompress}
                  alpha={sheetAlpha}
                  flat={isFirefox}
                />
              )}

              {/* PAGE SPOTS — highlights; strength × fade from Hero (no blob wall). */}
              {spotStrength > 0.01 && (
                <SpotField
                  strength={spotStrength}
                  sizeT={spotSizeT}
                  zCompress={zCompress}
                  flat={isFirefox}
                />
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
    </>
  );
}

// ============================================================
// SpotField — flat blue/pink highlights. Both depth-pick and reveal use
// one rectangle per spot with a single (x,y) on the top page and one
// depthPct inside the cube.
//
// Each spot renders as ONE flat <div> that is a DIRECT child of
// .xp-prism (same preserve-3d trick as StackWireframe — wrapping in
// an intermediate div flattens child translateZ in Chrome). The div
// has a translateZ applied so it floats at its depthPct during pick.
//
// sizeT ∈ [0..1] (Hero may stop short of 1 via `HERO_SCROLL.spotSizeTEnd` so
// spots never fully tile before the blob fade).
// zCompress ∈ [0..1]:
//   1 — full stack depth (pick): z = -STACK_DEPTH × depthPct.
//   0 — flat (flatten+untilt): z = 0, all spots coplanar on top page.
//
// `strength` is spot opacity (0..1). Hero multiplies geometric phase by
// (1 − spotBlobFade) so pink/blue dissolves into final copy instead of
// holding on a solid tiled field.
// ============================================================
function SpotField({ strength, sizeT, zCompress, flat = false }) {
  if (strength <= 0.001) return null;
  return (
    <React.Fragment>
      {PAGE_SPOTS.map((s, i) => {
        // Spots are positioned within the TEXT block (not the full prism),
        // so they always appear over the words — never in the margins.
        // (xCenterPct, yCenterPct) is a percentage inside the TEXT_W×TEXT_H
        // rectangle, which is centered at (TEXT_OFFSET_X, TEXT_OFFSET_Y)
        // relative to the prism center.
        const xCenterPct = s.xPct + s.wPct / 2;
        const yCenterPct = s.yPct + s.hPct / 2;
        const xPx   = TEXT_OFFSET_X + (xCenterPct / 100) * TEXT_W - TEXT_W / 2;
        const yPx   = TEXT_OFFSET_Y + (yCenterPct / 100) * TEXT_H - TEXT_H / 2;
        const zPx   = -STACK_DEPTH * s.depthPct * zCompress;
        const flatDepth = STACK_DEPTH * s.depthPct * zCompress;
        const fullW = (s.wPct / 100) * TEXT_W;
        const fullH = (s.hPct / 100) * TEXT_H;
        const w     = lerp(PICK_SPOT_W, fullW, sizeT);
        const h     = lerp(PICK_SPOT_H, fullH, sizeT);
        return (
          <div
            key={i}
            className="xp-spot"
            style={{
              left:       `calc(50% + ${xPx}px)`,
              top:        `calc(50% + ${yPx}px)`,
              width:      `${w}px`,
              height:     `${h}px`,
              marginLeft: `${-w / 2}px`,
              marginTop:  `${-h / 2}px`,
              transform:  flat
                ? `translate3d(${flatDepth * FF_DEPTH_X}px, ${flatDepth * FF_DEPTH_Y}px, 0)`
                : `translateZ(${zPx}px)`,
              background: COL[s.color],
              opacity:    strength,
            }}
          />
        );
      })}
    </React.Fragment>
  );
}

// ============================================================
// PageStack — depth as a growing deck of real skill pages.
//   The top sheet (user's skill) sits at z = 0.
//   8 alternative-skill pages sit behind at z = -LAYER_SPACING × i
//   for i ∈ 1..8 (i.e. -28.5, -57, … -228).
// Each layer is a 360×460 page rendered at its own z, with a short
// YAML-frontmatter hint of an alternative skill. As pStack grows,
// `depth` sweeps from 0 → STACK_DEPTH; layers fade in once `depth`
// crosses their plane, so the box appears to extrude page by page.
//
// Rendering: each layer is a DIRECT child of .xp-prism (via
// React.Fragment). Same reason as StackWireframe — a wrapping
// preserve-3d div flattens child z translations in Chrome and
// the deeper layers all collapse onto z = 0.
// ============================================================
function PageStack({ depth, alpha, flat = false }) {
  // 8 unique drafts reused twice: layers 1+9, 2+10, … 8+16 render
  // the same SKILL.md. Two of the eight are "dense" (release-notes,
  // changelog-drafter) so every fourth visible page reads as a
  // solid rectangle of text rather than a half-page triangle.
  const altSkills = window.ALT_SKILLS || [];
  const uniqueCount = altSkills.length || 1;
  const layers = [];
  for (let idx = 0; idx < LAYER_COUNT; idx++) {
    layers.push(altSkills[idx % uniqueCount]);
  }

  // Layer i (1..16) enters when `depth` reaches its z plane, then
  // eases up to full strength over a small window. Slight overlap
  // gives a smooth, page-by-page reveal instead of stepped pops.
  const layerAlpha = (i) => {
    const planeZ = LAYER_SPACING * (i - 0.5);
    return ease(clamp01((depth - planeZ) / 10));
  };

  return (
    <React.Fragment>
      {layers.map((skill, idx) => {
        if (!skill) return null;
        const i  = idx + 1;
        const op = layerAlpha(i) * alpha;
        if (op <= 0.001) return null;

        // Each deeper page reads slightly dimmer to suggest atmospheric
        // falloff and to keep the top sheet visually dominant.
        const dim = 1 - 0.035 * idx;
        const visibleDepth = Math.min(depth, LAYER_SPACING * i);
        const flatX = visibleDepth * FF_DEPTH_X;
        const flatY = visibleDepth * FF_DEPTH_Y;

        return (
          <div
            key={`${i}-${skill.name}`}
            className="xp-page-stack-layer"
            style={{
              transform: flat
                ? `translate3d(calc(-50% + ${flatX}px), calc(-50% + ${flatY}px), 0) scale(${1 - idx * 0.004})`
                : `translate3d(-50%, -50%, ${-LAYER_SPACING * i}px)`,
              opacity:   op,
              filter:    `brightness(${dim})`,
            }}
          >
            <div className="xp-page-stack-text">
              {skill.draft.map((L, li) => (
                <div key={li} className="xp-paper-line">{L || '\u00A0'}</div>
              ))}
            </div>
          </div>
        );
      })}
    </React.Fragment>
  );
}

window.Hero = Hero;
