// SitAccordion — vertical photo-accordion home feed (replaces OneSitFeed).
//
// Inspired by aristidebenoist.com's strip accordion, transposed vertical:
// every sit is a thin horizontal row stacked top-to-bottom inside a
// fixed 100vh container. Mouse-Y position over the container picks
// which row is "active" — the active row expands tall, others compress
// to thin strips. Smooth flex-basis transition keeps the motion fluid
// as the cursor slides across.
//
// Year dividers (small labeled rows) sit between sit-year groups but
// don't participate in the expand/collapse — they're fixed-height.
//
// All videos always play when their row is intersecting the viewport
// AND no sit overlay is open (body.has-sit-overlay pauses all). This
// gives the page a living, breathing quality even at idle.
//
// Click an active row → onOpen({ sit }) → SitPage overlay slides up.

const {
  useEffect: useAccEffect,
  useRef: useAccRef,
  useState: useAccState,
  useMemo: useAccMemo,
  useCallback: useAccCallback,
} = React;

// Canvas renderer config — aristide-faithful constants derived empirically
// from measuring aristidebenoist.com via CDP (see research/aristide-cdp/).
//
// THE EMPIRICAL TRUTH (measured across 30 tiles, 5 scroll moments):
// every tile stays UNIFORM width × height at all times. No per-tile
// magnification, brightness, or size gradient — ever. Aristide's "smooth
// and spread among tiles" feel comes from two shared-across-all-tiles
// effects:
//   1. Frame-rate-independent damped lerp on scroll position (Damp, not
//      linear lerp), giving identical perceived speed regardless of FPS.
//   2. A single shared Y-axis rotation — the WebGL shader applies the
//      SAME rotateY(-0.4 * latency.rotate) to every tile. latency.rotate
//      ramps 0 → ~1.7 during fast scroll and decays back via the
//      `latency` gap (smoothed difference between target and currLatency).
//
// Aristide's vertex shader uses a center-bulge zoom: vertices within
// BULGE_RADIUS of canvas-center X get pulled forward in Z (perspective
// makes them appear larger), falling off via easeInOutQuad, scaled by
// latency.x (0 at rest, ~1 during fast scroll). We reproduce that in 2D
// by scaling each tile directly:
//
//   dist    = |anchorX - centerX|
//   falloff = max(0, 1 - easeInOutQuad(dist / BULGE_RADIUS))
//   zoom    = 1 + falloff * intensity * BULGE_PEAK
//
// At rest intensity=0 → zoom=1 for every tile (uniform). Mid-scroll
// intensity rises; center tile peaks at (1 + BULGE_PEAK), neighbors
// taper off smoothly, edge tiles stay at 1.
//
// STRIP_W  — tile "window" width at baseline zoom=1
// PITCH    — window + gap (at index i, anchorX = centerX + i*PITCH - xCurr)
// LERP     — base lerp coefficient for Damp (aristide uses 0.08)
// MIN_DELTA — snap to target below this gap (prevents infinite jitter)
// LATENCY_SCALE — smoothed-gap magnitude at which zoom saturates (500px)
// BULGE_RADIUS — horizontal reach of the zoom effect (~400px, ratio of 500*winPsdRatio in aristide)
// BULGE_PEAK   — scale delta at center at peak latency (1 + 0.35 = 1.35x)
// REST_SAT / REST_BRI — dim filter at rest (uniform across tiles)
// BRIGHT_BOOST — additional brightness multiplier at peak zoom (matches
//   aristide's `d = min(z*.005, .7)` brightness varying)
const CANVAS_STRIP_W = 200;
const CANVAS_STRIP_PITCH = 236;   // 200 tile + 36 gap (was 12 gap, 3× wider)
const CANVAS_FOCAL_X = 0.5;
const CANVAS_FOCAL_Y = 0.38;
const CANVAS_LERP = 0.08;
const CANVAS_MIN_DELTA = 0.25;
const CANVAS_LATENCY_SCALE = 500;
const CANVAS_LATENCY_EPSILON = 0.5;
// Bulge radius — how far the enlargement effect reaches from center.
// Widened from a tight 420px spotlight so ~5-6 tiles on each side catch
// a soft zoom. Combined with the lowered CANVAS_BULGE_PEAK, each tile
// lifts subtly but the wave spans visibly further — a gentler swell.
const CANVAS_BULGE_RADIUS = 1100;
// Zoom + saturation effects are tuned as a BROAD, SUBTLE wave rather
// than a tight, punchy bulge. Lower per-tile peaks (quieter max) with
// much wider radii (more tiles participating) reads as a soft wash
// rolling through the strip. Each individual tile only shifts a little,
// but ~8-10 of them shift together — the combined motion is the effect.
const CANVAS_BULGE_PEAK = 0.32;
const CANVAS_BRIGHT_BOOST = 0.20;
// Peak saturation above 1.0 for the actively-bulged tile — still vivid
// but toned down so the bloom feels gentle, not neon.
const CANVAS_SCROLL_SAT_PEAK = 1.22;
// Saturation radius — color bloom reaches ~9 tiles on each side of
// center (tile pitch ~236px). Eased falloff keeps the center peak while
// the flanks still catch meaningful color.
const CANVAS_SAT_RADIUS = 2200;
// Vertical lift retired — tiles now grow EVENLY around the strip's
// vertical centerline (top and bottom expand by the same amount), which
// reads as a cleaner magnification than the earlier lift-and-grow mix.
const CANVAS_LIFT_PEAK_PX = 0;
// Intro sequence has four phases:
//   loading  — veil + loader visible; minimum 1.8s even if all covers are
//              cached so the counter has room to breathe and feel deliberate
//   chrome   — veil/loader unmount; corner nav fades in while accordion +
//              timeline still held off-screen (~900ms)
//   cascading — accordion tiles + timeline bars roll in from the right (2.2s)
//   done     — everything at rest
// Each tile i starts at x = cssW + PITCH * 3 * i (further tiles start
// further right — "further tiles travel longer" wave look) and eases in.
// Bulge effect is suppressed during the cascade so the motion reads cleanly.
const CANVAS_LOADING_MIN_MS = 1800;
// Chrome phase is now a tiny buffer (80ms) so the veil can unmount
// before the cascade begins. Corners fade in DURING cascading, not
// during chrome — they come alive together with the strip motion.
const CANVAS_CHROME_FADE_MS = 80;
const CANVAS_CASCADE_MS = 2200;
const CANVAS_CASCADE_INDEX_LAG = 3;  // tile i starts at PITCH * LAG * i past the right edge

// Module-level flag — true after the full intro (loading → chrome →
// cascading → done) has run once in this browser tab. SitAccordion
// unmounts when the user leaves the home route and re-mounts when they
// return; we don't want to put them through the 0-100 counter + slide-in
// every time. Resets automatically on full page reload (module reloads).
let __ACCORDION_INTRO_SEEN__ = false;
// Tiles are fully B&W at rest (saturate(0)) and ramp up to full color
// as the bulge wave passes through them. CANVAS_REST_SAT = 0 is the
// resting floor; the per-tile formula lifts it to 1.0 at peak bulge:
//   sat = REST_SAT + falloff * intensity * (1 - REST_SAT)
// i.e. saturation is a direct read-out of "am I under the wave right
// now" — idle feed reads as monochrome archive, live scroll blooms color.
const CANVAS_REST_SAT = 0.0;
const CANVAS_REST_BRI = 0.78;

