/* Dailies Flow 1 — Feed app
   Loaded as <script type="text/babel"> after React + iOS frame + Tweaks panel
   ============================================================ */

const { useState, useEffect, useRef, useCallback, useMemo, Fragment } = React;

/* ---------- Data ---------- */
const ITEMS = [
  { id: 1,  ip: 'ELOISE',       assetType: 'Character pose',   version: 'v2',        blurb: 'Reading on the radiator.',                kind: 'image',   author: 'Maya Chen',    role: 'Lead Character Artist',    when: '8m',  thumb: 'eloise-photo', due: '2026-05-26', status: 'needs-review', returnedFrom: 'v1', versions: [ { label: 'v1', when: 'Yest.', notes: [ { who: 'Brian', text: 'Silhouette reads flat. Push the back arch and lift the chin.' }, { who: 'Brian', text: 'Warm the radiator glow on her face.' } ] } ] },
  { id: 2,  ip: 'BUCKET LIST',  assetType: 'Color pass',       version: 'Final',     episode: 'Ep. 4', blurb: 'Cold open.',            kind: 'video',   author: 'Jordan Park',  role: 'Editor',                   when: '21m', dur: '2:34',  thumb: 'bucket', due: '2026-05-27', status: 'needs-review', reviewers: ['Brian', 'Eve'], approvals: ['Eve'] },
  { id: 13, ip: 'ELOISE',       assetType: 'Script',           version: 'Draft 3.a', episode: 'Pilot', blurb: '\u201cTurtle Party\u201d \u2014 ready for your pass.', kind: 'pdf', author: 'MJ Offen', role: 'Writer', when: '11m', docKind: 'Script', pages: 7, due: '2026-05-27', status: 'needs-review' },
  { id: 14, ip: 'ELOISE',       assetType: 'Animatic',         version: 'Cut A',     episode: 'Pilot', blurb: '\u201cTurtle Party\u201d \u2014 first cut for timing.', kind: 'video', author: 'Tom Hayes', role: 'Senior Editor', when: '14m', dur: '1:48', thumb: 'eloise-cream', due: '2026-05-28', status: 'needs-review' },
  { id: 15, ip: 'ELOISE',       assetType: 'Episode song',     version: 'v1',        episode: 'Pilot', track: '\u201cTurtle Party\u201d Theme', blurb: 'K-pop cut for the end-credits dance beat.', kind: 'audio', author: 'Ren Kato', role: 'Music Supervisor', when: '17m', dur: '2:00', thumb: 'eloise-cream', due: '2026-05-28', status: 'needs-review', reviewers: ['Brian', 'Kathleen'], approvals: ['Kathleen'], versions: [ { label: 'Music pass', when: '30m', notes: [ { who: 'Kathleen', text: 'Hook reads. Keep the drop tight into the chorus.' } ] } ] },
  { id: 3,  ip: 'IS\u00d7OP',   assetType: 'Title card',       version: 'Variant',   blurb: '\u201cThe Crew Lands.\u201d',             kind: 'image',   author: 'Lin Wei',      role: 'Title Design',             when: '32m', thumb: 'speed', due: '2026-06-02', status: 'needs-review' },
  { id: 4,  ip: 'ELOISE',       assetType: 'Mood board',                             blurb: 'Kitchen scene \u2014 lamp warmth check.', kind: 'image',   author: 'Ava Bell',     role: 'Art Director',             when: '47m', thumb: 'eloise-cream', due: '2026-05-28', status: 'needs-review' },
  { id: 5,  ip: 'BUCKET LIST',  assetType: 'Sponsor reel',     version: 'Cut B',     blurb: 'Pacing feels long?',                      kind: 'video',   author: 'Marcus Reed',  role: 'Editor',                   when: '1h',  dur: '1:12',  thumb: 'bucket', due: '2026-05-26', status: 'out-for-pass', versions: [ { label: 'Cut A', when: 'Yest.', notes: [ { who: 'Brian', text: 'Tighten the middle 20 seconds. It drags before the logo.' } ] } ] },
  { id: 6,  ip: 'ELOISE',       assetType: 'Endpaper',         version: 'Final ink', blurb: 'Illustration \u2014 final ink pass.',     kind: 'image',   author: 'Maya Chen',    role: 'Lead Character Artist',    when: '1h',  thumb: 'eloise-photo', due: '2026-05-25', status: 'approved', reviewers: ['Brian', 'Eve'], approvals: ['Brian', 'Eve'] },
  { id: 7,  ip: 'BUCKET LIST',  assetType: 'Sizzle reel',      version: 'Intro',     blurb: 'Needs your eye.',                         kind: 'video',   author: 'Tom Hayes',    role: 'Senior Editor',            when: '1h',  dur: '0:48',  thumb: 'bucket', due: '2026-06-10', status: 'needs-review' },
  { id: 9,  ip: 'BUCKET LIST',  assetType: 'Stinger',          version: 'Alt B',     episode: 'Ep. 5', blurb: 'Without music bed.',    kind: 'video',   author: 'Jordan Park',  role: 'Editor',                   when: '2h',  dur: '0:22',  thumb: 'bucket', due: '2026-06-02', status: 'needs-review' },
  { id: 10, ip: 'IS\u00d7OP',   assetType: 'Key art',                                blurb: 'Luffy pose & lighting.',                  kind: 'image',   author: 'Lin Wei',      role: 'Title Design',             when: '3h',  thumb: 'speed', due: '2026-06-10', status: 'out-for-pass', versions: [ { label: 'v1', when: 'Mon', notes: [ { who: 'Brian', text: 'Push the key light warmer and drop the background a stop.' } ] } ] },
  { id: 12, ip: 'ELOISE',       assetType: 'Spread',           version: 'Layout B',  episode: 'Ch. 2', blurb: 'Double-page.',          kind: 'image',   author: 'Ava Bell',     role: 'Art Director',             when: 'Yest.', thumb: 'eloise-cream', due: '2026-06-10', status: 'needs-review' },
];

/* ---------- Projects: grouping label + priority ----------
   Project-level metadata keyed by item.ip. Priority drives the IP-group order
   and the group-head flag. Deadlines used to live here; they are now per-asset
   (item.due) because the studio reviews assets, not whole projects, to a date. */
const PROJECTS = {
  'ELOISE':      { label: 'Eloise',      priority: 'High'   },
  'BUCKET LIST': { label: 'Bucket List', priority: 'High'   },
  'IS\u00d7OP':  { label: 'IS\u00d7OP',   priority: 'Medium' },
  'MISC':        { label: 'Misc',        priority: 'Low'    },
};
const PRIORITY_RANK = { High: 3, Medium: 2, Low: 1 };

function projectMeta(ip) {
  return PROJECTS[ip] || { label: ip, priority: 'Low' };
}

/* ---------- Dates & urgency ----------
   TODAY is the session's reference date (matches the slate eyebrow, Tue May 27).
   ISO 'YYYY-MM-DD' sorts lexically = chronologically, so due dates sort raw.
   Day deltas run off UTC midnight so a DST boundary never moves a due date. */
const TODAY = '2026-05-27';
function isoToUTC(iso) { const [y, m, d] = iso.split('-').map(Number); return Date.UTC(y, m - 1, d); }
function daysUntil(iso) { return Math.round((isoToUTC(iso) - isoToUTC(TODAY)) / 86400000); }

const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
/* 'YYYY-MM-DD' -> 'Fri \u00b7 May 29' (the due-date group header). */
function fmtDateLong(iso) {
  if (!iso) return '';
  const [y, m, d] = iso.split('-').map(Number);
  const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
  return `${WEEKDAYS[wd]} \u00b7 ${MONTHS[m - 1]} ${d}`;
}
/* Terse relative line for the due chip: 'Overdue 2d' / 'Today' / 'Tomorrow' /
   'in 5d' / 'May 29'. Reads like a status, not a calendar. */
function fmtDue(iso) {
  if (!iso) return 'No date';
  const n = daysUntil(iso);
  if (n < 0) return `Overdue ${-n}d`;
  if (n === 0) return 'Today';
  if (n === 1) return 'Tomorrow';
  if (n <= 7) return `in ${n}d`;
  const [, m, d] = iso.split('-').map(Number);
  return `${MONTHS[m - 1]} ${d}`;
}

/* Urgency buckets, hottest first. temp feeds the temperature class shared with
   priority dots (.due-overdue coral / .due-soon amber / .due-later teal) so the
   queue reads urgency by color without ever touching the reserved gold. */
const URGENCY = {
  overdue: { rank: 0, label: 'Overdue',   temp: 'overdue' },
  today:   { rank: 1, label: 'Today',     temp: 'overdue' },
  soon:    { rank: 2, label: 'This Week', temp: 'soon'    },
  later:   { rank: 3, label: 'Later',     temp: 'later'   },
  none:    { rank: 4, label: 'No Date',   temp: 'later'   },
};
function urgencyKey(item) {
  if (!item.due) return 'none';
  const n = daysUntil(item.due);
  if (n < 0) return 'overdue';
  if (n === 0) return 'today';
  if (n <= 7) return 'soon';
  return 'later';
}
function dueTemp(item) { return URGENCY[urgencyKey(item)].temp; }
function dueSortKey(item) { return item.due || '9999-12-31'; }

/* 'Waiting for your eyes' = a version that still needs my review. out-for-pass
   (ball with the IC) is still in discussion and stays in the feed. There is no
   "locked" resting state: an approved asset is terminal and drops out of the
   feed into the Past Approvals archive. */
function isWaiting(item) { return item.status === 'needs-review'; }
/* Approved is the decisive sign-off; such items leave the feed entirely. */
function isApproved(item) { return item.status === 'approved'; }

/* Projects in display order: priority desc, then label. Shared by the summary
   breakdowns so they read as one stable table of contents (deadline no longer
   lives at the project level, so the soonest-due tiebreak moved into groupBy). */
const PROJECT_ORDER = Object.keys(PROJECTS).sort((a, b) => {
  const pr = PRIORITY_RANK[PROJECTS[b].priority] - PRIORITY_RANK[PROJECTS[a].priority];
  if (pr) return pr;
  return PROJECTS[a].label < PROJECTS[b].label ? -1 : 1;
});

/* Per-project waiting counts (needs-review only) for the summary breakdowns. */
function countByProject(items) {
  const c = {};
  Object.keys(PROJECTS).forEach(k => { c[k] = 0; });
  items.forEach(it => { if (isWaiting(it)) c[it.ip] = (c[it.ip] || 0) + 1; });
  return c;
}

/* ---------- Grouping: by project, urgency, or due date ----------
   One engine, three axes. Every group is { id, kind, title, items } plus
   axis-specific header fields. Items inside a group always sort by due date
   ascending (the queue's primary driver); the incoming order (newest-first)
   is the stable tiebreak. No-date sinks last on every axis. */
function bySoonestDue(items) {
  return items
    .map((it, i) => [it, i])
    .sort((a, b) => {
      const da = dueSortKey(a[0]), db = dueSortKey(b[0]);
      if (da !== db) return da < db ? -1 : 1;
      return a[1] - b[1];
    })
    .map(pair => pair[0]);
}
function soonestDue(items) {
  return items.reduce((min, it) => { const k = dueSortKey(it); return k < min ? k : min; }, '9999-12-31');
}

function groupBy(items, axis) {
  if (axis === 'urgency') return groupByUrgency(items);
  if (axis === 'due') return groupByDue(items);
  return groupByIp(items);
}

/* IP: largest project group first; ties keep the first-seen feed order. */
function groupByIp(items) {
  const order = [];
  const byIp = new Map();
  items.forEach(it => {
    if (!byIp.has(it.ip)) { byIp.set(it.ip, []); order.push(it.ip); }
    byIp.get(it.ip).push(it);
  });
  return order
    .map((ip, idx) => {
      const groupItems = bySoonestDue(byIp.get(ip));
      const meta = projectMeta(ip);
      return { id: ip, kind: 'ip', title: meta.label, ip, priority: meta.priority, soonest: soonestDue(groupItems), order: idx, items: groupItems };
    })
    .sort((a, b) => {
      const count = b.items.length - a.items.length;
      if (count) return count;
      return a.order - b.order;
    });
}

/* Urgency: fixed bucket order, hottest first. */
function groupByUrgency(items) {
  const byKey = new Map();
  items.forEach(it => { const k = urgencyKey(it); if (!byKey.has(k)) byKey.set(k, []); byKey.get(k).push(it); });
  return [...byKey.keys()]
    .map(k => ({ id: k, kind: 'urgency', title: URGENCY[k].label, temp: URGENCY[k].temp, rank: URGENCY[k].rank, items: bySoonestDue(byKey.get(k)) }))
    .sort((a, b) => a.rank - b.rank);
}

/* Due date: one group per calendar day, ascending; undated sinks last. */
function groupByDue(items) {
  const order = [];
  const byDay = new Map();
  items.forEach(it => { const k = dueSortKey(it); if (!byDay.has(k)) { byDay.set(k, []); order.push(k); } byDay.get(k).push(it); });
  return order
    .map(k => {
      const undated = k === '9999-12-31';
      return { id: k, kind: 'due', title: undated ? 'No Date' : fmtDateLong(k), iso: undated ? null : k, temp: undated ? 'later' : dueTemp({ due: k }), items: bySoonestDue(byDay.get(k)) };
    })
    .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
}

/* ---------- Icons (BSP minimal vocabulary) ---------- */
const ArrowNE = ({size=14}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M7 17L17 7M17 7H7M17 7v10"/>
  </svg>
);
const PlayIcon = ({size=14}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" stroke="none">
    <path d="M7 5l13 7-13 7V5z"/>
  </svg>
);
const DocIcon = ({size=24}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
    <path d="M14 2H7a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V7l-5-5z"/>
    <path d="M14 2v5h5"/>
    <path d="M9 13h6M9 17h4"/>
  </svg>
);
const MicTinyIcon = ({size=10}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="3" width="6" height="11" rx="3"/>
    <path d="M5 11a7 7 0 0014 0M12 18v3"/>
  </svg>
);
const BackChevron = () => (
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M15 18l-6-6 6-6"/>
  </svg>
);

/* ---------- Thumbnail ---------- */
function Thumb({ item, mono = 'auto', children }) {
  const isVideo = item.kind === 'video';
  const isReq = item.kind === 'request';
  const isAudio = item.kind === 'audio';

  if (isReq) {
    return (
      <div className="thumb thumb-request">
        <DocIcon size={32}/>
        <span>Text request</span>
      </div>
    );
  }

  if (item.kind === 'pdf') {
    return (
      <div className="thumb thumb-pdf">
        <div className="pg">
          <i className="s"></i>
          <i className="c"></i>
          <i></i>
          <i className="s"></i>
          <i className="c"></i>
          <i></i>
        </div>
      </div>
    );
  }

  // pick bg + mono treatment
  let bgClass = '';
  let label = '';
  let imgSrc = null;
  switch (item.thumb) {
    case 'eloise-photo':
      bgClass = ''; // image fills
      imgSrc = 'assets/eloise.jpg';
      break;
    case 'eloise-cream':
      bgClass = 'thumb-bg-eloise';
      label = 'ELOISE';
      break;
    case 'bucket':
      bgClass = 'thumb-bg-bucket';
      label = 'BLF';
      break;
    case 'speed':
      bgClass = 'thumb-bg-speed';
      label = 'IS\u00d7OP';
      break;
    case 'misc':
    default:
      bgClass = 'thumb-bg-misc';
      label = 'MISC';
      break;
  }

  return (
    <div className={`thumb ${bgClass} ${isVideo ? 'video' : ''} ${isAudio ? 'audio' : ''}`}>
      {imgSrc && <img src={imgSrc} alt={item.ip}/>}
      {!imgSrc && label && <div className={`thumb-mono ${mono === 'small' ? 'small' : ''}`}>{label}</div>}
      {isAudio && <div className="thumb-wave" aria-hidden="true">{Array.from({ length: 14 }, (_, i) => <i key={i}/>)}</div>}
      {(isVideo || isAudio) && (
        <div className="play-mark">
          <PlayIcon size={mono === 'small' ? 12 : 16}/>
        </div>
      )}
      {children}
    </div>
  );
}

/* ---------- Effort tag (asset type + duration) ---------- */
function EffortTag({ item }) {
  if (item.kind === 'video') {
    return (
      <span className="effort-tag">
        <span className="icon"><PlayIcon size={9}/></span>
        {item.dur}
      </span>
    );
  }
  if (item.kind === 'request') {
    return <span className="effort-tag">Read</span>;
  }
  if (item.kind === 'pdf') {
    return (
      <span className="effort-tag">
        <span className="icon"><DocIcon size={10}/></span>
        {item.pages ? `${item.pages}p` : (item.docKind || 'PDF')}
      </span>
    );
  }
  if (item.kind === 'audio') {
    return (
      <span className="effort-tag">
        <span className="icon"><WaveIcon size={11}/></span>
        {item.dur}
      </span>
    );
  }
  return <span className="effort-tag">Image</span>;
}

/* Asset-type banner: a full-width strip of raw gold text at the very top of the
   card (type, then a "·" separator + key metric), divided from the body by a
   hairline. Replaces the old trailing effort pill so every card states its type
   the same way, up top, where the eye lands first. */
function typeBannerParts(item) {
  switch (item.kind) {
    case 'video':   return ['Video', item.dur || null];
    case 'pdf':     return [item.docKind || 'Document', item.pages ? `${item.pages} ${item.pages === 1 ? 'page' : 'pages'}` : null];
    case 'request': return ['Text request', null];
    case 'audio':   return ['Audio', item.dur || null];
    default:        return ['Image', null];
  }
}
function TypeBanner({ item, showDue }) {
  const [type, meta] = typeBannerParts(item);
  return (
    <div className="card-type">
      <span className="ct-type">{type}</span>
      {meta && <span className="ct-sep" aria-hidden="true">{'\u00b7'}</span>}
      {meta && <span className="ct-meta">{meta}</span>}
      {showDue && <DueChip item={item}/>}
    </div>
  );
}

/* Higher-level asset name: assetType + version. The show lives in the group
   header and the episode/chapter is a broken-out tag, so neither repeats here. */
function AssetTitle({ item }) {
  if (!item.assetType) return null;
  return (
    <div className="asset-title">
      <span className="at-type">{item.assetType}</span>
      {item.version && <span className="at-version">{item.version}</span>}
    </div>
  );
}

/* Broken-out tags. showIp is true when the group header does NOT already carry
   the property (urgency / due-date axes), so the card restates it; episode/
   chapter shows only when present. */
function CardTags({ item, showIp }) {
  return (
    <>
      {showIp && <span className="ip-tag">{item.ip}</span>}
      {item.episode && <span className="ep-tag">{item.episode}</span>}
    </>
  );
}

/* Due chip: the queue's loudest non-gold signal. Temperature reads urgency
   (coral overdue/today, amber this-week, teal later); the dot + terse relative
   ('Overdue 2d', 'Today', 'in 5d') land where the eye hits the card first. */
function DueChip({ item }) {
  return (
    <span className={`due-chip due-${dueTemp(item)}`} title={item.due ? fmtDateLong(item.due) : 'No date'}>
      <span className="due-dot" aria-hidden="true"></span>
      <span className="due-rel">{fmtDue(item.due)}</span>
    </span>
  );
}

const FEED_REVIEWER = 'Brian';

function hasNamedApproval(item, name) {
  const approvals = item.approvals || [];
  for (let i = 0; i < approvals.length; i++) if (approvals[i] === name) return true;
  return false;
}
function hasMyNote(item) {
  const versions = item.versions || [];
  for (let v = 0; v < versions.length; v++) {
    const notes = versions[v].notes || [];
    for (let n = 0; n < notes.length; n++) if (notes[n].who === FEED_REVIEWER) return true;
  }
  return false;
}
function hasOtherNote(item) {
  const versions = item.versions || [];
  for (let v = 0; v < versions.length; v++) {
    const notes = versions[v].notes || [];
    for (let n = 0; n < notes.length; n++) {
      const who = notes[n].who;
      if (who && who !== FEED_REVIEWER) return true;
    }
  }
  return false;
}
function hasMyReview(item) {
  return hasNamedApproval(item, FEED_REVIEWER) || hasMyNote(item);
}
function hasStakeholderInput(item) {
  const approvals = item.approvals || [];
  for (let i = 0; i < approvals.length; i++) if (approvals[i] && approvals[i] !== FEED_REVIEWER) return true;
  return hasOtherNote(item);
}
function ReviewSignals({ item }) {
  const mine = hasMyReview(item);
  const stakeholder = hasStakeholderInput(item);
  if (!mine && !stakeholder) return null;
  return (
    <>
      {mine && <span className="review-signal mine">Reviewed by you</span>}
      {stakeholder && <span className="review-signal stakeholder">Stakeholder input</span>}
    </>
  );
}

/* Feed-level review signals distinguish my history from other stakeholder activity. */

/* Status pill: the iteration state, kept quiet and secondary (due + IP drive,
   not status). Approved assets leave the feed, so the pill only ever marks work
   still in discussion: out for a pass, returned, or a partial sign-off tally. */
function StatusPill({ item }) {
  if (item.status === 'out-for-pass') return <span className="status-pill st-pass">Out for a pass</span>;
  if (item.returnedFrom) return <span className="status-pill st-back">Back {'\u00b7'} {item.version}</span>;
  const reviewers = item.reviewers || null;
  if (reviewers && reviewers.length > 1) {
    return <span className="status-pill st-signed">{(item.approvals || []).length}/{reviewers.length} signed</span>;
  }
  return null;
}

/* ---------- The three card layouts ---------- */
function cardClass(layout, item) {
  return ['card', layout, `st-${item.status}`, item.returnedFrom ? 'is-returned' : ''].filter(Boolean).join(' ');
}

function StoryboardCard({ item, onOpen, axis }) {
  const showIp = axis !== 'ip';
  return (
    <article className={cardClass('storyboard', item)} onClick={() => onOpen(item)}>
      <Thumb item={item}/>
      <div className="caption">
        <AssetTitle item={item}/>
        <div className="meta-row">
          <CardTags item={item} showIp={showIp}/>
          <StatusPill item={item}/>
          <ReviewSignals item={item}/>
        </div>
        <div className="blurb">{item.blurb}</div>
        <div className="footline">
          <div className="author-row">
            <span className="who">{item.author}</span>
            <span className="dot"></span>
            <span className="when">{item.when}</span>
          </div>
          {axis !== 'due' && <DueChip item={item}/>}
        </div>
      </div>
    </article>
  );
}

