// All four lenses + command palette + app shell.
const { useState, useMemo, useEffect, useRef, useCallback } = React;

// LensHelp component is defined in brief.jsx (loads first); used here by the
// lens header h1s.

// ──────── BriefRow—shared wrapper around <BriefTile> ────────
// Per the no-touch contract on brief.jsx, lens-side concerns (selection,
// focus outline, outcome shading, drag handles for beach reorder) live in a
// thin wrapper that adds class hooks + inline style + click capture without
// modifying the tile itself. The wrapper also captures shift-clicks to drive
// range selection without firing the tile's expand-on-click.
//
// Props mirror BriefTile + the shell's selection/focus/drag plumbing.
function BriefRow(props) {
  const {
    brief,
    selected, focused, outcomeStateToken,
    onSelectClick,             // (briefId, e)—called on plain or shift click
    draggable, onDragStart, onDragOver, onDrop, onDragEnd, dragging,
    showDragHandle,
    decision,
    children,                  // the actual <BriefTile/> element to wrap
  } = props;
  // Capture-phase click: if shift is held, swallow the click so the tile
  // doesn't toggle expand, and route to selection. Plain click still passes
  // through (tile owns expand-on-click behavior).
  const onClickCapture = (e) => {
    if (e.shiftKey || e.metaKey) {
      e.stopPropagation();
      e.preventDefault();
      onSelectClick && onSelectClick(brief.id, e);
    }
  };
  // Inline style applies the outcome shading token (item 4.6). Falsy =
  // no inline tint, so existing tile chrome is unchanged.
  const style = outcomeStateToken
    ? { '--outcome-state': outcomeStateToken }
    : null;
  const cls = [
    'brief-row',
    selected ? 'selected' : '',
    focused ? 'focused-tile' : '',
    outcomeStateToken ? 'has-outcome-state' : '',
    dragging ? 'dragging' : '',
    decision?.priorityPin != null ? 'pinned' : '',
  ].filter(Boolean).join(' ');
  return (
    <div
      className={cls}
      style={style}
      data-brief-row-id={brief.id}
      onClickCapture={onClickCapture}
      draggable={draggable || undefined}
      onDragStart={onDragStart}
      onDragOver={onDragOver}
      onDrop={onDrop}
      onDragEnd={onDragEnd}
    >
      {showDragHandle && (
        <span className="drag-handle" title="Drag to reorder priority within this rail" aria-hidden="true">
          ⋮⋮
        </span>
      )}
      {decision?.priorityPin != null && (
        <span className="pin-badge" title={`Priority-pinned (#${decision.priorityPin + 1})`}>
          ★
        </span>
      )}
      {children}
    </div>
  );
}

// ──────── Recommended-action hint (item 4.3) ────────
// Tiny chip rendered alongside the BriefTile's drag handle / pin badge
// strip. Shows which action the runtime verdict recommends so the operator
// knows what Enter will fire on the focused brief. Pure visual hint —
// confirmation still requires a keystroke (the App-level 'Enter' handler).
function RecommendedActionHint({ action }) {
  if (!action) return null;
  const label = action === 'keep' ? 'Keep' : action === 'defer' ? 'Defer' : 'Skip';
  return (
    <span
      className={'rec-action-hint rec-action-' + action}
      title={`Verdict recommends: ${label}. Press ↵ on the focused tile to fire—this is a suggestion, not a recorded decision.`}
    >
      Suggest: {label} ↵
    </span>
  );
}

// ──────── Lens 1: Decision Queue (daily driver) ────────
// All hard filters (collection + verdict + decision) applied at App level
// before briefs reach this lens. Queue's only own concern: sort order
// (undecided High Opportunity briefs first, then verdict order, then volume).
// Today's briefs cap—per editorial frame: latest-pull initial presentation
// should be 10–max 15 briefs so the operator can triage without choice
// paralysis. Default to 12; "show all" toggle expands to the full queue.
// Optional focus-mode cap. Defaults to OFF (strategists see the full inventory)
//—the cap is opt-in via the "focus on top N" toggle. Right move for inventory-
// discovery state (post-bulk-seed, pre-focus). Once focus narrows (content team
// lead picks 2-3 collections to prioritize), the operator opts into focus mode
// to avoid choice paralysis on the focused subset.
const QUEUE_FOCUS_CAP = 15;

