/* ───────────────────────────────────────────────────────────────────────
   landing.jsx — Peripheral portfolio / linktree landing ("one evening")
   Dark, warm, photo-led. Copy is dissected from Morgan's real YouTube
   channel description (fetched S66). Native scroll; two pinned scenes
   (hero, Out pan). Scoped under .landing-portfolio so the dark theme never
   leaks into the cream /merch store. Rendered at /preview.
   Globals reused: Icon, onCursor.
   ─────────────────────────────────────────────────────────────────────── */
const { useState: useStateL, useEffect: useEffectL, useRef: useRefL } = React;

const lpHover = () => { try { onCursor('hover'); } catch (e) {} };
const lpOut   = () => { try { onCursor('default'); } catch (e) {} };

const PREFERS_REDUCED = typeof window !== 'undefined'
  && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const DESKTOP = typeof window !== 'undefined'
  && window.matchMedia && window.matchMedia('(min-width: 901px)').matches;
const MOTION = !PREFERS_REDUCED;

/* ── In-app browser deep-linking (Instagram/TikTok webview → native app) ── */
const UA = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
const IN_APP = /Instagram|FBAN|FBAV|FB_IAB|Line\/|Snapchat|musical_ly|BytedanceWebview|Twitter|Pinterest/i.test(UA);
const IS_IOS = /iPhone|iPad|iPod/i.test(UA);

