/* ───────────────────────────────────────────────────────────────────────
   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);
      // opts.lead (in screens) starts progress BEFORE the track pins — so a pinned
      // scene can begin moving while it's still scrolling up into view
      const lead = (opts && opts.lead ? opts.lead : 0) * vh;
      onProgress(Math.min(1, Math.max(0, (lead - r.top) / (range + lead))));
    };
    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). */
/* Lando-grammar CLUSTERS (S69): each screen = one big full-colour CENTRE feature,
   a few smaller CORNER satellites (some desaturated so they recede into the bg),
   and one centred QUOTE (top or bottom). The row pans horizontally between them.
   Same locked image set as before — only party/space/press, never photos/ — just
   rearranged into clusters. (Third quote parked below for now.) */
const COLLAGE = [
  {
    quote: 'Electronic music doesn’t have to be attention-grabbing to be powerful.',
    quotePos: 'top',
    center: { src: '/assets/landing/party/5.jpg', w: 'lg' },
    corners: [
      { pos: 'tl', src: '/assets/landing/party/1.jpg',  w: 'sm' },
      { pos: 'bl', src: '/assets/landing/space/s4.jpg',  w: 'md', mono: true },
      { pos: 'tr', src: '/assets/landing/space/s12.jpg', w: 'sm', mono: true },
      { pos: 'br', src: '/assets/landing/party/6.jpg',  w: 'md' },
    ],
  },
  {
    quote: 'Sometimes the most memorable moments in music aren’t the loudest; they’re the ones you almost miss.',
    quotePos: 'bottom',
    center: { src: '/assets/landing/press/pr9.jpg', w: 'lg' },
    corners: [
      { pos: 'tl', src: '/assets/landing/space/s8.jpg', w: 'sm', mono: true },
      { pos: 'bl', src: '/assets/landing/party/3.jpg',  w: 'md' },
      { pos: 'br', src: '/assets/landing/party/8.jpg',  w: 'sm' },
    ],
  },
];
// parked: 'An invitation to slow down, listen more closely, and experience it differently.'