function QueueLens({ briefs, decisions, expanded, onToggle, onDecide, onJump, trendMatched, revenueOn, engagementOn, personaOn, personaId, sort, setSort, clearSort, sortDirty,
  // 4.x—UX upgrades plumbed through from App
  selectedIds, toggleSelectOne, selectRange, focusedId, setFocusedId,
  registerVisibleIds, recommendedActionFor, outcomeStateTokenFor,
}) {
  // focusMode: false = show all (default); true = cap visible to top QUEUE_FOCUS_CAP
  const [focusMode, setFocusMode] = useState(false);

  // Skip filtering moved to App level (2026-05-04) so every lens—queue,
  // beach, gap-map, log—respects the same default. The lens just consumes
  // the already-filtered briefs list passed in.
  const queueBriefs = briefs;

  // Split briefs by lifecycle: fresh-intake briefs surface in their own
  // strip at top so weekly inflow gets triaged before it ages into the
  // main queue. Sort within each split per the rail's Sort dropdown:
  //   - 'default' / 'default-static' → verdict order → volume DESC (intake),
  //     undecided → verdict → volume DESC (rest)
  //   - 'volatility' (item 4.19) → sum of normalized abs deltas across
  //     volume/KD/CPC since last snapshot, descending. Briefs with no
  //     snapshot sort to the bottom of the volatility group.
  //   - other non-default → ordered by chosen dimension; rest still keeps
  //     undecided-first as a meta-sort layered on top
  const sortBy = sort?.by || 'default';
  const sortDir = sort?.dir || 'desc';
  const sortBy2 = sort?.by2 || 'none';
  const sortDir2 = sort?.dir2 || 'desc';

  // Snapshot map for volatility sort (item 4.19). Read-only here—App owns
  // the write path on mount. Cached per render via useMemo.
  const snapshotMap = useMemo(() => {
    try { return JSON.parse(localStorage.getItem('dk-v4-snapshot-map') || '{}'); }
    catch { return {}; }
  }, []);

  // Volatility = sum of normalized abs deltas across (volume, KD, CPC).
  // Briefs without a snapshot return -1 so they sort to bottom in DESC mode.
  const volatilityFor = useCallback((b) => {
    const snap = snapshotMap[b.id];
    if (!snap) return -1;
    const vNorm = snap.v ? Math.abs(((b.totalVolume || 0) - snap.v) / Math.max(1, snap.v)) : 0;
    const kNorm = snap.k ? Math.abs(((b.avgKD || 0) - snap.k) / Math.max(1, snap.k)) : 0;
    const cNorm = snap.c ? Math.abs(((b.avgCPC || 0) - snap.c) / Math.max(0.01, snap.c)) : 0;
    return vNorm + kNorm + cNorm;
  }, [snapshotMap]);

  const isStaticDefault = (sortBy === 'default' || sortBy === 'default-static');
  const { intake, rest } = useMemo(() => {
    const intake = [];
    const rest = [];
    queueBriefs.forEach(b => {
      const ls = briefLifecycleState(b, decisions[b.id]);
      // Intake strip excludes briefs the operator has triaged (keep/skip/defer).
      // TVE-only annotations don't disqualify—those are framework overrides,
      // not triage commitments.
      if (ls === 'fresh' && !decisions[b.id]?.action) intake.push(b);
      else rest.push(b);
    });
    // Pinned briefs (priorityPin != null) always render first in `rest`,
    // ordered by their pin index ASC. Item 4.5 + 4.2's "Pin to top".
    const pinSort = (a, b) => {
      const pa = decisions[a.id]?.priorityPin, pb = decisions[b.id]?.priorityPin;
      if (pa == null && pb == null) return 0;
      if (pa == null) return 1;
      if (pb == null) return -1;
      return pa - pb;
    };
    if (isStaticDefault) {
      // Composite-score DESC is the granular opportunity ordering—the same
      // signal that drives the verdict tier (volume × invKD × CPC × residual,
      // 75/10/10/5 weights). Use it directly so within-tier ordering is
      // by-opportunity rather than by-volume-only. Memoize per render.
      const scoreCache = new Map();
      const opportunityScore = (b) => {
        if (scoreCache.has(b.id)) return scoreCache.get(b.id);
        let s = 0;
        try {
          if (typeof window.computeVerdictBreakdown === 'function') {
            s = window.computeVerdictBreakdown(b)?.score ?? 0;
          }
        } catch { /* fallback to volume below */ }
        if (!Number.isFinite(s) || s === 0) s = (b.totalVolume || 0) / 1e9;
        scoreCache.set(b.id, s);
        return s;
      };
      // Insufficient-data briefs sink to the bottom within each decision
      // category—sufficient-signal briefs get triage focus first.
      const isInsufficient = (b) => {
        try {
          return typeof window.briefRuntimeTierLabel === 'function'
            && window.briefRuntimeTierLabel(b) === 'insufficient-data';
        } catch { return false; }
      };
      intake.sort((a, b) => {
        const ia = isInsufficient(a) ? 1 : 0;
        const ib = isInsufficient(b) ? 1 : 0;
        if (ia !== ib) return ia - ib;
        return opportunityScore(b) - opportunityScore(a);
      });
      rest.sort((a, b) => {
        // Pinned first—across decision state.
        const pinDelta = pinSort(a, b);
        if (pinDelta !== 0) return pinDelta;
        // Decision-action category ordering: undecided → keep → defer → skip.
        const decOrd = { undefined: 0, null: 0, '': 0, keep: 1, defer: 2, skip: 3 };
        const dA = decisions[a.id]?.action || '';
        const dB = decisions[b.id]?.action || '';
        const dvA = decOrd[dA] ?? 4;
        const dvB = decOrd[dB] ?? 4;
        if (dvA !== dvB) return dvA - dvB;
        // Within each decision category, sufficient-signal briefs first,
        // then insufficient-data, both sorted by opportunity DESC.
        const ia = isInsufficient(a) ? 1 : 0;
        const ib = isInsufficient(b) ? 1 : 0;
        if (ia !== ib) return ia - ib;
        return opportunityScore(b) - opportunityScore(a);
      });
    } else if (sortBy === 'volatility') {
      // Volatility sort (item 4.19)—DESC by default, snapshot-less briefs
      // sink to bottom of the volatility group via the -1 sentinel.
      const dirMul = sortDir === 'asc' ? 1 : -1;
      const sortVol = (arr, preFilter) => {
        const keyed = arr.map(b => ({
          b,
          k1: volatilityFor(b),
          decided: !!decisions[b.id]?.action,
        }));
        keyed.sort((a, c) => {
          if (preFilter) {
            const pinDelta = pinSort(a.b, c.b);
            if (pinDelta !== 0) return pinDelta;
            if (a.decided !== c.decided) return a.decided ? 1 : -1;
          }
          // Snapshot-less briefs (-1) always sort last regardless of dir.
          if (a.k1 < 0 && c.k1 >= 0) return 1;
          if (c.k1 < 0 && a.k1 >= 0) return -1;
          if (a.k1 !== c.k1) return (a.k1 - c.k1) * dirMul;
          // Tiebreaker—volume DESC.
          return (c.b.totalVolume || 0) - (a.b.totalVolume || 0);
        });
        return keyed.map(o => o.b);
      };
      const intakeSorted = sortVol(intake, false);
      const restSorted = sortVol(rest, true);
      intake.length = 0; intake.push(...intakeSorted);
      rest.length = 0; rest.push(...restSorted);
    } else {
      const dirMul = sortDir === 'asc' ? 1 : -1;
      const dirMul2 = sortDir2 === 'asc' ? 1 : -1;
      // Precompute sort keys once per brief—avoids O(n log n) recomputation
      // of briefSortKey() inside the comparator.
      const useSecondary = sortBy2 && sortBy2 !== 'none';
      const sortViaKeys = (arr, preFilter) => {
        const keyed = arr.map(b => ({
          b,
          k1: briefSortKey(b, sortBy, personaId),
          k2: useSecondary ? briefSortKey(b, sortBy2, personaId) : 0,
          decided: !!decisions[b.id]?.action,
        }));
        keyed.sort((a, c) => {
          if (preFilter) {
            // Pinned briefs always surface first regardless of decision state.
            const pinDelta = pinSort(a.b, c.b);
            if (pinDelta !== 0) return pinDelta;
            // For `rest`, undecided briefs must surface first.
            if (a.decided !== c.decided) return a.decided ? 1 : -1;
          }
          if (a.k1 !== c.k1) return (a.k1 - c.k1) * dirMul;
          if (useSecondary && a.k2 !== c.k2) return (a.k2 - c.k2) * dirMul2;
          return 0;
        });
        return keyed.map(o => o.b);
      };
      const intakeSorted = sortViaKeys(intake, false);
      const restSorted = sortViaKeys(rest, true);
      intake.length = 0; intake.push(...intakeSorted);
      rest.length = 0; rest.push(...restSorted);
    }
    return { intake, rest };
  }, [briefs, decisions, sortBy, sortDir, sortBy2, sortDir2, personaId, isStaticDefault, volatilityFor]);

  // Default = no cap (full inventory visible). When the operator opts into
  // focus mode, cap applies to TOTAL visible (intake + rest combined); intake
  // gets priority because fresh-intake briefs are triage priority.
  let intakeToShow, restToShow;
  if (!focusMode) {
    intakeToShow = intake;
    restToShow = rest;
  } else if (intake.length >= QUEUE_FOCUS_CAP) {
    intakeToShow = intake.slice(0, QUEUE_FOCUS_CAP);
    restToShow = [];
  } else {
    intakeToShow = intake;
    restToShow = rest.slice(0, QUEUE_FOCUS_CAP - intake.length);
  }
  const intakeHidden = intake.length - intakeToShow.length;
  const restHidden = rest.length - restToShow.length;
  const hiddenCount = intakeHidden + restHidden;

  // Register the lens's visible-id list for App's selection / focus / Cmd+A
  // bindings. Order matches the on-screen sequence (intake first, then rest).
  const visibleIds = useMemo(
    () => [...intakeToShow.map(b => b.id), ...restToShow.map(b => b.id)],
    [intakeToShow, restToShow]
  );
  useEffect(() => {
    if (registerVisibleIds) registerVisibleIds('queue', visibleIds);
  }, [visibleIds, registerVisibleIds]);

  const onSelectClick = useCallback((id, e) => {
    if (e.shiftKey && selectRange) selectRange(id);
    else if (toggleSelectOne) toggleSelectOne(id);
  }, [selectRange, toggleSelectOne]);

  const renderTile = (b) => {
    const recAction = recommendedActionFor ? recommendedActionFor(b) : null;
    const outcomeToken = outcomeStateTokenFor ? outcomeStateTokenFor(b.id) : null;
    return (
      <BriefRow
        key={b.id}
        brief={b}
        decision={decisions[b.id]}
        selected={selectedIds && selectedIds.has(b.id)}
        focused={focusedId === b.id}
        outcomeStateToken={outcomeToken}
        onSelectClick={onSelectClick}
      >
        <BriefTile
          brief={b}
          decision={decisions[b.id]}
          expanded={expanded===b.id}
          onToggle={()=>{ setFocusedId && setFocusedId(b.id); onToggle(b.id); }}
          onDecide={onDecide}
          onJump={onJump}
          revenueOn={revenueOn}
          engagementOn={engagementOn}
          personaOn={personaOn}
          personaId={personaId}
          trendMatch={trendMatched ? (trendMatched.has(b.id) ? 'hit' : 'dim') : null}
        />
        {recAction && <RecommendedActionHint action={recAction} />}
      </BriefRow>
    );
  };

  return (
    <>
      <div className="lens-header">
        <h1>Today's briefs <LensHelp lensId="queue" /></h1>
        <span className="sub">
          {!focusMode
            ? `${queueBriefs.length} brief${queueBriefs.length === 1 ? '' : 's'} · full inventory`
            : `${intakeToShow.length + restToShow.length} of ${queueBriefs.length} · focus mode (top ${QUEUE_FOCUS_CAP})`}
        </span>
        <div className="right">
          {/* Focus-mode toggle. Off by default—strategists see the full
              inventory until they narrow focus. On = cap visible to top N for
              triage focus on a narrowed subset. */}
          <button
            className={'log-window-toggle ' + (focusMode ? 'active' : '')}
            onClick={() => setFocusMode(f => !f)}
            title={focusMode ? `Show all ${queueBriefs.length} briefs` : `Focus on top ${QUEUE_FOCUS_CAP} for triage (intake first, then queue)`}
          >
            {focusMode ? `↶ show all (${queueBriefs.length})` : `focus on top ${QUEUE_FOCUS_CAP}`}
          </button>
          <span className="dim mono" style={{fontSize:10, letterSpacing:'.1em', textTransform:'uppercase'}}>↵ to expand · esc to collapse</span>
        </div>
        {/* Sort controls—own row beneath the title, anchored left, expands
            right as selections are made (secondary "then by" appears when
            primary is non-default). asc/desc toggle disabled in default mode
            (default has fixed verdict→vol secondary). "clear sort" reverts
            to default; dimmed when already at default. */}
        {setSort && (
          <div className="lens-sort-row">
            <label className="lens-sort-label">sort</label>
            <select
              className="sort-by"
              value={sort.by}
              onChange={e => setSort(s => ({ ...s, by: e.target.value }))}
              title="Primary sort dimension"
            >
              {/* 4.19—surface volatility + default-static alongside the
                  util.js-defined dimensions. Keep the legacy 'default' label
                  visible (back-compat for prior URLs / saved presets). */}
              <option value="volatility">signal volatility (Δ since last visit)</option>
              <option value="default-static">default-static (verdict → vol)</option>
              {BRIEF_SORT_OPTIONS.filter(opt => opt.id !== 'default').map(opt => (
                <option key={opt.id} value={opt.id}>{opt.label}</option>
              ))}
              {/* Hidden 'default' kept selectable for older saved sort state. */}
              <option value="default" style={{display:'none'}}>default (legacy)</option>
            </select>
            <button
              className={'sort-dir ' + sort.dir}
              onClick={() => setSort(s => ({ ...s, dir: s.dir === 'desc' ? 'asc' : 'desc' }))}
              disabled={sort.by === 'default' || sort.by === 'default-static'}
              title={(sort.by === 'default' || sort.by === 'default-static') ? 'Direction not applicable in default sort' : `Toggle to ${sort.dir === 'desc' ? 'ascending' : 'descending'}`}
            >
              {sort.dir === 'desc' ? '↓ desc' : '↑ asc'}
            </button>
            {sort.by !== 'default' && sort.by !== 'default-static' && (
              <>
                <label className="lens-sort-label">then by</label>
                <select
                  className="sort-by"
                  value={sort.by2}
                  onChange={e => setSort(s => ({ ...s, by2: e.target.value }))}
                  title="Secondary sort dimension (tiebreaker within primary)"
                >
                  <option value="none">(none)</option>
                  {BRIEF_SORT_OPTIONS.filter(opt => opt.id !== 'default' && opt.id !== sort.by).map(opt => (
                    <option key={opt.id} value={opt.id}>{opt.label}</option>
                  ))}
                </select>
                <button
                  className={'sort-dir ' + sort.dir2}
                  onClick={() => setSort(s => ({ ...s, dir2: s.dir2 === 'desc' ? 'asc' : 'desc' }))}
                  disabled={sort.by2 === 'none'}
                  title={sort.by2 === 'none' ? 'Pick a secondary dimension first' : `Toggle to ${sort.dir2 === 'desc' ? 'ascending' : 'descending'}`}
                >
                  {sort.dir2 === 'desc' ? '↓ desc' : '↑ asc'}
                </button>
              </>
            )}
            <button
              className={'lens-sort-clear ' + (sortDirty ? 'dirty' : 'idle')}
              disabled={!sortDirty}
              onClick={clearSort}
              title="Reset sort to default (verdict → volume)"
            >
              clear
            </button>
          </div>
        )}
      </div>

      {intakeToShow.length > 0 && (
        <div className="intake-strip">
          <div className="intake-strip-head">
            <span className="intake-glyph">●</span>
            <span className="intake-title">Recent intake</span>
            <span className="intake-meta">
              {intake.length} new brief{intake.length === 1 ? '' : 's'} · added in last {FRESH_DAYS} days · triage these first (bi-weekly cadence)
              {intakeHidden > 0 && <> · <b>showing top {intakeToShow.length}</b></>}
            </span>
          </div>
          {intakeToShow.map(renderTile)}
        </div>
      )}

      {queueBriefs.length === 0 ? (
        <div className="empty">
          <div className="big">no briefs match</div>
          <div>clear filters in the rail or paste a different trend</div>
        </div>
      ) : (
        <>
          {intakeToShow.length > 0 && restToShow.length > 0 && (
            <div className="queue-divider" role="separator" aria-label="Working queue">
              <span className="queue-divider-label">
                Working queue · {rest.length} brief{rest.length === 1 ? '' : 's'}
                {restHidden > 0 && <span className="dim"> · showing top {restToShow.length}</span>}
              </span>
            </div>
          )}
          {restToShow.map(renderTile)}
          {focusMode && hiddenCount > 0 && (
            <div className="log-history-footer">
              <span className="dim mono">{hiddenCount} brief{hiddenCount === 1 ? '' : 's'} hidden by focus-mode cap (top {QUEUE_FOCUS_CAP} only)</span>
              <button className="log-window-toggle inline" onClick={() => setFocusMode(false)}>show all →</button>
            </div>
          )}
        </>
      )}
    </>
  );
}