function nativeScheme(platform, url) {
  try {
    switch (platform) {
      case 'youtube':   return url.replace(/^https?:\/\//, 'youtube://');
      case 'instagram': return 'instagram://user?username=peripheral.vinyl';
      case 'tiktok':    return url.replace(/^https?:\/\//, 'snssdk1233://');
      case 'soundcloud':return url.replace(/^https?:\/\//, 'soundcloud://');
      case 'spotify': { const m = url.match(/open\.spotify\.com\/(.+)$/); return m ? 'spotify://' + m[1] : null; }
      default: return null;
    }
  } catch (e) { return null; }
}
function openSmart(platform, url, e) {
  if (!IN_APP || !platform) return;
  const scheme = nativeScheme(platform, url);
  if (!scheme) return;
  e.preventDefault();
  if (!IS_IOS) {
    const pkg = { youtube: 'com.google.android.youtube', instagram: 'com.instagram.android',
      tiktok: 'com.zhiliaoapp.musically', soundcloud: 'com.soundcloud.android', spotify: 'com.spotify.music' }[platform];
    const bare = url.replace(/^https?:\/\//, '');
    window.location.href = `intent://${bare}#Intent;scheme=https;${pkg ? 'package=' + pkg + ';' : ''}end`;
    setTimeout(() => { window.location.href = url; }, 700);
    return;
  }
  let fell = false;
  const fb = setTimeout(() => { if (!fell) window.location.href = url; }, 650);
  document.addEventListener('visibilitychange', () => { fell = true; clearTimeout(fb); }, { once: true });
  window.location.href = scheme;
}

/* ── Scroll-progress driver (pinned scenes) ─────────────────────────────
   Calls onProgress(0→1) as `trackRef` scrolls through the viewport. rAF-
   throttled, reads layout once per frame. Disabled under reduced-motion or
   when `enabled` is false — in which case CSS defaults are the resting look. */
function useScrollProgress(trackRef, onProgress, opts) {
  const enabled = !opts || opts.enabled !== false;
  useEffectL(() => {
    if (!MOTION || !enabled) return;
    const el = trackRef.current;
    if (!el) return;
    let raf = 0;
    const compute = () => {
      raf = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight || 1;
      const range = Math.max(1, r.height - vh);
      onProgress(Math.min(1, Math.max(0, -r.top / range)));
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    compute();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [enabled]);
}

/* ── Desktop smooth scroll — eases the REAL window scroll (sticky-safe) ──── */
function useSmoothScroll() {
  useEffectL(() => {
    if (!MOTION || !DESKTOP) return;
    if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    let target = window.scrollY, cur = target, raf = 0;
    const maxY = () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
    const loop = () => {
      cur += (target - cur) * 0.14;
      if (Math.abs(target - cur) < 0.5) { cur = target; window.scrollTo(0, Math.round(cur)); raf = 0; return; }
      window.scrollTo(0, Math.round(cur));
      raf = requestAnimationFrame(loop);
    };
    const onWheel = (e) => {
      if (e.defaultPrevented) return;                        // a section (e.g. the sets row) consumed it
      if (document.documentElement.classList.contains('lp-menu-open')) return; // menu overlay open
      if (e.ctrlKey || e.metaKey || e.altKey) return;        // let zoom / shortcuts pass
      if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;   // let horizontal trackpad pass
      e.preventDefault();
      target = Math.min(maxY(), Math.max(0, target + e.deltaY));
      if (!raf) raf = requestAnimationFrame(loop);
    };
    const onScroll = () => { if (!raf) { cur = target = window.scrollY; } }; // sync keyboard/scrollbar/anchor
    window.addEventListener('wheel', onWheel, { passive: false });
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => { if (raf) cancelAnimationFrame(raf); window.removeEventListener('wheel', onWheel); window.removeEventListener('scroll', onScroll); };
  }, []);
}

/* ── Eased scroll-to-section (menu jumps) — own rAF; native smooth scroll
      is unreliable and fights useSmoothScroll ─────────────────────────── */
function jumpTo(id) {
  const el = document.getElementById(id);
  if (!el) return;
  const targetY = Math.max(0, Math.round(el.getBoundingClientRect().top + window.scrollY));
  if (PREFERS_REDUCED) { window.scrollTo(0, targetY); return; }
  const startY = window.scrollY, dist = targetY - startY, dur = 680;
  const ease = (p) => 1 - Math.pow(1 - p, 3);
  let t0 = null;
  const step = (ts) => {
    if (t0 === null) t0 = ts;
    const p = Math.min(1, (ts - t0) / dur);
    window.scrollTo(0, Math.round(startY + dist * ease(p)));
    if (p < 1) requestAnimationFrame(step);
  };
  requestAnimationFrame(step);
}

/* ── Reveal (IntersectionObserver) ────────────────────────────────────── */
function Reveal({ children, className = '', delay = 0, style }) {
  const ref = useRefL(null);
  const [seen, setSeen] = useStateL(PREFERS_REDUCED);
  // `instant` = the element was ALREADY on-screen when it first mounted (e.g.
  // a refresh while scrolled to this section). Skip the entrance transition so
  // it doesn't animate in from blank — that read as a "weird load".
  const [instant, setInstant] = useStateL(false);
  useEffectL(() => {
    if (PREFERS_REDUCED || seen) return;
    const el = ref.current; if (!el) return;
    let firstCb = true;
    const io = new IntersectionObserver((es) => {
      const en = es[0];
      if (en && en.isIntersecting) { if (firstCb) setInstant(true); setSeen(true); io.disconnect(); }
      firstCb = false;
    }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, [seen]);
  return (
    <div ref={ref} className={`lp-reveal ${seen ? 'in' : ''} ${instant ? 'lp-reveal-instant' : ''} ${className}`}
         style={{ transitionDelay: seen && delay && !instant ? `${delay}ms` : '0ms', ...style }}>{children}</div>
  );
}

/* ── Loop crossfade — looping videos snap hard at the restart point. Two
      stacked copies of the clip: as the leading one nears its end, the other
      starts from 0 and fades in OVER it (the video dissolves into itself —
      Morgan: no dip to black), then they swap roles. ─────────────────────── */
function useCrossfadeLoop(aRef, bRef, fade = 0.8) {
  useEffectL(() => {
    if (!MOTION) return;
    const A = aRef.current, B = bRef.current;
    if (!A || !B) return;
    A.loop = false; B.loop = false;      // we manage the restart ourselves
    let lead = A, tail = B, raf = 0;
    B.style.opacity = '0';
    const swap = (ended) => {
      ended.style.opacity = '0';
      try { ended.pause(); } catch (e) {}
      lead = ended === A ? B : A;
      tail = ended;
      lead.style.opacity = '1';
    };
    const eA = () => swap(A), eB = () => swap(B);
    A.addEventListener('ended', eA);
    B.addEventListener('ended', eB);
    const tick = () => {
      raf = requestAnimationFrame(tick);
      const d = lead.duration;
      if (!d || lead.paused) return;
      const rem = d - lead.currentTime;
      if (rem < fade) {
        if (tail.paused) {
          try { tail.currentTime = 0; const pr = tail.play(); if (pr && pr.catch) pr.catch(() => {}); } catch (e) {}
          tail.style.zIndex = '2'; lead.style.zIndex = '1'; // incoming paints on top
        }
        tail.style.opacity = Math.min(1, 1 - rem / fade).toFixed(2);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => { cancelAnimationFrame(raf); A.removeEventListener('ended', eA); B.removeEventListener('ended', eB); };
  }, []);
}

/* ── Liquid buttons — the fill blob is born at the cursor's entry point and
      lags after it while hovering (CSS vars --bx/--by/--bs; the ::before on
      .lp-btn/.lp-pill does the rendering). One delegated listener set. ──── */
function useLiquidButtons() {
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const SEL = '.lp-btn, .lp-pill';
    const setVars = (el, e, scale) => {
      const r = el.getBoundingClientRect();
      el.style.setProperty('--bx', (((e.clientX - r.left) / r.width) * 100).toFixed(1) + '%');
      el.style.setProperty('--by', (((e.clientY - r.top) / r.height) * 100).toFixed(1) + '%');
      if (scale !== null) el.style.setProperty('--bs', scale);
    };
    const over = (e) => {
      const el = e.target.closest && e.target.closest(SEL);
      if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
      setVars(el, e, '1');
    };
    const move = (e) => {
      const el = e.target.closest && e.target.closest(SEL);
      if (el) setVars(el, e, null);
    };
    const out = (e) => {
      const el = e.target.closest && e.target.closest(SEL);
      if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
      setVars(el, e, '0');
    };
    document.addEventListener('pointerover', over);
    document.addEventListener('pointermove', move, { passive: true });
    document.addEventListener('pointerout', out);
    return () => {
      document.removeEventListener('pointerover', over);
      document.removeEventListener('pointermove', move);
      document.removeEventListener('pointerout', out);
    };
  }, []);
}

/* ── Line-lift heading — the text rises out of a clipped line once the
      surrounding Reveal fires (OFF+BRAND / landonorris.com device) ─────── */
function H2L({ children, className = '' }) {
  return (
    <h2 className={`lp-h2 lp-lift ${className}`}>
      <span className="lp-lift-in">{children}</span>
    </h2>
  );
}

/* ── Parallax (drift within an overflow-hidden frame) ─────────────────── */
function useParallax(strength = 0.06, scale = 1) {
  const ref = useRefL(null);
  useEffectL(() => {
    if (!MOTION) return;
    const el = ref.current; if (!el) return;
    let raf = 0;
    const update = () => {
      raf = 0;
      const r = el.getBoundingClientRect();
      const off = ((r.top + r.height / 2) - (window.innerHeight || 1) / 2) * -strength;
      el.style.transform = `translate3d(0, ${off.toFixed(1)}px, 0)` + (scale !== 1 ? ` scale(${scale})` : '');
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    // the image box can be a pre-decode placeholder at mount, so recompute the
    // offset once it has actually loaded (otherwise the transform is frozen on
    // the wrong box until the first scroll — the "jump on refresh")
    if (!el.complete) el.addEventListener('load', update, { once: true });
    return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); el.removeEventListener('load', update); if (raf) cancelAnimationFrame(raf); };
  }, [strength, scale]);
  return ref;
}
function ParallaxImg({ src, strength = 0.05, scale = 1.16, alt = '' }) {
  // eager + async-decode: these are key section photos, and lazy-loading made
  // them pop in after paint on a refresh-while-scrolled-here
  return <img ref={useParallax(strength, scale)} src={src} alt={alt} decoding="async" />;
}

/* ── Data ─────────────────────────────────────────────────────────────── */
const SPACE = ['s1','s2','s3','s4','s5','s6','s7','s8','s9','s10','s11','s12','s13'].map((n) => `/assets/landing/space/${n}.jpg`);
const PARTY = ['1','2','3','4','5','6','7','8'].map((n) => `/assets/landing/party/${n}.jpg`);

/* Portfolio collage for the "Then the room gets bigger" pan — built on the
   landonorris.com gallery grammar (studied 06/07): SPARSE tiles with lots of
   empty space between, big vertical scatter across the viewport, tiny mono
   captions above each photo, a couple of desaturated shots for rhythm, and a
   pull-quote interleaved in the flow.
   w = tile size, v = vertical drop (0 top → 3 low), g = gap before the tile
   (s/m/l), mono = duotone treatment. Captions are placeholders ("Live",
   "Press", "The room") until Morgan supplies real venue/year strings. */
/* ⚠️ FINAL — do not reshuffle (Morgan: keep them set). photos/p1-p8 are
   byte-dupes of party/1-8; never use the photos/ folder here. 10 photos:
   5 Enlight club + press/pr9 (white-wall portrait) + 4 distinct room shots,
   none of which is room4 (the "It starts at home" hero). */
const COLLAGE = [
  { src: '/assets/landing/party/1.jpg',   w: 'sm', v: 0, g: null, cap: 'Enlight, 2024' },
  { src: '/assets/landing/press/pr9.jpg', w: 'xl', v: 1, g: 'm',  cap: 'Portrait, 2024' },
  { src: '/assets/landing/space/s4.jpg',  w: 'md', v: 3, g: 'l',  cap: 'The room' },
  { quote: 'Electronic music doesn’t have to be attention-grabbing to be powerful.', v: 1, g: 'l' },
  { src: '/assets/landing/party/8.jpg',   w: 'lg', v: 0, g: 'l',  cap: 'Enlight, 2024' },
  { src: '/assets/landing/space/s12.jpg', w: 'sm', v: 3, g: 's',  cap: 'The room' },
  { src: '/assets/landing/party/6.jpg',   w: 'md', v: 1, g: 'm',  cap: 'Enlight, 2024' },
  { src: '/assets/landing/space/s8.jpg',  w: 'md', v: 2, g: 'l',  cap: 'The room' },
  { src: '/assets/landing/party/3.jpg',   w: 'lg', v: 0, g: 'm',  cap: 'Enlight, 2024', mono: true },
  { src: '/assets/landing/party/5.jpg',   w: 'xl', v: 1, g: 'm',  cap: 'Enlight, 2024' },
];

/* All channel sets, newest first (titles verified via YouTube oEmbed, S66).
   YT_SETS[0] is the latest set and gets the featured slot. The list is also
   refreshed live from /api/sets (channel RSS via a CF Function) — new uploads
   take the featured slot automatically; this static list is the fallback. */
function cleanSet(id, rawTitle) {
  let title = String(rawTitle || '').split('|')[0].trim();
  let ep = null;
  const m = title.match(/[-–]\s*(?:ep|no)\.?\s*(\d+)\s*$/i);
  if (m) { ep = parseInt(m[1], 10); title = title.slice(0, m.index).trim(); }
  return { id, ep, title };
}
const YT_SETS = [
  { id: 'MfQs0ID3gno', ep: 55, title: "Progressive + Melodic House Vinyl Mix" },
  { id: 'GsucddkiVUA', ep: 54, title: "Deep Melodic House Mix on Vinyl" },
  { id: '5bs9jPQWdyU', ep: 53, title: "These are my favourite Melodic House & Techno tracks (Vinyl Mix)" },
  { id: 'diU_tntn_Ik', ep: 52, title: "Golden Hour Grooves - Progressive House Mix on Vinyl" },
  { id: 'pwvy7RkWDYE', ep: 51, title: "This is a Melodic Deep House Mix on Vinyl" },
  { id: 'rexNczkNQF4', ep: 50, title: "Best of Afterlife - Vinyl Only Melodic Techno Mix" },
  { id: 'ytCJ9kpZN_k', ep: 49, title: "If My Living Room Had a Bouncer, This is What I’d Play (Vinyl Mix)" },
  { id: 'DgJKPE1qC_8', ep: 48, title: "Deep Progressive House Vinyl Mix on a Sunny Spring morning" },
  { id: 'nL5ZrcRDFrk', ep: 47, title: "Melodic Breaks with 90s Soul (Vinyl Mix)" },
  { id: 'JOausRjk20g', ep: 46, title: "1 hour vinyl mix of pure Lane 8, Sultan + Shepard, Ocula, Le Youth, PRAANA, Datskie" },
  { id: 'lMjAlvvucfA', ep: 45, title: "Melodic Deep House and Techno Vinyl Mix" },
  { id: '0k-b47KKW-M', ep: null, title: "These are My Top 20 Melodic House & Techno Tracks of 2024" },
  { id: 'bz4usvA-Nlw', ep: null, title: "I played a 2 hour melodic house mix with Datskie in my living room" },
  { id: 'FqSC6-34iQs', ep: 43, title: "Organic House DJ Mix on Vinyl" },
  { id: '8Ajpxk_j6v8', ep: null, title: "This is how I mix my favourite melodic house and techno tracks on vinyl only" },
  { id: '6dVeZ0YhafY', ep: null, title: "playing my favourite melodic house tunes on a stormy afternoon" },
  { id: 'SfrOGG6J_Po', ep: 40, title: "melodic tunes to listen to while you work" },
  { id: 'YJHe04lZhPg', ep: 39, title: "Afro / Organic House Mix on Vinyl" },
  { id: 'PkHt804g7Pk', ep: 38, title: "Melodic Techno Mix on Vinyl" },
  { id: 'RWW7Pbuhh9c', ep: 37, title: "Melodic House Mix on Vinyl (with friends)" },
  { id: 'InllqHN53b8', ep: null, title: "Sunset Melodic House Mix on a Ferry [Ben Böhmer vs Lane 8]" },
  { id: 'LJk2ayIU9E8', ep: 36, title: "Organic House Mix on Vinyl" },
  { id: '4MPt6IM78So', ep: 35, title: "Deep Progressive House Mix on Vinyl" },
  { id: 'XZduegMvGNk', ep: null, title: "Melodic House and Techno dj mix, live @ Het Sieraad Amsterdam" },
  { id: 'hEmMsVQeqC8', ep: 34, title: "Sultan + Shepard Melodic House Vinyl Mix" },
  { id: 'Ds7Bm664h0E', ep: 33, title: "Organic House and Techno Vinyl Mix" },
  { id: 'iuIT8wYrkv4', ep: 32, title: "Melodic House and Techno Vinyl Mix" },
  { id: 'RcG9WrecI0Q', ep: 31, title: "Melodic Techno Vinyl Mix" },
  { id: 'ZrDtkPNWwo4', ep: 30, title: "Melodic Deep House Vinyl Mix" },
];
const ytUrl = (id) => `https://youtu.be/${id}`;
const ytThumb = (id) => `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`;
const ytThumbFallback = (e, id) => { e.currentTarget.src = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; };
const catStamp = (ep) => `PER ${String(ep).padStart(3, '0')}`;

const HERO_SOCIALS = [
  { k: 'yt',      url: 'https://www.youtube.com/@peripheralvinyl',          platform: 'youtube',    label: 'YouTube' },
  { k: 'ig',      url: 'https://www.instagram.com/peripheral.vinyl',        platform: 'instagram',  label: 'Instagram' },
  { k: 'sc',      url: 'https://soundcloud.com/peripheralvinyl',            platform: 'soundcloud', label: 'SoundCloud' },
  { k: 'spotify', url: 'https://open.spotify.com/user/31zygcqtusf4yur7vtayweoyvtzu', platform: 'spotify', label: 'Spotify' },
  { k: 'tt',      url: 'https://www.tiktok.com/@peripheralvinyl',           platform: 'tiktok',     label: 'TikTok' },
  { k: 'discord', url: 'https://discord.gg/qwEG4WAvAq',                     platform: null,         label: 'Discord' },
];
/* Footer row: TikTok out, Patreon in (Morgan) */
const FOOT_SOCIALS = [
  ...HERO_SOCIALS.filter((s) => s.k !== 'tt' && s.k !== 'discord'),
  { k: 'patreon', url: 'https://www.patreon.com/peripheralvinyl/membership', platform: null, label: 'Patreon' },
  ...HERO_SOCIALS.filter((s) => s.k === 'discord'),
];
const LINK_GROUPS = [
  { title: 'Listen', links: [
    { label: 'Latest set', sub: 'YouTube', url: 'https://youtu.be/MfQs0ID3gno', icon: 'yt', platform: 'youtube' },
    { label: 'Vinyl Mixes', sub: 'YouTube', url: 'https://www.youtube.com/@peripheralvinyl', icon: 'yt', platform: 'youtube' },
    { label: 'More Vinyl Mixes', sub: 'SoundCloud', url: 'https://soundcloud.com/peripheralvinyl', icon: 'sc', platform: 'soundcloud' },
    { label: 'Listen on Spotify', sub: 'Spotify', url: 'https://open.spotify.com/user/31zygcqtusf4yur7vtayweoyvtzu', icon: 'spotify', platform: 'spotify' },
  ]},
  { title: 'Follow', links: [
    { label: 'YouTube', sub: '@peripheralvinyl', url: 'https://www.youtube.com/@peripheralvinyl', icon: 'yt', platform: 'youtube' },
    { label: 'Instagram', sub: '@peripheral.vinyl', url: 'https://www.instagram.com/peripheral.vinyl', icon: 'ig', platform: 'instagram' },
    { label: 'TikTok', sub: '@peripheralvinyl', url: 'https://www.tiktok.com/@peripheralvinyl', icon: 'tt', platform: 'tiktok' },
    { label: 'Discord', sub: 'Join the community', url: 'https://discord.gg/qwEG4WAvAq', icon: 'discord', platform: null },
  ]},
  { title: 'Support', links: [
    { label: 'Patreon', sub: 'Free to join', url: 'https://www.patreon.com/peripheralvinyl/membership', icon: 'patreon', platform: null },
    { label: 'Buy Me a Coffee', sub: 'One-off support', url: 'https://buymeacoffee.com/peripheralvinyl', icon: 'coffee', platform: null },
    { label: 'Bandcamp Gift Card', sub: 'Bandcamp', url: 'https://bandcamp.com/gift_cards', icon: 'bandcamp', platform: null },
    { label: 'PayPal Donation', sub: 'PayPal', url: 'https://www.paypal.com/donate/?hosted_button_id=52APFG2J96AM4', icon: 'paypal', platform: null },
  ]},
  { title: 'Contact', links: [
    { label: 'Bookings & enquiries', sub: 'booking@peripheralvinyl.com', url: 'mailto:booking@peripheralvinyl.com', icon: 'mail', platform: null },
    { label: 'Collaborations', sub: 'collab@peripheralvinyl.com', url: 'mailto:collab@peripheralvinyl.com', icon: 'mail', platform: null },
    { label: 'Send me your promos', sub: 'Demo submissions', url: 'https://forms.gle/kjB9zxitifTvqts38', icon: 'note', platform: null },
  ]},
];
const MENU_SECTIONS = [
  ['hero', 'Home'], ['room', 'The room'], ['about', 'About'], ['sets', 'Sets'],
  ['out', 'Gallery'], ['shop', 'Merch'], ['support', 'Community'],
];

/* ── Hero (pinned video — visuals unchanged; the track is longer so the
      statement can rise over the darkened end. The scene completes at
      q = p*1.6 so the zoom/veil finish before the statement arrives.) ──── */
function PHero() {
  const trackRef = useRefL(null), vidRef = useRefL(null), ovRef = useRefL(null), veRef = useRefL(null);
  const edgeLRef = useRefL(null), edgeRRef = useRefL(null), inkRef = useRefL(null);
  const vidARef = useRefL(null), vidBRef = useRefL(null);
  useCrossfadeLoop(vidARef, vidBRef);
  // invisible ink-reveal (S67): an unseen soft zone, a touch bigger than the
  // record cursor, follows the pointer; the letters it sits over turn ink.
  // SINGLE text layer via background-clip:text (an ink clone stacked on the
  // cream original left a pale anti-aliasing halo around the dark glyphs) —
  // JS just moves the radial gradient painted through the letterforms.
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    if (!(window.CSS && CSS.supports && (CSS.supports('-webkit-background-clip', 'text') || CSS.supports('background-clip', 'text')))) return;
    const word = inkRef.current; if (!word) return;
    const stage = word.closest('.lp-hero-stage'); if (!stage) return;
    word.classList.add('lp-wordmark-clip'); // transparent color + clip:text
    // Ink TRAIL (S69): a persistent head blob at the cursor + a fading string of
    // ink drops left along the recent path, so quick flicks smear a tail through
    // the letters and it settles back to a single blob when the pointer rests.
    const HEAD_R = 44;      // resting head size (~ the record cursor + a touch)
    const LIFE = 300;       // ms a trail drop lives before it fades out
    const MAXPTS = 16;      // cap on live drops
    const GAP = 6;          // min px between drops (denser on slow moves)
    let tx = 0, ty = 0, hx = 0, hy = 0, hr = 0, phx = 0, phy = 0, seeded = false, hovering = false, raf = 0;
    const pts = [];         // trail drops, newest first: {x, y, t}
    const blob = (x, y, r, inkPct) =>
      `radial-gradient(circle ${r.toFixed(0)}px at ${x.toFixed(0)}px ${y.toFixed(0)}px, #0b0a08 0%, #0b0a08 ${inkPct.toFixed(0)}%, transparent 100%)`;
    const paint = (now) => {
      const layers = [];
      // trail first (painted UNDER the head): older + smaller + softer
      for (const p of pts) {
        const a = 1 - (now - p.t) / LIFE; // 1 fresh -> 0 gone
        if (a <= 0) continue;
        const r = HEAD_R * (0.35 + 0.5 * a); // tapers as it ages
        if (r < 3) continue;
        layers.unshift(blob(p.x, p.y, r, 30 + 25 * a));
      }
      if (hr > 1) layers.push(blob(hx, hy, hr, 55)); // solid head on top
      layers.push('linear-gradient(#ece4d2, #ece4d2)'); // cream base
      word.style.backgroundImage = layers.length > 1 ? layers.join(',') : '';
    };
    const tick = () => {
      raf = 0;
      const now = performance.now();
      hx += (tx - hx) * 0.28; hy += (ty - hy) * 0.28; // head tracks close
      hr += ((hovering ? HEAD_R : 0) - hr) * 0.18;    // grow in / shrink out
      const moved = Math.hypot(tx - hx, ty - hy);
      const headStep = Math.hypot(hx - phx, hy - phy); // how far the head moved this frame
      if (hovering) {                                  // drop ink only while the head travels
        const last = pts[0];
        const far = last ? Math.hypot(hx - last.x, hy - last.y) > GAP : headStep > 0.5;
        if (far) {
          pts.unshift({ x: hx, y: hy, t: now });
          if (pts.length > MAXPTS) pts.pop();
        }
      }
      phx = hx; phy = hy;
      while (pts.length && now - pts[pts.length - 1].t > LIFE) pts.pop(); // cull dead
      paint(now);
      // keep going only while moving, a trail is still fading, or the head is
      // still growing in / shrinking out — otherwise rest with the head painted
      const settling = Math.abs((hovering ? HEAD_R : 0) - hr) > 0.5;
      if (moved > 0.4 || pts.length || settling) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      const b = word.getBoundingClientRect();
      tx = e.clientX - b.left; ty = e.clientY - b.top;
      if (!seeded) { hx = phx = tx; hy = phy = ty; seeded = true; } // no fly-in from 0,0
      hovering = true;
      if (!raf) raf = requestAnimationFrame(tick);
    };
    const onLeave = () => { hovering = false; if (!raf) raf = requestAnimationFrame(tick); };
    stage.addEventListener('pointermove', onMove, { passive: true });
    stage.addEventListener('pointerleave', onLeave);
    return () => {
      stage.removeEventListener('pointermove', onMove);
      stage.removeEventListener('pointerleave', onLeave);
      if (raf) cancelAnimationFrame(raf);
      word.classList.remove('lp-wordmark-clip');
      word.style.backgroundImage = '';
    };
  }, []);
  useScrollProgress(trackRef, (p) => {
    const q = Math.min(1, p * 1.6);
    const s = 1 + q * 0.18;
    if (vidRef.current) vidRef.current.style.transform = `scale(${s.toFixed(3)})`;
    if (veRef.current)  veRef.current.style.opacity = (0.5 + q * 0.5).toFixed(3);
    if (ovRef.current)  { ovRef.current.style.opacity = Math.max(0, 1 - q * 1.5).toFixed(3); ovRef.current.style.transform = `translateY(${(q * -50).toFixed(1)}px) scale(${(1 - q * 0.05).toFixed(3)})`; }
    // edge bars track the video's centre-scale so they slide out with the zoom instead of clipping it
    const shift = ((window.innerWidth || 0) / 2) * (s - 1);
    if (edgeLRef.current) edgeLRef.current.style.transform = `translateX(${(-shift).toFixed(1)}px) scaleX(${s.toFixed(3)})`;
    if (edgeRRef.current) edgeRRef.current.style.transform = `translateX(${shift.toFixed(1)}px) scaleX(${s.toFixed(3)})`;
  }, { enabled: DESKTOP });
  return (
    <div className="lp-hero-track" id="hero" ref={trackRef}>
      <header className="lp-hero-stage">
        <div className="lp-hero-media" aria-hidden="true">
          {/* two copies so the loop can crossfade into itself; the wrapper
              carries the scroll zoom so both stay in sync */}
          <div className="lp-hero-vidwrap" ref={vidRef}>
            <video ref={vidARef} className="lp-hero-video" src="/assets/landing/hero-space.mp4"
                   poster="/assets/landing/hero-space-poster.jpg" muted loop playsInline
                   autoPlay={MOTION} preload="metadata" />
            <video ref={vidBRef} className="lp-hero-video lp-vid-b" src="/assets/landing/hero-space.mp4"
                   muted playsInline preload="auto" />
          </div>
          <div className="lp-hero-veil" ref={veRef} />
        </div>
        <div className="lp-hero-edge l" aria-hidden="true" ref={edgeLRef} />
        <div className="lp-hero-edge r" aria-hidden="true" ref={edgeRRef} />
        <div className="lp-hero-overlay" ref={ovRef}>
          <h1 className="lp-wordmark" ref={inkRef}>PERIPHERAL</h1>
        </div>
      </header>
    </div>
  );
}

/* ── Statement — rises over the darkened hero. Copy = line one of the
      channel description. Blur→focus enacts the name. ─────────────────── */
function PStatement() {
  return (
    <section className="lp-statement" id="statement">
      <div className="lp-statement-glow" aria-hidden="true" />
      <Reveal className="lp-statement-inner">
        <h2 className="lp-statement-copy">
          <span className="lp-statement-line">Vinyl-only melodic mixes that unfold slowly,</span>
          <span className="lp-statement-line">carried by the warmth of analogue sound.</span>
        </h2>
      </Reveal>
    </section>
  );
}

/* ── The room ─────────────────────────────────────────────────────────── */
function PRoom() {
  const collageRef = useRefL(null);
  // magnetic room photos (Morgan): each image eases toward the cursor within
  // its frame while hovered, springs back on leave. Set on a .lp-mag span so
  // it never fights the reveal (outer) / parallax (img) / rotate transforms.
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const wrap = collageRef.current; if (!wrap) return;
    const mags = Array.from(wrap.querySelectorAll('.lp-mag'));
    const st = mags.map(() => ({ x: 0, y: 0, tx: 0, ty: 0 }));
    let raf = 0;
    const tick = () => {
      raf = 0; let moving = false;
      mags.forEach((m, i) => {
        const c = st[i];
        c.x += (c.tx - c.x) * 0.09; c.y += (c.ty - c.y) * 0.09;
        if (Math.abs(c.tx - c.x) > 0.2 || Math.abs(c.ty - c.y) > 0.2) moving = true;
        m.style.setProperty('--mx', c.x.toFixed(1) + 'px');
        m.style.setProperty('--my', c.y.toFixed(1) + 'px');
      });
      if (moving) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      mags.forEach((m, i) => {
        const r = m.getBoundingClientRect();
        const dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
        const inside = Math.abs(dx) < r.width / 2 + 60 && Math.abs(dy) < r.height / 2 + 60;
        st[i].tx = inside ? dx * 0.07 : 0;
        st[i].ty = inside ? dy * 0.07 : 0;
      });
      if (!raf) raf = requestAnimationFrame(tick);
    };
    const onLeave = () => { st.forEach((c) => { c.tx = 0; c.ty = 0; }); if (!raf) raf = requestAnimationFrame(tick); };
    window.addEventListener('pointermove', onMove, { passive: true });
    wrap.addEventListener('pointerleave', onLeave);
    return () => { window.removeEventListener('pointermove', onMove); wrap.removeEventListener('pointerleave', onLeave); if (raf) cancelAnimationFrame(raf); };
  }, []);
  return (
    <section className="lp-room" id="room">
      <div className="lp-room-copy">
        <Reveal><H2L>It starts at home.</H2L></Reveal>
        <Reveal delay={70}><p className="lp-lead">Every set begins in one room: a pair of turntables, a growing wall of records, and not much else.</p></Reveal>
        <Reveal delay={140}><p>This is the space the whole project is built around: warm light, plants, and the quiet clutter of a room that is actually lived in.</p></Reveal>
        <Reveal delay={200}><p>Nothing here is staged for a studio. It is calm, intimate and analogue by design: a place made for sitting with a record rather than scrolling past it.</p></Reveal>
        <Reveal delay={260}><p>It is where the digging, the mixing, and the sets all come together: the sound comes straight from the room you are looking at.</p></Reveal>
        <Reveal delay={380} className="lp-cta-gap">
          <a href="https://www.instagram.com/peripheral.vinyl" target="_blank" rel="noreferrer" className="lp-pill"
             onClick={(e) => openSmart('instagram', 'https://www.instagram.com/peripheral.vinyl', e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
            {Icon.ig({ width: 15, height: 15 })} Follow on Instagram
          </a>
        </Reveal>
      </div>
      <div className="lp-room-collage" ref={collageRef}>
        {/* big portrait + small square inset; inset swapped from the decks shot
            to a home shot (s11) — swap the inset filename to change it */}
        <Reveal className="lp-room-a"><span className="lp-mag"><ParallaxImg src="/assets/landing/space/room4.jpg" strength={0.03} scale={1.08} alt="The Peripheral listening room, Morgan reading on the sofa" /></span></Reveal>
        <Reveal className="lp-room-b" delay={140}>
          <div className="lp-room-b-in"><span className="lp-mag"><ParallaxImg src="/assets/landing/space/s11.jpg" strength={0.05} scale={1.12} alt="Morgan's listening room at home" /></span></div>
        </Reveal>
      </div>
    </section>
  );
}

/* ── Pick a record — latest set featured, then every set as a 16:9 card in
      a horizontal scroller (wheel-over pans, drag, native touch). Cards
      link straight to YouTube; the vinyl still slips out on hover. ──────── */
function PSets() {
  const rowRef = useRefL(null), draggedRef = useRefL(false);
  const [sets, setSets] = useStateL(YT_SETS);
  // live refresh from the channel RSS (via /api/sets, CF Function). Fails
  // silently everywhere it can't run (local dev, function missing) — the
  // static list is the fallback.
  useEffectL(() => {
    let dead = false;
    fetch('/api/sets')
      .then((r) => (r.ok ? r.json() : null))
      .then((data) => {
        if (dead || !data || !Array.isArray(data.videos) || data.videos.length === 0) return;
        // second line of defence: full sets only, never Shorts/misc uploads
        const fresh = data.videos
          .filter((v) => /mix|vinyl|dj set/i.test(v.title || '') && !/#shorts?/i.test(v.title || ''))
          .map((v) => cleanSet(v.id, v.title));
        if (fresh.length === 0) return;
        const known = new Set(fresh.map((v) => v.id));
        setSets([...fresh, ...YT_SETS.filter((s) => !known.has(s.id))]);
      })
      .catch(() => {});
    return () => { dead = true; };
  }, []);
  // NOTE: no wheel handler here — vertical scrolling over the row must keep
  // scrolling the PAGE (Morgan). Horizontal trackpad panning is native.
  // drag-to-scroll with the mouse; suppress the click that ends a drag
  useEffectL(() => {
    const el = rowRef.current; if (!el) return;
    let down = false, sx = 0, sl = 0;
    const md = (e) => { if (e.pointerType !== 'mouse') return; down = true; draggedRef.current = false; sx = e.clientX; sl = el.scrollLeft; };
    const mm = (e) => { if (!down) return; if (Math.abs(e.clientX - sx) > 5) { draggedRef.current = true; el.classList.add('drag'); } el.scrollLeft = sl - (e.clientX - sx); };
    const up = () => { down = false; el.classList.remove('drag'); };
    el.addEventListener('pointerdown', md);
    window.addEventListener('pointermove', mm);
    window.addEventListener('pointerup', up);
    return () => { el.removeEventListener('pointerdown', md); window.removeEventListener('pointermove', mm); window.removeEventListener('pointerup', up); };
  }, []);
  const cardClick = (e, s) => {
    if (draggedRef.current) { e.preventDefault(); return; }
    openSmart('youtube', ytUrl(s.id), e);
  };
  const latest = sets[0];
  const rest = sets.slice(1);
  return (
    <section className="lp-sets" id="sets">
      <div className="lp-sets-wrap">
        <Reveal><H2L>The latest set.</H2L></Reveal>
        <Reveal delay={80}>
          <a className="lp-latest" href={ytUrl(latest.id)} target="_blank" rel="noreferrer"
             onClick={(e) => openSmart('youtube', ytUrl(latest.id), e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
            <span className="lp-latest-art">
              <img src={ytThumb(latest.id)} alt="" loading="lazy" onError={(e) => ytThumbFallback(e, latest.id)} />
              <span className="lp-card-shade" aria-hidden="true" />
              {latest.ep && <span className="lp-card-cat">{catStamp(latest.ep)}</span>}
            </span>
            <span className="lp-latest-meta">
              <span className="lp-latest-title">{latest.title}</span>
              {latest.ep && <span className="lp-latest-ep">Episode {latest.ep}</span>}
              <span className="lp-btn lp-latest-btn">Watch on YouTube {Icon.arrow({ width: 16, height: 16, className: 'lp-btn-arrow' })}</span>
            </span>
          </a>
        </Reveal>
        <Reveal delay={60}>
          <div className="lp-row-head">
            <h3 className="lp-h3">Pick a record.</h3>
            <div className="lp-row-links">
              <a href="https://www.youtube.com/@peripheralvinyl" target="_blank" rel="noreferrer" className="lp-pill"
                 onClick={(e) => openSmart('youtube', 'https://www.youtube.com/@peripheralvinyl', e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
                {Icon.yt({ width: 14, height: 14 })} All sets
              </a>
              <a href="https://soundcloud.com/peripheralvinyl" target="_blank" rel="noreferrer" className="lp-pill"
                 onClick={(e) => openSmart('soundcloud', 'https://soundcloud.com/peripheralvinyl', e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
                {Icon.sc({ width: 14, height: 14 })} SoundCloud
              </a>
              <a href="https://open.spotify.com/show/3Ia7Hu25CLcYrsCjzgvQR7" target="_blank" rel="noreferrer" className="lp-pill"
                 onClick={(e) => openSmart('spotify', 'https://open.spotify.com/show/3Ia7Hu25CLcYrsCjzgvQR7', e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
                {Icon.spotify({ width: 14, height: 14 })} Spotify
              </a>
            </div>
          </div>
        </Reveal>
      </div>
      <Reveal delay={140}>
        <div className="lp-crate" ref={rowRef}>
          {rest.map((s) => (
            <a key={s.id} className="lp-card" href={ytUrl(s.id)} target="_blank" rel="noreferrer"
               onClick={(e) => cardClick(e, s)} onMouseEnter={lpHover} onMouseLeave={lpOut}
               aria-label={`Watch ${s.title} on YouTube`}>
              <span className="lp-disc" aria-hidden="true" />
              <span className="lp-card-art">
                <img src={ytThumb(s.id)} alt="" loading="lazy" draggable="false" onError={(e) => ytThumbFallback(e, s.id)} />
                <span className="lp-card-shade" aria-hidden="true" />
                {s.ep && <span className="lp-card-cat">{catStamp(s.ep)}</span>}
                <span className="lp-card-playhint">
                  <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg> Watch
                </span>
              </span>
              <span className="lp-card-meta">
                <span className="lp-card-title">{s.title}</span>
                {s.ep && <span className="lp-card-ep">EP. {s.ep}</span>}
              </span>
            </a>
          ))}
        </div>
      </Reveal>
    </section>
  );
}

/* ── Out in the dark — pinned: the strip fills the screen, pans through all
      photos on vertical scroll, then releases. Mobile/reduced: native swipe. */
function POut() {
  const trackRef = useRefL(null), rowRef = useRefL(null), secRef = useRefL(null);
  const panRef = useRefL({ cur: 0, target: 0, raf: 0 });
  useScrollProgress(trackRef, (p) => {
    const s = panRef.current;
    if (rowRef.current) {
      const dist = rowRef.current.scrollWidth - (window.innerWidth || 0);
      s.target = -(p * Math.max(0, dist));
    }
    // background journey (Morgan): the section ENTERS near-video-dark (so the
    // edge fades onto the video instead of a hard brown cut), warms to brown
    // once it has covered the screen, then fades to black across the pan
    if (secRef.current) {
      const dark = [20, 16, 13], brown = [46, 22, 14], black = [10, 9, 8];
      const lerp3 = (a, b, t) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)}, ${Math.round(a[1] + (b[1] - a[1]) * t)}, ${Math.round(a[2] + (b[2] - a[2]) * t)})`;
      const c = p < 0.18 ? lerp3(dark, brown, p / 0.18) : lerp3(brown, black, Math.min(1, (p - 0.18) / 0.72));
      secRef.current.style.setProperty('--out-bg', c);
    }
    // heavy ease toward the target (the pan resists rushing — Lando feel) and
    // a tiny velocity-driven skew so the tiles lean into the motion and settle
    // back: inertia/flow rather than a rigid slab
    if (!s.raf) {
      const step = () => {
        s.raf = 0;
        const prev = s.cur;
        s.cur += (s.target - s.cur) * 0.06;
        const vel = s.cur - prev;
        const skew = Math.max(-3.5, Math.min(3.5, vel * 0.05));
        if (rowRef.current) rowRef.current.style.transform = `translate3d(${s.cur.toFixed(1)}px,0,0) skewX(${skew.toFixed(2)}deg)`;
        if (Math.abs(s.target - s.cur) > 0.4) s.raf = requestAnimationFrame(step);
        else if (rowRef.current) rowRef.current.style.transform = `translate3d(${s.cur.toFixed(1)}px,0,0)`;
      };
      s.raf = requestAnimationFrame(step);
    }
  }, { enabled: DESKTOP });
  // drag-to-scroll with a mouse — only relevant when the strip is a native
  // scroller (mobile/reduced-motion layout); harmless in the pinned scene
  useEffectL(() => {
    const el = rowRef.current; if (!el) return;
    let down = false, sx = 0, sl = 0;
    const md = (e) => { if (e.pointerType !== 'mouse') return; down = true; sx = e.clientX; sl = el.scrollLeft; };
    const mm = (e) => { if (!down) return; if (Math.abs(e.clientX - sx) > 4) el.classList.add('drag'); el.scrollLeft = sl - (e.clientX - sx); };
    const up = () => { down = false; el.classList.remove('drag'); };
    el.addEventListener('pointerdown', md);
    window.addEventListener('pointermove', mm);
    window.addEventListener('pointerup', up);
    return () => { el.removeEventListener('pointerdown', md); window.removeEventListener('pointermove', mm); window.removeEventListener('pointerup', up); };
  }, []);
  // magnetic tiles (desktop): each tile eases away from the cursor with a
  // soft falloff and springs back — sets --mx/--my, composed with the CSS
  // stagger var so the pan/layout transforms are untouched
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const row = rowRef.current; if (!row) return;
    const tiles = Array.from(row.querySelectorAll('.lp-out-card'));
    const st = tiles.map(() => ({ x: 0, y: 0, tx: 0, ty: 0 }));
    let raf = 0;
    const R = 460, MAX = 9; // wide falloff, small drift (was 340/16: too fast/jumpy)
    const tick = () => {
      raf = 0;
      let moving = false;
      tiles.forEach((t, i) => {
        const c = st[i];
        c.x += (c.tx - c.x) * 0.055;
        c.y += (c.ty - c.y) * 0.055;
        if (Math.abs(c.tx - c.x) > 0.15 || Math.abs(c.ty - c.y) > 0.15) moving = true;
        t.style.setProperty('--mx', c.x.toFixed(1) + 'px');
        t.style.setProperty('--my', c.y.toFixed(1) + 'px');
      });
      if (moving) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      tiles.forEach((t, i) => {
        const r = t.getBoundingClientRect();
        const dx = (r.left + r.width / 2) - e.clientX;
        const dy = (r.top + r.height / 2) - e.clientY;
        const d = Math.hypot(dx, dy) || 1;
        const f = Math.max(0, 1 - d / R);
        const s = f * f * MAX;
        st[i].tx = (dx / d) * s;
        st[i].ty = (dy / d) * s;
      });
      if (!raf) raf = requestAnimationFrame(tick);
    };
    const onLeave = () => { st.forEach((c) => { c.tx = 0; c.ty = 0; }); if (!raf) raf = requestAnimationFrame(tick); };
    row.addEventListener('pointermove', onMove, { passive: true });
    row.addEventListener('pointerleave', onLeave);
    return () => { row.removeEventListener('pointermove', onMove); row.removeEventListener('pointerleave', onLeave); if (raf) cancelAnimationFrame(raf); };
  }, []);
  return (
    <section className="lp-out" id="out" ref={secRef}>
      <div className="lp-out-track" ref={trackRef}>
        <div className="lp-out-stage">
          <div className="lp-outrow lp-collage" ref={rowRef}>
            {COLLAGE.map((t, i) => t.quote ? (
              <div className={`lp-out-quote lp-v${t.v} ${t.g ? `lp-g-${t.g}` : ''}`} key={i}>{t.quote}</div>
            ) : (
              <figure className={`lp-out-card lp-t-${t.w} lp-v${t.v} ${t.g ? `lp-g-${t.g}` : ''} ${t.mono ? 'lp-mono' : ''}`} key={i}>
                {t.cap && <figcaption className="lp-out-cap">{t.cap}</figcaption>}
                <span className="lp-out-imgw"><img src={t.src} alt="" loading="lazy" draggable="false" /></span>
              </figure>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ── Chapter stage — Muzi's fixed-background effect. The video is PLANTED
      (position:sticky, pulled up behind the About section via a negative
      margin) so it is already pinned at top:0 while About covers it. About
      then slides up to REVEAL it, and the sets section scrolls up OVER it —
      the video itself never translates while visible. (position:fixed would
      be ideal but a transformed ancestor rules it out, so sticky it is.)
      Letterboxed (contain) so the baked black frame is never cropped; a
      constant scrim keeps the cream text legible. Lives inside .lp-scene
      between PAbout and PSets. Mobile / reduced-motion: static band. ─────── */
function PChapter({ img, video, poster }) {
  const aRef = useRefL(null), bRef = useRefL(null);
  useCrossfadeLoop(aRef, bRef);
  return (
    <div className="lp-chapter-stage" aria-hidden="true">
      {video ? (
        <>
          <video ref={aRef} className="lp-chapter-media" src={video} poster={poster} muted loop playsInline
                 autoPlay={MOTION} preload="metadata" />
          <video ref={bRef} className="lp-chapter-media lp-vid-b" src={video} muted playsInline preload="auto" />
        </>
      ) : (
        <img className="lp-chapter-media" src={img} alt="" loading="lazy" />
      )}
      <div className="lp-chapter-veil" />
    </div>
  );
}

/* ── About — Morgan, in the warm lamp light ───────────────────────────── */
function PAbout() {
  return (
    <section className="lp-about" id="about">
      <div className="lp-about-bg" aria-hidden="true"><img src={SPACE[2]} alt="" decoding="async" /></div>
      <div className="lp-about-veil" aria-hidden="true" />
      <div className="lp-about-grid">
      <div className="lp-about-photo">
        <Reveal><ParallaxImg src={SPACE[2]} strength={0.04} scale={1.1} alt="Morgan at home in the warm lamp light" /></Reveal>
      </div>
      <div className="lp-about-copy">
        <Reveal><H2L>Hey, I&rsquo;m Morgan.</H2L></Reveal>
        <Reveal delay={70}><p className="lp-lead lp-oneline">Originally from South Africa, now based in Amsterdam.</p></Reveal>
        <Reveal delay={140}><p>Peripheral started at home, with the idea to bring back the skill of mixing on vinyl to a scene where it has long been forgotten: one room, a pair of turntables, and a small collection of records I added to where I could.</p></Reveal>
        <Reveal delay={200}><p>I fell for vinyl for the ritual of it: the digging, the sleeves, the patience of playing a record all the way through. That is what I try to bring to every set.</p></Reveal>
        <Reveal delay={260}><p>Peripheral is my take on a modern listening experience, somewhere between a DJ set, a listening bar, and a curated journey through sound: electronic music doesn&rsquo;t have to be attention-grabbing to be powerful.</p></Reveal>
        <Reveal delay={320}><p>Each set is built with intention: slower pacing, careful selection, and a focus on how tracks interact over time. In a scene saturated with digital, controller-driven sets, this brings the focus back to the art of DJing: vinyl, curation, and a deeper connection to the music.</p></Reveal>
        <Reveal delay={380} className="lp-cta-gap">
          <a href="mailto:booking@peripheralvinyl.com" className="lp-pill" onMouseEnter={lpHover} onMouseLeave={lpOut}>
            Bookings &amp; enquiries
          </a>
        </Reveal>
      </div>
      </div>
    </section>
  );
}

/* ── Wear the sound — light studio band; the model stands on the section's
      bottom edge (photo bg colour = section bg, no frame). Clicking the CTA
      ZOOMS INTO the rounded merch card (it expands from its own rect to fill
      the screen) before navigating — as if entering it. ─────────────────── */
function PMerch({ navigate }) {
  const leavingRef = useRefL(false);
  // The transition must SURVIVE this component unmounting (navigate() tears the
  // landing down mid-flight), so it's a plain DOM node, not a portal: expand a
  // panel from the card's rect → navigate underneath it → fade over the store.
  const go = (e) => {
    e.preventDefault();
    if (leavingRef.current) return;
    if (PREFERS_REDUCED) { navigate({ name: 'home' }, '/merch'); return; }
    leavingRef.current = true;
    const card = document.querySelector('.lp-merch');
    const r = card ? card.getBoundingClientRect()
                   : { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
    const host = document.createElement('div');
    host.className = 'landing-portfolio lp-shopzoom-portal';
    const zoom = document.createElement('div');
    zoom.className = 'lp-shopzoom';
    zoom.style.top = r.top + 'px'; zoom.style.left = r.left + 'px';
    zoom.style.width = r.width + 'px'; zoom.style.height = r.height + 'px';
    host.appendChild(zoom);
    document.body.appendChild(host);
    requestAnimationFrame(() => requestAnimationFrame(() => zoom.classList.add('go'))); // expand to fill
    setTimeout(() => navigate({ name: 'home' }, '/merch'), 640);
    setTimeout(() => zoom.classList.add('out'), 720);   // fade over the mounted store
    setTimeout(() => host.remove(), 1320);
  };
  return (
    <section className="lp-merch" id="shop">
      <div className="lp-merch-grid">
        <div className="lp-merch-copy">
          <Reveal><H2L>Wear the sound.</H2L></Reveal>
          <Reveal delay={80}><p><span className="lp-merch-tag">Official Peripheral merch.</span>Printed and embroidered apparel, made to order and shipped worldwide.</p></Reveal>
          <Reveal delay={160}>
            <a href="/merch" className="lp-btn" onClick={go} onMouseEnter={lpHover} onMouseLeave={lpOut}>
              Enter the shop {Icon.arrow({ width: 16, height: 16, className: 'lp-btn-arrow' })}
            </a>
          </Reveal>
        </div>
        <Reveal className="lp-merch-shot" delay={100}><img src="/assets/landing/merch-model.jpg" alt="Peripheral baggy tee" loading="eager" decoding="sync" fetchPriority="high" /></Reveal>
      </div>
    </section>
  );
}

/* ── Community — full-screen hero: the night room, lamps glowing ────────── */
function PSupport() {
  return (
    <section className="lp-support" id="support">
      <div className="lp-support-bg" aria-hidden="true"><img src={SPACE[6]} alt="" loading="lazy" /></div>
      <div className="lp-support-veil" aria-hidden="true" />
      <Reveal className="lp-support-inner">
        <H2L>Help build this into something bigger.</H2L>
        <p>If this resonates, Patreon is the closest way in: first listens, behind the scenes, and a say in what happens next. Free to join, no card required.</p>
        <div className="lp-support-cta">
          <a href="https://www.patreon.com/peripheralvinyl/membership" target="_blank" rel="noreferrer" className="lp-btn"
             onMouseEnter={lpHover} onMouseLeave={lpOut}>{Icon.patreon({ width: 16, height: 16 })} Join free on Patreon</a>
          <a href="https://buymeacoffee.com/peripheralvinyl" target="_blank" rel="noreferrer" className="lp-pill lp-coffee"
             onMouseEnter={lpHover} onMouseLeave={lpOut}>{Icon.coffee({ width: 15, height: 15 })} Buy me a coffee</a>
        </div>
      </Reveal>
    </section>
  );
}

/* ── Links pop-up — the linktree as a cream record-insert card. Opened by
      the fixed top-left link button via the lp-open-links event. ────────── */
function PLinksModal() {
  const [open, setOpen] = useStateL(false);
  useEffectL(() => {
    const onOpen = () => setOpen(true);
    window.addEventListener('lp-open-links', onOpen);
    return () => window.removeEventListener('lp-open-links', onOpen);
  }, []);
  useEffectL(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('keydown', onKey);
    document.documentElement.classList.add('lp-menu-open'); // same scroll lock as the menu
    return () => { document.removeEventListener('keydown', onKey); document.documentElement.classList.remove('lp-menu-open'); };
  }, [open]);
  return ReactDOM.createPortal(
    <div className="landing-portfolio lp-modal-portal">
      <div className={`lp-modal ${open ? 'open' : ''}`} role="dialog" aria-modal="true" aria-label="Links"
           onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}>
        <div className="lp-modal-card">
          <div className="lp-modal-head">
            <span className="lp-modal-title">Links</span>
            <button className="lp-modal-x" onClick={() => setOpen(false)} aria-label="Close"
                    onMouseEnter={lpHover} onMouseLeave={lpOut}>
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
            </button>
          </div>
          <div className="lp-modal-groups">
            {LINK_GROUPS.map((g) => (
              <div className="lp-modal-group" key={g.title}>
                <div className="lp-modal-grouptitle">{g.title}</div>
                {g.links.map((l) => (
                  <a key={l.label} href={l.url} target={l.url.startsWith('mailto:') ? undefined : '_blank'} rel="noreferrer"
                     className="lp-modal-link" onClick={(e) => openSmart(l.platform, l.url, e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
                    <span className="lp-modal-linklabel">{l.label}</span>
                    {l.icon && Icon[l.icon]
                      ? <span className="lp-modal-linkico" aria-label={l.sub}>{Icon[l.icon]({ width: 15, height: 15 })}</span>
                      : <span className="lp-modal-linksub">{l.sub}</span>}
                  </a>
                ))}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>,
    document.body
  );
}

/* ── Burger menu — appears after the hero; two thick bars → X. Also opened
      by the "All my links" button via the lp-open-menu event. ───────────── */
function PMenu({ navigate }) {
  const [open, setOpen] = useStateL(false);
  const [vis, setVis] = useStateL(false);
  const [dark, setDark] = useStateL(false);
  useEffectL(() => {
    let raf = 0;
    const compute = () => {
      raf = 0;
      setVis(window.scrollY > (window.innerHeight || 800) * 0.9);
      // flip the fixed buttons to ink while they sit over the light merch card.
      // r.left check: the card is inset past the buttons on desktop, so they
      // hang over dark background beside it and must stay cream there
      const shop = document.getElementById('shop');
      if (shop) {
        const r = shop.getBoundingClientRect();
        setDark(r.top < 44 && r.bottom > 44 && r.left < 64);
      }
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    compute();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => { window.removeEventListener('scroll', onScroll); if (raf) cancelAnimationFrame(raf); };
  }, []);
  useEffectL(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('keydown', onKey);
    document.documentElement.classList.add('lp-menu-open'); // locks body scroll + pauses smooth-scroll
    return () => { document.removeEventListener('keydown', onKey); document.documentElement.classList.remove('lp-menu-open'); };
  }, [open]);
  const jump = (id) => { setOpen(false); jumpTo(id); };
  // Portal to <body>: the app's .page-fade wrapper has a transform, which would
  // otherwise capture position:fixed. Wrapped in .landing-portfolio so --lp-* vars resolve.
  return ReactDOM.createPortal(
    <div className={`landing-portfolio lp-menu-portal ${dark && !open ? 'dark' : ''}`}>
      <button className={`lp-linkbtn ${vis ? 'vis' : ''}`} aria-label="Links"
              onClick={() => window.dispatchEvent(new CustomEvent('lp-open-links'))}
              onMouseEnter={lpHover} onMouseLeave={lpOut}>
        <svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M10 13a5 5 0 0 0 7.54.54l2.9-2.9a5 5 0 0 0-7.07-7.07l-1.66 1.65" />
          <path d="M14 11a5 5 0 0 0-7.54-.54l-2.9 2.9a5 5 0 0 0 7.07 7.07l1.65-1.65" />
        </svg>
      </button>
      <button className={`lp-burger ${open ? 'on' : ''} ${vis || open ? 'vis' : ''}`} onClick={() => setOpen((o) => !o)}
              aria-label={open ? 'Close menu' : 'Menu'} aria-expanded={open}
              onMouseEnter={lpHover} onMouseLeave={lpOut}><span /><span /></button>
      <div className={`lp-menu ${open ? 'open' : ''}`} role="dialog" aria-modal="true" aria-label="Menu"
           onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}>
        <div className="lp-menu-inner">
          <nav className="lp-menu-nav" aria-label="Sections">
            {MENU_SECTIONS.map(([id, label]) => (
              <button key={id} className="lp-menu-navlink" onClick={() => jump(id)}
                      onMouseEnter={lpHover} onMouseLeave={lpOut}>{label}</button>
            ))}
          </nav>
          <div className="lp-menu-links">
            {LINK_GROUPS.map((g) => (
              <div className="lp-menu-group" key={g.title}>
                <div className="lp-menu-grouptitle">{g.title}</div>
                {g.links.map((l) => (
                  <a key={l.label} href={l.url} target={l.url.startsWith('mailto:') ? undefined : '_blank'} rel="noreferrer"
                     className="lp-menu-link" onClick={(e) => openSmart(l.platform, l.url, e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
                    {l.icon && Icon[l.icon] && <span className="lp-menu-linkico" aria-hidden="true">{Icon[l.icon]({ width: 13, height: 13 })}</span>}
                    <span className="lp-menu-linklabel">{l.label}</span>
                  </a>
                ))}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>,
    document.body
  );
}

/* ── Footer — wordmark + socials only ─────────────────────────────────── */
function PFooter() {
  return (
    <footer className="lp-foot" id="contact">
      <div className="lp-foot-mark">PERIPHERAL</div>
      <div className="lp-foot-socials">
        {FOOT_SOCIALS.map((s) => (
          <a key={s.k} href={s.url} target="_blank" rel="noreferrer" aria-label={s.label}
             onClick={(e) => openSmart(s.platform, s.url, e)} onMouseEnter={lpHover} onMouseLeave={lpOut}>
            {Icon[s.k] ? Icon[s.k]({ width: 16, height: 16 }) : s.label}
          </a>
        ))}
      </div>
    </footer>
  );
}

/* ── Root — the whole evening, top to bottom ──────────────────────────── */
function LandingPortfolio({ navigate }) {
  useSmoothScroll();
  useLiquidButtons();
  const rootRef = useRefL(null);
  useEffectL(() => {
    document.documentElement.classList.add('lp-active');
    return () => document.documentElement.classList.remove('lp-active');
  }, []);
  // where the letterboxed 16:9 chapter video's side edges land on screen —
  // the corner-fillet caps (.lp-about / .lp-out pseudos) position off these
  useEffectL(() => {
    const el = rootRef.current; if (!el) return;
    const set = () => {
      const vw = window.innerWidth || 0, vh = window.innerHeight || 0;
      const w = Math.min(vw, vh * (16 / 9));      // displayed 16:9 video width
      const scale = w / 1920;                      // clip is authored at 1920w
      el.style.setProperty('--vid-l', ((vw - w) / 2).toFixed(1) + 'px');
      el.style.setProperty('--vid-in', (26 * scale).toFixed(1) + 'px');  // baked ~26px border (cropdetect: 24px L / 30px R)
      el.style.setProperty('--vid-cr', (16 * scale).toFixed(1) + 'px');  // fairly sharp corner
    };
    set();
    window.addEventListener('resize', set);
    return () => window.removeEventListener('resize', set);
  }, []);
  return (
    <div className="landing-portfolio" ref={rootRef}>
      <PHero />
      <PStatement />
      <PRoom />
      <div className="lp-scene">
        <PAbout />
        <PChapter video="/assets/landing/chapter-set55.mp4" poster="/assets/landing/chapter-set55-poster.jpg" />
        <PSets />
      </div>
      <POut />
      <PMerch navigate={navigate} />
      <PSupport />
      <PFooter />
      <PMenu navigate={navigate} />
      <PLinksModal />
    </div>
  );
}

window.LandingPortfolio = LandingPortfolio;