// Accordion props: `onOpen(slug|payload)` opens the sit overlay,
// `onNav(routeKey)` routes at the App level (used by the contact footer).
const SitAccordion = ({ onOpen, onNav }) => {
  // Build rows: sits sorted reverse-chronological, year dividers between
  // year boundaries. Sort explicitly — window.SITS isn't guaranteed sorted.
  const rows = useAccMemo(() => {
    // Home accordion shows only sits that have actual media — a blank strip
    // with just a gradient plate adds noise without paying off visually.
    // Sits without media still live in SITS for History + deep-link overlays.
    const hasMedia = (s) => (s.media || []).some(m =>
      m && m.src && !m.src.startsWith('linear-') && !m.src.startsWith('radial-')
    );
    const sits = (window.SITS || [])
      .filter(s => s.status !== 'available')
      .filter(hasMedia)
      .slice()
      .sort((a, b) => (a.start_date < b.start_date ? 1 : -1));
    const out = [];
    let lastYear = null;
    let yearCounter = 0;
    for (const sit of sits) {
      const year = sit.start_date.slice(0, 4);
      if (year !== lastYear) {
        // Unique key per divider — sorted data should never repeat years,
        // but counter guarantees uniqueness if data ever drifts.
        out.push({ kind: 'year', year, key: `y-${year}-${yearCounter++}` });
        lastYear = year;
      }
      out.push({ kind: 'sit', sit, key: sit.slug });
    }
    return out;
  }, []);

  // Flat sit list — no year dividers; the canvas renders one window
  // per media-having sit.
  const sitRows = useAccMemo(() => rows.filter(r => r.kind === 'sit'), [rows]);

  const [hoverIdx, setHoverIdx] = useAccState(-1);
  const hostRef = useAccRef(null);
  const containerRef = useAccRef(null);
  const canvasRef = useAccRef(null);
  const captureRef = useAccRef(null);
  const rafRef = useAccRef(0);
  // Active = hovered sit's slug (or null). Derived so callers reading
  // `activeKey` below keep working with no plumbing changes.
  const activeKey = hoverIdx >= 0 ? sitRows[hoverIdx]?.sit.slug || null : null;

  // All sizing/coloring helpers removed — aristide-style horizontal band
  // uses flex-grow for width modulation (active:idle = 5:1), uniform
  // heights via flex stretch. Only the active strip differs.

  // Virtual scroll target and current (smoothed) positions. Not React
  // state — the canvas effect below updates these per rAF and redraws.
  // Scroll state modeled on aristide's `_A.h.x` + global `latency`:
  //   target          — user's wheel-input destination
  //   current         — damped chase of target (drives tile X offset)
  //   currLatency     — second damped chase, same rate as current (for tilt)
  //   latency         — smoothed |target - currLatency| gap → drives tilt
  //   lastTs          — last rAF timestamp for frame-rate-independent damp
  //   cascadeStartTs  — perf.now() when the load-in cascade begins (0 = not yet)
  //   cascadeEase     — current ease value (1 = start of cascade, 0 = finished)
  const scrollRef = useAccRef({
    target: 0, current: 0, currLatency: 0, latency: 0,
    max: 0, lastTs: 0, running: false, rafId: 0,
    // If the intro already ran in this session, start with cascadeEase=0
    // so tiles render at their resting positions immediately. Fresh
    // first visit starts at 1 (tiles off-screen right).
    cascadeStartTs: 0, cascadeEase: __ACCORDION_INTRO_SEEN__ ? 0 : 1,
  });
  // Load-in phase: 'loading' (images decoding) → 'cascading' (tiles animating in) → 'done'.
  // Only 'loading' triggers a React render because the loader chrome is DOM.
  // 'cascading' and 'done' transitions are driven via refs inside the rAF
  // loop, so no re-render churn during the animation.
  // If the intro has already played in this tab session, mount straight
  // into the final 'done' phase — no loader, no cascade, no veil. This
  // covers the user clicking Labs / About / Travels and coming back to
  // Home. A real page refresh drops __ACCORDION_INTRO_SEEN__ back to
  // false so the first visit still gets the full reveal.
  const [introPhase, setIntroPhase] = useAccState(__ACCORDION_INTRO_SEEN__ ? 'done' : 'loading');
  const [decodedCount, setDecodedCount] = useAccState(0);
  // Expose current intro phase on <body> so CSS elsewhere can opt in to
  // the site-wide fade-in. `body[data-intro]` drives any `.intro-fade`
  // element's opacity across the site.
  useAccEffect(() => {
    document.body.dataset.intro = introPhase;
    return () => { delete document.body.dataset.intro; };
  }, [introPhase]);
  const introTotal = useAccMemo(() => sitRows.length, [sitRows]);
  const [introPct, setIntroPct] = useAccState(0);

  // Drive the 0 → 100 counter on a time budget so it always reads as a
  // paced progression even when every cover is cached. If real decode
  // runs slower than the budget, display the min of (time, decode) so
  // we don't advertise completion before assets have actually arrived.
  useAccEffect(() => {
    if (introPhase !== 'loading') return;
    const startTs = performance.now();
    let rafId = 0;
    const tick = (ts) => {
      const elapsed = ts - startTs;
      const timePct = Math.min(100, (elapsed / CANVAS_LOADING_MIN_MS) * 100);
      const decodedPct = introTotal ? Math.min(100, (decodedCount / introTotal) * 100) : 100;
      const display = Math.round(Math.min(timePct, decodedPct));
      setIntroPct((prev) => prev === display ? prev : display);
      if (timePct < 100 || decodedPct < 100) rafId = requestAnimationFrame(tick);
    };
    rafId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafId);
  }, [introPhase, decodedCount, introTotal]);

  // loading → chrome: fires only when BOTH the counter has hit 100 AND
  // the minimum loading window has elapsed. Decouples the dramatic hold
  // from actual asset-decode time.
  useAccEffect(() => {
    if (introPhase !== 'loading') return;
    if (introPct < 100) return;
    if (introTotal > 0 && decodedCount < introTotal) return;
    setIntroPhase('chrome');
  }, [introPhase, introPct, decodedCount, introTotal]);

  // chrome → cascading: hold the corners in view for CHROME_FADE_MS so
  // the user sees the wordmark + nav appear first, THEN kick off the
  // accordion + timeline cascade. The veil + loader are already gone.
  useAccEffect(() => {
    if (introPhase !== 'chrome') return;
    const handle = setTimeout(() => {
      const s = scrollRef.current;
      s.cascadeStartTs = performance.now();
      s.cascadeEase = 1;
      if (!s.running && s.__ensureRunning) s.__ensureRunning();
      setIntroPhase('cascading');
    }, CANVAS_CHROME_FADE_MS);
    return () => clearTimeout(handle);
  }, [introPhase]);

  // cascading → done: once tiles + bars finish settling.
  useAccEffect(() => {
    if (introPhase !== 'cascading') return;
    const handle = setTimeout(() => {
      setIntroPhase('done');
      __ACCORDION_INTRO_SEEN__ = true;  // skip intro on subsequent mounts
    }, CANVAS_CASCADE_MS + 120);
    return () => clearTimeout(handle);
  }, [introPhase]);
  // Expose scroll state on window for debugging (dev only).
  useAccEffect(() => {
    if (typeof window !== 'undefined') window.__accState = scrollRef.current;
  }, []);

  useAccEffect(() => () => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
  }, []);

  // Active sit for the label overlay.
  const hoverSit = hoverIdx >= 0 ? sitRows[hoverIdx]?.sit : null;

  // Canvas-rendered aristide-style strip band:
  //   - Single <canvas> covers the hero band
  //   - N cover images pre-loaded, drawn with cover-fit + focal point
  //   - rAF loop lerps scrollCurrent toward scrollTarget, then redraws
  //   - Wheel / drag / click handled on the canvas directly
  //   - Hover sets hoverIdx so the label overlay knows which sit is
  //     under the cursor
  useAccEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    if (!container || !canvas) return;

    // Opt out on touch-first devices — native swipe-to-scroll is better
    // than our synthesized handler on mobile.
    const mqFine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
    if (mqFine && !mqFine.matches) return;

    const state = scrollRef.current;
    const ctx = canvas.getContext('2d');

    // Pre-load cover media. Each slot is either:
    //   - {type:'image', el: HTMLImageElement}
    //   - {type:'video', el: HTMLVideoElement, ready: false}
    //   - {type:'plate', plate}
    // Both image and video elements are passed directly to
    // ctx.drawImage; video frames update as the element plays, so we
    // just keep the rAF loop running while any video is visible.
    const parseFocal = (focalStr) => {
      if (!focalStr) return [CANVAS_FOCAL_X, CANVAS_FOCAL_Y];
      const parts = String(focalStr).split(/\s+/).map(p => {
        const n = parseFloat(p);
        if (p.includes('%')) return n / 100;
        if (n > 1.01) return n / 100;
        return n;
      });
      return parts.length === 2 ? parts : [CANVAS_FOCAL_X, CANVAS_FOCAL_Y];
    };
    // Read dev-mode focal overrides saved via shift-click. These are keyed
    // by sit slug and persist across page reloads on the same browser, so
    // iterating on framing doesn't require pasting into sits.js until the
    // user is ready to commit.
    const FOCAL_STORAGE_KEY = 'accordion_focals_v1';
    const readFocalOverrides = () => {
      try {
        const raw = localStorage.getItem(FOCAL_STORAGE_KEY);
        return raw ? JSON.parse(raw) : {};
      } catch { return {}; }
    };
    const focalOverrides = readFocalOverrides();
    // Decoded counter — drives the 0→100 loader chrome. Mark each slot
    // decoded exactly once (on image/video first-ready or plate-only).
    // When decodedTotal === sitRows.length, we transition from 'loading'
    // to 'cascading'.
    let decodedTotal = 0;
    const markDecoded = () => {
      decodedTotal++;
      setDecodedCount(decodedTotal);
    };
    const imageSlots = sitRows.map(({ sit }) => {
      const cover = sit.media.find(m => m.feed) || sit.media[0];
      if (!cover) { markDecoded(); return { type: 'plate', plate: '#1a1714', focal: [0.5, 0.38] }; }
      // Order: localStorage override wins over sits.js → falls back to default.
      const focal = focalOverrides[sit.slug] || parseFocal(cover.focal);
      const plate = cover.plate || '#1a1714';
      if (!cover.src || cover.src.startsWith('linear-')) {
        markDecoded();
        return { type: 'plate', plate, focal };
      }
      if (cover.type === 'video') {
        const v = document.createElement('video');
        v.muted = true;
        v.loop = true;
        v.playsInline = true;
        v.preload = 'auto';
        v.autoplay = true;
        v.src = cover.src;
        const kick = () => v.play().catch(() => {});
        let markedOnce = false;
        const markOnce = () => { if (!markedOnce) { markedOnce = true; markDecoded(); } };
        v.addEventListener('canplay', () => { kick(); scheduleDraw(); markOnce(); });
        v.addEventListener('playing', () => scheduleDraw());
        v.addEventListener('loadeddata', () => { kick(); scheduleDraw(); markOnce(); });
        v.addEventListener('error', () => {
          console.warn('[canvas-strip] video failed to load', cover.src);
          markOnce();  // still advance the loader so we don't hang
        });
        kick();
        return { type: 'video', el: v, plate, focal };
      }
      const img = new Image();
      img.decoding = 'async';
      img.src = cover.src;
      img.onload = () => { scheduleDraw(); markDecoded(); };
      img.onerror = () => { markDecoded(); }; // stay on plate, still count
      return { type: 'image', el: img, plate, focal };
    });

    // Parse `linear-gradient(...)` plates to a representative hex; canvas
    // can't `fillStyle = 'linear-gradient(...)'` directly. We just pick
    // the last listed color (the darkest in our gradients) as the plate.
    const plateColor = (s) => {
      if (!s) return '#1a1714';
      const m = String(s).match(/#[0-9a-f]{3,8}|rgb\([^)]+\)/gi);
      return m ? m[m.length - 1] : '#1a1714';
    };

    let cssW = 0, cssH = 0;
    const resize = () => {
      const dpr = window.devicePixelRatio || 1;
      const rect = container.getBoundingClientRect();
      cssW = rect.width;
      cssH = rect.height;
      canvas.width = Math.round(cssW * dpr);
      canvas.height = Math.round(cssH * dpr);
      canvas.style.width = cssW + 'px';
      canvas.style.height = cssH + 'px';
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.scale(dpr, dpr);
      state.max = Math.max(0, (sitRows.length - 1) * CANVAS_STRIP_PITCH);
    };
    resize();

    // Returns natural width/height of a slot's source element; zero if
    // not yet loaded. Videos expose videoWidth/videoHeight once metadata
    // is decoded.
    const slotSize = (slot) => {
      if (!slot) return { w: 0, h: 0 };
      if (slot.type === 'image' && slot.el?.complete) {
        return { w: slot.el.naturalWidth, h: slot.el.naturalHeight };
      }
      if (slot.type === 'video' && slot.el) {
        return { w: slot.el.videoWidth, h: slot.el.videoHeight };
      }
      return { w: 0, h: 0 };
    };

    // Did we paint any video this frame? Tracks whether we need to keep
    // the rAF loop running at idle (videos advance frames even when no
    // scroll is happening).
    let drewVideoThisFrame = false;

    const draw = () => {
      if (!cssW || !cssH) return;
      ctx.clearRect(0, 0, cssW, cssH);
      ctx.fillStyle = '#141414';  // matches aristide bg
      ctx.fillRect(0, 0, cssW, cssH);
      drewVideoThisFrame = false;

      const centerX = cssW / 2;
      const centerY = cssH / 2;

      // Load-in cascade ease. 1 = tiles start off-screen right; 0 = at rest.
      // Uses a cubic ease-out applied to the time elapsed since cascade start.
      const cascadeEase = state.cascadeEase;

      // Intensity = |smoothed latency gap| / LATENCY_SCALE, clamped.
      // Suppress during cascade so the load-in motion reads cleanly
      // without bulge/brightness distraction.
      const rawIntensity = Math.min(1, Math.abs(state.latency) / CANVAS_LATENCY_SCALE);
      const intensity = rawIntensity * (1 - cascadeEase);

      // Base tile dims at zoom=1 (edge tiles never change).
      const baseW = CANVAS_STRIP_W;
      const baseH = cssH * 0.72;

      // Strip origin: first tile's CENTER sits at viewport center
      // (centerX), so the user is greeted with the first sit centered
      // on screen. Tile 0 occupies [centerX - baseW/2, centerX + baseW/2].
      // The timeline's first bar has its LEFT EDGE at the same x as
      // tile 0's left edge (centerX - baseW/2), so both strips begin at
      // the same visual start line. Bulge kernel is on centerX, so the
      // first tile is at peak-bulge position — during scroll it stays
      // magnified until the user scrolls enough to push it off-center.
      const STRIP_ORIGIN_X = centerX;

      // Paint order: draw edges first, center last, so bulged tiles
      // overlap their neighbors visually (matches aristide's z-order).
      const draws = [];
      for (let i = 0; i < sitRows.length; i++) {
        // Cascade offset: tile i starts at cssW + PITCH * LAG * i past
        // the right edge, easing to 0. Further tiles travel longer.
        const cascadeOffset = cascadeEase * (cssW + CANVAS_STRIP_PITCH * CANVAS_CASCADE_INDEX_LAG * i);
        const anchorX = STRIP_ORIGIN_X + i * CANVAS_STRIP_PITCH - state.current + cascadeOffset;
        // Widen cull bounds slightly for zoomed-up tiles.
        if (anchorX + baseW * 0.8 < 0) continue;
        if (anchorX - baseW * 0.8 > cssW) continue;

        const dist = Math.abs(anchorX - centerX);
        // easeInOutQuad — same curve aristide uses in vertex shader.
        const falloff = dist < CANVAS_BULGE_RADIUS
          ? 1 - easeInOutQuad(dist / CANVAS_BULGE_RADIUS)
          : 0;
        const zoom = 1 + falloff * intensity * CANVAS_BULGE_PEAK;

        draws.push({ i, anchorX, dist, falloff, zoom });
      }
      draws.sort((a, b) => a.falloff - b.falloff);  // edges first

      for (const d of draws) {
        // Width stays locked — tiles never widen. Height + vertical lift
        // + brightness absorb the intensity. A bulged tile is taller AND
        // floated upward, giving a "wave lifting the tile toward you"
        // feel as the wave passes through.
        const drawW = baseW;
        const drawH = baseH * d.zoom;
        const drawX = d.anchorX - drawW / 2;
        const lift = d.falloff * intensity * CANVAS_LIFT_PEAK_PX;
        const drawY = centerY - drawH / 2 - lift;

        // Brightness tracks the tight zoom falloff; only bulged tiles lift.
        const bri = CANVAS_REST_BRI + d.falloff * intensity * CANVAS_BRIGHT_BOOST;
        // Saturation uses its OWN wider radius + softer curve so the
        // color bloom visibly spreads across several tiles on each side
        // of center, dissipating smoothly outward — matches the "wave
        // of color" feel instead of a single spotlit tile. easeInOutQuad
        // gives a smooth dome; we also scale by intensity so the effect
        // only appears during active scrolling.
        const satDistNorm = Math.min(1, d.dist / CANVAS_SAT_RADIUS);
        const satFalloff = 1 - easeInOutQuad(satDistNorm);
        const sat = CANVAS_REST_SAT + satFalloff * intensity * (CANVAS_SCROLL_SAT_PEAK - CANVAS_REST_SAT);
        ctx.filter = `saturate(${sat.toFixed(3)}) brightness(${bri.toFixed(3)})`;

        const slot = imageSlots[d.i];
        const { w: mw, h: mh } = slotSize(slot);
        // Video-not-ready guard: if the slot is a video whose decoder
        // hasn't produced a frame yet (readyState < 2 = HAVE_CURRENT_DATA),
        // drawImage would paint a black rectangle. Fall back to the plate
        // color so tiles never flash black during buffer/seek/reattach.
        const videoUnready = slot.type === 'video'
          && slot.el
          && slot.el.readyState < 2;
        if (!mw || !mh || !slot.el || videoUnready) {
          ctx.fillStyle = plateColor(slot?.plate);
          ctx.fillRect(drawX, drawY, drawW, drawH);
          continue;
        }
        const stripAspect = drawW / drawH;            // == baseW/baseH, uniform crop
        const mediaAspect = mw / mh;
        let sw, sh;
        if (mediaAspect > stripAspect) { sh = mh; sw = sh * stripAspect; }
        else { sw = mw; sh = sw / stripAspect; }
        const [fx, fy] = slot.focal;
        const sx = Math.max(0, Math.min(mw - sw, mw * fx - sw / 2));
        const sy = Math.max(0, Math.min(mh - sh, mh * fy - sh / 2));
        try {
          ctx.drawImage(slot.el, sx, sy, sw, sh, drawX, drawY, drawW, drawH);
          if (slot.type === 'video') drewVideoThisFrame = true;
        } catch (err) {
          ctx.fillStyle = plateColor(slot?.plate);
          ctx.fillRect(drawX, drawY, drawW, drawH);
        }
      }
      ctx.filter = 'none';
    };

    // easeInOutQuad — same curve aristide uses (m<.5 ? 2m² : -1 + (4-2m)m).
    const easeInOutQuad = (m) => m < 0.5 ? 2 * m * m : -1 + (4 - 2 * m) * m;

    const scheduleDraw = () => {
      // Used by image onload — if the rAF loop isn't already running,
      // redraw once so the newly-decoded image appears.
      if (!state.running) draw();
    };

    // Frame-rate-independent exponential damp (mirrors aristide's R.Damp):
    //   factor = 1 - (1 - LERP) ** (dt_ms / 16.67)
    // At 60fps (dt=16.67) this reduces to the familiar LERP. At 30fps
    // (dt=33.33) it doubles the exponent, so one frame covers the same
    // perceptual distance as two frames at 60fps — no stuttering.
    const dampTowards = (curr, targ, lerp, dt) => {
      const factor = 1 - Math.pow(1 - lerp, dt / 16.67);
      return curr + (targ - curr) * factor;
    };

    const step = (ts) => {
      const dt = state.lastTs ? Math.min(48, ts - state.lastTs) : 16.67;
      state.lastTs = ts;

      // Primary scroll position — drives tile X offsets.
      const diff = state.target - state.current;
      const settled = Math.abs(diff) < CANVAS_MIN_DELTA;
      if (settled) {
        state.current = state.target;
      } else {
        state.current = dampTowards(state.current, state.target, CANVAS_LERP, dt);
      }

      // Latency track — a second damped chase of target. The gap between
      // target and currLatency is what drives the tilt. Same lerp rate as
      // current, so under steady pan the gap stays small; only when target
      // jumps does the gap spike, producing the transient tilt.
      state.currLatency = dampTowards(state.currLatency, state.target, CANVAS_LERP, dt);
      const gap = state.target - state.currLatency;
      // Smooth `latency` toward the instantaneous gap — gives the tilt a
      // slight release lag so it fades back to 0 gracefully after a flick.
      state.latency = dampTowards(state.latency, gap, CANVAS_LERP, dt);

      // Cascade ease: decays 1 → 0 over CANVAS_CASCADE_MS starting at
      // cascadeStartTs. If cascade hasn't begun yet (still loading), hold
      // at 1 so tiles stay off-screen right. `pow(linear, 3)` gives an
      // ease-out decay — fast initial movement, slow final settle.
      if (state.cascadeStartTs > 0) {
        const elapsed = ts - state.cascadeStartTs;
        const linear = Math.max(0, 1 - elapsed / CANVAS_CASCADE_MS);
        state.cascadeEase = linear * linear * linear;  // easeInCubic on the decay
      }

      draw();

      state.lastStepTs = ts;  // liveness timestamp for ensureRunning()

      const tiltActive = Math.abs(state.latency) > CANVAS_LATENCY_EPSILON
        || Math.abs(state.target - state.currLatency) > CANVAS_LATENCY_EPSILON;
      const cascadeActive = state.cascadeEase > 0.0001;
      if (!settled || tiltActive || cascadeActive || drewVideoThisFrame) {
        state.rafId = requestAnimationFrame(step);
      } else {
        state.running = false;
        state.rafId = 0;
        state.lastTs = 0; // so the next kick uses the default dt
      }
    };
    // How long ago step() actually ran (ms). Used as a liveness check —
    // the rAF id alone is not reliable because the browser silently drops
    // scheduled frames when the page is behind a lock (body overflow:
    // hidden during SitPage overlay). Without this, ensureRunning saw
    // state.rafId != 0 and stale state.running == true, short-circuited,
    // and step() never resumed when the user came back to Home.
    const STEP_STALE_MS = 120;
    const ensureRunning = () => {
      const now = performance.now();
      const aliveRecently = state.lastStepTs && (now - state.lastStepTs < STEP_STALE_MS);
      if (state.running && state.rafId && aliveRecently) return;
      if (state.rafId) cancelAnimationFrame(state.rafId);
      state.running = true;
      state.rafId = requestAnimationFrame(step);
    };
    state.__ensureRunning = ensureRunning;
    // Kick every detached video into play(). Called periodically (below)
    // AND instantly when body.has-sit-overlay is removed (user returning
    // from SitPage — the 300ms poll window otherwise creates a visible
    // "plate flash" on the accordion).
    const kickAllVideos = () => {
      for (const s of imageSlots) {
        if (s?.type === 'video' && s.el && s.el.paused) {
          s.el.play().catch(() => {});
        }
      }
    };
    // Videos keep the loop alive even when scroll is settled. Check on
    // an interval so we pick up a video becoming visible after the user
    // scrolls (then stops).
    const videoPulse = setInterval(() => {
      kickAllVideos();
      if (!state.running && imageSlots.some(s => s?.type === 'video' && s.el && s.el.readyState >= 2)) {
        ensureRunning();
      }
    }, 300);
    // Instant kick when SitPage closes — the moment has-sit-overlay is
    // removed from <body>, fire off play() on every detached video and
    // restart the rAF loop. Avoids the up-to-300ms plate-fallback window.
    const overlayObserver = new MutationObserver(() => {
      if (!document.body.classList.contains('has-sit-overlay')) {
        kickAllVideos();
        ensureRunning();
      }
    });
    overlayObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });

    draw();  // paint baseline (most images are still loading)

    const ro = typeof ResizeObserver !== 'undefined'
      ? new ResizeObserver(() => { resize(); draw(); })
      : null;
    ro && ro.observe(container);

    // Hit-test matches draw geometry: fixed-width tiles, height varies
    // by center proximity. Iterate all tiles, return the one whose rect
    // contains the cursor (at most one match since widths never overlap).
    const easeInOutQuadHit = (m) => m < 0.5 ? 2 * m * m : -1 + (4 - 2 * m) * m;
    const sitIndexAt = (clientX, clientY) => {
      const rect = canvas.getBoundingClientRect();
      const localX = clientX - rect.left;
      const localY = clientY - rect.top;
      const viewCenterX = rect.width / 2;
      const viewCenterY = rect.height / 2;
      const intensity = Math.min(1, Math.abs(state.latency) / CANVAS_LATENCY_SCALE);
      const baseH = rect.height * 0.72;
      // Iterate tiles; for each compute its current zoom + drawn rect.
      // Bulged tiles paint on top of neighbors, so iterate asc-falloff
      // (like the draw loop) so last-match wins for the center tile.
      const STRIP_ORIGIN_X = viewCenterX;  // first tile centered in viewport
      // Mirror the draw-loop cascadeOffset so the hit-test tracks each
      // tile's live screen position during the intro cascade. Further
      // tiles travel longer (same LAG scaling as the visual animation).
      const cascadeEase = state.cascadeEase || 0;
      let match = -1;
      for (let i = 0; i < sitRows.length; i++) {
        const cascadeOffset = cascadeEase * (rect.width + CANVAS_STRIP_PITCH * CANVAS_CASCADE_INDEX_LAG * i);
        const anchorX = STRIP_ORIGIN_X + i * CANVAS_STRIP_PITCH - state.current + cascadeOffset;
        const dist = Math.abs(anchorX - viewCenterX);
        const falloff = dist < CANVAS_BULGE_RADIUS
          ? 1 - easeInOutQuadHit(dist / CANVAS_BULGE_RADIUS)
          : 0;
        const zoom = 1 + falloff * intensity * CANVAS_BULGE_PEAK;
        const lift = falloff * intensity * CANVAS_LIFT_PEAK_PX;
        // Width locked; height + lift match draw loop.
        const drawW = CANVAS_STRIP_W;
        const drawH = baseH * zoom;
        const left = anchorX - drawW / 2;
        const right = anchorX + drawW / 2;
        const top = viewCenterY - drawH / 2 - lift;
        const bottom = top + drawH;
        if (localX >= left && localX < right && localY >= top && localY < bottom) {
          match = i;   // keep last (highest-falloff) match
        }
      }
      return match;
    };

    // Scroll-capture: the accordion sits inside a tall sticky wrapper
    // (.sit-accordion-capture → .sit-accordion-sticky). Native page scroll
    // drives state.target: as the user scrolls down through the capture
    // wrapper, the horizontal target advances 0 → max. Only after the
    // wrapper fully scrolls past does the archive become reachable. This
    // guarantees the user traverses the whole strip before reaching the
    // archive — no more scrolling past it.
    const captureEl = captureRef.current;
    let scrollMode = 'native';  // 'native' = use page scroll; 'off' = mobile
    const mqFineScroll = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
    if (mqFineScroll && !mqFineScroll.matches) scrollMode = 'off';

    // Scroll capture decomposes the vertical-scroll budget into two phases:
    //   [0, ACC_SAT] → accordion target advances 0 → max; timeline
    //     advances 0 → ACC_SAT ratio of its own max (slower than accordion)
    //   [ACC_SAT, 1] → accordion stays at max; timeline continues to its
    //     own max so the user must scroll through every bar before the
    //     archive becomes reachable.
    // This matches the user's intuition: timeline is a longer, slower
    // complement to the accordion.
    const ACC_SAT = 0.55;

    const syncCaptureHeight = () => {
      if (!captureEl || scrollMode === 'off') return;
      // Budget = accordion's full horizontal range / ACC_SAT, scaled up
      // 2× so the accordion moves at ~0.5x horizontal velocity vs. pre-
      // tuning (user request: slower, more deliberate scroll-to-pan).
      // Clamped so tiny strips don't produce a 100px wrapper and huge
      // strips don't produce a 20k px one.
      const viewportH = window.innerHeight;
      const budget = Math.min(9000, Math.max(1200, 2 * state.max / ACC_SAT));
      captureEl.style.height = (viewportH + budget) + 'px';
    };
    syncCaptureHeight();

    // (Auto-finish whoosh removed — confusing UX. User scrolls the
    // remaining timeline manually, same as before.)

    const onWindowScroll = () => {
      if (!captureEl || scrollMode === 'off') return;
      const rect = captureEl.getBoundingClientRect();
      const stickyH = window.innerHeight;
      const progressRange = captureEl.offsetHeight - stickyH;
      if (progressRange <= 0) { state.target = 0; return; }
      // -rect.top = how far into the capture region the viewport top has moved
      const scrolled = -rect.top;
      const progress = Math.max(0, Math.min(1, scrolled / progressRange));
      // Expose to HistoryTimeline so it can drive its own transform
      // independently of accordion.current (timeline continues past
      // accordion saturation).
      state.captureProgress = progress;
      // Accordion saturates at ACC_SAT.
      state.target = Math.min(state.max, (progress / ACC_SAT) * state.max);
      ensureRunning();
    };
    window.addEventListener('scroll', onWindowScroll, { passive: true });
    window.addEventListener('resize', syncCaptureHeight);
    // Kick once so initial state matches current scroll position
    onWindowScroll();

    // Hover-only mouse handler (drag removed — scroll is the one way to
    // traverse the strip, to keep progress coherent with window.scrollY).
    const onMouseMove = (e) => {
      const idx = sitIndexAt(e.clientX, e.clientY);
      setHoverIdx(prev => prev === idx ? prev : idx);
    };
    const onMouseLeave = () => {
      setHoverIdx(prev => prev === -1 ? prev : -1);
    };
    // Dev focal picker — shift-click computes the focal point (as a
     // fraction of the source image) for the clicked tile based on
     // where within the tile you clicked. Maps screen-click → source
     // image coords, then updates the tile's focal immediately AND
     // logs a JSON snippet you can paste into sits.js. Localhost-only.
    const isDevMode = () => {
      const h = window.location.hostname;
      return h === 'localhost' || h === '127.0.0.1' || h.startsWith('192.168.');
    };
    const pickFocal = (e) => {
      const idx = sitIndexAt(e.clientX, e.clientY);
      if (idx < 0) return;
      const slot = imageSlots[idx];
      if (!slot || !slot.el) return;
      const { w: mw, h: mh } = slotSize(slot);
      if (!mw || !mh) return;
      // Work backward through the same transforms used in draw()
      const rect = canvas.getBoundingClientRect();
      const localX = e.clientX - rect.left;
      const localY = e.clientY - rect.top;
      const viewCenterX = rect.width / 2;
      const viewCenterY = rect.height / 2;
      const cascadeOffsetPick = (state.cascadeEase || 0) * (rect.width + CANVAS_STRIP_PITCH * CANVAS_CASCADE_INDEX_LAG * idx);
      const anchorX = viewCenterX + idx * CANVAS_STRIP_PITCH - state.current + cascadeOffsetPick;
      const dist = Math.abs(anchorX - viewCenterX);
      const intensity = Math.min(1, Math.abs(state.latency) / CANVAS_LATENCY_SCALE);
      const falloff = dist < CANVAS_BULGE_RADIUS
        ? 1 - (dist / CANVAS_BULGE_RADIUS < 0.5
            ? 2 * Math.pow(dist / CANVAS_BULGE_RADIUS, 2)
            : -1 + (4 - 2 * dist / CANVAS_BULGE_RADIUS) * dist / CANVAS_BULGE_RADIUS)
        : 0;
      const zoom = 1 + falloff * intensity * CANVAS_BULGE_PEAK;
      const lift = falloff * intensity * CANVAS_LIFT_PEAK_PX;
      const drawW = CANVAS_STRIP_W;
      const drawH = rect.height * 0.72 * zoom;
      const drawX = anchorX - drawW / 2;
      const drawY = viewCenterY - drawH / 2 - lift;
      const contentZoom = 1; // content cover-fit is uniform; zoom is whole-tile

      // Local click normalized inside the draw rect (0..1)
      const rx = (localX - drawX) / drawW;
      const ry = (localY - drawY) / drawH;
      if (rx < 0 || rx > 1 || ry < 0 || ry > 1) return;

      // Compute source rect that was sampled (cover-fit + zoom)
      const stripAspect = drawW / drawH;
      const mediaAspect = mw / mh;
      let sw, sh;
      if (mediaAspect > stripAspect) { sh = mh; sw = sh * stripAspect; }
      else { sw = mw; sh = sw / stripAspect; }
      sw /= contentZoom;
      sh /= contentZoom;
      const [fx, fy] = slot.focal;
      const sx = Math.max(0, Math.min(mw - sw, mw * fx - sw / 2));
      const sy = Math.max(0, Math.min(mh - sh, mh * fy - sh / 2));

      // The clicked point in source-image coords:
      const clickSx = sx + rx * sw;
      const clickSy = sy + ry * sh;
      const newFx = clickSx / mw;
      const newFy = clickSy / mh;
      // Apply immediately for feedback
      slot.focal = [newFx, newFy];
      scheduleDraw();

      // Persist to localStorage under the sit's slug so the choice survives
      // page reloads. When you're ready to commit, run window.__dumpFocals()
      // in the console to get a paste-ready block for sits.js.
      const sit = sitRows[idx].sit;
      try {
        const existing = readFocalOverrides();
        existing[sit.slug] = [newFx, newFy];
        localStorage.setItem(FOCAL_STORAGE_KEY, JSON.stringify(existing));
      } catch {}

      const pct = (n) => `${(n * 100).toFixed(1)}%`;
      const cover = sit.media.find(m => m.feed) || sit.media[0];
      const msg = `[focal] ${sit.slug} :: ${pct(newFx)} ${pct(newFy)}   — add to ${cover?.src?.split('/').pop()}: focal: '${pct(newFx)} ${pct(newFy)}'`;
      console.log(msg);
      try { navigator.clipboard?.writeText(`focal: '${pct(newFx)} ${pct(newFy)}'`); } catch {}
    };

    // Dev helper: print all saved focal overrides as ready-to-paste lines
    // for sits.js. Call window.__dumpFocals() in the console.
    if (isDevMode()) {
      window.__dumpFocals = () => {
        const pct = (n) => `${(n * 100).toFixed(1)}%`;
        const overrides = readFocalOverrides();
        const lines = Object.entries(overrides).map(([slug, [fx, fy]]) =>
          `${slug}: '${pct(fx)} ${pct(fy)}'`
        );
        console.log('// Paste these focal values into the matching media entries in sits.js:');
        console.log(lines.join('\n'));
        return overrides;
      };
      window.__clearFocals = () => {
        localStorage.removeItem(FOCAL_STORAGE_KEY);
        console.log('[focal] cleared all stored overrides — reload to revert to sits.js defaults');
      };
    }

    const onClick = (e) => {
      if (e.shiftKey && isDevMode()) { pickFocal(e); return; }
      const idx = sitIndexAt(e.clientX, e.clientY);
      if (idx >= 0 && onOpen) onOpen({ sit: sitRows[idx].sit });
    };
    container.addEventListener('mousemove', onMouseMove);
    container.addEventListener('mouseleave', onMouseLeave);
    container.addEventListener('click', onClick);

    return () => {
      container.removeEventListener('mousemove', onMouseMove);
      container.removeEventListener('mouseleave', onMouseLeave);
      container.removeEventListener('click', onClick);
      window.removeEventListener('scroll', onWindowScroll);
      window.removeEventListener('resize', syncCaptureHeight);
      if (state.rafId) cancelAnimationFrame(state.rafId);
      clearInterval(videoPulse);
      ro && ro.disconnect();
      imageSlots.forEach(slot => {
        if (slot?.type === 'image' && slot.el) { slot.el.onload = null; slot.el.onerror = null; }
        if (slot?.type === 'video' && slot.el) { try { slot.el.pause(); slot.el.src = ''; } catch {} }
      });
    };
  }, [sitRows, onOpen]);

  // Atmosphere + per-tile hue layer removed — strips revert to aristide's
  // uniform-content approach. Only the active strip differs from peers.

  // Page-level summary: a thin trust strip across the top, fades after
  // user starts interacting. Same data as OneSitFeed used.
  const stats = window.SITE_STATS;
  const rs = window.REVIEW_STATS;
  const summary = stats && rs ? [
    'house + dog sitting',
    'boulder, co',
    `${stats.years} yrs`,
    `${rs.count} verified reviews`,
    `${rs.average.toFixed(2)}/5`,
  ] : null;

  return (
    <div ref={hostRef} className="sit-accordion-host">
      <h1 className="visually-hidden">
        Dylan Agema — house and dog sitter, Boulder, Colorado
      </h1>

      {/* Full-page load veil — solid dark overlay above all content
          during the 'loading' phase. Only the loader chrome punches
          through. When phase flips to 'cascading' the veil unmounts
          (CSS takes care of its own fade-out via animation-fill-mode). */}
      {introPhase === 'loading' && (
        <div className="accordion-veil" aria-hidden="true" />
      )}
      {/* Load-in chrome — counts 0→100 as cover images decode, then hides
          when the cascade animation begins. Pinned top-left, appears only
          during the 'loading' phase. */}
      {introPhase === 'loading' && (
        <div className="accordion-loader" aria-live="polite" aria-label={`Loading ${introPct}%`}>
          <span className="accordion-loader__count mono-caps">
            {String(introPct).padStart(3, '0')}
          </span>
          <span className="accordion-loader__bar" aria-hidden="true">
            <span
              className="accordion-loader__bar-fill"
              style={{ transform: `scaleX(${introPct / 100})` }}
            />
          </span>
        </div>
      )}


      {/* Scroll-capture sandwich: the .sit-accordion-capture wrapper is
          tall (100vh + horizontal budget). The inner .sit-accordion-sticky
          is position:sticky so it pins to viewport top while the wrapper
          scrolls past. Progress through the wrapper maps to horizontal
          strip scroll. The accordion AND the timeline both live inside
          the sticky — they share one flush-left origin and glide together
          for the entire capture range. User must reach the end before
          the archive below comes into view. */}
      <div ref={captureRef} className="sit-accordion-capture">
        <div className="sit-accordion-sticky">
          <div className="sit-accordion-sticky__inner">
            <div
              ref={containerRef}
              className="sit-accordion"
              data-active={activeKey ? 'true' : 'false'}
            >
              <canvas ref={canvasRef} className="sit-accordion__canvas" />
            </div>
            <HistoryTimeline
              activeKey={activeKey}
              onOpen={onOpen}
              syncScroll={scrollRef}
              introPhase={introPhase}
            />
          </div>
        </div>
      </div>

      {/* History archive — folded-in scroll section. Shows every sit
          (including those without media on R2 yet) grouped by year, so
          the strip band above isn't the only way to reach a memory. */}
      <SitArchive onOpen={onOpen} onNav={onNav} />

      {/* End-of-feed contact block — minimal by request. No name, no
          tagline. Four equal-weight links: Book a sit, Email, About,
          TrustedHousesitters. Each is mono-caps so the whole row reads
          as a single horizontal affordance line. */}
      <footer className="accordion-contact intro-fade" aria-label="contact">
        <nav className="accordion-contact__links mono-caps">
          <a href="mailto:dylan.agema@gmail.com?subject=Housesit%20request&body=Hi%20Dylan%20%E2%80%94%0A%0AI'd%20like%20to%20check%20your%20availability%20for%20a%20sit.%0A%0ADates%3A%0ALocation%3A%0APet(s)%3A%0A%0AThanks%2C%0A">Book a sit ↗</a>
          <a href="mailto:dylan.agema@gmail.com">Email ↗</a>
          {onNav && <button type="button" onClick={() => onNav('info')}>About</button>}
          <a href="https://www.trustedhousesitters.com/house-and-pet-sitters/united-states/colorado/boulder/l/2277339/" target="_blank" rel="noopener noreferrer">Trustedhousesitters ↗</a>
        </nav>
      </footer>
    </div>
  );
};