// ──────── Production-mix tracker (TH Framework v1, Section 4b) ────────
// Tracks ratio of T1 / T2 / T3 keeps against the 60/30/10 production target.
// Renders as a compact horizontal bar across the top of the Beach lens so the
// operator can see allocation drift at a glance.
function ProductionMixTracker({ briefs, decisions }) {
  const mix = productionMix(briefs, decisions);
  if (mix.keepsTotal === 0) {
    return (
      <div className="production-mix production-mix-empty">
        <span className="pm-label">Production mix · TH Framework target</span>
        <span className="pm-empty">No keeps yet—target ratio is <b>60% T1 · 30% T2 · 10% T3</b> (Section 4b)</span>
      </div>
    );
  }
  const fmt = (n) => (n * 100).toFixed(0) + '%';
  const deltaPill = (d) => {
    if (Math.abs(d) < 0.05) return <span className="pm-delta pm-on-target">on target</span>;
    if (d > 0) return <span className="pm-delta pm-over">+{(d*100).toFixed(0)}pp</span>;
    return <span className="pm-delta pm-under">{(d*100).toFixed(0)}pp</span>;
  };
  return (
    <div className="production-mix">
      <div className="pm-head">
        <span className="pm-label">Production mix · TH Framework target</span>
        <span className="pm-keeps">{mix.keepsTotal} keep{mix.keepsTotal===1?'':'s'} · target 60/30/10</span>
      </div>
      <div className="pm-bar">
        <div className="pm-seg pm-t1" style={{flexBasis: (mix.actualPct[1]*100||0.5)+'%'}} title={`T1: ${mix.counts[1]} keeps (${fmt(mix.actualPct[1])})`}>
          T1 · {mix.counts[1]} · {fmt(mix.actualPct[1])}
        </div>
        <div className="pm-seg pm-t2" style={{flexBasis: (mix.actualPct[2]*100||0.5)+'%'}} title={`T2: ${mix.counts[2]} keeps (${fmt(mix.actualPct[2])})`}>
          T2 · {mix.counts[2]} · {fmt(mix.actualPct[2])}
        </div>
        <div className="pm-seg pm-t3" style={{flexBasis: (mix.actualPct[3]*100||0.5)+'%'}} title={`T3: ${mix.counts[3]} keeps (${fmt(mix.actualPct[3])})`}>
          T3 · {mix.counts[3]} · {fmt(mix.actualPct[3])}
        </div>
      </div>
      <div className="pm-deltas">
        <span>T1 {deltaPill(mix.deltaPct[1])}</span>
        <span>T2 {deltaPill(mix.deltaPct[2])}</span>
        <span>T3 {deltaPill(mix.deltaPct[3])}</span>
      </div>
    </div>
  );
}