function IndexCard({ item, onOpen, axis }) {
  const showIp = axis !== 'ip';
  const showDue = axis !== 'due';
  return (
    <article className={cardClass('indexcard', item)} onClick={() => onOpen(item)}>
      <TypeBanner item={item} showDue={showDue}/>
      <div className="ix-main">
        <Thumb item={item} mono="small"/>
        <div className="body">
          <AssetTitle item={item}/>
          <div className="top-row">
            <CardTags item={item} showIp={showIp}/>
            <StatusPill item={item}/>
            <ReviewSignals item={item}/>
          </div>
          <div className="blurb">{item.blurb}</div>
          <div className="bottom-row">
            <span className="who-line">
              <strong>{item.author}</strong> &middot; {item.when}
            </span>
          </div>
        </div>
      </div>
    </article>
  );
}

function MarqueeCard({ item, onOpen, axis }) {
  const showIp = axis !== 'ip';
  return (
    <article className={cardClass('marquee', item)} onClick={() => onOpen(item)}>
      <Thumb item={item} mono="large"/>
      <div className="scrim"></div>
      <span className="bracket tl"></span>
      <span className="bracket br"></span>
      <div className="overlay">
        <div className="marquee-top">
          {axis !== 'due' && <DueChip item={item}/>}
          <StatusPill item={item}/>
          <ReviewSignals item={item}/>
        </div>
        <AssetTitle item={item}/>
        <div className="meta-row">
          <CardTags item={item} showIp={showIp}/>
        </div>
        <div className="blurb">{item.blurb}</div>
        <div className="who-row">
          <span className="who-line"><strong>{item.author}</strong> &middot; {item.role} &middot; {item.when}</span>
        </div>
      </div>
    </article>
  );
}

const CardByLayout = { storyboard: StoryboardCard, indexcard: IndexCard, marquee: MarqueeCard };