// ---- Single sit row ------------------------------------------------
// Renders one sit's cover (video or photo) cropped to a horizontal
// slice. Active row expands tall via CSS flex. All videos play when
// in viewport and no overlay is open.
const SitAccordionRow = ({ sit, sitIdx, total, isActive, onOpen }) => {
  const cover = useAccMemo(() => {
    return sit.media.find(m => m.feed) || sit.media[0] || null;
  }, [sit]);
  const isVideo = cover && cover.type === 'video' && cover.src;
  const plate = (cover && cover.plate) || 'linear-gradient(180deg, #2e2b25, #0c0b09)';
  const isGradient = plate.startsWith('linear-') || plate.startsWith('radial-');
  const hasImage = cover && cover.src && cover.type !== 'video' && !cover.src.startsWith('linear-');
  const bg = hasImage ? `url(${cover.src})` : (isGradient ? plate : `url(${plate})`);

  const rootRef = useAccRef(null);
  const videoRef = useAccRef(null);
  // Video starts transparent; reveals over the plate when the browser
  // reports it can play. Prevents a black flash over the plate while the
  // video buffers.
  const [videoReady, setVideoReady] = useAccState(false);

  // Play when in viewport AND no overlay open. Pause otherwise.
  // IO + body class observer like OneSitFrame.
  useAccEffect(() => {
    const v = videoRef.current;
    const root = rootRef.current;
    if (!v || !root) return;
    let inView = false;
    const sync = () => {
      const overlayOpen = document.body.classList.contains('has-sit-overlay');
      if (inView && !overlayOpen) v.play().catch(() => {});
      else { try { v.pause(); } catch {} }
    };
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) inView = e.isIntersecting;
      sync();
    }, { threshold: 0 });
    io.observe(root);
    const mo = new MutationObserver(sync);
    mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
    return () => { io.disconnect(); mo.disconnect(); };
  }, []);

  const d1 = new Date(sit.start_date + 'T00:00:00');
  const month = d1.toLocaleString('en', { month: 'short' }).toUpperCase();
  const year = d1.getFullYear();

  const handleClick = () => {
    if (onOpen) onOpen({ sit });
  };

  return (
    <div
      ref={rootRef}
      data-row-key={sit.slug}
      className={`sit-accordion__row${isActive ? ' is-active' : ''}`}
      onClick={handleClick}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}
      role="button"
      tabIndex={0}
      aria-label={`Open ${sit.location}, ${month} ${year}`}
    >
      {/* Focal point for the cover crop. Every photo is a dog or cat, so
          the subject almost always sits in the upper third of a landscape
          frame — default to `50% 38%` which favors that band. Override
          per-media with `cover.focal` (e.g. 'right center', '30% 20%')
          in sits.js when a specific photo needs tuning. */}
      <div
        className="sit-accordion__row-media"
        style={{ '--focal': cover?.focal || '50% 38%' }}
      >
        <div className="sit-accordion__row-plate" style={{ backgroundImage: bg }} />
        {isVideo && (
          <video
            ref={videoRef}
            src={cover.src}
            muted loop playsInline preload="auto"
            disablePictureInPicture disableRemotePlayback controls={false}
            data-ready={videoReady ? 'true' : 'false'}
            onCanPlay={() => setVideoReady(true)}
            onLoadedData={() => setVideoReady(true)}
          />
        )}
      </div>

      {/* Vignette gradient at the bottom for label contrast. */}
      <div className="sit-accordion__row-vignette" aria-hidden />

      {/* Corner labels — only fully readable when active or hovered. */}
      <div className="sit-accordion__row-label">
        <span className="mono-caps">{sit.location_short}</span>
        <span className="mono-caps">{month} {year}</span>
      </div>
    </div>
  );
};