// ──────── Lens 2: Beach Workspace (Authority Ladder reimagined) ────────
// Throughput re-baseline (see js/util.js → THROUGHPUT):
//   • Ready-now target: 3–5/wk per active vertical (was 8–12)
//   • Building-toward unlocks at 6 ready-now keeps top-25 (was 12)
//   • Earned-later unlocks at 15 cumulative + median pos ≤ 30 (was 25)
// Rationale: National team is full-time on TrendHunter—no L&E or Newsroom
// CSAs splitting the slate. Quality + Curious-Optimizer-affinity over volume.
function BeachLens({ briefs, decisions, expanded, onToggle, onDecide, onReorderPins, onJump, revenueOn, engagementOn, personaOn, personaId, trendMatched,
  // 4.x—UX upgrades plumbed through from App
  selectedIds, toggleSelectOne, selectRange, focusedId, setFocusedId,
  registerVisibleIds, recommendedActionFor, outcomeStateTokenFor,
  // Item 7 / 8—multi-objective + cohort-mode
  objective, cohortMode, target,
}) {
  const tileProps = { revenueOn, engagementOn, personaOn, personaId };
  const tm = (id) => trendMatched ? (trendMatched.has(id) ? 'hit' : 'dim') : null;
  // Drag-and-drop state for priority reorder within a rail (item 4.5).
  // dragInfo: { id, fromColl, fromTail } | null
  const [dragInfo, setDragInfo] = useState(null);
  const onSelectClick = useCallback((id, e) => {
    if (e.shiftKey && selectRange) selectRange(id);
    else if (toggleSelectOne) toggleSelectOne(id);
  }, [selectRange, toggleSelectOne]);
  // Drag handlers (item 4.5). Order persists to D1 by writing
  // priorityPin: <newIndex> to each affected brief's decision row. Only
  // tiles within the same collection+tail rail accept drops; cross-rail
  // drops are rejected silently (preserves the rail's KD-band invariant).
  const onDragStartTile = useCallback((id, fromColl, fromTail) => (e) => {
    setDragInfo({ id, fromColl, fromTail });
    try {
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', id);
    } catch {}
  }, []);
  const onDragOverTile = useCallback((e) => {
    if (!dragInfo) return;
    e.preventDefault();
    try { e.dataTransfer.dropEffect = 'move'; } catch {}
  }, [dragInfo]);
  const onDropTile = useCallback((targetId, targetColl, targetTail, railOrder) => (e) => {
    e.preventDefault();
    if (!dragInfo) return;
    if (dragInfo.fromColl !== targetColl || dragInfo.fromTail !== targetTail) {
      // Cross-rail drop—rejected. Keeps each rail's KD-band invariant intact.
      setDragInfo(null);
      return;
    }
    const ids = railOrder.slice();
    const fromIdx = ids.indexOf(dragInfo.id);
    const toIdx = ids.indexOf(targetId);
    if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
      setDragInfo(null);
      return;
    }
    // Move dragged tile to target position; reassign priorityPin to all
    // tiles in the rail. Audit 2026-05-04 H1: route through onReorderPins
    // (batched local update + only changed pins push to worker) instead of
    // firing N independent onDecide calls. Falls back to onDecide if the
    // app shell hasn't supplied onReorderPins yet (e.g., older lens consumer).
    const moved = ids.splice(fromIdx, 1)[0];
    ids.splice(toIdx, 0, moved);
    if (typeof onReorderPins === 'function') {
      onReorderPins(ids);
    } else {
      ids.forEach((bid, i) => onDecide(bid, { priorityPin: i }));
    }
    setDragInfo(null);
  }, [dragInfo, onDecide, onReorderPins]);
  const onDragEndTile = useCallback(() => setDragInfo(null), []);
  // Group briefs by TH collection (Mind & Body / Experiences / Everyday).
  // Within each rail, sort by cluster opportunity score (TH Framework v1
  // Section 4b: sweet-spot keyword count × sweet-spot volume) descending —
  // highest-opportunity briefs surface first.
  const beaches = useMemo(() => {
    const byC = {};
    Object.keys(TH_COLLECTIONS).forEach(cid => {
      byC[cid] = { id: cid, name: TH_COLLECTIONS[cid].name, glyph: TH_COLLECTIONS[cid].glyph, desc: TH_COLLECTIONS[cid].desc, long:[], mid:[], head:[] };
    });
    briefs.forEach(b => {
      const cid = inferTHCollection(b);
      if (!byC[cid]) byC[cid] = { id: cid, name: TH_COLLECTIONS[cid]?.name || cid, glyph: TH_COLLECTIONS[cid]?.glyph || '·', desc: TH_COLLECTIONS[cid]?.desc || '', long:[], mid:[], head:[] };
      const tail = inferTailClass(b);
      byC[cid][tail].push(b);
    });
    // Sort each rail by:
    //   1. priorityPin ASC (drag-to-reorder + pin-to-top, item 4.5/4.2)
    //   2. cluster opportunity score DESC
    //   3. volume DESC tiebreaker
    // Precompute oppScore once per brief—avoids O(n log n) calls into
    // clusterOpportunityScore() inside the comparator.
    const sortByOpp = (rail) => {
      const keyed = rail.map(b => ({
        b,
        pin: decisions[b.id]?.priorityPin,
        score: clusterOpportunityScore(b).score,
        vol: b.totalVolume || 0,
      }));
      keyed.sort((a, c) => {
        // priorityPin first—pinned tiles rendered in pin-order, then
        // unpinned tiles by oppScore.
        const ap = a.pin == null ? Infinity : a.pin;
        const cp = c.pin == null ? Infinity : c.pin;
        if (ap !== cp) return ap - cp;
        if (a.score !== c.score) return c.score - a.score;
        return c.vol - a.vol;
      });
      return keyed.map(o => o.b);
    };
    Object.values(byC).forEach(beach => {
      beach.long = sortByOpp(beach.long);
      beach.mid = sortByOpp(beach.mid);
      beach.head = sortByOpp(beach.head);
    });
    // Order: Mind / Body → Experiences → Everyday Living (canonical relaunch order)
    const order = ['mind-body', 'experiences', 'everyday-living'];
    return order.map(cid => byC[cid]).filter(Boolean);
  }, [briefs, decisions]);

  // Register the lens's visible-id list (item 4.1 / 4.2 plumbing). Order
  // matches on-screen reading order: each beach's long → mid → head, in the
  // canonical collection sequence.
  const visibleIds = useMemo(() => {
    const out = [];
    beaches.forEach(b => {
      b.long.forEach(x => out.push(x.id));
      b.mid.forEach(x => out.push(x.id));
      b.head.forEach(x => out.push(x.id));
    });
    return out;
  }, [beaches]);
  useEffect(() => {
    if (registerVisibleIds) registerVisibleIds('beach', visibleIds);
  }, [visibleIds, registerVisibleIds]);

  // Per-rail tile renderer—wraps each BriefTile in a BriefRow that adds
  // selection / focus / outcome shading + drag handles (item 4.5).
  const renderRailTile = (b, collId, tail, railIds) => {
    const recAction = recommendedActionFor ? recommendedActionFor(b) : null;
    const outcomeToken = outcomeStateTokenFor ? outcomeStateTokenFor(b.id) : null;
    return (
      <BriefRow
        key={b.id}
        brief={b}
        decision={decisions[b.id]}
        selected={selectedIds && selectedIds.has(b.id)}
        focused={focusedId === b.id}
        outcomeStateToken={outcomeToken}
        onSelectClick={onSelectClick}
        draggable={true}
        showDragHandle={true}
        onDragStart={onDragStartTile(b.id, collId, tail)}
        onDragOver={onDragOverTile}
        onDrop={onDropTile(b.id, collId, tail, railIds)}
        onDragEnd={onDragEndTile}
        dragging={dragInfo?.id === b.id}
      >
        <BriefTile brief={b} decision={decisions[b.id]} expanded={expanded===b.id}
          onToggle={()=>{ setFocusedId && setFocusedId(b.id); onToggle(b.id); }}
          onDecide={onDecide} onJump={onJump} trendMatch={tm(b.id)} {...tileProps} />
        {recAction && <RecommendedActionHint action={recAction} />}
      </BriefRow>
    );
  };

  return (
    <>
      <div className="lens-header collections-header">
        <div className="lens-header-row">
          <h1>Beach <LensHelp lensId="beach" /></h1>
          <span className="lens-pill">Beach view</span>
        </div>
        <p className="lens-blurb">
          The three post-relaunch collections—<b>Mind &amp; Body</b>, <b>Experiences</b>, <b>Everyday Living</b>—are
          how TH organizes its surface area. Inside each collection, briefs are stacked into three difficulty rails:
          ones you can win <b>now</b>, ones you're <b>building toward</b>, and the headtail you'll <b>earn later</b>.
        </p>
        <div className="lens-targets">
          <span className="lt-label">Cadence</span>
          <span className="lt-val">{THROUGHPUT.readyNowPerWeek.lo}–{THROUGHPUT.readyNowPerWeek.hi}</span>
          <span className="lt-unit">ready-now articles / wk per active collection</span>
        </div>
      </div>
      {/* Item 29 / 43—per-Play summary cards. Renders one card per Play
          with brief count, keep ratio, outcome capture %, and average
          opportunity score (pulled from cohortPercentilesFor for the same
          Play subset when the helper is available). Sits above the columns
          so the operator can see Play-level health before diving into rails. */}
      <PerPlaySummaryStrip briefs={briefs} decisions={decisions} cohortMode={cohortMode} target={target || objective} />

      {/* TH Framework v1—production-mix tracker (60/30/10 target across kept briefs) */}
      <ProductionMixTracker briefs={briefs} decisions={decisions} />

      {/* Compounding rates—moved above the columns so it reads as orientation
          context, not a footer. */}
      <CompoundingStrip />

      <div className="beach-columns">
      {beaches.map(beach => {
        // Rolling-60d unlock model (2026-04-28): the team's *recent* trajectory
        // gates the harder rails, not lifetime accumulation. A collection that
        // earned Building three months ago re-locks if recent commits dried up.
        // Honest signal of "are we currently winning here?" instead of a
        // one-shot achievement.
        const recentKeeps = recentKeepCount(
          [...beach.long, ...beach.mid],
          decisions,
          ROLLING_UNLOCK_DAYS
        );
        // TH Framework v1—topic-level snapshot for this collection: does it
        // have enough cluster volume / KW count / sweet-spot KWs to qualify
        // as a viable content category per Section 1 minimums?
        const collBriefs = [...beach.long, ...beach.mid, ...beach.head];
        const topicSnapshot = topicLevelSnapshot(collBriefs);
        const lifetimeKeeps = beach.long.filter(b=>decisions[b.id]?.action==='keep').length
          + beach.mid.filter(b=>decisions[b.id]?.action==='keep').length;
        const targetMet = recentKeeps >= THROUGHPUT.buildingUnlock.value;
        const earnedMet = recentKeeps >= THROUGHPUT.earnedUnlock.value;
        const total = beach.long.length + beach.mid.length + beach.head.length;
        return (
          <div key={beach.id} data-coll={beach.id} className="beach">
            {/* COLLECTION HEADER—generous spacing, three clear blocks */}
            <div className="beach-head">
              <div className="beach-identity">
                <span className="beach-glyph">{beach.glyph}</span>
                <div className="beach-naming">
                  <h2 className="beach-name">{beach.name}</h2>
                  <p className="beach-desc">{beach.desc}</p>
                </div>
              </div>
              <div className="beach-stats">
                <div className="bs-cell">
                  <div className="bs-v">{total}</div>
                  <div className="bs-l">briefs</div>
                </div>
                <div className="bs-cell" title={`${lifetimeKeeps} kept lifetime; ${recentKeeps} kept in last ${ROLLING_UNLOCK_DAYS} days`}>
                  <div className="bs-v">{recentKeeps}</div>
                  <div className="bs-l">kept · {ROLLING_UNLOCK_DAYS}d</div>
                </div>
                <div className="bs-cell">
                  <div className="bs-v">{Math.round(100 * lifetimeKeeps / Math.max(1,total))}<span className="bs-pct">%</span></div>
                  <div className="bs-l">commit rate</div>
                </div>
              </div>
              <div className="beach-progress">
                <div className="bp-row">
                  <span className="bp-step done"><span className="bp-dot"></span>Ready now</span>
                  <span className={'bp-step ' + (targetMet ? 'done' : 'pending')}>
                    <span className="bp-dot"></span>Building <span className="bp-frac">{recentKeeps}/{THROUGHPUT.buildingUnlock.value}</span>
                  </span>
                  <span className={'bp-step ' + (earnedMet ? 'done' : 'pending')}>
                    <span className="bp-dot"></span>Earned <span className="bp-frac">{recentKeeps}/{THROUGHPUT.earnedUnlock.value}</span>
                  </span>
                </div>
              </div>
            </div>

            {/* TH Framework v1—topic-level minimums for category viability.
                Section 1: 500K vol / 500 kws / 5 clusters / 50 sweet-spot. */}
            <div className="topic-snapshot">
              <div className="ts-head">
                <span className="ts-label">Topic-level viability</span>
                <span className="ts-meta dim mono">framework v1 minimums</span>
              </div>
              <div className="ts-grid">
                {[
                  { key: 'totalClusterVolume', label: 'cluster vol',  val: topicSnapshot.totalClusterVolume, min: TOPIC_LEVEL_MINIMUMS.totalClusterVolume, fmt: fmtVol },
                  { key: 'numberOfKeywords',   label: 'keywords',     val: topicSnapshot.numberOfKeywords,   min: TOPIC_LEVEL_MINIMUMS.numberOfKeywords,   fmt: (n)=>n.toLocaleString() },
                  { key: 'topicClusters',      label: 'clusters',     val: topicSnapshot.topicClusters,      min: TOPIC_LEVEL_MINIMUMS.topicClusters,      fmt: (n)=>String(n) },
                  { key: 'sweetSpotKeywords',  label: 'sweet-spot',   val: topicSnapshot.sweetSpotKeywords,  min: TOPIC_LEVEL_MINIMUMS.sweetSpotKeywords,  fmt: (n)=>String(n) },
                ].map(c => (
                  <div key={c.key} className={'ts-cell ' + (topicSnapshot.clearance[c.key] ? 'pass' : 'fail')}
                       title={`${c.label}: ${c.fmt(c.val)} (min ${c.fmt(c.min)})`}>
                    <div className="ts-v">{c.fmt(c.val)}</div>
                    <div className="ts-l">{c.label}</div>
                    <div className="ts-min">min {c.fmt(c.min)}</div>
                  </div>
                ))}
              </div>
            </div>

            <div className="beach-body">
              {/* Ready now—working surface */}
              <div className="rail-strip now">
                <div className="rail-bar">
                  <div className="rail-bar-main">
                    <span className="rail-name long">Ready now</span>
                    <span className="rail-purpose">Today's working surface—write these</span>
                  </div>
                  <div className="rail-bar-meta">
                    <span className="rail-meta-item"><b>KD 0–29</b> · longtail</span>
                    <span className="rail-meta-item">target <b>{THROUGHPUT.readyNowPerWeek.lo}–{THROUGHPUT.readyNowPerWeek.hi}</b> / week</span>
                    <span className="rail-count">{beach.long.length}</span>
                  </div>
                </div>
                <div className="rail-tiles">
                  {beach.long.length === 0
                    ? <div className="rail-empty">no longtail briefs in this collection yet—paste a trend to expand</div>
                    : (() => {
                        const railIds = beach.long.map(x => x.id);
                        return beach.long.map(b => renderRailTile(b, beach.id, 'long', railIds));
                      })()}
                </div>
              </div>

              {/* Building toward */}
              <div className={'rail-strip ' + (targetMet?'':'locked')}>
                <div className="rail-bar">
                  <div className="rail-bar-main">
                    <span className="rail-name mid">Building toward</span>
                    <span className="rail-purpose">
                      {targetMet
                        ? `Foothold earned—${recentKeeps} keeps in last ${ROLLING_UNLOCK_DAYS}d. Start activating selectively`
                        : `Locked—needs ${THROUGHPUT.buildingUnlock.value - recentKeeps} more keeps in last ${ROLLING_UNLOCK_DAYS}d to unlock`}
                    </span>
                  </div>
                  <div className="rail-bar-meta">
                    <span className="rail-meta-item"><b>KD 30–49</b> · midtail</span>
                    <span className="rail-meta-item">{recentKeeps}/{THROUGHPUT.buildingUnlock.value} · {ROLLING_UNLOCK_DAYS}d</span>
                    <span className="rail-count">{beach.mid.length}</span>
                  </div>
                </div>
                <div className="rail-tiles">
                  {beach.mid.length === 0
                    ? <div className="rail-empty">no midtail briefs</div>
                    : (() => {
                        const railIds = beach.mid.map(x => x.id);
                        return beach.mid.map(b => renderRailTile(b, beach.id, 'mid', railIds));
                      })()}
                </div>
              </div>

              {/* Earned later—always rendered (consistent 3-rail grid across all collections) */}
              <div className={'rail-strip ' + (earnedMet?'':'locked')}>
                <div className="rail-bar">
                  <div className="rail-bar-main">
                    <span className="rail-name head">Earned later</span>
                    <span className="rail-purpose">
                      {earnedMet
                        ? `Compounding stretch unlocked—${recentKeeps} keeps in last ${ROLLING_UNLOCK_DAYS}d`
                        : `Locked—needs ${THROUGHPUT.earnedUnlock.value - recentKeeps} more keeps in last ${ROLLING_UNLOCK_DAYS}d`}
                    </span>
                  </div>
                  <div className="rail-bar-meta">
                    <span className="rail-meta-item"><b>KD 50+</b> · headtail</span>
                    <span className="rail-meta-item">{recentKeeps}/{THROUGHPUT.earnedUnlock.value} · {ROLLING_UNLOCK_DAYS}d</span>
                    <span className="rail-count">{beach.head.length}</span>
                  </div>
                </div>
                <div className="rail-tiles">
                  {beach.head.length === 0
                    ? <div className="rail-empty">no headtail briefs in this collection yet—would surface here once a KD-50+ brief enters</div>
                    : (() => {
                        const railIds = beach.head.map(x => x.id);
                        return beach.head.map(b => renderRailTile(b, beach.id, 'head', railIds));
                      })()}
                </div>
              </div>
            </div>
          </div>
        );
      })}
      </div>
    </>
  );
}