/* ---------- Project group (accordion) ---------- */
const GroupChevron = ({ size = 16 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M6 9l6 6 6-6"/>
  </svg>
);

function FeedGroup({ group, open, onToggle, Card, onOpen }) {
  const { id, kind, title, items } = group;
  const slug = String(id).toLowerCase().replace(/[^a-z0-9]+/g, '-');
  const bodyId = `group-body-${slug}`;
  const waiting = items.filter(isWaiting).length;
  const extra = items.length - waiting;
  return (
    <section className={`project-group feed-group group-${kind} ${open ? 'open' : ''}`}>
      <button
        type="button"
        className="group-head"
        aria-expanded={open}
        aria-controls={bodyId}
        onClick={onToggle}
      >
        {(kind === 'urgency' || kind === 'due') && <span className={`group-temp temp-${group.temp}`} aria-hidden="true"></span>}
        <span className="group-title">{title}</span>
        <span className="group-count">
          {waiting} waiting{extra > 0 && <span className="group-extra"> {'\u00b7'} +{extra}</span>}
        </span>
        <span className="group-flags">
          {kind === 'ip' && (
            <>
              <span className={`prio prio-${group.priority.toLowerCase()}`}>
                <span className="prio-dot" aria-hidden="true"></span>
                {group.priority}
              </span>
              <span className="flag-sep" aria-hidden="true"></span>
              <DueChip item={{ due: group.soonest === '9999-12-31' ? null : group.soonest }}/>
            </>
          )}
          {kind === 'due' && group.iso && (
            <span className={`due-chip due-${group.temp}`}>
              <span className="due-dot" aria-hidden="true"></span>
              <span className="due-rel">{fmtDue(group.iso)}</span>
            </span>
          )}
        </span>
        <span className="group-chevron" aria-hidden="true"><GroupChevron/></span>
      </button>
      <div className="group-body" id={bodyId} role="region" aria-label={title} aria-hidden={!open}>
        <div className="group-body-inner">
          <div className="group-grid">
            {items.map(item => (
              <Card key={item.id} item={item} onOpen={onOpen} axis={kind} />
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ---------- Summary block ---------- */
function Summary({ items, variant }) {
  const counts = useMemo(() => countByProject(items), [items]);
  const total = items.filter(isWaiting).length;

  if (variant === 'strip') {
    return (
      <div className="summary strip reveal d2">
        <div className="strip-num">
          <span className="n">{total}</span>
          <span className="l">Waiting</span>
        </div>
        <div className="strip-breakdown">
          {PROJECT_ORDER.map(key => (
            <div className="strip-cell" key={key}>
              <span className="n">{counts[key]}</span>
              <span className="l">{PROJECTS[key].label}</span>
            </div>
          ))}
        </div>
      </div>
    );
  }

  return (
    <div className="summary reveal d2">
      <div className="summary-top">
        <span className="summary-num">{total}</span>
        <span className="summary-of">
          <strong>Waiting</strong>
          For your eyes today
        </span>
      </div>
      <div className="summary-breakdown">
        {PROJECT_ORDER.map(key => (
          <div className="bd-cell" key={key}>
            <div className="n">{counts[key]}</div>
            <div className="l">{PROJECTS[key].label}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ---------- Mic / send icons (Flow 2) ---------- */
const MicFullIcon = ({size=20}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="3" width="6" height="11" rx="3"/>
    <path d="M5 11a7 7 0 0014 0M12 18v3"/>
  </svg>
);
const StopIcon = ({size=12}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor">
    <rect x="6" y="6" width="12" height="12"/>
  </svg>
);
const SendArrow = ({size=18}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M5 12h14M13 6l6 6-6 6"/>
  </svg>
);

/* ---------- Scripted transcripts (faked voice-to-text) ---------- */
const TRANSCRIPTS = {
  ELOISE: [
    "The warmth is reading right \u2014 the cream wash on the wall is working. Push the silhouette one more notch though; her hair could be wilder, more attitude.",
    "And the dog. The dog is pulling focus. Either drop the pug's eyeline so he's looking up at her, or push him back another six inches.",
    "Otherwise this is the closest we've been to the book. Send it to Maya \u2014 say I'd like to see the radiator angle too.",
  ],
  'BUCKET LIST': [
    "Pacing in the middle is dragging. The second beat \u2014 the dad's reaction shot \u2014 it's a full count too long.",
    "Color pass is great. Hold on the wide for one more frame at 0:34, then cut in tight.",
    "Sound bed feels generic. Ask Marcus to try something with more bottom end.",
  ],
  'IS\u00d7OP': [
    "Composition is strong. The crew silhouettes against the sky read at any size.",
    "Type is the problem \u2014 the kanji need more breathing room, and the English subtitle is fighting the lockup.",
    "Try a version where the title sits lower and we let the sky carry the top third.",
  ],
  MISC: [
    "Direction's solid. Bring me a cut with temp sound next pass.",
    "And tighten the open by about three seconds \u2014 we lose them on the slow burn.",
  ],
};
function transcriptFor(item, callIndex) {
  if (!item) return '';
  const pool = TRANSCRIPTS[item.ip] || TRANSCRIPTS.MISC;
  return pool[callIndex % pool.length];
}

/* ---------- Structured feedback generator (scripted "LLM" output) ---------- */
const STRUCTURED = {
  ELOISE: {
    headline: 'Pose v2 \u2014 silhouette push, redirect the dog',
    prose: 'Warmth and palette are working. The cream wash carries the spread. Silhouette needs one more level of attitude before we lock the pose. The dog is also pulling focus and needs redirection.',
    actions: [
      'Push the silhouette one more notch \u2014 wilder hair, more attitude.',
      'Redirect the dog: eyeline up, or move him back six inches.',
      'Loop Maya \u2014 ask for the radiator-angle alternative too.',
    ],
  },
  'BUCKET LIST': {
    headline: 'Cold open \u2014 pacing + sound bed',
    sections: [
      { label: 'Early', prose: 'Open is strong. Color pass is great.', actions: ['Hold the wide one more frame at the opening.'] },
      { label: 'Middle', prose: 'Second beat \u2014 the dad reaction \u2014 runs a full count too long. Pacing drags.', actions: ['Trim the dad reaction by one count.', 'Tighten the music bridge underneath.'] },
      { label: 'Late', prose: 'Sound bed reads generic in the closing moments.', actions: ['Ask Marcus for an alt bed with more bottom end.'] },
    ],
  },
  'IS\u00d7OP': {
    headline: 'Title card \u2014 composition holds, type is the fight',
    prose: 'Composition is strong; silhouettes against the sky read at any size. The type lockup is fighting itself \u2014 kanji needs more breathing room, and the English subtitle is crowding the mark.',
    actions: [
      'Open the kanji tracking; give it breathing room.',
      'Move the English subtitle below the lockup.',
      'Try a variant with the title lower \u2014 let the sky carry the top third.',
    ],
  },
  MISC: {
    headline: 'Direction is solid \u2014 tighten + bring sound',
    prose: 'Direction is solid. Pacing on the open is slack; trim a few seconds. Next pass should have temp sound so we can read it the way an audience will.',
    actions: [
      'Trim the opening by roughly three seconds.',
      'Bring me the next pass with temp sound.',
    ],
  },
};
function structureFeedback(item) {
  if (!item) return null;
  const base = STRUCTURED[item.ip] || STRUCTURED.MISC;
  return { ...base, isVideo: item.kind === 'video', ip: item.ip };
}

/* ---------- Agent talk-back lines (Flow 3) ---------- */
const AGENT_EDITS = [
  {
    instruction: 'Make the second action about color, not composition.',
    applies: () => true,
    transform: (s) => {
      if (s.sections) {
        return {
          ...s,
          sections: s.sections.map((sec, i) => i === 1 ? {
            ...sec,
            actions: sec.actions.map((a, j) => j === 0 ? a.replace(/pacing|reaction/i, 'warmth in the grade') : a),
          } : sec),
        };
      }
      return {
        ...s,
        actions: s.actions.map((a, i) => i === 1 ? 'Warm up the dog\u2019s grade so he reads softer alongside her.' : a),
      };
    },
    response: 'Updated. I rewrote action two to focus on warmth and grade.',
  },
];
function applyScriptedAgentEdit(structured) {
  const edit = AGENT_EDITS[0];
  return { newStructured: edit.transform(structured), agentMessage: edit.response, userMessage: edit.instruction };
}

function fmtTime(s) {
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${m}:${String(r).padStart(2, '0')}`;
}

/* ---------- Live waveform (scripted, no real audio) ---------- */
function Waveform({ active, bars = 32 }) {
  // Stable per-bar phases so the animation looks like a real signal
  const phases = useMemo(() => {
    return Array.from({ length: bars }, (_, i) => ({
      delay: -(((i * 137) % 1000) / 1000) * 1.1,
      duration: 0.8 + ((i * 53) % 100) / 100 * 0.7,
      scale: 0.55 + ((i * 89) % 100) / 100 * 0.45,
    }));
  }, [bars]);
  return (
    <div className={`waveform ${active ? 'active' : ''}`} aria-hidden="true">
      {phases.map((p, i) => (
        <span
          key={i}
          className="wave-bar"
          style={{
            animationDelay: `${p.delay}s`,
            animationDuration: `${p.duration}s`,
            '--scale': p.scale,
          }}
        />
      ))}
    </div>
  );
}

/* ---------- Feedback bar (chat-style input, always present) ---------- */
function FeedbackBar({ feedbackState, draft, onDraftChange, onMicToggle, onSend, secs }) {
  const taRef = useRef(null);
  // Auto-grow textarea
  useEffect(() => {
    const t = taRef.current;
    if (!t) return;
    t.style.height = 'auto';
    t.style.height = Math.min(t.scrollHeight, 200) + 'px';
  }, [draft, feedbackState]);

  const isRecording = feedbackState === 'recording';
  const hasDraft = draft.trim().length > 0;
  const showSend = isRecording || hasDraft;

  return (
    <div className="feedback-bar" data-state={feedbackState}>
      <div className="fb-inner">
        {!isRecording && (
          <textarea
            ref={taRef}
            className="fb-input"
            placeholder="Type or tap the mic to speak"
            value={draft}
            onChange={e => onDraftChange(e.target.value)}
            rows={1}
            autoFocus={feedbackState === 'paused'}
          />
        )}
        {isRecording && (
          <div className="fb-rec">
            <Waveform active/>
            <div className="fb-rec-meta">
              <span className="rec-dot" aria-hidden="true"></span>
              <span className="rec-label">Listening</span>
              <span className="rec-time">{fmtTime(secs)}</span>
            </div>
          </div>
        )}
        <div className="fb-actions">
          <button
            type="button"
            className={`fb-mic ${isRecording ? 'recording' : ''}`}
            onClick={onMicToggle}
            aria-label={isRecording ? 'Pause recording' : 'Start recording'}
          >
            {isRecording ? <StopIcon size={12}/> : <MicFullIcon size={20}/>}
          </button>
          <button
            type="button"
            className="fb-send"
            onClick={onSend}
            disabled={!showSend}
            aria-label="Send for validation"
            style={{
              border: '1px solid',
              borderColor: showSend ? '#FFD200' : 'rgba(255,255,255,0.1)',
              backgroundColor: showSend ? '#FFD200' : 'transparent',
              color: showSend ? '#1E2A33' : 'rgba(255,255,255,0.6)',
              cursor: showSend ? 'pointer' : 'not-allowed',
            }}
          >
            <SendArrow size={18}/>
          </button>
        </div>
      </div>
      {feedbackState === 'paused' && draft && (
        <div className="fb-hint">
          {'Transcript saved \u2014 edit, add more, or send.'}
        </div>
      )}
    </div>
  );
}

/* ---------- Validation view (Flow 3) ---------- */
function EditableText({ value, onChange, className, multiline = false }) {
  const ref = useRef(null);
  const isFirst = useRef(true);
  useEffect(() => {
    // Only push value to DOM on first mount; after that, the user owns it
    if (isFirst.current && ref.current) {
      ref.current.textContent = value;
      isFirst.current = false;
    }
  }, []);
  // When `value` changes from outside (e.g. agent edit), sync
  useEffect(() => {
    if (!isFirst.current && ref.current && ref.current.textContent !== value) {
      ref.current.textContent = value;
    }
  }, [value]);
  return (
    <span
      ref={ref}
      className={`editable ${className || ''}`}
      contentEditable
      suppressContentEditableWarning
      onBlur={e => onChange(e.currentTarget.textContent)}
    />
  );
}

function ValidationView({ structured, onStructuredChange, agentLog, isVideo }) {
  if (!structured) return null;

  const updateProse = (key, val, sectionIdx) => {
    const next = { ...structured };
    if (sectionIdx != null && next.sections) {
      next.sections = next.sections.map((s, i) => i === sectionIdx ? { ...s, [key]: val } : s);
    } else {
      next[key] = val;
    }
    onStructuredChange(next);
  };
  const updateAction = (val, actIdx, sectionIdx) => {
    const next = { ...structured };
    if (sectionIdx != null && next.sections) {
      next.sections = next.sections.map((s, i) => i === sectionIdx ? {
        ...s,
        actions: s.actions.map((a, j) => j === actIdx ? val : a),
      } : s);
    } else {
      next.actions = next.actions.map((a, i) => i === actIdx ? val : a);
    }
    onStructuredChange(next);
  };

  return (
    <div className="validation">
      <div className="v-eyebrow">
        <span className="v-lbl">Structured Feedback</span>
        <span className="v-meta">{'Pre-flight \u2014 your eyes only'}</span>
      </div>

      <div className="v-headline">
        <EditableText
          value={structured.headline}
          onChange={v => updateProse('headline', v)}
        />
      </div>

      {/* Single-prose (image) vs sectioned (video) */}
      {!structured.sections && (
        <>
          <div className="v-section">
            <div className="v-section-head">
              <span className="v-sec-lbl">Actions</span>
              <span className="v-sec-count">{structured.actions.length}</span>
            </div>
            <ul className="v-actions">
              {structured.actions.map((a, i) => (
                <li key={i} className="v-action">
                  <span className="v-bullet">{String(i + 1).padStart(2, '0')}</span>
                  <EditableText
                    value={a}
                    onChange={v => updateAction(v, i)}
                    className="v-action-text"
                  />
                </li>
              ))}
            </ul>
          </div>
          <div className="v-section">
            <div className="v-section-head">
              <span className="v-sec-lbl">Context</span>
            </div>
            <p className="v-prose">
              <EditableText
                value={structured.prose}
                onChange={v => updateProse('prose', v)}
              />
            </p>
          </div>
        </>
      )}

      {structured.sections && (
        <div className="v-sections">
          {structured.sections.map((sec, si) => (
            <div key={si} className="v-vsec">
              <div className="v-vsec-head">
                <span className="v-vsec-tag">{sec.label}</span>
                <span className="v-vsec-rule"></span>
              </div>
              <p className="v-prose v-prose-tight">
                <EditableText
                  value={sec.prose}
                  onChange={v => updateProse('prose', v, si)}
                />
              </p>
              <ul className="v-actions v-actions-tight">
                {sec.actions.map((a, i) => (
                  <li key={i} className="v-action">
                    <span className="v-bullet">{String(i + 1).padStart(2, '0')}</span>
                    <EditableText
                      value={a}
                      onChange={v => updateAction(v, i, si)}
                      className="v-action-text"
                    />
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      )}

      {agentLog.length > 0 && (
        <div className="v-agent-log">
          {agentLog.map((msg, i) => (
            <div key={i} className={`v-agent-msg ${msg.role}`}>
              <span className="v-agent-eb">{msg.role === 'user' ? 'You' : 'Agent'}</span>
              <span className="v-agent-body">{msg.text}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------- Agent input bar (Flow 3, mode-shifted from FeedbackBar) ---------- */
const SparkleIcon = ({size=14}) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/>
  </svg>
);

function AgentInput({ agentState, secs, onMicToggle, onSend, draft, onDraftChange }) {
  const taRef = useRef(null);
  useEffect(() => {
    const t = taRef.current;
    if (!t) return;
    t.style.height = 'auto';
    t.style.height = Math.min(t.scrollHeight, 140) + 'px';
  }, [draft, agentState]);

  const isRecording = agentState === 'recording';
  const isThinking = agentState === 'thinking';

  return (
    <div className="agent-bar" data-state={agentState}>
      <div className="agent-eyebrow">
        <SparkleIcon size={11}/>
        <span>To Agent</span>
      </div>
      <div className="agent-inner">
        {!isRecording && !isThinking && (
          <textarea
            ref={taRef}
            className="agent-input"
            placeholder={'Tell the agent what to change\u2026 or send as is.'}
            value={draft}
            onChange={e => onDraftChange(e.target.value)}
            rows={1}
          />
        )}
        {isRecording && (
          <div className="agent-rec">
            <Waveform active/>
            <div className="agent-rec-meta">
              <span className="rec-dot" aria-hidden="true"></span>
              <span className="rec-label">Agent listening</span>
              <span className="rec-time">{fmtTime(secs)}</span>
            </div>
          </div>
        )}
        {isThinking && (
          <div className="agent-thinking">
            <span className="agent-think-dots">
              <span></span><span></span><span></span>
            </span>
            <span className="agent-think-lbl">{'Agent applying edit\u2026'}</span>
          </div>
        )}
        <div className="agent-actions">
          <button
            type="button"
            className={`agent-mic ${isRecording ? 'recording' : ''}`}
            onClick={onMicToggle}
            disabled={isThinking}
            aria-label={isRecording ? 'Stop' : 'Talk to agent'}
            style={{
              border: '1px dashed',
              borderColor: isRecording ? '#FFD200' : 'rgba(255,210,0,0.5)',
              backgroundColor: isRecording ? '#FFD200' : 'transparent',
              color: isRecording ? '#1E2A33' : '#FFD200',
            }}
          >
            {isRecording ? <StopIcon size={12}/> : <SparkleIcon size={16}/>}
          </button>
          <button
            type="button"
            className="agent-send"
            onClick={onSend}
            disabled={isRecording || isThinking}
            aria-label="Request a pass"
            style={{
              border: '1px solid #FFD200',
              backgroundColor: '#FFD200',
              color: '#1E2A33',
              opacity: (isRecording || isThinking) ? 0.4 : 1,
              cursor: (isRecording || isThinking) ? 'not-allowed' : 'pointer',
            }}
          >
            <SendArrow size={18}/>
          </button>
        </div>
      </div>
    </div>
  );
}

/* ============================================================
   PDF / Document review (Flow 2, document variant)
   A faux shooting-script "page" with real selectable text. Highlight a
   passage -> drop a comment -> an anchored thread opens in the margin rail,
   where other reviewers and the writer (IC) interact. Mounted in parallel
   with ReviewOverlay (App routes by item.kind) so neither component takes a
   conditional hook path.
   ============================================================ */

const CheckIcon = ({ size = 12 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
);
const CommentIcon = ({ size = 14 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
);
const ExpandIcon = ({ size = 16 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
);
const CollapseIcon = ({ size = 16 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3M16 3v3a2 2 0 0 0 2 2h3M3 16h3a2 2 0 0 1 2 2v3M21 16h-3a2 2 0 0 0-2 2v3"/></svg>
);

const PDF_ME = { name: 'Brian Robbins', initials: 'BR', role: 'Co-Founder \u00b7 CEO' };

const PDF_DOC = {
  title: 'ELOISE AND ADLEY',
  subtitle: '\u201cTurtle Party\u201d \u00b7 Pilot',
  slug: 'Written by MJ Offen \u00b7 Draft 3.a (04.09.26)',
  foot: 'Eloise and Adley \u00b7 \u201cTurtle Party\u201d \u00b7 Pilot',
  blocks: [
    { id: 'b1',   type: 'scene',      text: 'EXT. PLAZA HOTEL - DAY (LIVE ACTION \u2014 VLOG STYLE)' },
    { id: 'b2',   type: 'action',     text: 'ADLEY arrives with her family. DAD comically struggles with a tower of luggage until a BELLHOP rushes over to help, while Mom wrangles the younger kids into the LOBBY.' },
    { id: 'b3',   type: 'character',  text: 'DAD' },
    { id: 'b4',   type: 'dialogue',   text: 'Oh, why thank you. So fancy!' },
    { id: 'b5',   type: 'character',  text: 'BELLHOP' },
    { id: 'b6',   type: 'dialogue',   text: 'Of course, sir. Happy to be of service.' },
    { id: 'b7',   type: 'action',     text: 'Dad runs off to catch up with Adley.' },
    { id: 'b8',   type: 'action',     text: 'Adley stops in the middle of the front foyer, hands on hips, taking it all in. Dad puts his hand on her shoulder.' },
    { id: 'b9',   type: 'character',  text: 'DAD' },
    { id: 'b10',  type: 'dialogue',   text: 'We made it, Adley! The legendary Plaza Hotel in New York City! Who\u2019s ready to vacay in style?' },
    { id: 'b11',  type: 'action',     text: 'Dad feigns fixing an imaginary bow tie around his neck.' },
    { id: 'b12',  type: 'character',  text: 'ADLEY' },
    { id: 'b13',  type: 'dialogue',   text: 'I\u2019ve got a feeling this is going to be the BEST \u201cBest Day Ever\u201d EVER!' },
    { id: 'b14',  type: 'action',     text: 'Adley spreads her arms and does a TWIRL of excitement.' },
    { id: 'b15',  type: 'action',     text: 'Enter BILL, the Jack-of-All-Trades Plaza employee \u2014 moving too quickly while carrying a stack of WRAPPED GIFTS. Catching a ride on top is SKIPPERDEE, Eloise\u2019s pet turtle.' },
    { id: 'b16',  type: 'action',     text: 'As Adley twirls and Bill can\u2019t see over the precarious tower of gifts, they collide. The gifts \u2014 AND TURTLE \u2014 go flying.' },
    { id: 'b17',  type: 'action',     text: 'Adley spots the flying reptile and leaps for the save. WHEW!' },
    { id: 'b18',  type: 'character',  text: 'ADLEY' },
    { id: 'b19',  type: 'dialogue',   text: 'Here you go, lil guy! You\u2019re okay.' },
    { id: 'b20',  type: 'character',  text: 'BILL' },
    { id: 'b21',  type: 'paren',      text: '(has NYC accent)' },
    { id: 'b22',  type: 'dialogue',   text: 'Thank you! Miss Eloise would\u2019ve thrown me to the pigeons if anything happened to Skipperdee!' },
    { id: 'b23',  type: 'character',  text: 'ADLEY' },
    { id: 'b24',  type: 'dialogue',   text: 'Eloise? Who\u2019s\u2014' },
    { id: 'b25',  type: 'action',     text: 'Skipperdee makes a cute turtle coo, distracting Adley.' },
    { id: 'b26',  type: 'character',  text: 'ADLEY' },
    { id: 'b27',  type: 'dialogue',   text: 'Oh! You\u2019re a cutie, Skipperdee.' },
    { id: 'b28',  type: 'action',     text: 'Adley GIGGLES and pats the turtle \u2014 it\u2019s a funny name. Dad reaches her side as Adley hands back Skipperdee.' },
    { id: 'b29',  type: 'action',     text: 'NICO and NAVEY look from Bill, to Dad, to Bill (both Shaun).' },
    { id: 'b30',  type: 'character',  text: 'NAVEY' },
    { id: 'b31',  type: 'dialogue',   text: 'You guys look alike.' },
    { id: 'b32',  type: 'character',  text: 'NICO' },
    { id: 'b33',  type: 'dialogue',   text: 'Yeah!' },
    { id: 'b34',  type: 'character',  text: 'DAD / BILL' },
    { id: 'b35',  type: 'paren',      text: '(eyeing each other)' },
    { id: 'b36',  type: 'dialogue',   text: 'I don\u2019t see it. / I don\u2019t see it.' },
    { id: 'b37',  type: 'character',  text: 'BILL' },
    { id: 'b38',  type: 'dialogue',   text: 'Well, can\u2019t keep the Princess of the Plaza waiting! Enjoy your stay.' },
    { id: 'b39',  type: 'action',     text: 'Bill moves on as Adley and her siblings GIGGLE. Mom, with baby ENZO, shoos them to keep moving toward the ELEVATORS.' },
    { id: 'b40',  type: 'scene',      text: 'INT. ADLEY\u2019S ROOM AT THE PLAZA - SOON (LIVE ACTION)' },
    { id: 'b41',  type: 'action',     text: 'Her Dad opens the door for her but keeps moving with the rest of the family. Adley enters her room and is WOWED anew.' },
    { id: 'b42',  type: 'character',  text: 'DAD (LIVE ACTION)' },
    { id: 'b43',  type: 'dialogue',   text: 'Cool room, A! We\u2019re right next door. Coming, honey! Oof!' },
    { id: 'b44',  type: 'action',     text: 'She throws herself on the big bed and makes a starfish shape.' },
    { id: 'b45',  type: 'action',     text: 'Her hand touches something that has fallen between the pillows \u2014 a gilded ENVELOPE. Instantly curious, she sits up and reads the outside:' },
    { id: 'b46',  type: 'character',  text: 'ADLEY' },
    { id: 'b47',  type: 'dialogue',   text: '\u201cTo my turtle\u2019s hero?\u201d' },
    { id: 'b48',  type: 'action',     text: 'Perplexed, she quickly opens it: GLOWING LIGHT SHINES out of the envelope as she pulls out a GOLDEN TICKET \u2014 jaw dropped. Transfixed, she reads the message aloud:' },
    { id: 'b49',  type: 'character',  text: 'ADLEY' },
    { id: 'b50',  type: 'dialogue',   text: '\u201cEloise cordially invites you to Skipperdee\u2019s Terrific Turtle Birthday Party. Today at two pm.\u201d \u2026Who is this Eloise kid?' },
    { id: 'b51',  type: 'action',     text: 'As if answering, the Golden Ticket glows even BRIGHTER. Then, on a wall of the room, a NEW DOOR appears with a GOLD SLOT.' },
    { id: 'b52',  type: 'action',     text: 'Intuitively, Adley sticks the ticket in the Gold Slot to open the mysterious door.' },
    { id: 'b53',  type: 'scene',      text: 'PORTAL TO ANIMATED WORLD' },
    { id: 'b54',  type: 'action',     text: 'Behind this door is a swirling GLITTER VORTEX. Swirling inside are things Eloise loves: a brush, shoes, a hand mirror, plush toys. Think: Alice falling down the rabbit hole crossed with the magic wardrobe to Narnia. Adley steps in and, with her first step, a PINK FLASH OF LIGHT transforms her into ANIMATED ADLEY.' },
    { id: 'b55',  type: 'character',  text: 'ADLEY' },
    { id: 'b56',  type: 'dialogue',   text: 'A PORTAL! This is totally a portal! I know a portal when I\u2019m in one.' },
    { id: 'b57',  type: 'action',     text: 'She catches a glimpse of herself in the mirror floating by.' },
    { id: 'b58',  type: 'character',  text: 'ADLEY' },
    { id: 'b59',  type: 'dialogue',   text: 'Whoa! Loving my new look! I\u2019m so\u2026 animated!' },
    { id: 'b60',  type: 'action',     text: 'She makes a silly face in the mirror, bouncing along like in low gravity on the moon. Before she even exits, we hear the signature BEEP BEEP of Eloise\u2019s fancy, motorized toy car\u2026' },
    { id: 'b61',  type: 'character',  text: 'ADLEY' },
    { id: 'b62',  type: 'dialogue',   text: 'Hello? Anybody there? I found this ticket in my room at the Plaz\u2014 AH!' },
    { id: 'b63',  type: 'action',     text: 'Just now, she drops out of the shot like a trap door opened.' },
    { id: 'b64',  type: 'scene',      text: 'INT. PLAZA HALLWAY - DAY (ANIMATED)' },
    { id: 'b65',  type: 'action',     text: 'Adley drops from midair YELLING into the passenger seat of Eloise\u2019s pink kid\u2019s MERCEDES JEEP. WEENIE sits in the backseat, panting happily. ELOISE wears pink goggles and gloves, cruising fast through the hallways. Skipperdee (animated now) rides on the Jeep\u2019s HOOD, wearing a BLACK SCARF that blows back. He looks as happy as a turtle can be.' },
    { id: 'b66',  type: 'character',  text: 'ELOISE' },
    { id: 'b67',  type: 'dialogue',   text: 'There you are! Fashionably late, I see. I approve. Seatbelts! This baby can go up to 18 M.P.H. That stands for miles per hour \u2014 not for My Plaza Hotel, of course.' },
    { id: 'b68',  type: 'character',  text: 'ADLEY' },
    { id: 'b69',  type: 'dialogue',   text: 'Oh, right.' },
    { id: 'b70',  type: 'action',     text: 'Adley buckles up just as Eloise takes a hard turn. Adley YELPS, LAUGHS. Eloise HONKS the horn in a friendly way \u2014 she\u2019s coming in too fast to avoid colliding with a HOUSEKEEPER (GRACE), who holds a WHITE FLUFFY ROBE. Grace skillfully steps out of Eloise\u2019s path like it\u2019s a dance step.' },
    { id: 'b71',  type: 'character',  text: 'ELOISE' },
    { id: 'b72',  type: 'dialogue',   text: 'Oops \u2014 sorry, Grace! Loving the new Egyptian Cotton robes! So fluffy!' },
    { id: 'b73',  type: 'character',  text: 'GRACE' },
    { id: 'b74',  type: 'dialogue',   text: 'Be careful, Eloise!' },
    { id: 'b75',  type: 'character',  text: 'ELOISE' },
    { id: 'b76',  type: 'paren',      text: '(calling back)' },
    { id: 'b77',  type: 'dialogue',   text: 'I\u2019m trying my absolute hardest!' },
    { id: 'b78',  type: 'character',  text: 'ADLEY' },
    { id: 'b79',  type: 'dialogue',   text: 'So YOU\u2019RE Eloise!' },
    { id: 'b80',  type: 'character',  text: 'ELOISE' },
    { id: 'b81',  type: 'dialogue',   text: 'That\u2019s me! Eloise. 10 years old. A city kid, born and raised.' },
    { id: 'b82',  type: 'character',  text: 'ADLEY' },
    { id: 'b83',  type: 'dialogue',   text: 'Well, I\u2019m Adley and I\u2019m 10, too! Not quite a city girl, but I do love it here!' },
    { id: 'b84',  type: 'action',     text: 'Adley notices Skipperdee.' },
    { id: 'b85',  type: 'character',  text: 'ADLEY' },
    { id: 'b86',  type: 'dialogue',   text: 'And I love your turtle! Skipperdee!' },
    { id: 'b87',  type: 'action',     text: 'Skip turns and winks at Adley, tongue flapping in the wind.' },
    { id: 'b88',  type: 'action',     text: 'They drive down a WIDE STAIRWAY into the lobby, BUMPING their way down, making their voices vibrate.' },
    { id: 'b89',  type: 'character',  text: 'ELOISE' },
    { id: 'b90',  type: 'dialogue',   text: 'You saved his life. We HAD to invite you to the party!' },
    { id: 'b91',  type: 'action',     text: 'They reach the bottom of the staircase and zoom down another hallway. Adley looks around, realizes something.' },
    { id: 'b92',  type: 'character',  text: 'ADLEY' },
    { id: 'b93',  type: 'paren',      text: '(breaks 4th wall, to cam)' },
    { id: 'b94',  type: 'dialogue',   text: 'Huh, this looks like the Plaza Hotel where my family is staying!' },
    { id: 'b95',  type: 'character',  text: 'ELOISE' },
    { id: 'b96',  type: 'dialogue',   text: 'It is the Plaza. Where else could possibly be so perfectly perfect?! (baby talk) Only my pretty Plaza.' },
    { id: 'b97',  type: 'action',     text: 'Eloise pats a MARBLE PILLAR while tipped on two wheels as they take another hairpin turn. Weenie holds on by his teeth.' },
    { id: 'b98',  type: 'character',  text: 'ADLEY' },
    { id: 'b99',  type: 'dialogue',   text: 'Wait?! You live here?!' },
    { id: 'b100', type: 'character',  text: 'ELOISE' },
    { id: 'b101', type: 'dialogue',   text: 'Indeed. It\u2019s fabulously fantastic.' },
    { id: 'b102', type: 'character',  text: 'ADLEY' },
    { id: 'b103', type: 'paren',      text: '(making sure, amused)' },
    { id: 'b104', type: 'dialogue',   text: 'And you\u2019re really throwing a birthday party for Skipperdee?' },
    { id: 'b105', type: 'character',  text: 'ELOISE' },
    { id: 'b106', type: 'dialogue',   text: 'I am! And you\u2019re helping! We need party stuff. First up: flowers!' },
    { id: 'b107', type: 'action',     text: 'They drive past the CONCIERGE (WANONA) as she fluffs a huge central FLOWER ARRANGEMENT STAND. Eloise merely sticks out a hand and is handed a BOUQUET without slowing down.' },
    { id: 'b108', type: 'character',  text: 'ELOISE' },
    { id: 'b109', type: 'dialogue',   text: 'Peonies! Perfect! Thanks, Wanona. See you at the party?' },
    { id: 'b110', type: 'character',  text: 'WANONA' },
    { id: 'b111', type: 'dialogue',   text: 'I\u2019ll try! Happy birthday, Skipperdee!' },
    { id: 'b112', type: 'action',     text: 'Eloise passes the bouquet to Adley. Skipperdee eats a FLOWER as they drive down the long hall of hotel boutiques.' },
    { id: 'b113', type: 'character',  text: 'ELOISE' },
    { id: 'b114', type: 'dialogue',   text: 'This is Boutique Boulevard \u2014 the Plaza has it all: shops, salons, and here \u2014 a real Boulangerie. That\u2019s French for delicious cake.' },
    { id: 'b115', type: 'character',  text: 'ADLEY' },
    { id: 'b116', type: 'paren',      text: '(nods in understanding)' },
    { id: 'b117', type: 'dialogue',   text: 'Oh, right.' },
    { id: 'b118', type: 'action',     text: 'Weenie hangs his head out the side, tongue lolling, enjoying the breeze. When he sees CHEF PIERRE standing before a shop holding a cake, his mouth opens WIDER \u2014 a few drool drops fly. Eloise sees the threat: Weenie\u2019s about to drive-by eat it!' },
    { id: 'b119', type: 'character',  text: 'ELOISE' },
    { id: 'b120', type: 'paren',      text: '(points ahead)' },
    { id: 'b121', type: 'dialogue',   text: 'Grab that cake!' },
    { id: 'b122', type: 'action',     text: 'Adley leans out the side as they drive by CHEF PIERRE, holding out the open CAKE BOX. Adley takes it, fumbles, recovers. Weenie WHINES, disappointed.' },
    { id: 'b123', type: 'character',  text: 'ELOISE' },
    { id: 'b124', type: 'paren',      text: '(turning around)' },
    { id: 'b125', type: 'dialogue',   text: 'Merci, Pierre! C\u2019est MAGNIFIQUE!' },
    { id: 'b126', type: 'character',  text: 'CHEF PIERRE' },
    { id: 'b127', type: 'dialogue',   text: 'Enchant\u00e9, Eloise!' },
    { id: 'b128', type: 'action',     text: 'Adley semi-juggles the flowers and cake as Eloise careens around the corner \u2014 right at hotel manager MR. SALAMONE. He stands his ground, fuming, as Eloise comes to a SCREECHING HALT, leaving rubber tire marks on the marble floor. They wind up nose to nose as he bends down to look at Eloise.' },
    { id: 'b129', type: 'character',  text: 'MR. SALAMONE' },
    { id: 'b130', type: 'paren',      text: '(tea kettle rising)' },
    { id: 'b131', type: 'dialogue',   text: 'ELOISE! Hand over the keys. I am commandeering this vehicle!' },
    { id: 'b132', type: 'action',     text: 'Eloise throws it in reverse. She whispers in an aside to Adley:' },
    { id: 'b133', type: 'character',  text: 'ELOISE' },
    { id: 'b134', type: 'dialogue',   text: 'Hold on to your seat!' },
    { id: 'b135', type: 'action',     text: 'Checking her seatbelt, Adley GULPS, GRINS, and NODS. Eloise guns it \u2014 backwards. Mr. Salamone chases after them as Eloise skillfully drives backwards past GUESTS and a BELLHOP W/ BAGS.' },
    { id: 'b136', type: 'character',  text: 'ELOISE' },
    { id: 'b137', type: 'paren',      text: '(playing innocent)' },
    { id: 'b138', type: 'dialogue',   text: 'Sorry, Mr. Salamone! I don\u2019t know what\u2019s gotten into this thing!' },
    { id: 'b139', type: 'action',     text: 'Eloise YANKS the steering wheel hard left, sending them full TOKYO DRIFT straight toward an ELEVATOR, doors closing.' },
    { id: 'b140', type: 'character',  text: 'ADLEY' },
    { id: 'b141', type: 'dialogue',   text: 'OH\u2026 MY\u2026 WOW\u2026!' },
    { id: 'b142', type: 'action',     text: 'In the back seat, Weenie\u2019s tongue FLAPS in SLOW MOTION.' },
    { id: 'b143', type: 'action',     text: 'They drift through the doors just in time, skid to a stop. They see Mr. Salamone marching at them, finger pointing.' },
    { id: 'b144', type: 'character',  text: 'MR. SALAMONE' },
    { id: 'b145', type: 'paren',      text: '(O.C.)' },
    { id: 'b146', type: 'dialogue',   text: 'ELOISE! This is unaccept\u2014' },
  ],
};

const PDF_BLOCK_INDEX = {};
PDF_DOC.blocks.forEach((b, i) => { PDF_BLOCK_INDEX[b.id] = i; });

const PDF_SEED_THREADS = [
  {
    id: 't1', blockId: 'b22', quote: 'thrown me to the pigeons', resolved: true,
    comments: [
      { who: 'Eve Park', initials: 'EP', role: 'Creative Director', when: '2h', text: 'Bill\u2019s NYC voice is exactly right \u2014 \u201cthrown me to the pigeons\u201d is the texture we want for the live-action wrapper. More of this.' },
      { who: 'MJ Offen', initials: 'MJ', role: 'Writer', when: '1h', text: 'Thanks! Bill\u2019s our reliable comic relief \u2014 he carries the runner into act two.' },
    ],
  },
  {
    id: 't2', blockId: 'b67', quote: '18 M.P.H.', resolved: false,
    comments: [
      { who: 'Eve Park', initials: 'EP', role: 'Creative Director', when: '38m', text: 'Do we soften the speed gag? Legal flagged a kid \u201cdriving\u201d at a real speed last season \u2014 even animated.' },
      { who: 'MJ Offen', initials: 'MJ', role: 'Writer', when: '19m', text: 'It\u2019s fully inside the animated Plaza fantasy, no real vehicle. Happy to swap to \u201cturbo speed\u201d if legal still balks. Brian \u2014 your call?' },
    ],
  },
];

function pdfFindAnchor(blockId, quote) {
  const block = PDF_DOC.blocks.find(b => b.id === blockId);
  if (!block) return null;
  const start = block.text.indexOf(quote);
  if (start < 0) return null;
  return { startBlockId: blockId, startOffset: start, endBlockId: blockId, endOffset: start + quote.length };
}
function buildSeedThreads() {
  return PDF_SEED_THREADS.map(s => ({
    id: s.id,
    quote: s.quote,
    resolved: !!s.resolved,
    anchor: pdfFindAnchor(s.blockId, s.quote),
    comments: s.comments.map(c => ({ ...c })),
  }));
}
function pdfOrder(threads) {
  return [...threads].sort((a, b) => {
    const ai = a.anchor ? PDF_BLOCK_INDEX[a.anchor.startBlockId] : 1e9;
    const bi = b.anchor ? PDF_BLOCK_INDEX[b.anchor.startBlockId] : 1e9;
    if (ai !== bi) return ai - bi;
    return (a.anchor ? a.anchor.startOffset : 0) - (b.anchor ? b.anchor.startOffset : 0);
  });
}
// Expand a (possibly multi-block) anchor into per-block highlight ranges. A
// single-line note yields one segment; a selection dragged across lines yields
// the tail of the first block, every block between, and the head of the last.
function pdfAnchorSegments(anchor) {
  if (!anchor) return [];
  let si = PDF_BLOCK_INDEX[anchor.startBlockId];
  let ei = PDF_BLOCK_INDEX[anchor.endBlockId];
  if (si == null || ei == null) return [];
  let sOff = anchor.startOffset, eOff = anchor.endOffset;
  if (si > ei) { const t = si; si = ei; ei = t; const o = sOff; sOff = eOff; eOff = o; }
  const segs = [];
  for (let i = si; i <= ei; i++) {
    const block = PDF_DOC.blocks[i];
    const len = block.text.length;
    const start = i === si ? sOff : 0;
    const end = i === ei ? eOff : len;
    if (end > start) segs.push({ blockId: block.id, start, end });
  }
  return segs;
}
function pdfOffsetWithin(blockEl, node, nodeOffset) {
  const r = document.createRange();
  r.selectNodeContents(blockEl);
  try { r.setEnd(node, nodeOffset); } catch (e) { return null; }
  return r.toString().length;
}
function pdfClosestBlock(node) {
  let el = node && node.nodeType === 3 ? node.parentElement : node;
  while (el && !(el.dataset && el.dataset.blockId)) el = el.parentElement;
  return el;
}

function PdfDocBlock({ block, hls, activeId, onAnchor, onHover }) {
  if (!hls || hls.length === 0) {
    return <p className={'pdf-b ' + block.type} data-block-id={block.id}>{block.text}</p>;
  }
  const text = block.text;
  const sorted = [...hls].sort((a, b) => a.start - b.start);
  const segs = [];
  let i = 0;
  sorted.forEach(h => {
    const s = Math.max(h.start, i);
    if (h.start > i) segs.push({ t: text.slice(i, h.start) });
    if (h.end > s) segs.push({ t: text.slice(s, h.end), id: h.id, resolved: h.resolved });
    i = Math.max(i, h.end);
  });
  if (i < text.length) segs.push({ t: text.slice(i) });
  return (
    <p className={'pdf-b ' + block.type} data-block-id={block.id}>
      {segs.map((seg, k) => seg.id
        ? <mark key={k}
            className={'pdf-hl' + (seg.resolved ? ' resolved' : '') + (seg.id === activeId ? ' active' : '')}
            data-thread={seg.id}
            onMouseEnter={() => onHover && onHover(seg.id, true)}
            onMouseLeave={() => onHover && onHover(seg.id, false)}
            onClick={(e) => { e.stopPropagation(); onAnchor(seg.id); }}>{seg.t}</mark>
        : <Fragment key={k}>{seg.t}</Fragment>)}
    </p>
  );
}

function PdfDocument({ doc, item, threads, activeId, docRef, onSelect, onAnchor, onHover, onScrollClear }) {
  const byBlock = {};
  threads.forEach(t => {
    if (!t.anchor) return;
    pdfAnchorSegments(t.anchor).forEach(seg => {
      (byBlock[seg.blockId] = byBlock[seg.blockId] || []).push({ id: t.id, start: seg.start, end: seg.end, resolved: t.resolved });
    });
  });
  return (
    <div className="pdf-doc" ref={docRef} onMouseUp={onSelect} onScroll={onScrollClear}>
      {item && (
        <div className="pdf-ctx">
          <span className="ip">{item.ip}</span>
          <span className="who"><strong>{item.author}</strong></span>
          <span className="dot"></span>
          <span>{item.role}</span>
          <span className="dot"></span>
          <span>{item.when}</span>
        </div>
      )}
      <article className="pdf-page">
        <div className="pdf-page-head">
          <div className="pdf-page-title">{doc.title}</div>
          <div className="pdf-page-sub">{doc.subtitle}</div>
          <div className="pdf-page-slug">{doc.slug}</div>
        </div>
        {doc.blocks.map(b => (
          <PdfDocBlock key={b.id} block={b} hls={byBlock[b.id]} activeId={activeId} onAnchor={onAnchor} onHover={onHover} />
        ))}
        <div className="pdf-page-foot">{doc.title} {'\u00b7'} {doc.foot}</div>
      </article>
    </div>
  );
}

function PdfComposer({ placeholder, autoFocus, showCancel, onPost, onCancel }) {
  const [text, setText] = useState('');
  const ref = useRef(null);
  useEffect(() => { if (autoFocus && ref.current) ref.current.focus(); }, [autoFocus]);
  const grow = (el) => { if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 140) + 'px'; };
  const submit = () => { const t = text.trim(); if (!t) return; onPost(t); setText(''); if (ref.current) ref.current.style.height = 'auto'; };
  return (
    <div className="pdf-composer" onClick={(e) => e.stopPropagation()}>
      <textarea ref={ref} className="pdf-input" rows={1} placeholder={placeholder} value={text}
        onChange={(e) => { setText(e.target.value); grow(e.target); }}
        onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); } }} />
      <button type="button" className="pdf-mic" title="Dictate (eyes on the page)"><MicFullIcon size={16}/></button>
      <button type="button" className="pdf-send-c" onClick={submit} disabled={!text.trim()} title="Post comment"><SendArrow size={15}/></button>
      {showCancel && <button type="button" className="pdf-cancel" onClick={onCancel} title="Discard">{'\u00d7'}</button>}
    </div>
  );
}

function PdfThreadCard({ thread, active, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const isDraft = thread.comments.length === 0;
  return (
    <div className={'pdf-card' + (active ? ' active' : '') + (thread.resolved ? ' resolved' : '') + (isDraft ? ' draft' : '')}
      data-card={thread.id} onClick={() => onFocus(thread.id)}>
      <div className="pdf-card-quote">{'\u201c'}{thread.quote}{'\u201d'}</div>
      {thread.resolved && <div className="pdf-resolved-tag"><CheckIcon size={11}/> Resolved</div>}
      {thread.comments.length > 0 && (
        <div className="pdf-card-comments">
          {thread.comments.map((c, i) => (
            <div className="pdf-c" key={i}>
              <div className={'pdf-c-av' + (c.me ? ' me' : '')}>{c.initials}</div>
              <div className="pdf-c-body">
                <div className="pdf-c-head">
                  <span className="n">{c.who}</span>
                  <span className="r">{c.role}</span>
                  <span className="w">{c.when}</span>
                </div>
                <div className="pdf-c-text">{c.text}</div>
              </div>
            </div>
          ))}
        </div>
      )}
      {active && !thread.resolved && (
        <PdfComposer
          placeholder={isDraft ? 'Add a comment\u2026' : 'Reply to the thread\u2026'}
          autoFocus={isDraft}
          showCancel={isDraft}
          onPost={(t) => onPost(thread.id, t)}
          onCancel={() => onDiscard(thread.id)} />
      )}
      {!isDraft && (
        <div className="pdf-card-foot">
          {thread.resolved
            ? <button type="button" className="pdf-reopen" onClick={(e) => { e.stopPropagation(); onReopen(thread.id); }}>Reopen</button>
            : <button type="button" className="pdf-resolve" onClick={(e) => { e.stopPropagation(); onResolve(thread.id); }}><CheckIcon size={12}/> Resolve</button>}
        </div>
      )}
    </div>
  );
}

function PdfRail({ threads, activeId, sheetOpen, scrollRef, onClose, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const ordered = pdfOrder(threads);
  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;
  return (
    <aside className={'pdf-rail' + (sheetOpen ? ' sheet-open' : '')}>
      <div className="pdf-sheet-handle"></div>
      <div className="pdf-rail-head">
        <span className="pdf-rail-lbl">In the margin</span>
        <span className="pdf-rail-count">{openCount} open {'\u00b7'} {real.length} total</span>
        <button type="button" className="pdf-sheet-close" onClick={onClose} aria-label="Close notes">{'\u00d7'}</button>
      </div>
      <div className="pdf-rail-scroll" ref={scrollRef}>
        {threads.length === 0 && (
          <div className="pdf-rail-empty">Select any line in the script to leave a note. Other reviewers and the writer pick up the thread right here.</div>
        )}
        {ordered.map(t => (
          <PdfThreadCard key={t.id} thread={t} active={t.id === activeId}
            onFocus={onFocus} onPost={onPost} onResolve={onResolve} onReopen={onReopen} onDiscard={onDiscard} />
        ))}
      </div>
    </aside>
  );
}

function PdfReview({ item, open, onClose, onVerdict }) {
  const [threads, setThreads] = useState(buildSeedThreads);
  const [activeId, setActiveId] = useState(null);
  const [pending, setPending] = useState(null);
  const [sheetOpen, setSheetOpen] = useState(false);
  const [sent, setSent] = useState(false);
  const rootRef = useRef(null);
  const docRef = useRef(null);
  const railScrollRef = useRef(null);
  const uidRef = useRef(1);
  const [fullscreen, setFullscreen] = useState(false);
  const [hoverId, setHoverId] = useState(null);
  const [popPos, setPopPos] = useState(null);
  const closeTimer = useRef(null);
  const lastVerdictRef = useRef('pass');

  // Fresh notes each time a document is opened.
  useEffect(() => {
    if (!item) return;
    setThreads(buildSeedThreads());
    setActiveId(null); setPending(null); setSheetOpen(false); setSent(false); setFullscreen(false); setHoverId(null); setPopPos(null);
    uidRef.current = 1;
  }, [item && item.id]);

  // Bring the focused highlight and its card into view together.
  useEffect(() => {
    if (!activeId) return;
    const mark = docRef.current && docRef.current.querySelector('[data-thread="' + activeId + '"]');
    if (mark) mark.scrollIntoView({ block: 'center', behavior: 'smooth' });
    const card = railScrollRef.current && railScrollRef.current.querySelector('[data-card="' + activeId + '"]');
    if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [activeId, threads]);

  const pruneDrafts = useCallback(() => {
    setThreads(prev => prev.filter(t => t.comments.length > 0));
  }, []);

  const onSelect = useCallback(() => {
    const sel = window.getSelection();
    if (!sel || sel.isCollapsed || sel.rangeCount === 0) { setPending(null); return; }
    const range = sel.getRangeAt(0);
    let startBlock = pdfClosestBlock(range.startContainer);
    let endBlock = pdfClosestBlock(range.endContainer);
    if (!startBlock || !endBlock) { setPending(null); return; }
    let startOff = pdfOffsetWithin(startBlock, range.startContainer, range.startOffset);
    let endOff = pdfOffsetWithin(endBlock, range.endContainer, range.endOffset);
    if (startOff == null || endOff == null) { setPending(null); return; }
    // Run the anchor forward in document order whether the drag went up or down.
    const si = PDF_BLOCK_INDEX[startBlock.dataset.blockId];
    const ei = PDF_BLOCK_INDEX[endBlock.dataset.blockId];
    if (si > ei || (si === ei && endOff < startOff)) {
      const tb = startBlock; startBlock = endBlock; endBlock = tb;
      const to = startOff; startOff = endOff; endOff = to;
    }
    // Tighten onto visible text: shed whitespace grazed at the head of the first
    // line and the tail of the last line so highlights don't carry stray gaps.
    const headText = startBlock.textContent, tailText = endBlock.textContent;
    while (startOff < headText.length && /\s/.test(headText[startOff])) startOff++;
    while (endOff > 0 && /\s/.test(tailText[endOff - 1])) endOff--;
    const quote = sel.toString().trim();
    if (quote.length < 2) { setPending(null); return; }
    const anchor = { startBlockId: startBlock.dataset.blockId, startOffset: startOff, endBlockId: endBlock.dataset.blockId, endOffset: endOff };
    const rect = range.getBoundingClientRect();
    const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : { left: 0, top: 0 };
    setPending({ anchor, quote, x: rect.left + rect.width / 2 - rootRect.left, y: rect.top - rootRect.top });
  }, []);

  const focusThread = useCallback((id) => {
    setThreads(prev => prev.filter(t => t.comments.length > 0 || t.id === id));
    setActiveId(id); setSheetOpen(true); setPending(null); setHoverId(null);
  }, []);

  const startNewThread = useCallback(() => {
    setPending(p => {
      if (!p) return null;
      const id = 'u' + (uidRef.current++);
      const nt = { id, quote: p.quote, resolved: false, anchor: p.anchor, comments: [] };
      setThreads(prev => [...prev.filter(t => t.comments.length > 0), nt]);
      setActiveId(id); setSheetOpen(true);
      const s = window.getSelection(); if (s) s.removeAllRanges();
      return null;
    });
  }, []);

  const postComment = useCallback((id, text) => {
    setThreads(prev => prev.map(t => t.id === id
      ? { ...t, comments: [...t.comments, { who: PDF_ME.name, initials: PDF_ME.initials, role: PDF_ME.role, text, when: 'now', me: true }] }
      : t));
    setActiveId(id);
  }, []);

  const resolveThread = useCallback((id) => { setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: true } : t)); }, []);
  const reopenThread = useCallback((id) => { setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: false } : t)); setActiveId(id); }, []);
  const discardDraft = useCallback((id) => {
    setThreads(prev => prev.filter(t => t.id !== id || t.comments.length > 0));
    setActiveId(a => (a === id ? null : a));
  }, []);
  const closeSheet = useCallback(() => { setSheetOpen(false); setActiveId(null); pruneDrafts(); }, [pruneDrafts]);

  // ---- Full-screen popover hover/pin plumbing ----
  const cancelClose = useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []);
  const scheduleClose = useCallback(() => { cancelClose(); closeTimer.current = setTimeout(() => setHoverId(null), 160); }, [cancelClose]);
  const onAnchorHover = useCallback((id, enter) => {
    if (!fullscreen || activeId) return;       // no preview while a popover is pinned
    if (enter) { cancelClose(); setHoverId(id); } else { scheduleClose(); }
  }, [fullscreen, activeId, cancelClose, scheduleClose]);
  const exitFullscreen = useCallback(() => { setFullscreen(false); setHoverId(null); }, []);

  // Position the popover under (or above) its anchor; follow on scroll/resize.
  const popId = fullscreen ? (activeId || hoverId) : null;
  useEffect(() => {
    if (!fullscreen || !popId) { setPopPos(null); return; }
    const root = rootRef.current, docEl = docRef.current;
    if (!root || !docEl) return;
    const compute = () => {
      const mark = docEl.querySelector('[data-thread="' + popId + '"]');
      if (!mark) { setPopPos(null); return; }
      const rr = root.getBoundingClientRect();
      const mr = mark.getBoundingClientRect();
      const POP_W = 320, PAD = 14;
      let left = mr.left + mr.width / 2 - rr.left - POP_W / 2;
      left = Math.max(PAD, Math.min(left, rr.width - POP_W - PAD));
      const below = rr.height - (mr.bottom - rr.top);
      if (below < rr.height * 0.42) {
        setPopPos({ above: true, bottom: rr.height - (mr.top - rr.top) + 10, left, w: POP_W });
      } else {
        setPopPos({ above: false, top: mr.bottom - rr.top + 10, left, w: POP_W });
      }
    };
    compute();
    docEl.addEventListener('scroll', compute, { passive: true });
    window.addEventListener('resize', compute);
    return () => { docEl.removeEventListener('scroll', compute); window.removeEventListener('resize', compute); };
  }, [fullscreen, popId, threads]);

  // Esc: unpin a pinned note, then exit full screen.
  useEffect(() => {
    if (!fullscreen) return;
    const onKey = (e) => {
      if (e.key !== 'Escape') return;
      if (activeId) { setActiveId(null); pruneDrafts(); }
      else if (hoverId) { setHoverId(null); }
      else { setFullscreen(false); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [fullscreen, activeId, hoverId, pruneDrafts]);

  const commitVerdict = useCallback((verdict) => {
    if (!item) return;
    pruneDrafts();
    lastVerdictRef.current = verdict;
    const round = threads.filter(t => t.comments.length > 0).map(t => ({ who: 'Brian', text: t.comments[0].text }));
    setSent(true);
    setTimeout(() => onVerdict(item, verdict, round), 950);
  }, [item, onVerdict, pruneDrafts, threads]);

  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;
  const popThread = popId ? threads.find(t => t.id === popId) : null;
  const popPinned = !!activeId && popId === activeId;

  return (
    <div className={'review review--pdf' + (open ? ' open' : '') + (fullscreen ? ' fullscreen' : '')} ref={rootRef}>
      <div className="review-head">
        <button className="review-back" onClick={onClose}><BackChevron/> Feed</button>
        <span className="badge">{item ? 'Review \u00b7 Script Notes' : ''}</span>
      </div>
      <div className="pdf-body">
        <PdfDocument doc={PDF_DOC} item={item} threads={threads} activeId={activeId} docRef={docRef}
          onSelect={onSelect} onAnchor={focusThread} onHover={onAnchorHover} onScrollClear={() => setPending(null)} />
        <PdfRail threads={threads} activeId={activeId} sheetOpen={sheetOpen} scrollRef={railScrollRef}
          onClose={closeSheet} onFocus={focusThread} onPost={postComment}
          onResolve={resolveThread} onReopen={reopenThread} onDiscard={discardDraft} />
        {sheetOpen && <div className="pdf-sheet-backdrop" onClick={closeSheet}></div>}
        {!fullscreen && (
          <button className="pdf-fs-fab" onClick={() => setFullscreen(true)} title="Full screen" aria-label="Enter full screen">
            <ExpandIcon size={15}/> Full screen
          </button>
        )}
      </div>
      <div className="pdf-foot">
        <div className="pdf-foot-meta">
          <span><strong>{real.length}</strong> note{real.length !== 1 ? 's' : ''}</span>
          {openCount > 0 && <span className="pdf-foot-open">{openCount} unresolved</span>}
        </div>
        <div className="pdf-foot-actions">
          <button className="pdf-notes-toggle" onClick={() => setSheetOpen(true)}>Notes {'\u00b7'} {real.length}</button>
          <button className="verdict-btn pass" onClick={() => commitVerdict('pass')}>Request a pass</button>
          <button className="verdict-btn approve" onClick={() => commitVerdict('approve')}>Approve</button>
        </div>
      </div>
      {fullscreen && (
        <div className="pdf-fs-bar">
          <span className="pdf-fs-title">{PDF_DOC.title} {'\u00b7'} {PDF_DOC.subtitle}</span>
          <button className="pdf-fs-exit" onClick={exitFullscreen}><CollapseIcon size={14}/> Exit full screen</button>
        </div>
      )}
      {fullscreen && popThread && popPos && (
        <div
          className={'pdf-pop' + (popPinned ? ' pinned' : '') + (popPos.above ? ' above' : '')}
          style={popPos.above
            ? { bottom: popPos.bottom + 'px', left: popPos.left + 'px', width: popPos.w + 'px' }
            : { top: popPos.top + 'px', left: popPos.left + 'px', width: popPos.w + 'px' }}
          onMouseEnter={cancelClose}
          onMouseLeave={scheduleClose}
          onClick={() => { if (!popPinned) focusThread(popThread.id); }}
        >
          {popPinned && (
            <button className="pdf-pop-close" onClick={(e) => { e.stopPropagation(); setActiveId(null); setHoverId(null); pruneDrafts(); }} aria-label="Close note">{'\u00d7'}</button>
          )}
          <PdfThreadCard thread={popThread} active={popPinned}
            onFocus={focusThread} onPost={postComment} onResolve={resolveThread} onReopen={reopenThread} onDiscard={discardDraft} />
        </div>
      )}
      {pending && (
        <button className="pdf-add-btn" style={{ left: pending.x + 'px', top: pending.y + 'px' }}
          onMouseDown={(e) => e.preventDefault()} onClick={startNewThread}>
          <CommentIcon size={13}/> Comment
        </button>
      )}
      {sent && (
        <div className="sent-overlay" aria-live="polite">
          <div className="sent-card">
            <div className="sent-eb">{lastVerdictRef.current === 'approve' ? 'Approved' : 'Sent for a pass'}</div>
            <div className="sent-msg">{lastVerdictRef.current === 'approve' ? 'Script locked on your sign-off. Returning to feed.' : 'Script notes back with the writer. Returning to feed.'}</div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ============================================================
   Video review (Flow 2, motion variant)
   A real <video> over a two-lane timeline: the lower strip scrubs, the upper
   "notes lane" is the comment surface. Click a frame or drag a range to drop a
   note; every note anchors to a point or span in time and shows as a marker on
   the lane, so the reviewer sees at a glance where the feedback lands. Threads
   open in the right rail (a bottom sheet on mobile), mirroring the document
   review. Self-contained: styled entirely by video-review.css off shared tokens.
   ============================================================ */

const PauseIcon = ({ size = 14 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" stroke="none"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
);
const FrameIcon = ({ size = 14 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M12 5v14"/></svg>
);

const VIDEO_SRC = 'assets/Big_Buck_Bunny_1080_10s_5MB.mp4';
const VIDEO_FPS = 30;            // step granularity for arrow-key frame-stepping
const VIDEO_MIN_SPAN = 0.18;     // a shorter drag collapses to a single-frame note

/* The reviewer ("me") is the same C-suite identity as the document review. */
const VID_ME = PDF_ME;

/* Seed notes so the timeline opens with feedback already on it: one trimmed
   range (with a reply), one locked frame, one resolved sting. Times are clamped
   to the real duration on load. Voice: cutting-room terse, no exclaim points. */
const VIDEO_SEED_THREADS = [
  { id: 'v1', t1: 1.1, t2: 3.0, resolved: false, comments: [
    { who: 'Brian Robbins', initials: 'BR', role: 'Co-Founder \u00b7 CEO', text: 'Cold open runs a beat long. Trim the lead-in so we cut to the action sooner.', when: '2h', me: true },
    { who: 'Jordan Park', initials: 'JP', role: 'Editor', text: 'On it \u2014 pulling eight frames off the head.', when: '1h' },
  ] },
  { id: 'v2', t1: 6.4, t2: null, resolved: false, comments: [
    { who: 'Kathleen Grace', initials: 'KG', role: 'Head of Post', text: 'Lock this frame for the key art. The light reads.', when: '40m' },
  ] },
  { id: 'v3', t1: 8.7, t2: null, resolved: true, comments: [
    { who: 'Brian Robbins', initials: 'BR', role: 'Co-Founder \u00b7 CEO', text: 'Logo sting lands clean. Pass.', when: '3h', me: true },
  ] },
];

function vidClamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function vidTC(s) { s = Math.max(0, s); const m = Math.floor(s / 60), r = Math.floor(s % 60); return m + ':' + String(r).padStart(2, '0'); }
function vidTCf(s) { s = Math.max(0, s); const m = Math.floor(s / 60), r = Math.floor(s % 60), d = Math.floor((s * 10) % 10); return m + ':' + String(r).padStart(2, '0') + '.' + d; }
function vidIsSpan(t) { return t.t2 != null && t.t2 - t.t1 >= 0.02; }
function vidLabel(t) { return vidIsSpan(t) ? vidTCf(t.t1) + '\u2013' + vidTCf(t.t2) : vidTCf(t.t1); }
function buildVideoThreads(dur) {
  const D = dur && isFinite(dur) ? dur : 10;
  return VIDEO_SEED_THREADS.map(s => ({
    id: s.id, resolved: !!s.resolved,
    t1: vidClamp(s.t1, 0, D),
    t2: s.t2 == null ? null : vidClamp(s.t2, 0, D),
    comments: s.comments.map(c => ({ ...c })),
  }));
}
function vidOrder(threads) { return [...threads].sort((a, b) => (a.t1 - b.t1) || ((a.t2 || a.t1) - (b.t2 || b.t1))); }

function VideoMarker({ thread, duration, active, onFocus }) {
  if (!duration) return null;
  const span = vidIsSpan(thread);
  const left = (thread.t1 / duration) * 100;
  const width = span ? Math.max(((thread.t2 - thread.t1) / duration) * 100, 0.8) : 0;
  const cls = 'vid-mk ' + (span ? 'span' : 'point')
    + (thread.resolved ? ' resolved' : '') + (active ? ' active' : '')
    + (thread.comments.length === 0 ? ' draft' : '');
  const first = thread.comments[0];
  return (
    <button type="button" className={cls}
      style={span ? { left: left + '%', width: width + '%' } : { left: left + '%' }}
      onPointerDown={(e) => e.stopPropagation()}
      onClick={(e) => { e.stopPropagation(); onFocus(thread.id); }}
      aria-label={'Note at ' + vidLabel(thread) + (first ? ': ' + first.text : ' (draft)')}>
      <span className="vid-mk-dot" aria-hidden="true"></span>
      <span className="vid-mk-tip" aria-hidden="true">
        <b>{vidLabel(thread)}</b>
        {first ? <><span className="who">{first.who}</span><em>{first.text}</em></> : <em>New note</em>}
      </span>
    </button>
  );
}

function VideoComposer({ placeholder, autoFocus, showCancel, onPost, onCancel }) {
  const [text, setText] = useState('');
  const ref = useRef(null);
  useEffect(() => { if (autoFocus && ref.current) ref.current.focus(); }, [autoFocus]);
  const grow = (el) => { if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 140) + 'px'; };
  const submit = () => { const t = text.trim(); if (!t) return; onPost(t); setText(''); if (ref.current) ref.current.style.height = 'auto'; };
  return (
    <div className="vid-composer" onClick={(e) => e.stopPropagation()}>
      <textarea ref={ref} className="vid-input" rows={1} placeholder={placeholder} value={text}
        onChange={(e) => { setText(e.target.value); grow(e.target); }}
        onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); } }} />
      <button type="button" className="vid-mic" title="Dictate (eyes on the frame)"><MicFullIcon size={16}/></button>
      <button type="button" className="vid-send-c" onClick={submit} disabled={!text.trim()} title="Post comment"><SendArrow size={15}/></button>
      {showCancel && <button type="button" className="vid-cancel" onClick={onCancel} title="Discard">{'\u00d7'}</button>}
    </div>
  );
}

function VideoThreadCard({ thread, active, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const isDraft = thread.comments.length === 0;
  const span = vidIsSpan(thread);
  return (
    <div className={'vid-card' + (active ? ' active' : '') + (thread.resolved ? ' resolved' : '') + (isDraft ? ' draft' : '')}
      data-card={thread.id} onClick={() => onFocus(thread.id)}>
      <div className="vid-card-tc">
        <span className={'vid-tc-glyph ' + (span ? 'span' : 'point')} aria-hidden="true"></span>
        <span className="vid-tc-time">{vidLabel(thread)}</span>
        <span className="vid-tc-kind">{span ? 'range' : 'frame'}</span>
      </div>
      {thread.resolved && <div className="vid-resolved-tag"><CheckIcon size={11}/> Resolved</div>}
      {thread.comments.length > 0 && (
        <div className="vid-card-comments">
          {thread.comments.map((c, i) => (
            <div className="vid-c" key={i}>
              <div className={'vid-c-av' + (c.me ? ' me' : '')}>{c.initials}</div>
              <div className="vid-c-body">
                <div className="vid-c-head"><span className="n">{c.who}</span><span className="r">{c.role}</span><span className="w">{c.when}</span></div>
                <div className="vid-c-text">{c.text}</div>
              </div>
            </div>
          ))}
        </div>
      )}
      {active && !thread.resolved && (
        <VideoComposer placeholder={isDraft ? 'Add a note\u2026' : 'Reply to the thread\u2026'} autoFocus={isDraft} showCancel={isDraft}
          onPost={(t) => onPost(thread.id, t)} onCancel={() => onDiscard(thread.id)} />
      )}
      {!isDraft && (
        <div className="vid-card-foot">
          {thread.resolved
            ? <button type="button" className="vid-reopen" onClick={(e) => { e.stopPropagation(); onReopen(thread.id); }}>Reopen</button>
            : <button type="button" className="vid-resolve" onClick={(e) => { e.stopPropagation(); onResolve(thread.id); }}><CheckIcon size={12}/> Resolve</button>}
        </div>
      )}
    </div>
  );
}

function VideoRail({ threads, activeId, sheetOpen, scrollRef, onClose, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const ordered = vidOrder(threads);
  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;
  return (
    <aside className={'vid-rail' + (sheetOpen ? ' sheet-open' : '')}>
      <div className="vid-sheet-handle"></div>
      <div className="vid-rail-head">
        <span className="vid-rail-lbl">On the timeline</span>
        <span className="vid-rail-count">{openCount} open {'\u00b7'} {real.length} total</span>
        <button type="button" className="vid-sheet-close" onClick={onClose} aria-label="Close notes">{'\u00d7'}</button>
      </div>
      <div className="vid-rail-scroll" ref={scrollRef}>
        {threads.length === 0 && (
          <div className="vid-rail-empty">Click a frame or drag a range on the timeline to leave a note. Other reviewers and the editor pick up the thread right here.</div>
        )}
        {ordered.map(t => (
          <VideoThreadCard key={t.id} thread={t} active={t.id === activeId}
            onFocus={onFocus} onPost={onPost} onResolve={onResolve} onReopen={onReopen} onDiscard={onDiscard} />
        ))}
      </div>
    </aside>
  );
}

function VideoReview({ item, open, onClose, onVerdict }) {
  const [threads, setThreads] = useState(buildVideoThreads);
  const [activeId, setActiveId] = useState(null);
  const [sheetOpen, setSheetOpen] = useState(false);
  const [sent, setSent] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [duration, setDuration] = useState(10);
  const lastVerdictRef = useRef('pass');
  const rootRef = useRef(null);
  const videoRef = useRef(null);
  const trackRef = useRef(null);        // timeline geometry (shared by lane + scrub)
  const playheadRef = useRef(null);
  const playedRef = useRef(null);
  const timeLabelRef = useRef(null);
  const dragBandRef = useRef(null);
  const railScrollRef = useRef(null);
  const uidRef = useRef(1);
  const rafRef = useRef(0);
  const dragRef = useRef(null);         // { mode:'scrub'|'lane', startT, t2 }

  // Fresh notes + rewound video whenever a different reel is opened.
  useEffect(() => {
    if (!item) return;
    const v = videoRef.current;
    setThreads(buildVideoThreads(v && v.duration));
    setActiveId(null); setSheetOpen(false); setSent(false); setPlaying(false);
    uidRef.current = 1;
    if (v) { try { v.pause(); v.currentTime = 0; } catch (e) {} }
  }, [item && item.id]);

  // Pause when the panel slides away (close keeps the element mounted).
  useEffect(() => { if (!open) { const v = videoRef.current; if (v) { try { v.pause(); } catch (e) {} } } }, [open]);

  const paintHead = useCallback((t) => {
    const v = videoRef.current; if (!v) return;
    const dur = v.duration || 0;
    const pct = dur > 0 ? vidClamp(t / dur, 0, 1) * 100 : 0;
    if (playheadRef.current) playheadRef.current.style.left = pct + '%';
    if (playedRef.current) playedRef.current.style.width = pct + '%';
    if (timeLabelRef.current) timeLabelRef.current.textContent = vidTCf(t);
  }, []);

  // Smooth playhead while playing — direct DOM writes, no per-frame React render.
  useEffect(() => {
    if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; }
    const loop = () => {
      const v = videoRef.current; if (!v) return;
      paintHead(v.currentTime);
      if (!v.paused && !v.ended) rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [playing, paintHead]);

  const tFromClientX = useCallback((clientX) => {
    const el = trackRef.current, v = videoRef.current;
    if (!el || !v) return 0;
    const r = el.getBoundingClientRect();
    const dur = v.duration || duration || 0;
    return vidClamp((clientX - r.left) / r.width, 0, 1) * dur;
  }, [duration]);

  const seekTo = useCallback((t) => { const v = videoRef.current; if (!v) return; v.currentTime = t; paintHead(t); }, [paintHead]);
  const togglePlay = useCallback(() => { const v = videoRef.current; if (!v) return; if (v.paused) v.play(); else v.pause(); }, []);

  // Bring the focused note's card into view; the marker is always on-screen.
  useEffect(() => {
    if (!activeId) return;
    const card = railScrollRef.current && railScrollRef.current.querySelector('[data-card="' + activeId + '"]');
    if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [activeId, threads]);

  const pruneDrafts = useCallback(() => { setThreads(prev => prev.filter(t => t.comments.length > 0)); }, []);

  const startNewThread = useCallback((t1, t2) => {
    const v = videoRef.current; if (v && !v.paused) v.pause();
    seekTo(t1);
    const id = 'u' + (uidRef.current++);
    const nt = { id, t1, t2: t2 == null ? null : t2, resolved: false, comments: [] };
    setThreads(prev => [...prev.filter(t => t.comments.length > 0), nt]);
    setActiveId(id); setSheetOpen(true);
  }, [seekTo]);

  const commentFrame = useCallback(() => {
    const v = videoRef.current;
    startNewThread(v ? v.currentTime : 0, null);
  }, [startNewThread]);

  const focusThread = useCallback((id) => {
    setThreads(prev => {
      const t = prev.find(x => x.id === id);
      if (t) seekTo(t.t1);
      return prev.filter(x => x.comments.length > 0 || x.id === id);
    });
    const v = videoRef.current; if (v) v.pause();
    setActiveId(id); setSheetOpen(true);
  }, [seekTo]);

  const postComment = useCallback((id, text) => {
    setThreads(prev => prev.map(t => t.id === id
      ? { ...t, comments: [...t.comments, { who: VID_ME.name, initials: VID_ME.initials, role: VID_ME.role, text, when: 'now', me: true }] }
      : t));
    setActiveId(id);
  }, []);
  const resolveThread = useCallback((id) => setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: true } : t)), []);
  const reopenThread = useCallback((id) => { setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: false } : t)); setActiveId(id); }, []);
  const discardDraft = useCallback((id) => { setThreads(prev => prev.filter(t => t.id !== id || t.comments.length > 0)); setActiveId(a => (a === id ? null : a)); }, []);
  const closeSheet = useCallback(() => { setSheetOpen(false); setActiveId(null); pruneDrafts(); }, [pruneDrafts]);

  // ---- Scrub strip: drag the playhead to seek ----
  const onScrubDown = useCallback((e) => {
    e.preventDefault();
    const v = videoRef.current; if (v && !v.paused) v.pause();
    dragRef.current = { mode: 'scrub' };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    seekTo(tFromClientX(e.clientX));
  }, [seekTo, tFromClientX]);

  // ---- Notes lane: click a frame or drag a range to drop a note ----
  const paintBand = useCallback((t1, t2) => {
    const v = videoRef.current, el = dragBandRef.current; if (!v || !el) return;
    const dur = v.duration || 0; if (!dur) return;
    const a = Math.min(t1, t2), b = Math.max(t1, t2);
    el.style.left = (a / dur) * 100 + '%';
    el.style.width = Math.max(((b - a) / dur) * 100, 0) + '%';
    el.style.display = 'block';
  }, []);
  const onLaneDown = useCallback((e) => {
    e.preventDefault();
    const v = videoRef.current; if (v && !v.paused) v.pause();
    const t = tFromClientX(e.clientX);
    dragRef.current = { mode: 'lane', startT: t, t2: t };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    seekTo(t); paintBand(t, t);
  }, [seekTo, tFromClientX, paintBand]);

  const onPointerMove = useCallback((e) => {
    const d = dragRef.current; if (!d) return;
    const t = tFromClientX(e.clientX);
    if (d.mode === 'scrub') { seekTo(t); }
    else { d.t2 = t; seekTo(t); paintBand(d.startT, t); }
  }, [seekTo, tFromClientX, paintBand]);

  const onPointerUp = useCallback((e) => {
    const d = dragRef.current; if (!d) return;
    dragRef.current = null;
    try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
    if (d.mode !== 'lane') return;
    if (dragBandRef.current) dragBandRef.current.style.display = 'none';
    const a = Math.min(d.startT, d.t2), b = Math.max(d.startT, d.t2);
    if (b - a >= VIDEO_MIN_SPAN) startNewThread(a, b);
    else startNewThread(d.startT, null);
  }, [startNewThread]);

  const onKeyDown = useCallback((e) => {
    const tag = (e.target && e.target.tagName) || '';
    if (tag === 'TEXTAREA' || tag === 'INPUT') return;
    const v = videoRef.current; if (!v) return;
    const dur = v.duration || duration;
    if (e.key === ' ' || e.key === 'k') { e.preventDefault(); togglePlay(); }
    else if (e.key === 'ArrowLeft') { e.preventDefault(); v.pause(); seekTo(vidClamp(v.currentTime - (e.shiftKey ? 1 : 1 / VIDEO_FPS), 0, dur)); }
    else if (e.key === 'ArrowRight') { e.preventDefault(); v.pause(); seekTo(vidClamp(v.currentTime + (e.shiftKey ? 1 : 1 / VIDEO_FPS), 0, dur)); }
    else if (e.key === 'Escape' && activeId) { setActiveId(null); pruneDrafts(); }
  }, [togglePlay, seekTo, duration, activeId, pruneDrafts]);

  const onLoadedMeta = useCallback((e) => { setDuration(e.currentTarget.duration || 10); paintHead(0); }, [paintHead]);
  const onTimeUpdate = useCallback((e) => { if (!playing) paintHead(e.currentTarget.currentTime); }, [playing, paintHead]);

  const commitVerdict = useCallback((verdict) => {
    if (!item) return;
    const v = videoRef.current; if (v) v.pause();
    pruneDrafts();
    lastVerdictRef.current = verdict;
    const round = threads.filter(t => t.comments.length > 0).map(t => ({ who: 'Brian', text: t.comments[0].text }));
    setSent(true);
    setTimeout(() => onVerdict(item, verdict, round), 950);
  }, [item, onVerdict, pruneDrafts, threads]);

  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;

  return (
    <div className={'review review--video' + (open ? ' open' : '')} ref={rootRef} tabIndex={-1} onKeyDown={onKeyDown}>
      <div className="review-head">
        <button className="review-back" onClick={onClose}><BackChevron/> Feed</button>
        <span className="badge">{item ? 'Review \u00b7 Reel Notes' : ''}</span>
      </div>
      <div className="vid-body">
        <div className="vid-main">
          {item && (
            <div className="vid-ctx">
              <span className="ip">{item.ip}</span>
              <span className="who"><strong>{item.author}</strong></span>
              <span className="dot"></span>
              <span>{item.role}</span>
              {item.version && <><span className="dot"></span><span>{item.version}</span></>}
              {item.episode && <><span className="dot"></span><span>{item.episode}</span></>}
            </div>
          )}
          <div className="vid-stage">
            <video ref={videoRef} className="vid-el" src={VIDEO_SRC} playsInline preload="metadata"
              onLoadedMetadata={onLoadedMeta} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)}
              onEnded={() => setPlaying(false)} onTimeUpdate={onTimeUpdate} onClick={togglePlay} />
            <button type="button" className={'vid-playpause' + (playing ? ' playing' : '')} onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
              {playing ? <PauseIcon size={26}/> : <PlayIcon size={26}/>}
            </button>
          </div>
          <div className="vid-transport">
            <button type="button" className="vid-play-sm" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
              {playing ? <PauseIcon size={15}/> : <PlayIcon size={15}/>}
            </button>
            <span className="vid-time"><span ref={timeLabelRef}>0:00.0</span> <i>/</i> {vidTC(duration)}</span>
            <div className="vid-timeline" ref={trackRef}>
              <div className="vid-lane" onPointerDown={onLaneDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
                <div className="vid-drag-band" ref={dragBandRef} aria-hidden="true"></div>
                {duration > 0 && threads.map(t => (
                  <VideoMarker key={t.id} thread={t} duration={duration} active={t.id === activeId} onFocus={focusThread} />
                ))}
              </div>
              <div className="vid-scrub" onPointerDown={onScrubDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
                <div className="vid-played" ref={playedRef}></div>
              </div>
              <div className="vid-playhead" ref={playheadRef} aria-hidden="true"></div>
            </div>
            <button type="button" className="vid-frame-btn" onClick={commentFrame} title="Comment on the current frame">
              <FrameIcon size={15}/> <span>Comment</span>
            </button>
          </div>
          <div className="vid-hint">Click the timeline for a frame {'\u00b7'} drag for a range {'\u00b7'} space to play</div>
        </div>
        <VideoRail threads={threads} activeId={activeId} sheetOpen={sheetOpen} scrollRef={railScrollRef}
          onClose={closeSheet} onFocus={focusThread} onPost={postComment}
          onResolve={resolveThread} onReopen={reopenThread} onDiscard={discardDraft} />
        {sheetOpen && <div className="vid-sheet-backdrop" onClick={closeSheet}></div>}
      </div>
      <div className="vid-foot">
        <div className="vid-foot-meta">
          <span><strong>{real.length}</strong> note{real.length !== 1 ? 's' : ''}</span>
          {openCount > 0 && <span className="vid-foot-open">{openCount} unresolved</span>}
        </div>
        <div className="vid-foot-actions">
          <button className="vid-notes-toggle" onClick={() => setSheetOpen(true)}>Notes {'\u00b7'} {real.length}</button>
          <button className="verdict-btn pass" onClick={() => commitVerdict('pass')}>Request a pass</button>
          <button className="verdict-btn approve" onClick={() => commitVerdict('approve')}>Approve</button>
        </div>
      </div>
      {sent && (
        <div className="sent-overlay" aria-live="polite">
          <div className="sent-card">
            <div className="sent-eb">{lastVerdictRef.current === 'approve' ? 'Approved' : 'Sent for a pass'}</div>
            <div className="sent-msg">{lastVerdictRef.current === 'approve' ? 'Cut locked on your sign-off. Returning to feed.' : 'Reel notes back with the editor. Returning to feed.'}</div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ============================================================
   Audio review (Flow 2, sound variant)
   Web Audio track over a decoded waveform that doubles as the timeline:
   click a moment or drag a span directly on the wave to drop a note; every note
   anchors to a point or span in time and shows as a marker on the wave, so the
   reviewer sees at a glance where feedback lands. The played portion of the wave
   fills gold behind the playhead, SoundCloud-style. A span note can loop on
   repeat (A-B) so the reviewer sits on a passage while writing. Threads open in
   the right rail (a bottom sheet on mobile), mirroring the reel review.
   Self-contained: styled entirely by audio-review.css off shared tokens.
   ============================================================ */

const WaveIcon = ({ size = 14 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9v6M9 4v16M14 7v10M19 11v2"/></svg>
);
const LoopIcon = ({ size = 14 }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 2l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
);

const AUDIO_SRC = 'assets/eloise-song.mp3';
const AUDIO_MIN_SPAN = 0.8;       // a shorter drag collapses to a single moment
const AUDIO_SEEK = 5;             // arrow-key seek step (seconds)
const AUDIO_SEEK_BIG = 15;        // shift + arrow seek step
const AUDIO_PEAK_BUCKETS = 1100;  // waveform resolution, decoded once per track

/* The reviewer ("me") is the same C-suite identity as the other reviews. */
const AUD_ME = PDF_ME;

/* Seed notes so the wave opens with feedback already on it: one open span (with
   a reply), one locked moment on the drop, one resolved tail. Times clamp to the
   real duration on load. Voice: cutting-room terse, no exclaim points. */
const AUDIO_SEED_THREADS = [
  { id: 'a1', t1: 9.0, t2: 17.5, resolved: false, comments: [
    { who: 'Brian Robbins', initials: 'BR', role: 'Co-Founder \u00b7 CEO', text: 'Hook lands, but the lead vocal sits under the synth here. Bring it forward in the mix.', when: '2h', me: true },
    { who: 'Ren Kato', initials: 'RK', role: 'Music Supervisor', text: 'Riding the vocal up two dB across the section.', when: '1h' },
  ] },
  { id: 'a2', t1: 47.0, t2: null, resolved: false, comments: [
    { who: 'Kathleen Grace', initials: 'KG', role: 'Head of Post', text: 'Lock this drop for the episode button. It hits.', when: '40m' },
  ] },
  { id: 'a3', t1: 112.0, t2: null, resolved: true, comments: [
    { who: 'Brian Robbins', initials: 'BR', role: 'Co-Founder \u00b7 CEO', text: 'Outro tail rings clean. Pass.', when: '3h', me: true },
  ] },
];

function audClamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function audTC(s) { s = Math.max(0, s); const m = Math.floor(s / 60), r = Math.floor(s % 60); return m + ':' + String(r).padStart(2, '0'); }
function audTCf(s) { s = Math.max(0, s); const m = Math.floor(s / 60), r = Math.floor(s % 60), d = Math.floor((s * 10) % 10); return m + ':' + String(r).padStart(2, '0') + '.' + d; }
function audIsSpan(t) { return t.t2 != null && t.t2 - t.t1 >= 0.02; }
function audLabel(t) { return audIsSpan(t) ? audTCf(t.t1) + '\u2013' + audTCf(t.t2) : audTCf(t.t1); }
function buildAudioThreads(dur) {
  const D = dur && isFinite(dur) ? dur : 120;
  return AUDIO_SEED_THREADS.map(s => ({
    id: s.id, resolved: !!s.resolved,
    t1: audClamp(s.t1, 0, D),
    t2: s.t2 == null ? null : audClamp(s.t2, 0, D),
    comments: s.comments.map(c => ({ ...c })),
  }));
}
function audOrder(threads) { return [...threads].sort((a, b) => (a.t1 - b.t1) || ((a.t2 || a.t1) - (b.t2 || b.t1))); }

/* Peak extraction: mix channels to mono, bucket to maxima, normalize to 0..1.
   Runs once per track off the decoded buffer. */
function audComputePeaks(buffer, buckets) {
  const chs = buffer.numberOfChannels, len = buffer.length;
  const data = [];
  for (let c = 0; c < chs; c++) data.push(buffer.getChannelData(c));
  const peaks = new Float32Array(buckets);
  const block = Math.max(1, Math.floor(len / buckets));
  let max = 0;
  for (let i = 0; i < buckets; i++) {
    const start = i * block;
    let m = 0;
    for (let j = 0; j < block; j++) {
      const idx = start + j; if (idx >= len) break;
      let v = 0; for (let c = 0; c < chs; c++) { const a = data[c][idx]; v += a < 0 ? -a : a; }
      v /= chs; if (v > m) m = v;
    }
    peaks[i] = m; if (m > max) max = m;
  }
  if (max > 0) for (let i = 0; i < buckets; i++) peaks[i] /= max;
  return peaks;
}

/* Deterministic fallback when decode is blocked (file://, CORS): a signal-ish
   envelope so the surface still reads as a waveform. */
function audSyntheticPeaks(buckets) {
  const peaks = new Float32Array(buckets);
  for (let i = 0; i < buckets; i++) {
    const t = i / buckets;
    const env = 0.35 + 0.5 * Math.sin(Math.PI * t);
    const beat = 0.5 + 0.5 * Math.sin(t * 90);
    const noise = ((Math.sin(i * 12.9898) * 43758.5453) % 1 + 1) % 1;
    peaks[i] = audClamp(env * (0.55 + 0.45 * beat) * (0.7 + 0.3 * noise), 0.05, 1);
  }
  return peaks;
}

/* Draw mirrored bars to a canvas at device-pixel resolution. */
function audDrawBars(canvas, peaks, color, cssW, cssH) {
  if (!canvas || !peaks || cssW <= 0 || cssH <= 0) return;
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(cssW * dpr);
  canvas.height = Math.round(cssH * dpr);
  canvas.style.width = cssW + 'px';
  canvas.style.height = cssH + 'px';
  const ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, cssW, cssH);
  ctx.fillStyle = color;
  const pitch = 4, barW = 2, mid = cssH / 2, maxH = cssH - 4;
  const n = Math.max(1, Math.floor(cssW / pitch));
  for (let i = 0; i < n; i++) {
    const p0 = Math.floor((i / n) * peaks.length);
    const p1 = Math.max(p0 + 1, Math.floor(((i + 1) / n) * peaks.length));
    let m = 0; for (let p = p0; p < p1; p++) { if (peaks[p] > m) m = peaks[p]; }
    const h = Math.max(2, m * maxH), x = i * pitch + (pitch - barW) / 2, y = mid - h / 2;
    if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, barW, h, 1); ctx.fill(); }
    else ctx.fillRect(x, y, barW, h);
  }
}

function AudioMarker({ thread, duration, active, onFocus }) {
  if (!duration) return null;
  const span = audIsSpan(thread);
  const left = (thread.t1 / duration) * 100;
  const width = span ? Math.max(((thread.t2 - thread.t1) / duration) * 100, 0.8) : 0;
  const cls = 'aud-mk ' + (span ? 'span' : 'point')
    + (thread.resolved ? ' resolved' : '') + (active ? ' active' : '')
    + (thread.comments.length === 0 ? ' draft' : '');
  const first = thread.comments[0];
  return (
    <button type="button" className={cls}
      style={span ? { left: left + '%', width: width + '%' } : { left: left + '%' }}
      onPointerDown={(e) => e.stopPropagation()}
      onClick={(e) => { e.stopPropagation(); onFocus(thread.id); }}
      aria-label={'Note at ' + audLabel(thread) + (first ? ': ' + first.text : ' (draft)')}>
      <span className="aud-mk-stem" aria-hidden="true"></span>
      <span className="aud-mk-dot" aria-hidden="true"></span>
      <span className="aud-mk-tip" aria-hidden="true">
        <b>{audLabel(thread)}</b>
        {first ? <><span className="who">{first.who}</span><em>{first.text}</em></> : <em>New note</em>}
      </span>
    </button>
  );
}

function AudioComposer({ placeholder, autoFocus, showCancel, onPost, onCancel }) {
  const [text, setText] = useState('');
  const ref = useRef(null);
  useEffect(() => { if (autoFocus && ref.current) ref.current.focus(); }, [autoFocus]);
  const grow = (el) => { if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 140) + 'px'; };
  const submit = () => { const t = text.trim(); if (!t) return; onPost(t); setText(''); if (ref.current) ref.current.style.height = 'auto'; };
  return (
    <div className="aud-composer" onClick={(e) => e.stopPropagation()}>
      <textarea ref={ref} className="aud-input" rows={1} placeholder={placeholder} value={text}
        onChange={(e) => { setText(e.target.value); grow(e.target); }}
        onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); } }} />
      <button type="button" className="aud-mic" title="Dictate (ears on the track)"><MicFullIcon size={16}/></button>
      <button type="button" className="aud-send-c" onClick={submit} disabled={!text.trim()} title="Post comment"><SendArrow size={15}/></button>
      {showCancel && <button type="button" className="aud-cancel" onClick={onCancel} title="Discard">{'\u00d7'}</button>}
    </div>
  );
}

function AudioThreadCard({ thread, active, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const isDraft = thread.comments.length === 0;
  const span = audIsSpan(thread);
  return (
    <div className={'aud-card' + (active ? ' active' : '') + (thread.resolved ? ' resolved' : '') + (isDraft ? ' draft' : '')}
      data-card={thread.id} onClick={() => onFocus(thread.id)}>
      <div className="aud-card-tc">
        <span className={'aud-tc-glyph ' + (span ? 'span' : 'point')} aria-hidden="true"></span>
        <span className="aud-tc-time">{audLabel(thread)}</span>
        <span className="aud-tc-kind">{span ? 'range' : 'moment'}</span>
      </div>
      {thread.resolved && <div className="aud-resolved-tag"><CheckIcon size={11}/> Resolved</div>}
      {thread.comments.length > 0 && (
        <div className="aud-card-comments">
          {thread.comments.map((c, i) => (
            <div className="aud-c" key={i}>
              <div className={'aud-c-av' + (c.me ? ' me' : '')}>{c.initials}</div>
              <div className="aud-c-body">
                <div className="aud-c-head"><span className="n">{c.who}</span><span className="r">{c.role}</span><span className="w">{c.when}</span></div>
                <div className="aud-c-text">{c.text}</div>
              </div>
            </div>
          ))}
        </div>
      )}
      {active && !thread.resolved && (
        <AudioComposer placeholder={isDraft ? 'Add a note\u2026' : 'Reply to the thread\u2026'} autoFocus={isDraft} showCancel={isDraft}
          onPost={(t) => onPost(thread.id, t)} onCancel={() => onDiscard(thread.id)} />
      )}
      {!isDraft && (
        <div className="aud-card-foot">
          {thread.resolved
            ? <button type="button" className="aud-reopen" onClick={(e) => { e.stopPropagation(); onReopen(thread.id); }}>Reopen</button>
            : <button type="button" className="aud-resolve" onClick={(e) => { e.stopPropagation(); onResolve(thread.id); }}><CheckIcon size={12}/> Resolve</button>}
        </div>
      )}
    </div>
  );
}

function AudioRail({ threads, activeId, sheetOpen, scrollRef, onClose, onFocus, onPost, onResolve, onReopen, onDiscard }) {
  const ordered = audOrder(threads);
  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;
  return (
    <aside className={'aud-rail' + (sheetOpen ? ' sheet-open' : '')}>
      <div className="aud-sheet-handle"></div>
      <div className="aud-rail-head">
        <span className="aud-rail-lbl">On the track</span>
        <span className="aud-rail-count">{openCount} open {'\u00b7'} {real.length} total</span>
        <button type="button" className="aud-sheet-close" onClick={onClose} aria-label="Close notes">{'\u00d7'}</button>
      </div>
      <div className="aud-rail-scroll" ref={scrollRef}>
        {threads.length === 0 && (
          <div className="aud-rail-empty">Click a moment or drag a range on the wave to leave a note. The team and the music supervisor pick up the thread right here.</div>
        )}
        {ordered.map(t => (
          <AudioThreadCard key={t.id} thread={t} active={t.id === activeId}
            onFocus={onFocus} onPost={onPost} onResolve={onResolve} onReopen={onReopen} onDiscard={onDiscard} />
        ))}
      </div>
    </aside>
  );
}

function AudioReview({ item, open, onClose, onVerdict }) {
  const [threads, setThreads] = useState(buildAudioThreads);
  const [activeId, setActiveId] = useState(null);
  const [sheetOpen, setSheetOpen] = useState(false);
  const [sent, setSent] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [duration, setDuration] = useState(120);
  const [loopOn, setLoopOn] = useState(false);
  const [peaksReady, setPeaksReady] = useState(false);
  const lastVerdictRef = useRef('pass');
  const rootRef = useRef(null);
  const ctxRef = useRef(null);
  const bufferRef = useRef(null);
  const sourceRef = useRef(null);
  const gainRef = useRef(null);
  const trackRef = useRef(null);        // timeline geometry (shared by wave + scrub)
  const waveRef = useRef(null);         // the waveform surface (pointer + canvas host)
  const baseRef = useRef(null);         // unplayed waveform canvas
  const fillWrapRef = useRef(null);     // gold reveal clip (width = played %)
  const fillCanvasRef = useRef(null);   // gold waveform canvas (fixed px width)
  const scrubPlayedRef = useRef(null);
  const playheadRef = useRef(null);
  const dragBandRef = useRef(null);
  const timeLabelRef = useRef(null);
  const railScrollRef = useRef(null);
  const durationRef = useRef(120);
  const peaksRef = useRef(null);
  const loopOnRef = useRef(false);
  const loopRangeRef = useRef(null);    // { a, b } for the active span
  const uidRef = useRef(1);
  const rafRef = useRef(0);
  const dragRef = useRef(null);         // { mode:'scrub'|'lane', startT, t2, wasPlaying }
  const positionRef = useRef(0);
  const startedAtRef = useRef(0);
  const startedOffsetRef = useRef(0);
  const playingRef = useRef(false);

  const clockNow = useCallback(() => {
    const ctx = ctxRef.current;
    return ctx ? ctx.currentTime : performance.now() / 1000;
  }, []);

  const ensureAudioContext = useCallback(() => {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return null;
    let ctx = ctxRef.current;
    if (!ctx || ctx.state === 'closed') {
      ctx = new AC();
      ctxRef.current = ctx;
      gainRef.current = ctx.createGain();
      gainRef.current.connect(ctx.destination);
    } else if (!gainRef.current) {
      gainRef.current = ctx.createGain();
      gainRef.current.connect(ctx.destination);
    }
    return ctx;
  }, []);

  const paintHead = useCallback((t) => {
    const dur = durationRef.current || 0;
    const clamped = dur > 0 ? audClamp(t, 0, dur) : 0;
    const pct = dur > 0 ? audClamp(clamped / dur, 0, 1) * 100 : 0;
    if (playheadRef.current) {
      playheadRef.current.style.left = pct + '%';
      playheadRef.current.setAttribute('aria-valuenow', String(Math.round(clamped)));
      playheadRef.current.setAttribute('aria-valuetext', audTCf(clamped));
    }
    if (scrubPlayedRef.current) scrubPlayedRef.current.style.width = pct + '%';
    if (fillWrapRef.current) fillWrapRef.current.style.width = pct + '%';
    if (timeLabelRef.current) timeLabelRef.current.textContent = audTCf(clamped);
  }, []);

  const stopSource = useCallback(() => {
    const src = sourceRef.current;
    if (!src) return;
    sourceRef.current = null;
    src.onended = null;
    try { src.stop(0); } catch (e) {}
  }, []);

  const loopedPosition = useCallback((t) => {
    const dur = durationRef.current || 0;
    const range = loopOnRef.current && loopRangeRef.current;
    if (range && range.b > range.a && t >= range.b) {
      const len = range.b - range.a;
      return range.a + ((t - range.a) % len);
    }
    return dur > 0 ? audClamp(t, 0, dur) : 0;
  }, []);

  const getPosition = useCallback(() => {
    if (!playingRef.current) return positionRef.current;
    return loopedPosition(startedOffsetRef.current + (clockNow() - startedAtRef.current));
  }, [clockNow, loopedPosition]);

  const startPlayback = useCallback((from) => {
    const dur = durationRef.current || (bufferRef.current && bufferRef.current.duration) || 0;
    if (dur <= 0 || (!bufferRef.current && !peaksRef.current)) return;
    const range = loopOnRef.current && loopRangeRef.current;
    let offset = audClamp(from == null ? positionRef.current : from, 0, dur);
    if (range && range.b > range.a) {
      if (offset < range.a || offset >= range.b) offset = range.a;
    } else if (offset >= dur - 0.02) {
      offset = 0;
    }
    offset = audClamp(offset, 0, Math.max(dur - 0.001, 0));
    stopSource();
    const ctx = ensureAudioContext();
    if (ctx && ctx.state === 'suspended' && ctx.resume) {
      const resumed = ctx.resume();
      if (resumed && resumed.catch) resumed.catch(() => {});
    }
    positionRef.current = offset;
    startedOffsetRef.current = offset;
    startedAtRef.current = clockNow();
    playingRef.current = true;
    setPlaying(true);
    if (ctx && bufferRef.current) {
      const src = ctx.createBufferSource();
      src.buffer = bufferRef.current;
      if (range && range.b > range.a) {
        src.loop = true;
        src.loopStart = range.a;
        src.loopEnd = range.b;
      }
      src.connect(gainRef.current || ctx.destination);
      src.onended = () => {
        if (sourceRef.current !== src) return;
        sourceRef.current = null;
        if (!loopOnRef.current) {
          const end = durationRef.current || positionRef.current;
          positionRef.current = end;
          startedOffsetRef.current = end;
          playingRef.current = false;
          setPlaying(false);
          paintHead(end);
        }
      };
      sourceRef.current = src;
      try { src.start(0, offset); }
      catch (e) { sourceRef.current = null; }
    }
    paintHead(offset);
  }, [clockNow, ensureAudioContext, paintHead, stopSource]);

  const pausePlayback = useCallback(() => {
    if (!playingRef.current) return;
    const t = getPosition();
    stopSource();
    positionRef.current = t;
    startedOffsetRef.current = t;
    startedAtRef.current = clockNow();
    playingRef.current = false;
    setPlaying(false);
    paintHead(t);
  }, [clockNow, getPosition, paintHead, stopSource]);

  const stopPlayback = useCallback((at = 0) => {
    stopSource();
    const dur = durationRef.current || 0;
    const t = dur > 0 ? audClamp(at, 0, dur) : 0;
    positionRef.current = t;
    startedOffsetRef.current = t;
    startedAtRef.current = clockNow();
    playingRef.current = false;
    setPlaying(false);
    paintHead(t);
  }, [clockNow, paintHead, stopSource]);

  const seekTo = useCallback((t) => {
    const dur = durationRef.current || 0;
    const next = dur > 0 ? audClamp(t, 0, dur) : 0;
    positionRef.current = next;
    startedOffsetRef.current = next;
    startedAtRef.current = clockNow();
    if (playingRef.current) startPlayback(next);
    else paintHead(next);
  }, [clockNow, paintHead, startPlayback]);

  const togglePlay = useCallback(() => {
    if (playingRef.current) pausePlayback();
    else startPlayback(positionRef.current);
  }, [pausePlayback, startPlayback]);

  const tFromClientX = useCallback((clientX) => {
    const el = trackRef.current;
    if (!el) return 0;
    const r = el.getBoundingClientRect();
    const dur = durationRef.current || 0;
    return audClamp((clientX - r.left) / r.width, 0, 1) * dur;
  }, []);

  const pruneDrafts = useCallback(() => { setThreads(prev => prev.filter(t => t.comments.length > 0)); }, []);

  const startNewThread = useCallback((t1, t2) => {
    if (playingRef.current) pausePlayback();
    seekTo(t1);
    const id = 'u' + (uidRef.current++);
    const nt = { id, t1, t2: t2 == null ? null : t2, resolved: false, comments: [] };
    setThreads(prev => [...prev.filter(t => t.comments.length > 0), nt]);
    setActiveId(id); setSheetOpen(true);
  }, [pausePlayback, seekTo]);

  const commentMoment = useCallback(() => {
    startNewThread(getPosition(), null);
  }, [getPosition, startNewThread]);

  const focusThread = useCallback((id) => {
    const t = threads.find(x => x.id === id);
    if (t) {
      pausePlayback();
      seekTo(t.t1);
    }
    setThreads(prev => prev.filter(x => x.comments.length > 0 || x.id === id));
    setActiveId(id); setSheetOpen(true);
  }, [pausePlayback, seekTo, threads]);

  const postComment = useCallback((id, text) => {
    setThreads(prev => prev.map(t => t.id === id
      ? { ...t, comments: [...t.comments, { who: AUD_ME.name, initials: AUD_ME.initials, role: AUD_ME.role, text, when: 'now', me: true }] }
      : t));
    setActiveId(id);
  }, []);
  const resolveThread = useCallback((id) => setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: true } : t)), []);
  const reopenThread = useCallback((id) => { setThreads(prev => prev.map(t => t.id === id ? { ...t, resolved: false } : t)); setActiveId(id); }, []);
  const discardDraft = useCallback((id) => { setThreads(prev => prev.filter(t => t.id !== id || t.comments.length > 0)); setActiveId(a => (a === id ? null : a)); }, []);
  const closeSheet = useCallback(() => { setSheetOpen(false); setActiveId(null); pruneDrafts(); }, [pruneDrafts]);

  // Fresh notes + rewound track whenever a different asset is opened.
  useEffect(() => {
    if (!item) return;
    stopPlayback(0);
    setThreads(buildAudioThreads(durationRef.current));
    setActiveId(null); setSheetOpen(false); setSent(false);
    setLoopOn(false); loopOnRef.current = false; loopRangeRef.current = null;
    uidRef.current = 1;
  }, [item && item.id, stopPlayback]);

  // Pause when the panel slides away (close keeps the decoded buffer warm).
  useEffect(() => { if (!open) pausePlayback(); }, [open, pausePlayback]);

  // ---- Scrub strip and needle: drag the playhead to seek ----
  const onScrubDown = useCallback((e) => {
    e.preventDefault();
    const wasPlaying = playingRef.current;
    if (wasPlaying) pausePlayback();
    dragRef.current = { mode: 'scrub', wasPlaying };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    seekTo(tFromClientX(e.clientX));
  }, [pausePlayback, seekTo, tFromClientX]);

  // ---- Waveform: click a moment or drag a range to drop a note ----
  const paintBand = useCallback((t1, t2) => {
    const el = dragBandRef.current; if (!el) return;
    const dur = durationRef.current || 0; if (!dur) return;
    const lo = Math.min(t1, t2), hi = Math.max(t1, t2);
    el.style.left = (lo / dur) * 100 + '%';
    el.style.width = Math.max(((hi - lo) / dur) * 100, 0) + '%';
    el.style.display = 'block';
  }, []);
  const onLaneDown = useCallback((e) => {
    e.preventDefault();
    if (playingRef.current) pausePlayback();
    const t = tFromClientX(e.clientX);
    dragRef.current = { mode: 'lane', startT: t, t2: t };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    seekTo(t); paintBand(t, t);
  }, [pausePlayback, seekTo, tFromClientX, paintBand]);

  const onPointerMove = useCallback((e) => {
    const d = dragRef.current; if (!d) return;
    const t = tFromClientX(e.clientX);
    if (d.mode === 'scrub') { seekTo(t); }
    else { d.t2 = t; seekTo(t); paintBand(d.startT, t); }
  }, [seekTo, tFromClientX, paintBand]);

  const onPointerUp = useCallback((e) => {
    const d = dragRef.current; if (!d) return;
    dragRef.current = null;
    try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
    if (d.mode === 'scrub') {
      if (d.wasPlaying) startPlayback(positionRef.current);
      return;
    }
    if (dragBandRef.current) dragBandRef.current.style.display = 'none';
    const lo = Math.min(d.startT, d.t2), hi = Math.max(d.startT, d.t2);
    if (hi - lo >= AUDIO_MIN_SPAN) startNewThread(lo, hi);
    else startNewThread(d.startT, null);
  }, [startNewThread, startPlayback]);

  const toggleLoop = useCallback(() => {
    const range = loopRangeRef.current; if (!range) return;
    const current = getPosition();
    const next = !loopOnRef.current;
    loopOnRef.current = next; setLoopOn(next);
    if (next) {
      const start = current >= range.a && current < range.b ? current : range.a;
      startPlayback(start);
    } else if (playingRef.current) {
      startPlayback(current);
    } else {
      positionRef.current = current;
      paintHead(current);
    }
  }, [getPosition, paintHead, startPlayback]);

  const onKeyDown = useCallback((e) => {
    const tag = (e.target && e.target.tagName) || '';
    if (tag === 'TEXTAREA' || tag === 'INPUT') return;
    const dur = durationRef.current || 0;
    const current = getPosition();
    if (e.key === ' ' || e.key === 'k') { e.preventDefault(); togglePlay(); }
    else if (e.key === 'ArrowLeft') { e.preventDefault(); seekTo(current - (e.shiftKey ? AUDIO_SEEK_BIG : AUDIO_SEEK)); }
    else if (e.key === 'ArrowRight') { e.preventDefault(); seekTo(current + (e.shiftKey ? AUDIO_SEEK_BIG : AUDIO_SEEK)); }
    else if ((e.key === 'l' || e.key === 'L') && loopRangeRef.current) { e.preventDefault(); toggleLoop(); }
    else if (e.key === 'Escape' && activeId) { setActiveId(null); pruneDrafts(); }
  }, [togglePlay, seekTo, getPosition, activeId, pruneDrafts, toggleLoop]);

  // Repaint both waveform canvases at the current size, then re-apply the reveal.
  const drawAll = useCallback(() => {
    const wave = waveRef.current, peaks = peaksRef.current;
    if (!wave || !peaks) return;
    const cssW = wave.clientWidth, cssH = wave.clientHeight;
    if (cssW <= 0 || cssH <= 0) return;
    const cs = getComputedStyle(wave);
    const baseColor = (cs.getPropertyValue('--aud-wave') || '').trim() || 'rgba(244,246,247,0.20)';
    const fillColor = (cs.getPropertyValue('--aud-wave-played') || '').trim() || '#FFD200';
    audDrawBars(baseRef.current, peaks, baseColor, cssW, cssH);
    audDrawBars(fillCanvasRef.current, peaks, fillColor, cssW, cssH);
    paintHead(getPosition());
  }, [getPosition, paintHead]);

  const commitVerdict = useCallback((verdict) => {
    if (!item) return;
    pausePlayback();
    pruneDrafts();
    lastVerdictRef.current = verdict;
    const round = threads.filter(t => t.comments.length > 0).map(t => ({ who: 'Brian', text: t.comments[0].text }));
    setSent(true);
    setTimeout(() => onVerdict(item, verdict, round), 950);
  }, [item, onVerdict, pausePlayback, pruneDrafts, threads]);

  // Decode the track once (first open) to extract real peaks and keep the buffer
  // for seekable Web Audio playback. Fallback keeps the review UI usable.
  useEffect(() => {
    if (!open || !item) return;
    if (peaksRef.current) { drawAll(); return; }
    let cancelled = false;
    (async () => {
      let peaks, decoded = null;
      try {
        const resp = await fetch(AUDIO_SRC);
        const buf = await resp.arrayBuffer();
        const ctx = ensureAudioContext();
        if (!ctx) throw new Error('no AudioContext');
        decoded = await ctx.decodeAudioData(buf);
        peaks = audComputePeaks(decoded, AUDIO_PEAK_BUCKETS);
      } catch (e) {
        peaks = audSyntheticPeaks(AUDIO_PEAK_BUCKETS);
      }
      if (cancelled) return;
      if (decoded) {
        bufferRef.current = decoded;
        const d = decoded.duration || 120;
        durationRef.current = d;
        setDuration(d);
        setThreads(prev => prev.map(t => ({
          ...t,
          t1: audClamp(t.t1, 0, d),
          t2: t.t2 == null ? null : audClamp(t.t2, 0, d),
        })));
      }
      peaksRef.current = peaks;
      setPeaksReady(true);
    })();
    return () => { cancelled = true; };
  }, [open, item && item.id, ensureAudioContext, drawAll]);

  // Redraw when peaks arrive or the panel opens (layout settled on the next frame).
  useEffect(() => { if (open) drawAll(); }, [peaksReady, open, drawAll]);
  useEffect(() => {
    if (!open) return;
    const id = requestAnimationFrame(() => drawAll());
    return () => cancelAnimationFrame(id);
  }, [open, drawAll]);
  useEffect(() => {
    const wave = waveRef.current;
    if (!wave || typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(() => drawAll());
    ro.observe(wave);
    return () => ro.disconnect();
  }, [drawAll]);

  // Smooth playhead while playing. The clock is owned here, not by a media element.
  useEffect(() => {
    if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; }
    const loop = () => {
      const t = getPosition();
      positionRef.current = t;
      paintHead(t);
      const dur = durationRef.current || 0;
      if (!loopOnRef.current && dur > 0 && t >= dur - 0.02) {
        stopSource();
        positionRef.current = dur;
        startedOffsetRef.current = dur;
        playingRef.current = false;
        setPlaying(false);
        paintHead(dur);
        return;
      }
      if (playingRef.current) rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [playing, getPosition, paintHead, stopSource]);

  // Bring the focused note's card into view; the marker is always on-screen.
  useEffect(() => {
    if (!activeId) return;
    const card = railScrollRef.current && railScrollRef.current.querySelector('[data-card="' + activeId + '"]');
    if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [activeId, threads]);

  // Arm the loop to the active span; disarm and restart the source when needed.
  useEffect(() => {
    const at = threads.find(t => t.id === activeId);
    const prev = loopRangeRef.current;
    if (at && audIsSpan(at)) {
      const nextRange = { a: at.t1, b: at.t2 };
      const changed = !prev || prev.a !== nextRange.a || prev.b !== nextRange.b;
      const current = getPosition();
      loopRangeRef.current = nextRange;
      if (changed && loopOnRef.current && playingRef.current) {
        const start = current >= nextRange.a && current < nextRange.b ? current : nextRange.a;
        startPlayback(start);
      }
    } else {
      const current = getPosition();
      loopRangeRef.current = null;
      if (loopOnRef.current) {
        loopOnRef.current = false;
        setLoopOn(false);
        if (playingRef.current) startPlayback(current);
      }
    }
  }, [activeId, threads, getPosition, startPlayback]);

  useEffect(() => {
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      stopSource();
      const ctx = ctxRef.current;
      if (ctx && ctx.close) { try { ctx.close(); } catch (e) {} }
    };
  }, [stopSource]);

  const activeThread = threads.find(t => t.id === activeId) || null;
  const activeSpan = !!(activeThread && audIsSpan(activeThread));
  const real = threads.filter(t => t.comments.length > 0);
  const openCount = real.filter(t => !t.resolved).length;

  return (
    <div className={'review review--audio' + (open ? ' open' : '')} ref={rootRef} tabIndex={-1} onKeyDown={onKeyDown}>
      <div className="review-head">
        <button className="review-back" onClick={onClose}><BackChevron/> Feed</button>
        <span className="badge">{item ? 'Review \u00b7 Track Notes' : ''}</span>
      </div>
      <div className="aud-body">
        <div className="aud-main">
          {item && (
            <div className="aud-ctx">
              <span className="ip">{item.ip}</span>
              <span className="who"><strong>{item.author}</strong></span>
              <span className="dot"></span>
              <span>{item.role}</span>
              {item.version && <><span className="dot"></span><span>{item.version}</span></>}
              {item.episode && <><span className="dot"></span><span>{item.episode}</span></>}
            </div>
          )}
          <div className="aud-deck">
            <button type="button" className={'aud-cover' + (playing ? ' playing' : '')} onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
              <span className="aud-cover-mono" aria-hidden="true">{item ? item.ip : ''}</span>
              <span className="aud-eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
              <span className="aud-cover-btn">{playing ? <PauseIcon size={20}/> : <PlayIcon size={20}/>}</span>
            </button>
            <div className="aud-deck-meta">
              {item && <span className="aud-deck-eyebrow">{item.assetType}</span>}
              {item && <span className="aud-deck-title">{item.track || item.assetType}</span>}
              {item && <span className="aud-deck-sub">{item.author}{item.role ? ' \u00b7 ' + item.role : ''}</span>}
            </div>
          </div>
          <div className="aud-stage">
            <div className="aud-timeline" ref={trackRef}>
              <div className="aud-wave" ref={waveRef} onPointerDown={onLaneDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
                <canvas className="aud-wave-base" ref={baseRef} aria-hidden="true"></canvas>
                <div className="aud-wave-fill" ref={fillWrapRef} aria-hidden="true"><canvas ref={fillCanvasRef}></canvas></div>
                <div className="aud-drag-band" ref={dragBandRef} aria-hidden="true"></div>
                {duration > 0 && threads.map(t => (
                  <AudioMarker key={t.id} thread={t} duration={duration} active={t.id === activeId} onFocus={focusThread} />
                ))}
                {!peaksReady && <div className="aud-wave-load" aria-hidden="true">Reading waveform</div>}
              </div>
              <div className="aud-scrub" onPointerDown={onScrubDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
                <div className="aud-played" ref={scrubPlayedRef}></div>
              </div>
              <div className="aud-playhead" ref={playheadRef} role="slider" tabIndex={0}
                aria-label="Audio playhead" aria-valuemin={0} aria-valuemax={Math.round(duration)}
                aria-valuenow={0} aria-valuetext="0:00.0"
                onPointerDown={onScrubDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}></div>
            </div>
          </div>
          <div className="aud-transport">
            <button type="button" className="aud-play-sm" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
              {playing ? <PauseIcon size={15}/> : <PlayIcon size={15}/>}
            </button>
            <span className="aud-time"><span ref={timeLabelRef}>0:00.0</span> <i>/</i> {audTC(duration)}</span>
            <span className="aud-transport-gap"></span>
            <button type="button" className={'aud-loop-btn' + (loopOn ? ' on' : '')} onClick={toggleLoop} disabled={!activeSpan}
              title={activeSpan ? 'Loop the selected range' : 'Select a range to loop'} aria-pressed={loopOn}>
              <LoopIcon size={15}/> <span>Loop</span>
            </button>
            <button type="button" className="aud-moment-btn" onClick={commentMoment} title="Comment at the playhead">
              <CommentIcon size={15}/> <span>Comment</span>
            </button>
          </div>
          <div className="aud-hint">Click the wave for a moment {'\u00b7'} drag for a range {'\u00b7'} space to play{activeSpan ? ' \u00b7 L loops the range' : ''}</div>
        </div>
        <AudioRail threads={threads} activeId={activeId} sheetOpen={sheetOpen} scrollRef={railScrollRef}
          onClose={closeSheet} onFocus={focusThread} onPost={postComment}
          onResolve={resolveThread} onReopen={reopenThread} onDiscard={discardDraft} />
        {sheetOpen && <div className="aud-sheet-backdrop" onClick={closeSheet}></div>}
      </div>
      <div className="aud-foot">
        <div className="aud-foot-meta">
          <span><strong>{real.length}</strong> note{real.length !== 1 ? 's' : ''}</span>
          {openCount > 0 && <span className="aud-foot-open">{openCount} unresolved</span>}
        </div>
        <div className="aud-foot-actions">
          <button className="aud-notes-toggle" onClick={() => setSheetOpen(true)}>Notes {'\u00b7'} {real.length}</button>
          <button className="verdict-btn pass" onClick={() => commitVerdict('pass')}>Request a pass</button>
          <button className="verdict-btn approve" onClick={() => commitVerdict('approve')}>Approve</button>
        </div>
      </div>
      {sent && (
        <div className="sent-overlay" aria-live="polite">
          <div className="sent-card">
            <div className="sent-eb">{lastVerdictRef.current === 'approve' ? 'Approved' : 'Sent for a pass'}</div>
            <div className="sent-msg">{lastVerdictRef.current === 'approve' ? 'Track locked on your sign-off. Returning to feed.' : 'Track notes back with the team. Returning to feed.'}</div>
          </div>
        </div>
      )}
    </div>
  );
}
/* ---------- Review overlay (Flow 1 \u2192 Flow 2 fused) ---------- */
function ReviewOverlay({ item, open, onClose, onVerdict }) {
  const isImage = item && (item.kind === 'image');
  const isVideo = item && (item.kind === 'video');
  const isReq = item && (item.kind === 'request');

  const [feedbackState, setFeedbackState] = useState('idle'); // 'idle' | 'recording' | 'paused' | 'validating' | 'sent'
  const [draft, setDraft] = useState('');
  const [secs, setSecs] = useState(0);
  const [structured, setStructured] = useState(null);
  const [agentState, setAgentState] = useState('idle'); // 'idle' | 'recording' | 'thinking'
  const [agentDraft, setAgentDraft] = useState('');
  const [agentSecs, setAgentSecs] = useState(0);
  const [agentLog, setAgentLog] = useState([]);
  const callIdxRef = useRef(0);
  const agentEditCountRef = useRef(0);
  const lastVerdictRef = useRef('pass');

  // Reset state on item change
  useEffect(() => {
    setFeedbackState('idle');
    setDraft('');
    setSecs(0);
    setStructured(null);
    setAgentState('idle');
    setAgentDraft('');
    setAgentSecs(0);
    setAgentLog([]);
    callIdxRef.current = 0;
    agentEditCountRef.current = 0;
  }, [item?.id]);

  // Recording timer
  useEffect(() => {
    if (feedbackState !== 'recording') return;
    const id = setInterval(() => setSecs(s => s + 1), 1000);
    return () => clearInterval(id);
  }, [feedbackState]);

  // Agent recording timer
  useEffect(() => {
    if (agentState !== 'recording') return;
    const id = setInterval(() => setAgentSecs(s => s + 1), 1000);
    return () => clearInterval(id);
  }, [agentState]);

  const handleAgentMic = () => {
    if (agentState === 'recording') {
      // Stop recording → thinking → applies edit
      setAgentState('thinking');
      setAgentSecs(0);
      setTimeout(() => {
        const { newStructured, agentMessage, userMessage } = applyScriptedAgentEdit(structured);
        setStructured(newStructured);
        setAgentLog(log => [
          ...log,
          { role: 'user',  text: userMessage  },
          { role: 'agent', text: agentMessage },
        ]);
        agentEditCountRef.current += 1;
        setAgentState('idle');
      }, 1500);
    } else if (agentState === 'idle') {
      setAgentState('recording');
    }
  };

  const handleMic = () => {
    if (feedbackState === 'recording') {
      // Pause \u2192 transcript surfaces
      const line = transcriptFor(item, callIdxRef.current);
      callIdxRef.current += 1;
      setDraft(prev => prev ? prev.replace(/\s+$/, '') + '\n\n' + line : line);
      setFeedbackState('paused');
      setSecs(0);
    } else {
      setFeedbackState('recording');
    }
  };

  const handleSend = () => {
    // From feedback bar → validate, not finalize
    if (feedbackState === 'recording' || feedbackState === 'paused' || feedbackState === 'idle') {
      const text = draft.trim() || (feedbackState === 'recording' ? transcriptFor(item, 0) : '');
      const finalDraft = text.length ? text : 'Looks good. Push the visual one notch.';
      setDraft(finalDraft);
      setStructured(structureFeedback(item, finalDraft));
      setFeedbackState('validating');
      setSecs(0);
    }
  };

  // The validate stage commits a Request-a-pass: the structured notes go back to
  // the author. Approve is the head button. Both route through commitVerdict.
  const commitVerdict = (verdict) => {
    if (!item) return;
    lastVerdictRef.current = verdict;
    setFeedbackState('sent');
    const text = (draft || '').trim();
    const round = text ? [{ who: 'Brian', text }] : [];
    setTimeout(() => onVerdict(item, verdict, round), 900);
  };
  const handleFinalize = () => commitVerdict('pass');

  // Hide prior feedback during recording (eyes on asset)
  const recordingFocus = feedbackState === 'recording';
  const isValidating = feedbackState === 'validating';

  return (
    <div className={`review ${open ? 'open' : ''} ${isValidating ? 'validating' : ''}`} data-state={feedbackState}>
      <div className="review-head">
        <button className="review-back" onClick={isValidating ? () => setFeedbackState('paused') : onClose}>
          <BackChevron/> {isValidating ? 'Edit draft' : 'Feed'}
        </button>
        <span className="badge">
          {feedbackState === 'recording'  ? 'Recording'  :
           feedbackState === 'paused'     ? 'Draft'      :
           feedbackState === 'validating' ? 'Validate'   :
           feedbackState === 'sent'       ? 'Sent'       :
           'Review \u00b7 Focus'}
        </span>
        {feedbackState !== 'sent' && feedbackState !== 'recording' && (
          <button className="verdict-btn approve head-approve" onClick={() => commitVerdict('approve')}>Approve</button>
        )}
      </div>
      <div className="review-body">
        {item && !isValidating && (
          <>
            <div className="asset">
              {isImage && item.thumb === 'eloise-photo' && <img src="assets/eloise.jpg" alt="Eloise"/>}
              {(!isImage || item.thumb !== 'eloise-photo') && !isReq && (
                <div className={`thumb ${item.thumb === 'eloise-cream' ? 'thumb-bg-eloise' : item.thumb === 'bucket' ? 'thumb-bg-bucket' : item.thumb === 'speed' ? 'thumb-bg-speed' : 'thumb-bg-misc'}`} style={{width:'100%',height:'100%',aspectRatio:'unset',border:'none'}}>
                  <div className="thumb-mono" style={{fontSize:80}}>
                    {item.thumb === 'eloise-cream' ? 'ELOISE' : item.thumb === 'bucket' ? 'BLF' : item.thumb === 'speed' ? 'IS\u00d7OP' : 'MISC'}
                  </div>
                  {isVideo && (
                    <div className="play-mark" style={{width:64,height:64}}>
                      <PlayIcon size={22}/>
                    </div>
                  )}
                </div>
              )}
              {isReq && (
                <div className="thumb-request" style={{aspectRatio:'unset', width:'100%', height:'100%', border:'none'}}>
                  <DocIcon size={48}/>
                  <span>Text Request</span>
                </div>
              )}
              {recordingFocus && <div className="rec-halo" aria-hidden="true"></div>}
            </div>
            <div className={`context-col ${recordingFocus ? 'dim' : ''}`}>
              <h1>{item.ip} &middot; {item.kind.toUpperCase()}</h1>
              <div className="meta-line">
                <span className="ip-tag">{item.author}</span>
                <span className="effort-tag" style={{textTransform:'none',letterSpacing:'0.04em'}}>{item.role}</span>
                {isVideo && <EffortTag item={item}/>}
              </div>
              {item.returnedFrom && (
                <div className="returned-banner">
                  <span className="rb-tag">Back</span>
                  <span className="rb-text">{item.returnedFrom} {'\u2192'} {item.version} {'\u00b7'} addresses ~{item.versions && item.versions.length ? item.versions[item.versions.length - 1].notes.filter(n => n.who === 'Brian').length : 0} of your notes</span>
                </div>
              )}
              <div className="request-blurb">{item.blurb}</div>

              {item.versions && item.versions.length > 0 ? (
                <PriorNotes item={item}/>
              ) : (
                <div className="prior">
                  <div className="head"><span className="lbl">Prior Feedback</span></div>
                  <div className="quote">
                    {priorFor(item)}
                    <span className="signer">{'\u2014'} {priorSignerFor(item)} &middot; yesterday</span>
                  </div>
                </div>
              )}
            </div>
          </>
        )}
        {item && isValidating && (
          <>
            <div className="v-aside">
              <div className="v-asset-mini">
                {isImage && item.thumb === 'eloise-photo' && <img src="assets/eloise.jpg" alt="Eloise"/>}
                {(!isImage || item.thumb !== 'eloise-photo') && !isReq && (
                  <div className={`thumb ${item.thumb === 'eloise-cream' ? 'thumb-bg-eloise' : item.thumb === 'bucket' ? 'thumb-bg-bucket' : item.thumb === 'speed' ? 'thumb-bg-speed' : 'thumb-bg-misc'}`} style={{width:'100%',height:'100%',aspectRatio:'unset',border:'none'}}>
                    <div className="thumb-mono" style={{fontSize:48}}>
                      {item.thumb === 'eloise-cream' ? 'ELOISE' : item.thumb === 'bucket' ? 'BLF' : item.thumb === 'speed' ? 'IS\u00d7OP' : 'MISC'}
                    </div>
                    {isVideo && (
                      <div className="play-mark" style={{width:48,height:48}}>
                        <PlayIcon size={18}/>
                      </div>
                    )}
                  </div>
                )}
                {isReq && (
                  <div className="thumb-request" style={{aspectRatio:'unset', width:'100%', height:'100%', border:'none'}}>
                    <DocIcon size={32}/>
                    <span>Text</span>
                  </div>
                )}
              </div>
              <div className="v-aside-meta">
                <div className="v-ip-tag">{item.ip}</div>
                <div className="v-aside-who"><strong>{item.author}</strong>{'\u00a0\u00b7\u00a0'}{item.role}</div>
                <div className="v-aside-blurb">{item.blurb}</div>
              </div>
            </div>
            <ValidationView
              structured={structured}
              onStructuredChange={setStructured}
              agentLog={agentLog}
              isVideo={isVideo}
            />
          </>
        )}
      </div>
      {!isValidating && (
        <FeedbackBar
          feedbackState={feedbackState}
          draft={draft}
          onDraftChange={setDraft}
          onMicToggle={handleMic}
          onSend={handleSend}
          secs={secs}
        />
      )}
      {isValidating && (
        <AgentInput
          agentState={agentState}
          secs={agentSecs}
          onMicToggle={handleAgentMic}
          onSend={handleFinalize}
          draft={agentDraft}
          onDraftChange={setAgentDraft}
        />
      )}
      {feedbackState === 'sent' && (
        <div className="sent-overlay" aria-live="polite">
          <div className="sent-card">
            <div className="sent-eb">{lastVerdictRef.current === 'approve' ? 'Approved' : 'Sent for a pass'}</div>
            <div className="sent-msg">{lastVerdictRef.current === 'approve' ? 'Locked on your sign-off. Returning to feed.' : 'Notes back with the author. Returning to feed.'}</div>
          </div>
        </div>
      )}
    </div>
  );
}

/* The real last round of notes on a returned asset, replacing the faked single
   quote. Each note keeps its author so a multi-reviewer round reads true. */
function PriorNotes({ item }) {
  const versions = item.versions || [];
  const last = versions[versions.length - 1];
  if (!last || !last.notes || !last.notes.length) return null;
  return (
    <div className="prior">
      <div className="head"><span className="lbl">Your notes {'\u00b7'} {last.label}</span></div>
      <ul className="prior-notes">
        {last.notes.map((n, i) => (
          <li key={i} className="prior-note">
            <span className="pn-who">{n.who}</span>
            <span className="pn-text">{n.text}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

function priorFor(item) {
  if (!item) return '';
  const fallback = "Coming along nicely. Save the polish pass for after we lock direction.";
  const lines = {
    ELOISE: "The warmth is right. Push the silhouette one notch further before we move on.",
    'BUCKET LIST': "Pace is tight. Hold the second beat half a count longer \u2014 it'll land.",
    'IS\u00d7OP': "Composition is strong. Tighten the type so it reads at thumbnail size.",
    MISC: "Direction is solid. Bring me the trim with sound on next time."
  };
  return lines[item.ip] || fallback;
}
function priorSignerFor(item) {
  if (!item) return 'Brian';
  return item.ip === 'ELOISE' ? 'Brian' : item.ip === 'BUCKET LIST' ? 'Eve' : 'Brian';
}

/* ---------- Desktop slate (left column on wide screens) ---------- */
function fmtElapsed(s) {
  const m = Math.floor(s / 60);
  if (m === 0) return 'just started';
  if (m === 1) return '1 min in';
  if (m < 60) return `${m} min in`;
  const h = Math.floor(m / 60);
  const rm = m % 60;
  return rm ? `${h}h ${rm}m in` : `${h}h in`;
}

/* Sibling product surfaces, linked from the header (and the side-by-side slate).
   The launcher "Home" is intentionally absent — the wordmark is the route home —
   so the nav only lists the other working surfaces. */
const QUICK_LINKS = [
  { href: 'IC Home.html', label: 'IC home' },
  { href: 'IC Request.html', label: 'IC request' },
  { href: 'Feedback Thread.html', label: 'Feedback thread' },
];
function QuickLinks({ className }) {
  return (
    <nav className={'quick-links' + (className ? ' ' + className : '')} aria-label="Other surfaces">
      {QUICK_LINKS.map(l => (
        <a key={l.href} className="quick-link" href={l.href}>{l.label}</a>
      ))}
    </nav>
  );
}

function DesktopSlate({ items, sessionReviewed, sessionElapsed, onEnd }) {
  const counts = useMemo(() => countByProject(items), [items]);
  const waiting = items.filter(isWaiting).length;
  const overdue = items.filter(i => isWaiting(i) && urgencyKey(i) === 'overdue').length;
  const passCount = items.filter(i => i.status === 'out-for-pass').length;
  const doneCount = sessionReviewed.length;
  const hasDone = doneCount > 0;

  return (
    <aside className="desktop-slate">
      <div className="slate-eyebrow">
        <span className="dot"></span>
        <span className="lbl">Tue &middot; May 27</span>
        <span className="session">{fmtElapsed(sessionElapsed)}</span>
      </div>

      <div className="wordmark">
        <img className="wordmark-logo" src="assets/logo/dailies-logo.svg" alt="Dailies" />
        <span className="subtitle">{'For Your Eyes \u2014 Brian Robbins'}</span>
      </div>

      <div className="ds-rule"></div>

      <div className="ds-stat">
        <span className="n">{waiting}</span>
        <span className="l">
          <strong>Waiting</strong>
          For your eyes today
        </span>
      </div>

      <div className="ds-breakdown">
        {PROJECT_ORDER.map(key => (
          <div className="ds-bd-row" key={key}><span className="l">{PROJECTS[key].label}</span><span className="n">{counts[key]}</span></div>
        ))}
      </div>
      <a className="ds-approvals" href="Past Approvals.html?as=brian">
        <span>Past approvals</span>
        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17L17 7M9 7h8v8"/></svg>
      </a>
      <QuickLinks className="slate-quick"/>

      {(overdue > 0 || passCount > 0) && (
        <div className="ds-loops">
          {overdue > 0 && <span className="ds-loop overdue">{overdue} overdue</span>}
          {passCount > 0 && <span className="ds-loop pass">{passCount} out for a pass</span>}
        </div>
      )}

      {/* Session progress block */}
      <div className="ds-session">
        <div className="ds-session-head">
          <span className="ds-session-lbl">Session</span>
          <span className="ds-session-meta">{doneCount} moved</span>
        </div>
        {!hasDone && (
          <div className="ds-session-empty">{'No reviews yet \u2014 start with the top of the queue.'}</div>
        )}
        {hasDone && (
          <div className="ds-session-chips" aria-label="Items moved this session">
            {sessionReviewed.map((r, i) => (
              <span
                key={r.id}
                className={`ds-session-chip verdict-${r.verdict}`}
                title={`${r.ip} \u00b7 ${r.verdict === 'approve' ? 'approved' : 'sent for a pass'}`}
                style={{ animationDelay: `${i * 50}ms` }}
              >
                <span className={`ds-chip-glyph kind-${r.kind}`} aria-hidden="true"></span>
                <span className="ds-chip-ip">{r.ip}</span>
              </span>
            ))}
          </div>
        )}
      </div>

      <div className="ds-foot">
        <div className="profile">
          <div className="profile-dot">BR</div>
          <div className="who-text">
            <strong>Brian Robbins</strong>
            Co-Founder &middot; CEO
          </div>
        </div>
        <button className="endbtn" onClick={onEnd}>End Session</button>
      </div>
    </aside>
  );
}

/* ---------- Mini header (sticky-ish on scroll) ---------- */
function MiniHead({ shown, remaining }) {
  return (
    <div className={`minihead ${shown ? 'shown' : ''}`}>
      <div style={{display:'flex',alignItems:'center',gap:10}}>
        <img className="minihead-logo" src="assets/logo/dailies-icon-flat.svg" alt="Dailies" />
        <span className="minihead-meta">{remaining} waiting</span>
      </div>
      <div className="profile-dot">BR</div>
    </div>
  );
}

/* ---------- Minimalist data deck (vertical view) ----------
   One quiet band that scrolls with the feed: every count on a single stat row
   (waiting leads in gold, the IP breakdown follows), the iteration loops on the
   right, and End Session tucked into the eyebrow. */
function DataDeck({ items, onEnd }) {
  const counts = useMemo(() => countByProject(items), [items]);
  const waiting = items.filter(isWaiting).length;
  const overdue = items.filter(i => isWaiting(i) && urgencyKey(i) === 'overdue').length;
  const passCount = items.filter(i => i.status === 'out-for-pass').length;

  return (
    <section className="deck reveal d1" aria-label="Today's queue">
      <div className="deck-inner">
        <div className="deck-eyebrow">
          <span className="dot" aria-hidden="true"></span>
          <span className="lbl">{'For Your Eyes \u2014 Brian Robbins'}</span>
          <button className="deck-end" onClick={onEnd}>End session</button>
        </div>

        <div className="deck-main">
          <dl className="deck-breakdown">
            <div className="cell cell-primary"><dt>Waiting</dt><dd>{waiting}</dd></div>
            {PROJECT_ORDER.map(key => (
              <div className="cell" key={key}><dt>{PROJECTS[key].label}</dt><dd>{counts[key]}</dd></div>
            ))}
          </dl>
          {(overdue > 0 || passCount > 0) && (
            <div className="deck-loops">
              {overdue > 0 && <span className="deck-loop overdue">{overdue} overdue</span>}
              {passCount > 0 && <span className="deck-loop pass">{passCount} out for a pass</span>}
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/* ---------- Pinned header (vertical view) ----------
   The shrunk index-style mark, always pinned. The waiting count fades in
   only after the deck scrolls past, keeping the top of the page calm. */
function Header({ countShown, remaining }) {
  return (
    <header className="v2-head">
      <div className="v2-head-inner">
        <a className="v2-head-mark" href="../index.html" aria-label="Dailies home">
          <img className="v2-head-icon" src="assets/logo/dailies-icon-flat.svg" alt="" />
          <img className="v2-head-word" src="assets/logo/dailies-wordmark.svg" alt="Dailies" />
        </a>
        <nav className="v2-head-nav" aria-label="Surfaces">
          {QUICK_LINKS.map(l => (
            <a key={l.href} className="v2-head-navlink" href={l.href}>{l.label}</a>
          ))}
          <a className="v2-head-navlink is-archive" href="Past Approvals.html?as=brian">Past approvals</a>
        </nav>
        <div className="v2-head-right">
          <span className={`v2-head-count ${countShown ? 'shown' : ''}`} aria-hidden={!countShown}>
            <span className="n">{remaining}</span> waiting
          </span>
          <span className="v2-head-meta">{'Tue \u00b7 May 27'}</span>
          <span className="profile-dot" title="Brian Robbins">BR</span>
        </div>
      </div>
    </header>
  );
}

/* ---------- Tap hint ---------- */
function TapHint({ visible }) {
  if (!visible) return null;
  return (
    <div className="tap-hint">
      Tap any item to enter focus
    </div>
  );
}

/* ---------- Toast after a verdict ---------- */
function Toast({ data }) {
  const shown = !!data;
  return (
    <div className={`toast ${shown ? 'shown' : ''} ${data && data.tone ? 'tone-' + data.tone : ''}`} aria-live="polite">
      <span className="toast-dot" aria-hidden="true"></span>
      <span className="toast-msg">{data ? data.msg : ''}</span>
    </div>
  );
}

/* ---------- End-of-session modal ---------- */
function EndSessionModal({ open, sessionReviewed, sessionElapsed, remaining, onResume, onReset }) {
  const ipCounts = useMemo(() => {
    const c = {};
    sessionReviewed.forEach(r => { c[r.ip] = (c[r.ip] || 0) + 1; });
    return c;
  }, [sessionReviewed]);
  const kindCounts = useMemo(() => {
    const c = { image: 0, video: 0, audio: 0, request: 0, pdf: 0 };
    sessionReviewed.forEach(r => { c[r.kind] = (c[r.kind] || 0) + 1; });
    return c;
  }, [sessionReviewed]);
  const done = sessionReviewed.length;
  const passed = sessionReviewed.filter(r => r.verdict === 'pass').length;
  const approved = sessionReviewed.filter(r => r.verdict === 'approve').length;
  const m = Math.floor(sessionElapsed / 60);
  const s = sessionElapsed % 60;
  const timeLabel = m === 0 ? `${s}s` : `${m}m ${String(s).padStart(2, '0')}s`;

  return (
    <div className={`end-modal ${open ? 'open' : ''}`} role="dialog" aria-modal="true">
      <div className="end-card">
        <div className="end-eb">
          <span className="dot"></span>
          <span className="lbl">Session Summary</span>
        </div>
        <div className="end-headline">
          {done > 0 ? `${done} moved.` : 'Session paused.'}
        </div>
        {done > 0 && (
          <div className="end-verdicts">
            {approved > 0 && <span className="end-verdict approve">{approved} approved</span>}
            {passed > 0 && <span className="end-verdict pass">{passed} sent for a pass</span>}
          </div>
        )}
        <div className="end-stats">
          <div className="end-stat">
            <span className="n">{done}</span>
            <span className="l">Moved</span>
          </div>
          <div className="end-stat">
            <span className="n">{timeLabel}</span>
            <span className="l">Elapsed</span>
          </div>
          <div className="end-stat">
            <span className="n">{remaining}</span>
            <span className="l">Waiting</span>
          </div>
        </div>
        {done > 0 && (
          <div className="end-breakdown">
            <div className="end-bd-head">Today's mix</div>
            <div className="end-bd-list">
              {Object.entries(ipCounts).map(([ip, n]) => (
                <div key={ip} className="end-bd-row">
                  <span className="end-bd-ip">{ip}</span>
                  <span className="end-bd-rule"></span>
                  <span className="end-bd-n">{n}</span>
                </div>
              ))}
              {(kindCounts.video > 0 || kindCounts.audio > 0 || kindCounts.request > 0 || kindCounts.pdf > 0) && (
                <div className="end-bd-kinds">
                  {kindCounts.image > 0   && <span><strong>{kindCounts.image}</strong> image{kindCounts.image > 1 ? 's' : ''}</span>}
                  {kindCounts.video > 0   && <span><strong>{kindCounts.video}</strong> video{kindCounts.video > 1 ? 's' : ''}</span>}
                  {kindCounts.request > 0 && <span><strong>{kindCounts.request}</strong> read{kindCounts.request > 1 ? 's' : ''}</span>}
                  {kindCounts.pdf > 0     && <span><strong>{kindCounts.pdf}</strong> doc{kindCounts.pdf > 1 ? 's' : ''}</span>}
                  {kindCounts.audio > 0   && <span><strong>{kindCounts.audio}</strong> track{kindCounts.audio > 1 ? 's' : ''}</span>}
                </div>
              )}
            </div>
          </div>
        )}
        <div className="end-actions">
          <button className="end-btn ghost" onClick={onResume}>Keep going</button>
          <button className="end-btn primary" onClick={onReset}>{'Close \u2192 Reset queue'}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- Main App ---------- */
const CONFIG = {
  cardLayout: 'indexcard',
  summaryStyle: 'strip',
  grainOpacity: 0.05,
  showHint: true,
};

/* Layout view persistence. localStorage holds the user's explicit last choice
   (sticky across reloads and across both feed entries); window.DAILIES_DEFAULT_VIEW
   lets a given HTML entry pick its first-visit default. */
const VIEW_STORAGE_KEY = 'dailies.feedView';
function readInitialView() {
  try {
    const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
    if (saved === 'sidebyside' || saved === 'vertical') return saved;
  } catch (e) { /* storage unavailable (private mode / file://) */ }
  return window.DAILIES_DEFAULT_VIEW === 'vertical' ? 'vertical' : 'sidebyside';
}

/* Group-by knob. Three axes the reviewer can pivot the queue on; due-date and
   IP are the drivers, urgency is the derived read. The explicit choice persists
   across reloads and both feed entries, same as the layout view. */
const GROUP_MODES = [
  { key: 'ip',      label: 'Project' },
  { key: 'urgency', label: 'Urgency' },
  { key: 'due',     label: 'Due date' },
];
const GROUP_LABEL = { ip: 'by project', urgency: 'by urgency', due: 'by due date' };
const GROUP_STORAGE_KEY = 'dailies.groupBy';
function readInitialGroupBy() {
  try {
    const saved = window.localStorage.getItem(GROUP_STORAGE_KEY);
    if (saved === 'ip' || saved === 'urgency' || saved === 'due') return saved;
  } catch (e) { /* storage unavailable */ }
  return 'ip';
}
function GroupBySwitch({ mode, onChange }) {
  return (
    <div className="groupby" role="tablist" aria-label="Group queue by">
      <span className="groupby-lbl">Group</span>
      <div className="groupby-seg">
        {GROUP_MODES.map(m => (
          <button
            key={m.key}
            type="button"
            role="tab"
            aria-selected={mode === m.key}
            className={`groupby-opt ${mode === m.key ? 'active' : ''}`}
            onClick={() => onChange(m.key)}
          >{m.label}</button>
        ))}
      </div>
    </div>
  );
}

function App() {
  const [items, setItems] = useState(ITEMS);
  const [activeItem, setActiveItem] = useState(null);
  const [reviewOpen, setReviewOpen] = useState(false);
  const [toast, setToast] = useState(null); // { msg, tone } or null
  const [showMiniHead, setShowMiniHead] = useState(false);
  const [sessionStart] = useState(() => Date.now());
  const [sessionReviewed, setSessionReviewed] = useState([]); // [{id, ip, kind, verdict, ts}]
  const [endModalOpen, setEndModalOpen] = useState(false);
  const [groupMode, setGroupMode] = useState(readInitialGroupBy);
  const scrollRef = useRef(null);

  // Layout view: 'sidebyside' (slate beside the feed) or 'vertical' (one scroll).
  const [view, setView] = useState(readInitialView);
  useEffect(() => {
    try { window.localStorage.setItem(VIEW_STORAGE_KEY, view); }
    catch (e) { /* storage unavailable */ }
  }, [view]);

  // Group-by axis persists like the view; the switch lives in the feed eyebrow.
  useEffect(() => {
    try { window.localStorage.setItem(GROUP_STORAGE_KEY, groupMode); }
    catch (e) { /* storage unavailable */ }
  }, [groupMode]);

  // The experiments menu (experiments.js) owns the layout switch UI now; it
  // persists the choice and notifies us here so we re-render without a reload.
  useEffect(() => {
    const onExperiment = (e) => {
      if (e.detail && e.detail.key === 'view') setView(e.detail.value);
    };
    window.addEventListener('dailies:experiment', onExperiment);
    return () => window.removeEventListener('dailies:experiment', onExperiment);
  }, []);

  // grain
  useEffect(() => {
    document.documentElement.style.setProperty('--grain-opacity', CONFIG.grainOpacity);
  }, []);

  // Live session-elapsed counter (re-render every 10s)
  const [nowTick, setNowTick] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setNowTick(n => n + 1), 10_000);
    return () => clearInterval(id);
  }, []);
  const sessionElapsed = Math.max(0, Math.floor((Date.now() - sessionStart) / 1000));

  const handleOpen = (item) => {
    setActiveItem(item);
    setReviewOpen(true);
  };
  const handleClose = () => {
    setReviewOpen(false);
    setTimeout(() => setActiveItem(null), 350);
  };
  // A review ends in a verdict. 'pass' sends the asset back to the IC (status
  // out-for-pass, still in discussion) and records this round's notes into its
  // version history, so it stays in the feed. 'approve' is the decisive sign-off:
  // the asset becomes terminal, drops out of the feed, and lands in the Past
  // Approvals archive. No in-between "locked" resting state lingers in the feed.
  const handleVerdict = (item, verdict, round) => {
    if (!item) return;
    setReviewOpen(false);
    setTimeout(() => {
      setItems(prev => prev.map(x => {
        if (x.id !== item.id) return x;
        if (verdict === 'pass') {
          const versions = (round && round.length)
            ? [...(x.versions || []), { label: x.version || 'this cut', when: 'now', notes: round }]
            : (x.versions || []);
          return { ...x, status: 'out-for-pass', versions };
        }
        const approvals = (x.approvals || []).includes('Brian') ? x.approvals : [...(x.approvals || []), 'Brian'];
        return { ...x, approvals, status: 'approved' };
      }));
      setSessionReviewed(prev => [...prev, { id: item.id, ip: item.ip, kind: item.kind, verdict, ts: Date.now() }]);
      setToast(verdict === 'pass'
        ? { msg: `Sent back for a pass \u00b7 ${item.ip}`, tone: 'pass' }
        : { msg: `Approved \u00b7 ${item.ip}`, tone: 'approve' });
      setTimeout(() => setToast(null), 3200);
      setActiveItem(null);
    }, 200);
  };

  const handleEndSession = () => setEndModalOpen(true);
  const handleResetSession = () => {
    setItems(ITEMS);
    setSessionReviewed([]);
    setEndModalOpen(false);
  };

  const onScroll = useCallback((e) => {
    const top = e.target.scrollTop;
    setShowMiniHead(top > (view === 'vertical' ? 150 : 110));
  }, [view]);

  const Card = CardByLayout[CONFIG.cardLayout] || StoryboardCard;

  // Approved assets are terminal — they drop out of the feed into Past Approvals.
  const feedItems = useMemo(() => items.filter(i => !isApproved(i)), [items]);
  const waiting = feedItems.filter(isWaiting).length;
  const allDone = waiting === 0;

  // Group the queue on the chosen axis; within every group, due date leads.
  const groups = useMemo(() => groupBy(feedItems, groupMode), [feedItems, groupMode]);
  // First group open by default; switching axis (group ids change) re-opens the
  // first one via the survivor effect below.
  const [openGroups, setOpenGroups] = useState(() => {
    const initial = groupBy(ITEMS.filter(i => !isApproved(i)), readInitialGroupBy());
    return new Set(initial.length ? [initial[0].id] : []);
  });
  const toggleGroup = useCallback((id) => {
    setOpenGroups(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }, []);
  // Keep at least one group open: if none of the open ids survive (a verdict
  // moved an item, or the axis changed), fall open to the new first group.
  useEffect(() => {
    if (!groups.length) return;
    setOpenGroups(prev => (groups.some(g => prev.has(g.id)) ? prev : new Set([groups[0].id])));
  }, [groups]);

  const isVertical = view === 'vertical';

  // Shared between both layouts; in the vertical view it sits inside a centered
  // .col, in the side-by-side view it sits directly in the scroll.
  const feedColumn = (
    <>
      {/* Feed eyebrow + the group-by knob */}
      <div className="feed-eyebrow reveal d3">
        <span className="lbl">{`Queue \u2014 ${GROUP_LABEL[groupMode]}`}</span>
        <span className="rule"></span>
        <GroupBySwitch mode={groupMode} onChange={setGroupMode}/>
      </div>

      {/* Feed */}
      {!allDone && (
        <div className="feed reveal d4">
          {groups.map(group => (
            <FeedGroup
              key={group.id}
              group={group}
              open={openGroups.has(group.id)}
              onToggle={() => toggleGroup(group.id)}
              Card={Card}
              onOpen={handleOpen}
            />
          ))}
        </div>
      )}

      {allDone && (
        <div className="empty">
          <div className="num">0</div>
          <div className="eb">Caught Up</div>
          <div className="msg">{'Nothing waiting on your eyes. The open passes are still out with the team.'}</div>
          {sessionReviewed.length > 0 && (
            <div className="empty-session">
              <div className="empty-session-line">
                <span className="n">{sessionReviewed.length}</span>
                <span className="l">moved this session</span>
              </div>
              <div className="empty-session-meta">{fmtElapsed(sessionElapsed)}</div>
            </div>
          )}
        </div>
      )}

      <div className="endline">
        <span className="rule"></span>
        <span className="lbl">End of Queue</span>
        <span className="rule"></span>
      </div>
    </>
  );

  return (
    <div className={isVertical ? 'frame frame-v2' : 'frame'}>
      {!isVertical && <span className="frame-bracket tl" aria-hidden="true"></span>}
      {!isVertical && <span className="frame-bracket br" aria-hidden="true"></span>}

      {isVertical
        ? <Header countShown={showMiniHead} remaining={waiting}/>
        : <MiniHead shown={showMiniHead} remaining={waiting}/>}

      {!isVertical && (
        <DesktopSlate
          items={feedItems}
          sessionReviewed={sessionReviewed}
          sessionElapsed={sessionElapsed}
          onEnd={handleEndSession}
        />
      )}

      <div className="scroll" ref={scrollRef} onScroll={onScroll}>
        {isVertical ? (
          <DataDeck
            items={feedItems}
            sessionReviewed={sessionReviewed}
            sessionElapsed={sessionElapsed}
            onEnd={handleEndSession}
          />
        ) : (
          <div className="slate">
            <div className="slate-eyebrow reveal d1">
              <span className="dot"></span>
              <span className="lbl">Tue &middot; May 27</span>
              <span className="session">Morning Session</span>
            </div>
            <div className="wordmark reveal d1">
              <img className="wordmark-logo" src="assets/logo/dailies-logo.svg" alt="Dailies" />
              <span className="subtitle">{'For Your Eyes \u2014 Brian Robbins'}</span>
            </div>
            <Summary items={feedItems} variant={CONFIG.summaryStyle}/>
          </div>
        )}

        {isVertical ? <div className="col">{feedColumn}</div> : feedColumn}
      </div>

      <TapHint visible={CONFIG.showHint && sessionReviewed.length === 0 && !reviewOpen}/>
      <Toast data={toast}/>

      <ReviewOverlay
        item={activeItem && activeItem.kind !== 'pdf' && activeItem.kind !== 'video' && activeItem.kind !== 'audio' ? activeItem : null}
        open={reviewOpen && !!activeItem && activeItem.kind !== 'pdf' && activeItem.kind !== 'video' && activeItem.kind !== 'audio'}
        onClose={handleClose}
        onVerdict={handleVerdict}
      />
      <PdfReview
        item={activeItem && activeItem.kind === 'pdf' ? activeItem : null}
        open={reviewOpen && !!activeItem && activeItem.kind === 'pdf'}
        onClose={handleClose}
        onVerdict={handleVerdict}
      />
      <VideoReview
        item={activeItem && activeItem.kind === 'video' ? activeItem : null}
        open={reviewOpen && !!activeItem && activeItem.kind === 'video'}
        onClose={handleClose}
        onVerdict={handleVerdict}
      />
      <AudioReview
        item={activeItem && activeItem.kind === 'audio' ? activeItem : null}
        open={reviewOpen && !!activeItem && activeItem.kind === 'audio'}
        onClose={handleClose}
        onVerdict={handleVerdict}
      />
      <EndSessionModal
        open={endModalOpen}
        sessionReviewed={sessionReviewed}
        sessionElapsed={sessionElapsed}
        remaining={waiting}
        onResume={() => setEndModalOpen(false)}
        onReset={handleResetSession}
      />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