// Helper — which interaction tier a sit supports:
//   'full'   — has media (click opens overlay; media shown; reviews shown)
//   'review' — no media but has a review (click expands an inline quote)
//   'none'   — neither; non-interactive entry, shown as a line in the record
const hasMediaFn = (s) => (s.media || []).some(m =>
  m && m.src && !m.src.startsWith('linear-') && !m.src.startsWith('radial-')
);
const sitTier = (s) => {
  if (hasMediaFn(s)) return 'full';
  const rev = window.REVIEW_BY_SLUG && window.REVIEW_BY_SLUG[s.slug];
  if (rev && rev.quote) return 'review';
  return 'none';
};

// ---- HistoryTimeline ------------------------------------------------
// Duration-weighted bar strip covering EVERY completed sit (not just
// the media-having subset shown in the hero strip above). Inspired by
// the Travels page's TravelsTimeline but styled to match the hero —
// same 10px corners, 4px gap, dark palette. Year labels overlaid on the
// strip between year-groups. Interaction tiers:
//
//   'full'   (has media)  → click opens the sit overlay
//   'review' (has review) → click expands an inline review quote below
//   'none'                → non-interactive reference bar
//
// Color: bars use the same warm-ink range as the hero strip, with
// 'full' tier slightly brighter than 'review' / 'none' so the viewer
// can scan where the photographed sits cluster.
const HistoryTimeline = ({ activeKey, onOpen, syncScroll, introPhase = 'done' }) => {
  const allSits = useAccMemo(() => (window.SITS || [])
    .filter(s => s.status !== 'available')
    .slice()
    .sort((a, b) => a.start_date < b.start_date ? 1 : -1), []);

  const [hoverSlug, setHoverSlug] = useAccState(null);
  const [expandedSlug, setExpandedSlug] = useAccState(null);
  const trackRef = useAccRef(null);

  // The timeline tracks the SCROLL-CAPTURE progress (0..1), not the
  // accordion's horizontal offset. The accordion saturates at ACC_SAT
  // (= 0.55 of capture scroll) while the timeline continues to 1.0 —
  // so the user must keep scrolling through the timeline after the
  // accordion has hit its end, before the archive comes into view.
  // This makes the timeline feel SLOWER than the accordion: same
  // vertical scroll distance moves the timeline proportionally less.
  useAccEffect(() => {
    const track = trackRef.current;
    if (!track || !syncScroll?.current) return;
    const mqFine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
    if (mqFine && !mqFine.matches) return;

    let rafId = 0;
    const update = () => {
      const accState = syncScroll.current;
      if (!accState) return;
      const progress = Math.max(0, Math.min(1, accState.captureProgress || 0));
      const timelineMax = Math.max(0, track.scrollWidth - (track.parentElement?.clientWidth || 0));
      const x = progress * timelineMax;
      track.style.transform = `translate3d(${-x}px, 0, 0)`;
      rafId = requestAnimationFrame(update);
    };
    rafId = requestAnimationFrame(update);
    return () => { if (rafId) cancelAnimationFrame(rafId); };
  }, [syncScroll]);

  const parse = (iso) => new Date(iso + 'T00:00:00').getTime();
  const daysOf = (s) => Math.max(1, Math.round((parse(s.end_date) - parse(s.start_date)) / 86400000));
  const fmtDate = (iso) => {
    const d = new Date(iso + 'T00:00:00');
    return `${d.toLocaleString('en',{month:'short'}).toLowerCase()} ${d.getUTCDate()}`;
  };

  // Active sit = cursor-hovered on this strip OR cursor-hovered on hero strip above.
  const activeSit = hoverSlug
    ? allSits.find(s => s.slug === hoverSlug)
    : (activeKey ? allSits.find(s => s.slug === activeKey) : null);

  if (!allSits.length) return null;

  const expandedSit = expandedSlug ? allSits.find(s => s.slug === expandedSlug) : null;
  const expandedReview = expandedSit && window.REVIEW_BY_SLUG
    ? window.REVIEW_BY_SLUG[expandedSit.slug]
    : null;

  const handleBarClick = (s) => {
    const tier = sitTier(s);
    if (tier === 'full' && onOpen) onOpen({ sit: s });
    else if (tier === 'review') {
      // Toggle the inline review — collapse if it's already the active one.
      setExpandedSlug(prev => prev === s.slug ? null : s.slug);
    }
  };

  return (
    <section className="sit-timeline" aria-label="full history timeline">
      <div
        ref={trackRef}
        className={`sit-timeline__track sit-timeline__track--intro-${introPhase}`}
        onMouseLeave={() => setHoverSlug(null)}
        role="list"
      >
        {(() => {
          // Interleave year divider labels with bars so they live INSIDE
          // the track and translate with it as the user scrolls. Each
          // year label is a flex-item with 0 width — it sits in line
          // without occupying horizontal space, anchored visually to the
          // bar that follows it.
          const elements = [];
          let lastYear = null;
          // --bar-idx drives the per-bar cascade offset during load-in;
          // each bar starts at `100vw + idx*700px` off-screen right,
          // then transitions to 0 over ~2.2s with a small per-bar delay.
          let barIdx = 0;
          for (const s of allSits) {
            const y = s.start_date.slice(0, 4);
            if (y !== lastYear) {
              elements.push(
                <span
                  key={`y-${y}`}
                  className="sit-timeline__year mono-caps"
                  aria-hidden="true"
                >
                  {y}
                </span>
              );
              lastYear = y;
            }
            const tier = sitTier(s);
            const days = daysOf(s);
            const active = hoverSlug === s.slug || activeKey === s.slug || expandedSlug === s.slug;
            const clickable = tier !== 'none';
            const idx = barIdx++;
            elements.push(
              <button
                type="button"
                key={s.slug}
                role="listitem"
                className={`sit-timeline__bar sit-timeline__bar--${tier}${active ? ' is-active' : ''}`}
                style={{ '--days': days, '--bar-idx': idx }}
                onMouseEnter={() => setHoverSlug(s.slug)}
                onFocus={() => setHoverSlug(s.slug)}
                onBlur={() => setHoverSlug(null)}
                onClick={clickable ? () => handleBarClick(s) : undefined}
                aria-label={`${s.location_short}, ${fmtDate(s.start_date)} – ${fmtDate(s.end_date)}${tier === 'none' ? ' (record only — hover for details)' : ''}`}
                data-tier={tier}
                tabIndex={0}
                aria-disabled={!clickable}
              />
            );
          }
          return elements;
        })()}
      </div>

      {/* Hover/active meta line — only renders when a bar is active.
          No idle hint text (keeps the strip minimal). */}
      <div className="sit-timeline__meta mono-caps intro-fade" aria-live="polite">
        {activeSit && (
          <>
            <span className="sit-timeline__meta-loc">{activeSit.location_short}</span>
            <span className="sit-timeline__meta-sep">·</span>
            <span>{fmtDate(activeSit.start_date)} – {fmtDate(activeSit.end_date)}</span>
            <span className="sit-timeline__meta-sep">·</span>
            <span>{daysOf(activeSit)}d</span>
          </>
        )}
      </div>

      {/* Inline review expansion — appears when a review-only bar is clicked. */}
      {expandedReview && (
        <div className="sit-timeline__review" role="region" aria-label={`review from ${expandedReview.reviewer_first}`}>
          <button
            type="button"
            className="sit-timeline__review-close"
            onClick={() => setExpandedSlug(null)}
            aria-label="Close review"
          >
            ×
          </button>
          <span className="sit-timeline__review-glyph" aria-hidden="true">&#8220;</span>
          <blockquote className="sit-timeline__review-quote">
            {expandedReview.quote}
          </blockquote>
          <p className="sit-timeline__review-attrib mono-caps">
            — {expandedReview.reviewer_first}
            {expandedReview.reviewer_city ? `, ${expandedReview.reviewer_city}` : ''}
            {expandedSit && <> · {expandedSit.location_short} · {fmtDate(expandedSit.start_date)} – {fmtDate(expandedSit.end_date)}</>}
          </p>
        </div>
      )}
    </section>
  );
};