/* 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);
  // LIQUID REVEAL (S69): the pointer punches a hole through the cream PERIPHERAL
  // so the hero video behind shows — like the earlier reveal, but the hole's edge
  // is rippled by an SVG feTurbulence/feDisplacementMap filter, so it reads as
  // pushing THROUGH a liquid, and it's LOCALIZED to the cursor (the rest of the
  // word stays crisp and still). A short trail of holes heals shut behind the head.
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    if (!window.SVGFEDisplacementMapElement) return; // SVG filter primitive unsupported
    const word = inkRef.current; if (!word) return;
    const stage = word.closest('.lp-hero-stage'); if (!stage) return;
    const mask = document.getElementById('lp-holemask');
    const bg = document.getElementById('lp-hole-bg');
    const g = document.getElementById('lp-hole-g');
    if (!mask || !bg || !g) return;
    const disp = document.getElementById('lp-liquid-disp');
    const NS = 'http://www.w3.org/2000/svg';

    const HEAD_R = 58;     // reveal radius under the cursor
    const LIFE = 300;      // ms a hole lives before it heals shut (short tail = less "worm")
    const GAP = 18;        // px between trail holes (ripple + overlap keeps it continuous)
    const POOL = 22;       // reused circle elements (no per-frame DOM churn)
    const RBASE = 16, RMAX = 40; // ripple amplitude eased by head speed: calm -> big slosh
    // one reused pool of circles inside the ripple-filtered group
    const circles = [];
    for (let i = 0; i < POOL; i++) {
      const c = document.createElementNS(NS, 'circle');
      c.setAttribute('fill', '#000'); c.setAttribute('r', '0');
      c.setAttribute('cx', '-99'); c.setAttribute('cy', '-99');
      g.appendChild(c); circles.push(c);
    }
    // size the mask's user space to the wordmark's box (so cx/cy = px in its frame)
    const sizeMask = () => {
      const r = word.getBoundingClientRect();
      const W = Math.max(1, Math.ceil(r.width)), H = Math.max(1, Math.ceil(r.height));
      mask.setAttribute('width', W); mask.setAttribute('height', H);
      bg.setAttribute('width', W); bg.setAttribute('height', H);
    };
    sizeMask();
    word.style.webkitMaskImage = 'url(#lp-holemask)'; word.style.maskImage = 'url(#lp-holemask)';
    word.style.webkitMaskRepeat = 'no-repeat'; word.style.maskRepeat = 'no-repeat';

    let tx = 0, ty = 0, hx = 0, hy = 0, hvx = 0, hvy = 0, hrad = 0, rscale = RBASE, seeded = false, hovering = false, raf = 0;
    const pts = []; // trail holes, newest first: {x, y, t, s}
    const paint = (now) => {
      let n = 0;
      // head hole first (biggest / freshest)
      if (hovering || hrad > 1) { const c = circles[n++]; c.setAttribute('cx', hx.toFixed(1)); c.setAttribute('cy', hy.toFixed(1)); c.setAttribute('r', hrad.toFixed(1)); }
      for (const p of pts) {
        if (n >= POOL) break;
        const a = 1 - (now - p.t) / LIFE; // 1 fresh -> 0 healed
        if (a <= 0) continue;
        const c = circles[n++];
        c.setAttribute('cx', p.x.toFixed(1)); c.setAttribute('cy', p.y.toFixed(1));
        c.setAttribute('r', (HEAD_R * (0.35 + 0.55 * a) * p.s).toFixed(1)); // varied size (less uniform tube) + shrinks as it heals
      }
      for (; n < POOL; n++) circles[n].setAttribute('r', '0'); // park the rest
    };
    const tick = () => {
      raf = 0;
      const now = performance.now();
      // spring head with momentum → it coasts on after the pointer stops (inertia)
      // and follows loosely rather than sticking exactly to the cursor
      hvx = hvx * 0.84 + (tx - hx) * 0.12; hx += hvx;
      hvy = hvy * 0.84 + (ty - hy) * 0.12; hy += hvy;
      const speed = Math.hypot(hvx, hvy);
      hrad += ((hovering ? HEAD_R : 0) - hrad) * 0.2;  // grow in / heal out
      // watery edge: ripple amplitude swells with speed, settles when calm
      rscale += (Math.min(RMAX, RBASE + speed * 1.6) - rscale) * 0.2;
      if (disp) disp.setAttribute('scale', rscale.toFixed(1));
      const last = pts[0];
      if (hovering) {
        if (!last) { pts.unshift({ x: hx, y: hy, t: now, s: 0.8 + Math.random() * 0.4 }); }
        else {
          const dx = hx - last.x, dy = hy - last.y, dist = Math.hypot(dx, dy);
          if (dist > GAP) {
            const steps = Math.min(POOL, Math.ceil(dist / GAP));
            for (let k = 1; k <= steps; k++) pts.unshift({ x: last.x + dx * (k / steps), y: last.y + dy * (k / steps), t: now, s: 0.8 + Math.random() * 0.4 });
            while (pts.length > POOL) pts.pop();
          }
        }
      }
      while (pts.length && now - pts[pts.length - 1].t > LIFE) pts.pop();
      paint(now);
      const springing = speed > 0.15 || Math.hypot(tx - hx, ty - hy) > 0.4;
      const healing = Math.abs((hovering ? HEAD_R : 0) - hrad) > 0.4;
      if (springing || healing || pts.length) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      const b = word.getBoundingClientRect();
      tx = e.clientX - b.left; ty = e.clientY - b.top;
      if (!seeded) { hx = tx; hy = ty; seeded = true; }
      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);
    window.addEventListener('resize', sizeMask);
    return () => {
      stage.removeEventListener('pointermove', onMove);
      stage.removeEventListener('pointerleave', onLeave);
      window.removeEventListener('resize', sizeMask);
      if (raf) cancelAnimationFrame(raf);
      word.style.webkitMaskImage = ''; word.style.maskImage = '';
      circles.forEach((c) => c.remove());
    };
  }, []);
  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>
        {/* liquid reveal-mask for the wordmark (S69): a cursor-following hole (or
            short trail) punches the letters transparent so the video behind shows
            through — its edge rippled by feTurbulence/feDisplacementMap so the
            reveal reads as pushing through a liquid, localized to the cursor.
            JS sizes the mask + drives the hole circles in PHero's effect. */}
        <svg className="lp-liquid-defs" width="0" height="0" aria-hidden="true" focusable="false">
          <filter id="lp-ripple" x="-80%" y="-80%" width="260%" height="260%" colorInterpolationFilters="sRGB">
            <feTurbulence id="lp-liquid-turb" type="fractalNoise" baseFrequency="0.017 0.023" numOctaves="2" seed="7" result="n" />
            <feDisplacementMap id="lp-liquid-disp" in="SourceGraphic" in2="n" scale="22" xChannelSelector="R" yChannelSelector="G" />
          </filter>
          <mask id="lp-holemask" maskUnits="userSpaceOnUse" x="0" y="0" width="10" height="10">
            <rect id="lp-hole-bg" x="0" y="0" width="10" height="10" fill="#fff" />
            <g id="lp-hole-g" filter="url(#lp-ripple)" />
          </mask>
        </svg>
      </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">
      {/* glow highlight behind the copy removed (S69) — just the text now */}
      <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) => {
      // window-level listener → still fires under an open menu/links modal; settle to rest instead of drifting the background
      if (document.documentElement.classList.contains('lp-menu-open')) { st.forEach((c) => { c.tx = 0; c.ty = 0; }); if (!raf) raf = requestAnimationFrame(tick); return; }
      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>Home Is Where<br />the Music Is.</H2L></Reveal>
        <Reveal delay={70}><p className="lp-lead">Peripheral started at home: a pair of turntables, a growing collection of records, and an unwavering passion for electronic music.</p></Reveal>
        <Reveal delay={140}><p>In a scene saturated with digital, controller-driven sets, this project brings the focus back to the art of mixing on vinyl, curation, and a deeper connection to the music.</p></Reveal>
        <Reveal delay={200}><p>With inspiration from Japanese jazz kissas, my studio space has been curated to reflect that philosophy: calm, intimate and subtle, leaving space to sit with the music and notice the details that usually get lost.</p></Reveal>
        <Reveal delay={260}><p>These sessions are an invitation to slow down, listen more closely, and experience electronic music differently.</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 })} Explore the space
          </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/s5.jpg" strength={0.05} scale={1.12} alt="Warm lamp and plants in the listening room" /></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" />
            </span>
            <span className="lp-latest-meta">
              <span className="lp-latest-title">{latest.ep ? `${latest.title} - ep. ${latest.ep}` : latest.title}</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 })} YouTube
              </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" />
                <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 });
  const LEAD = 1; // pan begins ~1 screen before the gallery pins — i.e. as it enters view
  useScrollProgress(trackRef, (p) => {
    const s = panRef.current;
    // the row starts far RIGHT (big leading padding in CSS); scroll pans it LEFT.
    // Two-phase curve: photos MOVE as soon as they enter (phase A) but only reach
    // ~3/4 of the way left by the time the gallery pins, then finish the traverse
    // while pinned (phase B) — so start-time (LEAD) and the 3/4 target stay separate.
    if (rowRef.current && trackRef.current) {
      const vw = window.innerWidth || 0, vh = window.innerHeight || 1;
      const clusters = rowRef.current.querySelectorAll('.lp-out-cluster');
      if (clusters.length) {
        const centerPan = (el) => (el.offsetLeft + el.offsetWidth / 2) - vw / 2; // translate that centres el
        const panPin = Math.max(0, centerPan(clusters[0]));                       // cluster 1 centred at the pin
        const panEnd = Math.max(panPin, centerPan(clusters[clusters.length - 1])); // last cluster centred at the end
        const range = Math.max(1, trackRef.current.getBoundingClientRect().height - vh);
        const pPin = (LEAD * vh) / (range + LEAD * vh);   // the p value at the pin moment
        const pan = (p <= pPin)
          ? (pPin > 0 ? (p / pPin) * panPin : 0)          // phase A: enter → cluster 1 centred
          : panPin + ((p - pPin) / (1 - pPin)) * (panEnd - panPin); // phase B: cluster 1 → last cluster
        s.target = -pan;
      }
    }
    // background journey (Morgan): CREAM is the first colour — it blends up into
    // the video above via the section's top fade — holds through the approach,
    // then deepens to black by the end
    if (secRef.current) {
      const cream = [236, 228, 210], black = [10, 9, 8], ink = [26, 22, 16];
      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 t = Math.max(0, Math.min(1, (p - 0.82) / 0.18)); // cream dominates; black only comes in at the very end
      secRef.current.style.setProperty('--out-bg', lerp3(cream, black, t));
      // text colour tracks the bg so quotes stay legible: ink on cream → cream on black
      secRef.current.style.setProperty('--out-fg', lerp3(ink, cream, t));
    }
    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, lead: LEAD });
  // 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, vx: 0, vy: 0 }));
    let raf = 0;
    const R = 460, MAX = 14; // wide falloff + spring momentum → the tiles drift with inertia
    const tick = () => {
      raf = 0;
      let moving = false;
      tiles.forEach((t, i) => {
        const c = st[i];
        // spring (not a plain lerp) so tiles carry momentum and overshoot slightly
        c.vx = c.vx * 0.84 + (c.tx - c.x) * 0.055; c.x += c.vx;
        c.vy = c.vy * 0.84 + (c.ty - c.y) * 0.055; c.y += c.vy;
        if (Math.abs(c.tx - c.x) > 0.12 || Math.abs(c.ty - c.y) > 0.12 || Math.abs(c.vx) > 0.12 || Math.abs(c.vy) > 0.12) 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); };
  }, []);
  // subtle cursor parallax on the living-background layer (the warm glows drift
  // toward the pointer, composed with their own slow animations)
  useEffectL(() => {
    if (!MOTION || !DESKTOP || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const sec = secRef.current; if (!sec) return;
    const atmos = sec.querySelector('.lp-out-atmos'); if (!atmos) return;
    let raf = 0, tx = 0, ty = 0, cx = 0, cy = 0;
    const tick = () => {
      raf = 0;
      tx += (cx - tx) * 0.08; ty += (cy - ty) * 0.08;
      atmos.style.transform = `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px)`;
      if (Math.abs(cx - tx) > 0.3 || Math.abs(cy - ty) > 0.3) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      const r = sec.getBoundingClientRect();
      cx = ((e.clientX - r.left) / r.width - 0.5) * 46;   // subtle px parallax
      cy = ((e.clientY - r.top) / r.height - 0.5) * 26;
      if (!raf) raf = requestAnimationFrame(tick);
    };
    sec.addEventListener('pointermove', onMove, { passive: true });
    return () => { sec.removeEventListener('pointermove', onMove); 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">
          {/* living background: slow-drifting warm glows behind the photos
              (subtle motion so the bg isn't dead; sits BEHIND the photos) */}
          <div className="lp-out-atmos" aria-hidden="true">
            <span className="lp-atmos-glow lp-glow-1" />
            <span className="lp-atmos-glow lp-glow-2" />
          </div>
          <div className="lp-outrow lp-collage" ref={rowRef}>
            {COLLAGE.map((cl, ci) => {
              const photo = (p, extra) => (
                <div className={`lp-c-slot lp-c-${p.pos || extra}`} key={p.pos || 'center'}>
                  <figure className={`lp-out-card lp-t-${p.w} ${p.mono ? 'lp-mono' : ''}`}>
                    <span className="lp-out-imgw"><img src={p.src} alt="" loading="lazy" draggable="false" /></span>
                  </figure>
                </div>
              );
              return (
                <div className={`lp-out-cluster lp-cl-q${cl.quotePos}`} key={ci}>
                  {cl.quote && (
                    <div className={`lp-c-quote lp-c-quote-${cl.quotePos}`}>
                      <Reveal><span className="lp-qlift"><span className="lp-qlift-in">{cl.quote}</span></span></Reveal>
                    </div>
                  )}
                  {photo(cl.center, 'center')}
                  {cl.corners.map((c) => photo(c))}
                </div>
              );
            })}
          </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 is my take on a modern listening experience, somewhere between a DJ set, a listening bar, and a curated journey through sound.</p></Reveal>
        <Reveal delay={200}><p>Through carefully selected records, the project celebrates the talented artists behind the handcrafted emotional melodies, rich textures, and infectious grooves that define the melodic and progressive scene.</p></Reveal>
        <Reveal delay={260}><p>Peripheral is rooted in a simple idea: electronic music doesn&rsquo;t have to be attention-grabbing to be powerful. Each set is built with intention: slower pacing, careful selection, and a focus on how tracks interact and blend over time.</p></Reveal>
        <Reveal delay={320}><p>Whether you&rsquo;re listening intently or simply letting the music fill the room, I hope these sessions create the same sense of calm, curiosity, and connection that inspired them in the first place.</p></Reveal>
        <Reveal delay={380} className="lp-cta-gap">
          <div className="lp-cta-row">
            <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: 15, height: 15 })} Explore on YouTube
            </a>
            <a href="mailto:booking@peripheralvinyl.com" className="lp-pill" onMouseEnter={lpHover} onMouseLeave={lpOut}>
              {Icon.mail({ width: 15, height: 15 })} Bookings &amp; enquiries
            </a>
          </div>
        </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 whenever a LIGHT surface sits under them.
      const spans = (el) => { const r = el.getBoundingClientRect(); return r.top < 44 && r.bottom > 44; };
      let d = false;
      // 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 && spans(shop) && shop.getBoundingClientRect().left < 64) d = true;
      // the gallery while its scroll-driven background is light (cream phase)
      const out = document.getElementById('out');
      if (out && spans(out)) {
        const m = getComputedStyle(out).getPropertyValue('--out-bg').match(/\d+/g);
        if (m && (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) > 140) d = true;
      }
      setDark(d);
    };
    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;