// ──────── Compounding strip (single global; rendered at top of Beach lens) ──────────
// Historical seed from ETRP Step 1; Decision Log keeps will extend forward.
// Strategic signal 2026-04-27 ("mind-body compounds faster than everyday-living"):
// rate slope is the strategic signal—faster-rising = compounding faster.
function CompoundingStrip() {
  const data = (typeof window !== 'undefined' && window.COMPOUNDING_CURVES) || null;
  if (!data) return null;
  const collOrder = ['mind-body', 'experiences', 'everyday-living'];
  return (
    <div className="compounding-strip">
      <div className="cs-head">
        <h3>How fast each collection earns Google rankings</h3>
        <span className="cs-meta">
          window {data.window} · {data.rows_used} historical articles · source: ETRP audit {data.asof}
        </span>
      </div>
      <p className="cs-help">
        Each line shows the share of articles in that collection that landed inside Google's
        <b> top 25 search results</b> ("hits") over time. A <b>rising line</b> means the
        collection is compounding faster—every new article is likelier to rank than the last,
        because the topical authority around it keeps accumulating. A <b>flat or falling line</b>
        means published articles aren't earning rank traction. The <b>+/– pp pill</b> next to each
        collection name shows the percentage-point change from start of window to end. Once the
        Decision Log has ≥3 months of post-pivot decisions, this strip extends forward with
        active-decision outcomes alongside the historical baseline.
      </p>
      <div className="cs-grid">
        {collOrder.map(coll => {
          const series = (data.collections && data.collections[coll]) || [];
          const summary = (data.rate_summary && data.rate_summary[coll]) || null;
          const max = series.length ? Math.max(...series.map(p => p.top25_rate_pct), 1) : 1;
          const meta = TH_COLLECTIONS[coll];
          return (
            <div key={coll} className="cs-cell" data-coll={coll}>
              <div className="cs-cell-head">
                <span className="cs-glyph">{meta.glyph}</span>
                <span className="cs-name">{meta.name}</span>
                {summary && (
                  <span className={'cs-delta ' + (summary.delta_pp > 0 ? 'up' : summary.delta_pp < 0 ? 'down' : 'flat')}
                        title="percentage-point change in top-25 hit rate from window start to end">
                    {summary.delta_pp > 0 ? '+' : ''}{summary.delta_pp}pp
                  </span>
                )}
              </div>
              <svg className="cs-spark" viewBox="0 0 100 28" preserveAspectRatio="none">
                {series.length > 1 && (
                  <polyline
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    points={series.map((p, i) =>
                      `${(i / (series.length - 1)) * 100},${28 - (p.top25_rate_pct / max) * 26}`
                    ).join(' ')}
                  />
                )}
              </svg>
              <div className="cs-foot" title="cumulative top-25 hits / articles published, with the latest period's hit rate">
                {summary
                  ? <>{summary.cum_top25} of {summary.cum_articles} articles ranked top-25 · {summary.top25_rate_last}% latest period</>
                  : <span className="dim">no data yet</span>}
              </div>
            </div>
          );
        })}
      </div>
      <div className="cs-caveat">
        <b>Important caveat about this baseline:</b> the ETRP audit found that the verdict's
        predicted score had a slight <b>negative</b> correlation with actual page-view outcomes
        across every cohort tested (Spearman −0.19 to −0.31). The likely cause is editorial-selection
        bias—ambitious high-volume picks faced stiffer SERP competition than lower-scored picks
        that ended up over-performing. Treat the verdict as <em>opportunity surface</em>, not a
        PV predictor. Phase-5 recalibration retests this once the Decision Log has ≥3 months of
        active-decision outcomes.
      </div>
    </div>
  );
}