// ---- SitArchive ----------------------------------------------------
// Folded-in history archive. One row per sit, grouped by year. Includes
// all completed sits — even ones without R2 media — so the record is
// complete. Interaction tier:
//   'full'   — click opens the sit overlay (media page, shows photos + review)
//   'review' — click expands the review inline below the row
//   'none'   — no click action; the row is a record-only entry
const SitArchive = ({ onOpen, onNav }) => {
  const THIS_YEAR = new Date().getUTCFullYear();
  const allSits = useAccMemo(() => {
    return (window.SITS || [])
      .filter(s => s.status !== 'available')
      .slice()
      .sort((a, b) => a.start_date < b.start_date ? 1 : -1);
  }, []);

  const [expandedSlug, setExpandedSlug] = useAccState(null);

  // Whole-archive collapsed by default for the minimalist look. Clicking
  // "the archive" in the header toggles the entire year-list block.
  const [archiveOpen, setArchiveOpen] = useAccState(false);
  const [expanded, setExpanded] = useAccState(() => {
    const seen = new Set();
    const result = {};
    for (const s of allSits) {
      const y = s.start_date.slice(0, 4);
      if (!seen.has(y)) {
        seen.add(y);
        result[y] = parseInt(y, 10) === THIS_YEAR;
      }
    }
    return result;
  });

  const byYear = useAccMemo(() => {
    const grouped = {};
    for (const s of allSits) {
      const y = s.start_date.slice(0, 4);
      (grouped[y] ||= []).push(s);
    }
    return grouped;
  }, [allSits]);

  const years = Object.keys(byYear).sort().reverse();
  const toggleYear = (y) => setExpanded(s => ({ ...s, [y]: !s[y] }));

  const fmtRange = (s) => {
    const d1 = new Date(s.start_date + 'T00:00:00');
    const d2 = new Date(s.end_date + 'T00:00:00');
    const days = Math.max(1, Math.round((d2 - d1) / 86400000));
    const m = (d) => d.toLocaleString('en', { month: 'short' }).toLowerCase();
    return { range: `${m(d1)} ${d1.getUTCDate()} – ${m(d2)} ${d2.getUTCDate()}`, days };
  };

  if (!allSits.length) return null;
  return (
    <section
      className={`home-archive intro-fade${archiveOpen ? ' is-open' : ''}`}
      aria-label="full sit archive"
    >
      <header className="home-archive__head">
        <button
          type="button"
          className="home-archive__title mono-caps home-archive__toggle"
          onClick={() => setArchiveOpen((o) => !o)}
          aria-expanded={archiveOpen}
          aria-controls="home-archive-list"
        >
          <span className="home-archive__toggle-chev" aria-hidden="true">
            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 2 L7 5 L3 8" />
            </svg>
          </span>
          the archive
          <span className="home-archive__toggle-count mono-caps">{allSits.length} sits</span>
        </button>
      </header>
      {archiveOpen && <div id="home-archive-list">
      {years.map(y => {
        const sits = byYear[y];
        const open = !!expanded[y];
        return (
          <div key={y} className={`home-archive__year${open ? ' is-open' : ''}`}>
            <button
              type="button"
              className="home-archive__year-head"
              onClick={() => toggleYear(y)}
              aria-expanded={open}
            >
              <span className="home-archive__chev" aria-hidden="true">
                <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M3 2 L7 5 L3 8" />
                </svg>
              </span>
              <span className="home-archive__year-num">{y}</span>
              <span className="home-archive__year-count mono-caps">
                {sits.length} sit{sits.length === 1 ? '' : 's'}
              </span>
              <span className="home-archive__rule" aria-hidden="true" />
            </button>
            {open && (
              <ol className="home-archive__list">
                {sits.map(s => {
                  const { range, days } = fmtRange(s);
                  const tier = sitTier(s);
                  const isExpanded = expandedSlug === s.slug;
                  const handleOpen = () => {
                    if (tier === 'full' && onOpen) onOpen({ sit: s });
                    else if (tier === 'review') {
                      setExpandedSlug(prev => prev === s.slug ? null : s.slug);
                    }
                  };
                  const clickable = tier !== 'none';
                  const review = tier === 'review' && window.REVIEW_BY_SLUG
                    ? window.REVIEW_BY_SLUG[s.slug]
                    : null;
                  return (
                    <React.Fragment key={s.slug}>
                      <li
                        className={`home-archive__row home-archive__row--${tier}${isExpanded ? ' is-expanded' : ''}`}
                        onClick={clickable ? handleOpen : undefined}
                        onKeyDown={(e) => {
                          if (clickable && (e.key === 'Enter' || e.key === ' ')) {
                            e.preventDefault();
                            handleOpen();
                          }
                        }}
                        role={clickable ? 'button' : undefined}
                        tabIndex={clickable ? 0 : undefined}
                        aria-label={tier === 'full'
                          ? `Open ${s.location}, ${range}`
                          : tier === 'review'
                          ? `${isExpanded ? 'Collapse' : 'Read'} review for ${s.location}, ${range}`
                          : `${s.location}, ${range}`}
                        aria-expanded={tier === 'review' ? isExpanded : undefined}
                        aria-disabled={!clickable}
                      >
                        <span className="home-archive__row-date mono-caps">{range}</span>
                        <span className="home-archive__row-loc">{s.location_short}</span>
                        <span className="home-archive__row-days mono-caps">{days}d</span>
                        <span className="home-archive__row-dog mono-caps">
                          {s.dog || '—'}
                        </span>
                      </li>
                      {isExpanded && review && (
                        <li className="home-archive__review" role="region" aria-label={`Review from ${review.reviewer_first}`}>
                          <span className="home-archive__review-glyph" aria-hidden="true">&#8220;</span>
                          <blockquote className="home-archive__review-quote">
                            {review.quote}
                          </blockquote>
                          <p className="home-archive__review-attrib mono-caps">
                            — {review.reviewer_first}
                            {review.reviewer_city ? `, ${review.reviewer_city}` : ''}
                          </p>
                        </li>
                      )}
                    </React.Fragment>
                  );
                })}
              </ol>
            )}
          </div>
        );
      })}
      </div>}
    </section>
  );
};

window.SitAccordion = SitAccordion;
