// 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 }) => {
  const isMobile = useAccMemo(() => {
    const mqTouch = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
    const isTouch = mqTouch && !mqTouch.matches;
    const isNarrow = window.innerWidth <= 768;
    return isTouch || isNarrow;
  }, []);

  // 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__ || isMobile) ? '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. Still advance the decoded
    // counter so the intro phase can transition past 'loading'.
    const mqFine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
    if (mqFine && !mqFine.matches) {
      setDecodedCount(sitRows.length);
      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' || state.dragging) 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');
      };
    }

    // Click-and-drag: pointer events on the canvas container let the user
    // grab and swipe through the strip horizontally. While dragging, the
    // scroll handler is suppressed (state.dragging). On release, window
    // scrollY is synced to match the new target so scroll-capture stays
    // coherent. A 5px threshold distinguishes drag from click.
    const DRAG_THRESHOLD = 5;
    let dragStartX = 0;
    let dragStartTarget = 0;
    let dragMoved = 0;
    let dragSuppressClick = false;

    const syncScrollToTarget = () => {
      if (!captureEl) return;
      const stickyH = window.innerHeight;
      const progressRange = captureEl.offsetHeight - stickyH;
      if (progressRange <= 0 || state.max <= 0) return;
      const progress = Math.min(1, (state.target / state.max) * ACC_SAT);
      const scrolled = progress * progressRange;
      const captureTop = captureEl.getBoundingClientRect().top + window.scrollY;
      window.scrollTo({ top: captureTop + scrolled, behavior: 'instant' });
    };

    const onPointerDown = (e) => {
      if (e.button !== 0) return;
      dragStartX = e.clientX;
      dragStartTarget = state.target;
      dragMoved = 0;
      dragSuppressClick = false;
      state.dragging = true;
      try { container.setPointerCapture(e.pointerId); } catch {}
      container.style.cursor = 'grabbing';
    };
    const onPointerMove = (e) => {
      if (!state.dragging) return;
      const dx = e.clientX - dragStartX;
      dragMoved = Math.max(dragMoved, Math.abs(dx));
      if (dragMoved > DRAG_THRESHOLD) {
        dragSuppressClick = true;
        state.target = Math.max(0, Math.min(state.max, dragStartTarget - dx));
        ensureRunning();
      }
    };
    const onPointerUp = (e) => {
      if (!state.dragging) return;
      state.dragging = false;
      container.style.cursor = '';
      try { container.releasePointerCapture(e.pointerId); } catch {}
      if (dragMoved > DRAG_THRESHOLD) syncScrollToTarget();
    };
    container.addEventListener('pointerdown', onPointerDown);
    container.addEventListener('pointermove', onPointerMove);
    container.addEventListener('pointerup', onPointerUp);
    container.addEventListener('pointercancel', onPointerUp);

    const onClick = (e) => {
      if (dragSuppressClick) { dragSuppressClick = false; return; }
      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);
      container.removeEventListener('pointerdown', onPointerDown);
      container.removeEventListener('pointermove', onPointerMove);
      container.removeEventListener('pointerup', onPointerUp);
      container.removeEventListener('pointercancel', onPointerUp);
      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 + loader chrome — desktop only. Mobile
          renders DOM rows that don't need a preload gate. */}
      {!isMobile && introPhase === 'loading' && (
        <div className="accordion-veil" aria-hidden="true" />
      )}
      {!isMobile && 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>
      )}


      {isMobile ? (
        /* Mobile: render tappable DOM rows in a natural vertical scroll.
           No canvas, no sticky capture, no scroll hijacking. */
        <div className="sit-accordion" data-mobile="true">
          {rows.map((r) => r.kind === 'year' ? (
            <div key={r.key} className="sit-accordion__year mono-caps">{r.year}</div>
          ) : (
            <SitAccordionRow
              key={r.key}
              sit={r.sit}
              sitIdx={0}
              total={sitRows.length}
              isActive={false}
              onOpen={onOpen}
            />
          ))}
        </div>
      ) : (
        /* Desktop: scroll-capture sandwich — sticky canvas strip. */
        <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;