// ──────── Lens 3: Strategy Map (volume × difficulty quadrants) ────────
// Was "Gap Map". Renamed because the National team thinks about TH placement
// strategically, not in terms of a Newsroom-network keyword gap. Same 2×2
// mechanics; cleaner mental model.
function GapMapLens({ briefs, decisions, expanded, onToggle, onDecide, onJump, revenueOn, engagementOn, personaOn, personaId, trendMatched, outcomeStateTokenFor }) {
  const tileProps = { revenueOn, engagementOn, personaOn, personaId };
  const tm = (id) => trendMatched ? (trendMatched.has(id) ? 'hit' : 'dim') : null;
  // Quadrants: high-vol/low-KD = STRIKE; high-vol/high-KD = STUDY; low-vol/low-KD = COMPOUND; low-vol/high-KD = SKIP
  const quads = useMemo(() => {
    const q = { strike:[], study:[], compound:[], skip:[] };
    briefs.forEach(b => {
      const highVol = (b.totalVolume||0) >= 50000;
      const highKD = (b.avgKD ?? 0) >= 50;
      if (highVol && !highKD) q.strike.push(b);
      else if (highVol && highKD) q.study.push(b);
      else if (!highVol && !highKD) q.compound.push(b);
      else q.skip.push(b);
    });
    Object.keys(q).forEach(k => q[k].sort((a,b)=> (b.totalVolume||0)-(a.totalVolume||0)));
    return q;
  }, [briefs]);

  const renderQuad = (key, name, desc, accent) => (
    <div className="gapmap-quad">
      <div className="quad-head">
        <div className="name" style={{color: accent}}>{name}</div>
        <div className="desc">{desc}</div>
        <div className="count">{quads[key].length}</div>
      </div>
      {quads[key].slice(0, 6).map(b => {
        const outcomeToken = outcomeStateTokenFor ? outcomeStateTokenFor(b.id) : null;
        return (
          <BriefRow key={b.id} brief={b} decision={decisions[b.id]} outcomeStateToken={outcomeToken}>
            <BriefTile brief={b} decision={decisions[b.id]} expanded={expanded===b.id}
              onToggle={()=>onToggle(b.id)} onDecide={onDecide} onJump={onJump} trendMatch={tm(b.id)} {...tileProps} />
          </BriefRow>
        );
      })}
      {quads[key].length === 0 && <div className="rail-empty">no briefs</div>}
    </div>
  );

  return (
    <>
      <div className="lens-header">
        <h1>Gap map <LensHelp lensId="gap" /></h1>
        <span className="sub">where each brief lands on volume × difficulty—tells you what to do with it</span>
      </div>
      <div className="gapmap">
        {renderQuad('strike',   'STRIKE',   'high vol · low KD · winnable now',         'var(--go)')}
        {renderQuad('study',    'STUDY',    'high vol · high KD · pick long-tail entry','var(--test)')}
        {renderQuad('compound', 'COMPOUND', 'low vol · low KD · easy local wins',       'var(--kept)')}
        {renderQuad('skip',     'SKIP',     'low vol · high KD · don\'t chase',          'var(--skip)')}
      </div>
    </>
  );
}

// ──────── Lens 4: Decision Log (filter view + outcome capture) ────────
// Auto-outcomes integration: window.BRIEF_OUTCOMES is precomputed daily by
// scripts/precompute_brief_outcomes.py (GH Action). Operator does nothing —
// matched articles + total PVs + position state surface automatically per
// kept brief. Manual outcome capture in the drawer overrides the auto data
// when present (operator's note is authoritative; auto data is the default).
function LogLens({ briefs, decisions, expanded, onToggle, onDecide, onJump, revenueOn, engagementOn, personaOn, personaId, trendMatched, outcomeStateTokenFor }) {
  const tileProps = { revenueOn, engagementOn, personaOn, personaId };
  const tm = (id) => trendMatched ? (trendMatched.has(id) ? 'hit' : 'dim') : null;
  const autoOutcomes = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES) || null;
  const [showAll, setShowAll] = useState(false);
  // Item 26—re-decision horizon enforcement. When the chip is on, filter
  // to briefs whose stored decision.revisitAt is on or before today AND no
  // later decision (currently the schema only stores one decision per brief
  //—i.e. the latest write—so the "no later decision exists" predicate
  // reduces to "the brief's current decision still has a revisitAt set").
  const [dueOnly, setDueOnly] = useState(false);

  // Helpers for the revisit-horizon UI. `revisitDays(b)` returns days until
  // revisitAt (negative when overdue) or null when no revisitAt is recorded.
  // `parseRevisit(d)` accepts ISO date or date-time and returns Date|null.
  const todayMs = useMemo(() => {
    const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime();
  }, []);
  const parseRevisit = (d) => {
    if (!d || !d.revisitAt) return null;
    const t = Date.parse(d.revisitAt);
    return isNaN(t) ? null : t;
  };
  const revisitDays = (b) => {
    const t = parseRevisit(decisions[b.id]);
    if (t == null) return null;
    return Math.round((t - todayMs) / (24 * 60 * 60 * 1000));
  };

  // All decided briefs, full history, sorted newest-first
  const allDecided = useMemo(() => {
    return briefs
      // Decisions tab shows only briefs with an actual triage action.
      // TVE-only annotations stay invisible here—they're framework overrides,
      // not decisions, and showing them gave operators a "stuck in Decisions"
      // surprise when they only meant to flag velocity for the auto-skip path.
      .filter(b => decisions[b.id]?.action)
      .sort((a,b) => (decisions[b.id].ts||0) - (decisions[a.id].ts||0));
  }, [briefs, decisions]);

  // Default working surface: last 30 days. Toggle to show full history.
  const decided = useMemo(() => {
    let base = showAll ? allDecided : allDecided.filter(b => withinDays(decisions[b.id].ts, LOG_DEFAULT_DAYS));
    // Item 26—re-decision filter: keep only briefs whose revisitAt is due
    // on/before today; sort due-soon first (most overdue at the top).
    if (dueOnly) {
      base = base.filter(b => {
        const t = parseRevisit(decisions[b.id]);
        return t != null && t <= todayMs;
      });
      base = base.slice().sort((a, b) => {
        const ta = parseRevisit(decisions[a.id]) ?? Infinity;
        const tb = parseRevisit(decisions[b.id]) ?? Infinity;
        return ta - tb;
      });
    }
    return base;
  }, [allDecided, decisions, showAll, dueOnly, todayMs]);

  const hiddenCount = allDecided.length - decided.length;
  const dueCount = useMemo(() => allDecided.filter(b => {
    const t = parseRevisit(decisions[b.id]);
    return t != null && t <= todayMs;
  }).length, [allDecided, decisions, todayMs]);

  const hasAuto = (b) => {
    const a = autoOutcomes && autoOutcomes.outcomes && autoOutcomes.outcomes[b.id];
    return a && a.matched_articles > 0;
  };
  const stats = {
    total: decided.length,
    keep: decided.filter(b => decisions[b.id].action==='keep').length,
    skip: decided.filter(b => decisions[b.id].action==='skip').length,
    defer: decided.filter(b => decisions[b.id].action==='defer').length,
    outcome: decided.filter(b => {
      const o = decisions[b.id].outcome;
      return (o && (o.pv || o.position || o.headlineGrade)) || hasAuto(b);
    }).length,
    auto: decided.filter(hasAuto).length,
  };

  // CSV export—for paste into the editorial testing tracker.
  // Always exports full history regardless of UI window filter.
  // Phase-5 swap: direct service-account writeback to the tracker spreadsheet.
  const exportCsv = () => {
    const esc = (v) => {
      if (v == null) return '';
      const s = String(v);
      return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
    };
    const cols = [
      'brief_id','topic','recommended_article','vertical','content_type','th_collection',
      'tail_class','total_volume','avg_kd','avg_cpc','verdict','decision','note',
      'decided_at','outcome_pv','outcome_position','outcome_headline_grade',
    ];
    const lines = [cols.join(',')];
    allDecided.forEach(b => {
      const d = decisions[b.id] || {};
      const o = d.outcome || {};
      lines.push([
        b.id, b.topic, b.recommendedArticle, b.verticalId,
        inferContentType(b), inferTHCollection(b), inferTailClass(b),
        b.totalVolume, b.avgKD, b.avgCPC, window.briefRuntimeVerdict(b),
        d.action || '', d.note || '',
        d.ts ? new Date(d.ts).toISOString() : '',
        o.pv || '', o.position || '', o.headlineGrade || '',
      ].map(esc).join(','));
    });
    // Prepend UTF-8 BOM so Excel-Windows reads non-ASCII characters
    // (em dashes, smart quotes) correctly. The blob type also declares
    // UTF-8 explicitly for browsers that pay attention to it.
    const csv = '﻿' + lines.join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `dk-decisions-${new Date().toISOString().slice(0,10)}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    // Defer revoke so the click event has time to start the download —
    // some browsers race the synchronous revoke and abort the navigation.
    setTimeout(() => URL.revokeObjectURL(url), 100);
  };

  return (
    <>
      <div className="lens-header">
        <h1>Decisions <LensHelp lensId="log" /></h1>
        <span className="sub">
          {showAll
            ? `all-time history · ${allDecided.length} decision${allDecided.length === 1 ? '' : 's'}`
            : `last ${LOG_DEFAULT_DAYS} days · outcomes auto-sync from TRACKER_ENRICHED`}
        </span>
        <div className="right">
          <button
            className="log-window-toggle"
            onClick={() => setShowAll(s => !s)}
            title={showAll ? `Switch back to last ${LOG_DEFAULT_DAYS}d` : `Show all decisions (${allDecided.length} total)`}
          >
            {showAll ? `↶ last ${LOG_DEFAULT_DAYS}d` : `show all (${allDecided.length})`}
          </button>
          {autoOutcomes && autoOutcomes.asof && (
            <span
              className="auto-sync-badge"
              title={`window: last ${autoOutcomes.window_days || 180}d · ${autoOutcomes.briefs_with_matches || 0}/${autoOutcomes.briefs_total || 0} briefs matched · source: ${autoOutcomes.source || 'TRACKER_ENRICHED'}`}
            >
              <span className="auto-sync-dot"></span>
              auto-synced {autoOutcomes.asof}
            </span>
          )}
          <button
            className="export-btn"
            onClick={exportCsv}
            disabled={allDecided.length === 0}
            title={`Download all ${allDecided.length} decisions as CSV—paste into the editorial testing tracker`}
          >
            ↓ export csv
          </button>
        </div>
      </div>
      {/* Item 26—re-decision horizon chip. Sits above the stats strip so
          it's visually a filter, not a stat. Toggles state-only; sort and
          filter changes are applied above in the `decided` memo. */}
      <div className="log-filter-row">
        <button
          className={'log-filter-chip ' + (dueOnly ? 'active' : '')}
          onClick={() => setDueOnly(d => !d)}
          title="Show only briefs whose recorded revisit-by date is on or before today. Sorts most-overdue first."
        >
          <span className="lfc-dot"></span>
          Due for re-evaluation
          <span className="lfc-count">{dueCount}</span>
        </button>
        {dueOnly && dueCount === 0 && (
          <span className="dim mono" style={{fontSize: 10, marginLeft: 8}}>
            no briefs due—capture a revisitAt on a Keep / Defer to populate this list
          </span>
        )}
      </div>
      <div className="log-stats">
        <div><div className="v">{stats.total}</div><div className="l">total</div></div>
        <div><div className="v kept">{stats.keep}</div><div className="l">kept</div></div>
        <div><div className="v skipped">{stats.skip}</div><div className="l">skipped</div></div>
        <div><div className="v deferred">{stats.defer}</div><div className="l">deferred</div></div>
        <div><div className="v">{stats.outcome}</div><div className="l">w/ outcome</div></div>
        <div><div className="v">{stats.auto}</div><div className="l">auto-matched</div></div>
      </div>
      {decided.length === 0
        ? (
          allDecided.length === 0 ? (
            <div className="empty">
              <div className="big">log is empty</div>
              <div>Decisions captured anywhere in the console land here automatically.</div>
              <div className="hint">Open a brief on the <code>queue</code> or <code>beach</code> lens · click <code>keep</code> · <code>skip</code> · <code>defer</code></div>
            </div>
          ) : (
            <div className="empty">
              <div className="big">no decisions in last {LOG_DEFAULT_DAYS} days</div>
              <div>{allDecided.length} earlier decision{allDecided.length === 1 ? '' : 's'} in history.</div>
              <div className="hint">click <code>show all</code> above to expand the window</div>
            </div>
          )
        )
        : (
          <>
            {decided.map(b => {
              const outcomeToken = outcomeStateTokenFor ? outcomeStateTokenFor(b.id) : null;
              const days = revisitDays(b);
              return (
                <BriefRow key={b.id} brief={b} decision={decisions[b.id]} outcomeStateToken={outcomeToken}>
                  <BriefTile brief={b} decision={decisions[b.id]} expanded={expanded===b.id}
                    onToggle={()=>onToggle(b.id)} onDecide={onDecide} onJump={onJump} trendMatch={tm(b.id)} {...tileProps} />
                  {days != null && (
                    <span className={'revisit-badge ' + (days < 0 ? 'overdue' : days <= 3 ? 'soon' : 'future')}
                          title={`Revisit-by ${decisions[b.id].revisitAt} · ${days < 0 ? Math.abs(days) + 'd overdue' : days + 'd remaining'}`}>
                      {days < 0 ? `⚠ overdue ${Math.abs(days)}d` : `revisit in ${days}d`}
                    </span>
                  )}
                </BriefRow>
              );
            })}
            {!showAll && hiddenCount > 0 && (
              <div className="log-history-footer">
                <span className="dim mono">{hiddenCount} earlier decision{hiddenCount === 1 ? '' : 's'} hidden by {LOG_DEFAULT_DAYS}d window</span>
                <button className="log-window-toggle inline" onClick={() => setShowAll(true)}>show all →</button>
              </div>
            )}
          </>
        )}
    </>
  );
}

// ──────── Command palette ────────
function CommandPalette({ open, briefs, onClose, onJumpBrief, onSetLens, lenses }) {
  const [q, setQ] = useState('');
  const [sel, setSel] = useState(0);
  const inputRef = useRef(null);
  useEffect(() => { if (open) { setQ(''); setSel(0); setTimeout(()=>inputRef.current?.focus(), 10); } }, [open]);

  const items = useMemo(() => {
    const out = [];
    const ql = q.trim().toLowerCase();
    lenses.forEach(l => {
      if (!ql || l.label.toLowerCase().includes(ql) || l.id.includes(ql)) {
        out.push({ kind: 'lens', id: 'lens-'+l.id, label: 'Go to ' + l.label, meta: l.id, lensId: l.id });
      }
    });
    briefs.forEach(b => {
      const hay = (b.topic + ' ' + (b.recommendedArticle||'')).toLowerCase();
      if (!ql || hay.includes(ql)) {
        const v = briefRuntimeVerdict(b);
        out.push({
          kind:'brief', id: b.id, label: b.topic, meta: v + ' · ' + fmtVol(b.totalVolume),
          verdict: v,
        });
      }
    });
    return out;
  }, [q, briefs, lenses]);

  const onKey = (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(items.length-1, s+1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s-1)); }
    else if (e.key === 'Enter') {
      const it = items[sel];
      if (!it) return;
      if (it.kind === 'lens') onSetLens(it.lensId);
      else onJumpBrief(it.id);
      onClose();
    } else if (e.key === 'Escape') onClose();
  };

  if (!open) return null;
  let lastKind = '';
  return (
    <div className="cmdk-bg" onClick={onClose}>
      <div className="cmdk" onClick={e=>e.stopPropagation()}>
        <input ref={inputRef} className="cmdk-input" placeholder="Search briefs, jump to lens, run command…"
          value={q} onChange={e=>{setQ(e.target.value); setSel(0);}} onKeyDown={onKey} />
        <div className="cmdk-list">
          {items.length === 0 && <div className="cmdk-section">no matches</div>}
          {items.map((it, i) => {
            const showSection = it.kind !== lastKind;
            lastKind = it.kind;
            return (
              <React.Fragment key={it.id}>
                {showSection && <div className="cmdk-section">{it.kind === 'lens' ? 'Lenses' : 'Briefs'}</div>}
                <div className={'cmdk-item ' + (it.verdict==='high-opportunity'?'go':it.verdict==='worth-testing'?'test':it.verdict==='monitor'?'monitor':it.verdict==='skip'?'skip':'') + (i===sel?' sel':'')}
                  onClick={()=>{
                    if (it.kind==='lens') onSetLens(it.lensId);
                    else onJumpBrief(it.id);
                    onClose();
                  }}
                  onMouseEnter={()=>{ if (i !== sel) setSel(i); }}>
                  <span className="vd"></span>
                  <span className="label">{it.label}</span>
                  <span className="meta">{it.meta}</span>
                </div>
              </React.Fragment>
            );
          })}
        </div>
        <div className="cmdk-foot">
          <span>↵ open</span><span>↑↓ step</span><span>esc close</span>
          <span style={{marginLeft:'auto'}}>{items.length} results</span>
        </div>
      </div>
    </div>
  );
}

// ──────── PerPlaySummaryStrip (item 29 / 43) ────────
// Per-Play health snapshot rendered above Beach lens columns. Each card
// reports brief count, keep ratio, outcome capture %, and the Play's
// average opportunity-score percentile. The opportunity-score column reads
// from `cohortPercentilesFor(cohortMode, target)` when the helper is
// available, otherwise it falls back to the local clusterOpportunityScore
// for the same Play subset (so the strip still renders meaningfully when
// the util.js agent's helpers are still in flight).
function PerPlaySummaryStrip({ briefs, decisions, cohortMode, target }) {
  const verticals = (typeof window !== 'undefined' && window.VERTICALS) || [];
  const autoOutcomes = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES) || null;
  const cohortFn = (typeof window !== 'undefined' && typeof window.cohortPercentilesFor === 'function')
    ? window.cohortPercentilesFor : null;

  const plays = useMemo(() => {
    // Group briefs by Play (thV5.play_num + play_name). Briefs without a Play
    // identifier roll into the "Unassigned" bucket.
    const byPlay = new Map();
    briefs.forEach(b => {
      const pn = b.thV5?.play_num ?? null;
      const name = b.thV5?.play_name || (pn != null ? `Play ${pn}` : 'Unassigned');
      const channel = b.thV5?.channel || null;
      const key = String(pn ?? '_unassigned');
      if (!byPlay.has(key)) byPlay.set(key, { key, name, channel, playNum: pn, briefs: [] });
      byPlay.get(key).briefs.push(b);
    });
    return Array.from(byPlay.values()).sort((a, c) => {
      if (a.playNum == null && c.playNum == null) return 0;
      if (a.playNum == null) return 1;
      if (c.playNum == null) return -1;
      return a.playNum - c.playNum;
    });
  }, [briefs]);

  // Resolve glyph for a channel / play. Falls back to a small monospace dot.
  const glyphFor = (channel) => {
    if (channel && TH_COLLECTIONS[channel]) return TH_COLLECTIONS[channel].glyph;
    // cross-mb-el / cross-all etc.—surface from the constituent collections
    // when present, otherwise neutral.
    return '·';
  };

  // Cohort-aware opportunity score: prefer cohortPercentilesFor when wired,
  // averaged over the Play's briefs. Fallback uses clusterOpportunityScore
  // (already a sweet-spot-volume aggregate) and normalizes to 0..1 within
  // this Play subset for a comparable scale.
  const oppFor = (playBriefs) => {
    if (!playBriefs.length) return null;
    if (cohortFn) {
      try {
        const lookup = cohortFn(cohortMode || 'all-active', target || 'balanced').lookup || {};
        const scores = playBriefs.map(b => lookup[b.id]).filter(v => typeof v === 'number');
        if (scores.length) return scores.reduce((s, v) => s + v, 0) / scores.length;
      } catch {}
    }
    if (typeof clusterOpportunityScore === 'function') {
      const raw = playBriefs.map(b => (clusterOpportunityScore(b) || {}).score || 0);
      const max = Math.max(1, ...raw);
      const norm = raw.map(v => v / max);
      return norm.reduce((s, v) => s + v, 0) / norm.length;
    }
    return null;
  };

  return (
    <div className="play-summary-strip">
      <div className="pss-head">
        <span className="pss-label">Per-Play health snapshot</span>
        <span className="pss-meta">
          cohort: {cohortMode || 'all-active'} · target: {target || 'balanced'}
        </span>
      </div>
      <div className="pss-cards">
        {plays.map(p => {
          const total = p.briefs.length;
          const kept = p.briefs.filter(b => decisions[b.id]?.action === 'keep').length;
          const keepRatio = total > 0 ? kept / total : 0;
          const keptBriefs = p.briefs.filter(b => decisions[b.id]?.action === 'keep');
          const keptWithOutcomes = keptBriefs.filter(b => {
            const auto = autoOutcomes && autoOutcomes.outcomes && autoOutcomes.outcomes[b.id];
            const manual = decisions[b.id]?.outcome;
            return (auto && auto.matched_articles > 0)
                || (manual && (manual.pv || manual.position || manual.headlineGrade));
          }).length;
          const outcomeCapture = kept > 0 ? keptWithOutcomes / kept : 0;
          const opp = oppFor(p.briefs);
          return (
            <div key={p.key} className="pss-card" data-play-num={p.playNum}>
              <div className="pss-card-head">
                <span className="pss-glyph">{glyphFor(p.channel)}</span>
                <span className="pss-name">{p.name}</span>
                {p.playNum != null && <span className="pss-num">#{p.playNum}</span>}
              </div>
              <div className="pss-row">
                <span className="pss-rk">briefs</span>
                <span className="pss-rv">{total}</span>
              </div>
              <div className="pss-row" title={`${kept} of ${total} briefs kept`}>
                <span className="pss-rk">keep ratio</span>
                <span className="pss-rv">{(keepRatio * 100).toFixed(0)}%</span>
              </div>
              <div className="pss-row" title={`${keptWithOutcomes} of ${kept} kept briefs have outcomes`}>
                <span className="pss-rk">outcome capture</span>
                <span className="pss-rv">{kept === 0 ? '—' : `${(outcomeCapture * 100).toFixed(0)}%`}</span>
              </div>
              <div className="pss-row" title="avg cohort percentile via cohortPercentilesFor; fallback uses cluster sweet-spot volume normalized within Play">
                <span className="pss-rk">avg opportunity</span>
                <span className="pss-rv">{opp == null ? '—' : (opp * 100).toFixed(0) + '%'}</span>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Object.assign(window, { QueueLens, BeachLens, GapMapLens, LogLens, CommandPalette, BriefRow, PerPlaySummaryStrip });
