// Top-level shell—wires lenses, decisions, command palette, tweaks.
// 2026-04-28—refactored for the National team's TrendHunter pivot.
// 2026-05-03—UX/UI v4.x: batch ops, smart-default actions, side-by-side
// compare, drag reorder, outcome shading, filter presets, velocity footer,
// per-lens scroll preservation, volatility default sort, undo, gmail-style
// keyboard shortcuts.
const { useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef } = React;

// Guard against malformed URI sequences (e.g. `#brief/test-bad-frag-%E0`).
// decodeURIComponent throws URIError on bad %-escapes; we treat that as
// "no valid id" and let the caller fall through to its not-found path.
function _safeDecode(s) {
  try { return decodeURIComponent(s); } catch { return null; }
}

const LENSES = [
  { id: 'queue', label: "Today's briefs",   key: '1', desc: 'daily triage' },
  { id: 'beach', label: 'Beach',             key: '2', desc: 'authority workspace' },
  { id: 'gap',   label: 'Gap map',           key: '3', desc: 'volume × difficulty' },
  { id: 'log',   label: 'Decisions',         key: '4', desc: 'record + outcomes' },
  { id: 'docs',  label: 'Docs',              key: '5', desc: 'how this works' },
];

// Item 7 / 8—multi-objective + cohort-mode rail dropdowns. Persist across
// reloads so the operator's chosen lens of the slate sticks.
const OBJECTIVE_KEY = 'dk-v4-objective';
const COHORT_MODE_KEY = 'dk-v4-cohort-mode';
const OBJECTIVE_OPTIONS = [
  { value: 'balanced',   label: 'Balanced (default)' },
  { value: 'revenue',    label: 'Revenue' },
  { value: 'engagement', label: 'Engagement' },
  { value: 'reach',      label: 'Reach' },
];
const COHORT_MODE_OPTIONS = [
  { value: 'all-active',         label: 'All active (default)' },
  { value: 'same-vertical',      label: 'Same vertical' },
  { value: 'same-Play',          label: 'Same Play' },
  { value: 'same-content-type',  label: 'Same content type' },
];
function loadObjective() {
  try { return localStorage.getItem(OBJECTIVE_KEY) || 'balanced'; } catch { return 'balanced'; }
}
function loadCohortMode() {
  try { return localStorage.getItem(COHORT_MODE_KEY) || 'all-active'; } catch { return 'all-active'; }
}

const DEFAULT_TWEAKS = /*EDITMODE-BEGIN*/{
  "density": "comfortable",
  "revenueOverlay": false,
  "personaOverlay": false,
  "showTelemetry": true,
  "accentVerdict": "go",
  "decisionFlash": true,
  "personaId": "curious-optimizer",
  "engagementOverlay": true
}/*EDITMODE-END*/;

// Map runtime verdict → recommended-action ladder.
//   high-opportunity → Keep    (commit to the slate)
//   worth-testing    → Defer   (park, batch-test later)
//   monitor          → (none)  watch-state; no smart auto-fire (neither pursue nor skip)
//   skip             → Skip
// Mirrors the Smart-default-action contract (item 4.3). Used by the tile
// pre-highlight + Enter-to-fire shortcut + the help bubble.
function recommendedActionFor(brief) {
  const v = (typeof window.briefRuntimeVerdict === 'function')
    ? window.briefRuntimeVerdict(brief) : null;
  // CI-aware override (2026-05-04): when the verdict's 90% CI is wide
  // (>0.35 span—"low confidence" pill suffix), soften commitment-style
  // suggestions toward defer. The verdict could plausibly land elsewhere
  // with more data, so collecting that data first beats committing pipeline
  // capacity (high-opp → keep) or closing it down (skip → skip).
  const lowConf = (typeof window.ciIsLowConfidence === 'function')
    ? window.ciIsLowConfidence(brief) : false;
  if (v === 'high-opportunity') return lowConf ? 'defer' : 'keep';
  if (v === 'worth-testing')    return 'defer';
  if (v === 'monitor')          return null;  // watch-state: no smart auto-fire—editorial keeps on TH grounds or waits for demand
  if (v === 'skip')             return lowConf ? 'defer' : 'skip';
  return null;
}

// Outcome → CSS color-token map for tile shading (item 4.6). Tokens land
// in :root in styles.css; falsy state means no inline tint.
function outcomeStateTokenFor(briefId) {
  const auto = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES &&
    window.BRIEF_OUTCOMES.outcomes && window.BRIEF_OUTCOMES.outcomes[briefId]) || null;
  const s = auto && auto.best_position_state;
  if (s === 'in_zone')  return 'var(--c-confident-green)';
  if (s === 'foothold') return 'var(--c-on-track)';
  if (s === 'cold')     return 'var(--c-underperforming)';
  return null;
}

// Snapshot map persistence (item 4.12 hook for 4.19). Stored under a
// key tagged with the console version. The map is briefId → { volume, kd,
// cpc, ts }. Read on bootstrap, written on every visit. We only depend on
// "is the map non-empty" for default-sort selection here; the deeper
// snapshot diff that 4.12 will own is left to that agent.
const SNAPSHOT_KEY = 'dk-v4-snapshot-map';
function loadSnapshotMap() {
  try { return JSON.parse(localStorage.getItem(SNAPSHOT_KEY) || '{}'); }
  catch { return {}; }
}
function saveSnapshotMap(map) {
  try { localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(map)); } catch {}
}

// Filter-preset persistence (item 4.8).
const PRESETS_KEY = 'dk-v4-filter-presets';
const DEFAULT_PRESETS = [
  { name: 'Mon-morning triage',
    filters: { verdict: 'high-opportunity', decision: 'undecided', collection: 'all', personaFit: 'all' },
    sort: { by: 'default', dir: 'desc', by2: 'none', dir2: 'desc' } },
  { name: 'Re-decision queue',
    filters: { verdict: 'all', decision: 'all', collection: 'all', personaFit: 'all' },
    sort: { by: 'default', dir: 'desc', by2: 'none', dir2: 'desc' },
    revisitDue: true },
];
function loadPresets() {
  try {
    const stored = JSON.parse(localStorage.getItem(PRESETS_KEY) || 'null');
    if (Array.isArray(stored)) return stored;
  } catch {}
  return DEFAULT_PRESETS;
}
function savePresets(arr) {
  try { localStorage.setItem(PRESETS_KEY, JSON.stringify(arr)); } catch {}
}

function App() {
  // Item 45—public read-only per-brief link via URL hash routing.
  // `#brief/<id>` renders only that brief's tile + drawer with no rail / no
  // other lenses. Pure read-only; everything decision-side inert.
  const [readOnlyBriefId, setReadOnlyBriefId] = useState(() => {
    if (typeof window === 'undefined') return null;
    const m = (window.location.hash || '').match(/^#brief\/(.+)$/);
    return m ? _safeDecode(m[1]) : null;
  });
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const onHash = () => {
      const m = (window.location.hash || '').match(/^#brief\/(.+)$/);
      setReadOnlyBriefId(m ? _safeDecode(m[1]) : null);
    };
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  // Bookmarkable ?lens=<id> URL. Honored on first mount; later lens switches
  // don't rewrite the query string (history-burden vs. utility).
  const initialLens = useMemo(() => {
    if (typeof window === 'undefined') return 'queue';
    try {
      const params = new URLSearchParams(window.location.search || '');
      const wanted = params.get('lens');
      if (wanted && LENSES.some(L => L.id === wanted)) return wanted;
    } catch {}
    return 'queue';
  }, []);

  const [lens, setLens] = useState(initialLens);
  const [expanded, setExpanded] = useState(null);

  // Item 7—Objective overlay (rail dropdown). Item 8—Cohort-mode toggle.
  // Both threaded through to lenses so cohort-aware verdicts pick up the
  // operator's chosen target / cohort.
  const [objective, setObjective] = useState(() => loadObjective());
  const [cohortMode, setCohortMode] = useState(() => loadCohortMode());
  useEffect(() => {
    try { localStorage.setItem(OBJECTIVE_KEY, objective); } catch {}
  }, [objective]);
  useEffect(() => {
    try { localStorage.setItem(COHORT_MODE_KEY, cohortMode); } catch {}
  }, [cohortMode]);
  const [decisions, setDecisions] = useState(() => loadDecisions());
  const [decisionSyncStatus, setDecisionSyncStatus] = useState(() => getDecisionSyncStatus());
  const DEFAULT_FILTERS = { verdict:'all', decision:'all', collection:'all', personaFit:'all' };
  const [filters, setFilters] = useState(DEFAULT_FILTERS);
  // Skip-decisioned briefs are hidden from every lens (queue, beach, gap-map,
  // log) by default—triaged-out items shouldn't clutter active surfaces.
  // Toggle to reveal them when needed (e.g., to undo a skip). Recoverable.
  const [showSkipped, setShowSkipped] = useState(false);
  // Sort state—two-level. Primary `by` + `dir` is the sort dimension.
  // Secondary `by2` + `dir2` ('none' = no secondary) is the tiebreaker
  // applied within ties of primary. Default (primary='default') uses lens-
  // internal ordering—composite-opportunity DESC within each decision
  // category, with insufficient-signal briefs sinking. Independent of
  // filters. (2026-05-04: reverted the auto-switch to 'volatility' for
  // return visitors—opportunity ordering is the right default for
  // every visit, regardless of whether a snapshot map exists.)
  const initialSort = useMemo(
    () => ({ by: 'default', dir: 'desc', by2: 'none', dir2: 'desc' }),
    []
  );
  const DEFAULT_SORT_FALLBACK = { by: 'default', dir: 'desc', by2: 'none', dir2: 'desc' };
  const [sort, setSort] = useState(initialSort);
  const [trend, setTrend] = useState('');
  const filtersDirty = JSON.stringify(filters) !== JSON.stringify(DEFAULT_FILTERS) || trend.trim().length > 0;
  const sortDirty = sort.by !== initialSort.by || sort.dir !== initialSort.dir
                  || sort.by2 !== initialSort.by2 || sort.dir2 !== initialSort.dir2;
  const clearFilters = () => { setFilters(DEFAULT_FILTERS); setTrend(''); };
  const clearSort = () => setSort(initialSort);
  const [palette, setPalette] = useState(false);
  // Always call useState so the hook order is identical across renders even
  // if window.useTweaks ever becomes unavailable. The setter wraps merge
  // semantics so a `setTweak({ density: v })` never replaces sibling keys.
  // When tweaks-panel.jsx is loaded (the normal case), its hook also fires the
  // host postMessage; the fallback below skips that side effect harmlessly.
  const [tweaks, _setTweakRaw] = useState(DEFAULT_TWEAKS);
  const setTweak = React.useCallback((keyOrEdits, val) => {
    const edits = (typeof keyOrEdits === 'object' && keyOrEdits !== null)
      ? keyOrEdits : { [keyOrEdits]: val };
    _setTweakRaw(prev => ({ ...prev, ...edits }));
    if (window.parent && window.parent !== window) {
      try { window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, window.location.origin); } catch (e) {}
    }
  }, []);

  // Selection state (item 4.2)—a Set of briefIds. Lifted to App so the
  // floating action bar can inspect / mutate from outside the lens, and so
  // selection survives lens switches.
  const [selectedIds, setSelectedIds] = useState(() => new Set());
  // Last-clicked anchor for shift-range selection. `null` until first click.
  const lastSelectAnchorRef = useRef(null);
  // Focused brief for j/k/e/y/n/d/p shortcuts (item 4.1). Tracked via ref +
  // mirrored in state so the visual outline rerenders.
  const [focusedId, setFocusedId] = useState(null);
  // Which lens defined the current focus / selection—used to cap
  // Cmd+A's "select all visible" to the active lens's visible briefs.
  const visibleIdsByLensRef = useRef({});  // { lensId: [id, ...] }
  // Per-lens scroll position preservation (item 4.14).
  const lensScrollRef = useRef(new Map());  // Map<lensId, scrollTop>
  // Undo stack (item 4.11). Each entry: { briefId, prevDecision, ts }.
  const [undoStack, setUndoStack] = useState([]);
  const undoStackRef = useRef([]);
  // Audit H1 fix: ref mirror of decisions so onReorderPins's deferred push
  // can read the post-setState value without a stale-closure dependency.
  const decisionsRef = useRef({});
  useEffect(() => { undoStackRef.current = undoStack; }, [undoStack]);
  const [undoToast, setUndoToast] = useState(null);  // { msg, expiresAt }
  // Filter presets (item 4.8).
  const [presets, setPresets] = useState(() => loadPresets());
  // Modals—comparison + shortcuts help.
  const [compareOpen, setCompareOpen] = useState(false);
  const [helpOpen, setHelpOpen] = useState(false);
  // Pending action for the floating bar—when non-null, prompt for shared
  // rationale before applying. Shape: { action: 'keep'|'skip'|'defer'|'pin' }.
  const [pendingBatch, setPendingBatch] = useState(null);

  // Persist decisions to local cache; the per-click push to the worker
  // happens inside onDecide so we know which briefId changed.
  useEffect(() => { saveDecisions(decisions); decisionsRef.current = decisions; }, [decisions]);

  // Persist presets when they change.
  useEffect(() => { savePresets(presets); }, [presets]);

  // Bootstrap decisions from the worker on mount—fills in canonical
  // D1-backed state. If worker is unconfigured/unreachable, local cache
  // wins and sync-status pill flags it. Local clicks made WHILE bootstrap
  // is in flight must survive—merge bootstrap result UNDER current state
  // (current state wins per-key) so a fresh Keep isn't overwritten.
  const bootstrapInFlight = useRef(false);
  useEffect(() => {
    let mounted = true;
    bootstrapInFlight.current = true;
    bootstrapDecisions().then(d => {
      if (mounted && d) {
        // Merge: server-side state as base, local in-flight clicks layered on top.
        setDecisions(prev => ({ ...d, ...prev }));
      }
      if (mounted) setDecisionSyncStatus(getDecisionSyncStatus());
    }).finally(() => {
      bootstrapInFlight.current = false;
    });
    return () => { mounted = false; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Snapshot the current BRIEFS metric state on every visit so the next visit
  // can sort by volatility (item 4.19). Lightweight—just totalVolume / KD /
  // CPC keyed by id. Runs once per mount.
  useEffect(() => {
    try {
      const snap = {};
      (window.BRIEFS || []).forEach(b => {
        snap[b.id] = {
          v: b.totalVolume || 0,
          k: b.avgKD || 0,
          c: b.avgCPC || 0,
          ts: Date.now(),
        };
      });
      saveSnapshotMap(snap);
    } catch {}
  }, []);

  // Auto-dismiss the undo toast after its expiry.
  useEffect(() => {
    if (!undoToast) return;
    const ms = Math.max(0, undoToast.expiresAt - Date.now());
    const t = setTimeout(() => setUndoToast(null), ms);
    return () => clearTimeout(t);
  }, [undoToast]);

  const onToggle = useCallback((id) => setExpanded(e => e===id ? null : id), []);

  // Internal apply—performs the actual decision write (cache + worker).
  // Splits out of onDecide so the undo path can replay the prior state
  // without re-pushing an undo entry onto the stack.
  const applyDecision = useCallback((id, payload, opts) => {
    const skipUndoPush = !!(opts && opts.skipUndoPush);
    let pushPayload = null;
    let priorDecision = null;
    setDecisions(prev => {
      priorDecision = prev[id] ? JSON.parse(JSON.stringify(prev[id])) : null;
      const next = { ts: Date.now(), ...prev[id], ...payload };
      const o = next.outcome || {};
      const hasOutcome = o.pv || o.position || o.headlineGrade;
      const isEmpty = !next.action && !next.tve && !next.note && !hasOutcome
                   && (next.priorityPin == null);
      const out = { ...prev };
      if (isEmpty) {
        delete out[id];
        pushPayload = { action: null, note: null, tve: null, outcome: null, priorityPin: null };
      } else {
        out[id] = next;
        pushPayload = next;
      }
      return out;
    });
    if (pushPayload) {
      pushDecisionToWorker(id, pushPayload).then(() => {
        setDecisionSyncStatus(getDecisionSyncStatus());
      });
    }
    // Undo push happens AFTER the synchronous setDecisions queueing so
    // priorDecision is captured. Stack capped at 20.
    if (!skipUndoPush) {
      const entry = { briefId: id, prevDecision: priorDecision, ts: Date.now() };
      setUndoStack(prev => {
        const next = [...prev, entry];
        if (next.length > 20) next.splice(0, next.length - 20);
        return next;
      });
    }
    return priorDecision;
  }, []);

  const onDecide = useCallback((id, payload) => {
    applyDecision(id, payload);
  }, [applyDecision]);

  // Audit 2026-05-04 H1: Beach lens drag-and-drop fired N independent
  // onDecide calls per drop, generating N worker POSTs + N audit-history
  // rows. Batch helper updates ALL local rows in one setState pass, then
  // pushes pin-only updates to the worker—but only for briefs whose pin
  // actually changed (no-op writes filtered).
  const onReorderPins = useCallback((idsInOrder) => {
    const newPins = new Map(idsInOrder.map((bid, i) => [bid, i]));
    // audit-2026-05-10 R3 (N4): determine "actually changed" using the
    // PRE-state snapshot (decisionsRef.current at the moment of the call),
    // not the post-state inside the setTimeout. The previous approach
    // compared cur.priorityPin === pin AFTER the setState commit—at which
    // point cur.priorityPin already equals the new pin, so EVERY pin was
    // classified as a no-op and the worker push silently dropped 100% of
    // reorder writes. Capture the to-push set synchronously here.
    const preState = decisionsRef.current || {};
    const toPush = [];
    for (const [bid, pin] of newPins) {
      const prevPin = preState[bid]?.priorityPin;
      if (prevPin !== pin) toPush.push([bid, pin]);
    }
    setDecisions(prev => {
      const out = { ...prev };
      for (const [bid, pin] of newPins) {
        const cur = out[bid] || {};
        if (cur.priorityPin !== pin) {
          out[bid] = { ...cur, priorityPin: pin, ts: Date.now() };
        }
      }
      return out;
    });
    // Push the actually-changed pins. Wait one tick so decisionsRef has the
    // fresh full payload (note/action/etc.) for each brief—we want to
    // PUSH the post-merge state, not just {priorityPin}.
    setTimeout(() => {
      for (const [bid, pin] of toPush) {
        const cur = decisionsRef.current?.[bid] || {};
        const payload = { ...cur, priorityPin: pin };
        if (typeof pushDecisionToWorker === 'function') {
          pushDecisionToWorker(bid, payload).then(() => {
            setDecisionSyncStatus(getDecisionSyncStatus());
          });
        }
      }
    }, 0);
  }, []);

  const onJump = useCallback((lensId, briefId) => {
    setLens(lensId === 'gap' ? 'gap' : lensId);
    setExpanded(briefId);
    setTimeout(() => {
      const sel = `[data-brief-id="${CSS.escape(briefId)}"]`;
      const el = document.querySelector(sel);
      if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }, 80);
  }, []);

  // Per-lens scroll-position save / restore (item 4.14). The .content
  // element is the scroll container; we save before lens changes and
  // restore after.
  // Audit 2026-05-04 C3: prior version saved scroll AFTER lens commit, by
  // which point the scroller already showed the new lens—outgoing scroll
  // was never recorded. useLayoutEffect with a ref-comparison runs BEFORE
  // browser paint, so we capture the OLD lens's scrollTop before React
  // unmounts/swaps the lens content. Then a rAF restores the incoming.
  const prevLensRef = useRef(lens);
  useLayoutEffect(() => {
    const scroller = document.querySelector('.content');
    const prev = prevLensRef.current;
    if (scroller && prev !== lens) {
      // Save the OUTGOING lens's scroll position. At this point we're
      // BEFORE the lens-content swap, so scrollTop reflects the old lens.
      lensScrollRef.current.set(prev, scroller.scrollTop);
    }
    prevLensRef.current = lens;
  }, [lens]);
  useEffect(() => {
    // Restore the incoming lens's saved scrollTop after React commits the
    // new lens's content. rAF defers to first paint so layout is settled.
    const target = lensScrollRef.current.get(lens) || 0;
    requestAnimationFrame(() => {
      const s = document.querySelector('.content');
      if (s) s.scrollTop = target;
    });
  }, [lens]);

  // Selection helpers (item 4.2).
  const clearSelection = useCallback(() => {
    setSelectedIds(new Set());
    lastSelectAnchorRef.current = null;
  }, []);
  const toggleSelectOne = useCallback((id) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
    lastSelectAnchorRef.current = id;
  }, []);
  // Shift-click: select the contiguous range from anchor → id within the
  // current lens's visible-id ordering. If no anchor, behaves like a
  // single-select.
  const selectRange = useCallback((id) => {
    const list = visibleIdsByLensRef.current[lens] || [];
    const anchor = lastSelectAnchorRef.current;
    if (!anchor || !list.length) {
      toggleSelectOne(id);
      return;
    }
    const a = list.indexOf(anchor);
    const b = list.indexOf(id);
    if (a < 0 || b < 0) {
      toggleSelectOne(id);
      return;
    }
    const lo = Math.min(a, b), hi = Math.max(a, b);
    setSelectedIds(prev => {
      const next = new Set(prev);
      for (let i = lo; i <= hi; i++) next.add(list[i]);
      return next;
    });
  }, [lens, toggleSelectOne]);

  // Lens-side hook: each lens registers its visible-brief id ordering for
  // shift-click range + Cmd+A. Stable callback so lens effects don't churn.
  const registerVisibleIds = useCallback((lensId, ids) => {
    visibleIdsByLensRef.current[lensId] = ids;
  }, []);

  // Apply a batch action to all selected briefs—invoked once the user
  // confirms the shared rationale. Each brief gets its own onDecide call so
  // every decision lands on the worker independently.
  const applyBatchAction = useCallback((action, sharedNote) => {
    const ids = Array.from(selectedIds);
    if (action === 'pin') {
      ids.forEach((id, i) => applyDecision(id, { priorityPin: i, note: sharedNote || undefined }));
    } else {
      ids.forEach(id => applyDecision(id, { action, note: sharedNote || undefined }));
    }
    clearSelection();
    setPendingBatch(null);
  }, [selectedIds, applyDecision, clearSelection]);

  // Undo (item 4.11)—pops the latest entry, replays the prior state.
  const performUndo = useCallback(() => {
    const stack = undoStackRef.current;
    if (!stack.length) return;
    const entry = stack[stack.length - 1];
    setUndoStack(prev => prev.slice(0, -1));
    // Compute msg before mutating decisions so we can describe the change.
    const brief = (window.BRIEFS || []).find(b => b.id === entry.briefId);
    const topic = (brief && brief.topic) || entry.briefId;
    let beforeAction = 'none';
    setDecisions(prev => {
      const cur = prev[entry.briefId];
      beforeAction = (cur && cur.action) || 'none';
      const out = { ...prev };
      if (entry.prevDecision == null) {
        delete out[entry.briefId];
        pushDecisionToWorker(entry.briefId, { action: null, note: null, tve: null, outcome: null, priorityPin: null });
      } else {
        out[entry.briefId] = entry.prevDecision;
        pushDecisionToWorker(entry.briefId, entry.prevDecision);
      }
      return out;
    });
    const afterAction = entry.prevDecision?.action || 'none';
    setUndoToast({
      msg: `Reverted: ${beforeAction} → ${afterAction} on ${topic}`,
      expiresAt: Date.now() + 3000,
    });
  }, []);

  // Recommended action for the focused brief—used by Enter shortcut.
  const fireRecommendedOnFocused = useCallback(() => {
    if (!focusedId) return;
    const brief = (window.BRIEFS || []).find(b => b.id === focusedId);
    if (!brief) return;
    const action = recommendedActionFor(brief);
    if (!action) return;
    onDecide(brief.id, { action });
  }, [focusedId, onDecide]);

  // Move focus to next/previous brief in the active lens's visible-id list.
  const moveFocus = useCallback((delta) => {
    const list = visibleIdsByLensRef.current[lens] || [];
    if (!list.length) return;
    const i = focusedId ? list.indexOf(focusedId) : -1;
    let nextI;
    if (i < 0) nextI = delta > 0 ? 0 : list.length - 1;
    else nextI = Math.min(list.length - 1, Math.max(0, i + delta));
    const nextId = list[nextI];
    setFocusedId(nextId);
    requestAnimationFrame(() => {
      const sel = `[data-brief-id="${CSS.escape(nextId)}"]`;
      const el = document.querySelector(sel);
      if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    });
  }, [focusedId, lens]);

  // Toggle priority-pin on focused brief. Pin is a sentinel index; for the
  // single-tile shortcut we set it to 0 (top) when missing, clear when set.
  const togglePinOnFocused = useCallback(() => {
    if (!focusedId) return;
    const cur = decisions[focusedId];
    const isPinned = cur && cur.priorityPin != null;
    onDecide(focusedId, { priorityPin: isPinned ? null : 0 });
  }, [focusedId, decisions, onDecide]);

  // Keyboard shortcuts—Gmail-style + global modal/undo bindings.
  useEffect(() => {
    const onKey = (e) => {
      const tag = (e.target.tagName||'').toLowerCase();
      const inField = tag==='input' || tag==='textarea' || e.target?.isContentEditable;
      // Cmd/Ctrl-prefixed handlers fire even when an input is focused (Cmd+K,
      // Cmd+Z, Cmd+A) so they remain consistently global.
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault(); setPalette(true); return;
      }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
        if (undoStackRef.current.length === 0) return;
        e.preventDefault(); performUndo(); return;
      }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
        // Select all visible in active lens.
        const list = visibleIdsByLensRef.current[lens] || [];
        if (!list.length) return;
        if (lens === 'queue' || lens === 'beach') {
          e.preventDefault();
          setSelectedIds(new Set(list));
        }
        return;
      }
      if (inField) return;
      if (e.key === '1') setLens('queue');
      else if (e.key === '2') setLens('beach');
      else if (e.key === '3') setLens('gap');
      else if (e.key === '4') setLens('log');
      else if (e.key === '5') setLens('docs');
      else if (e.key === '/') { e.preventDefault(); document.querySelector('.trend-input')?.focus(); }
      else if (e.key === 'Escape') {
        if (helpOpen) { setHelpOpen(false); return; }
        if (compareOpen) { setCompareOpen(false); return; }
        if (pendingBatch) { setPendingBatch(null); return; }
        if (selectedIds.size) { clearSelection(); return; }
        setExpanded(null);
      }
      else if (e.key === '?') { e.preventDefault(); setHelpOpen(true); }
      else if (e.key === 'c') {
        // Side-by-side comparison (item 4.4)—requires 2-4 selected briefs.
        const n = selectedIds.size;
        if (n >= 2 && n <= 4) { e.preventDefault(); setCompareOpen(true); }
      }
      else if (lens === 'queue' || lens === 'beach') {
        // Gmail-style shortcuts (item 4.1) only apply on triage lenses.
        if (e.key === 'j') { e.preventDefault(); moveFocus(1); }
        else if (e.key === 'k') { e.preventDefault(); moveFocus(-1); }
        else if (e.key === 'e') {
          if (focusedId) { e.preventDefault(); onToggle(focusedId); }
        }
        else if (e.key === 'y' && focusedId) { e.preventDefault(); onDecide(focusedId, { action: 'keep' }); }
        else if (e.key === 'n' && focusedId) { e.preventDefault(); onDecide(focusedId, { action: 'skip' }); }
        else if (e.key === 'd' && focusedId) { e.preventDefault(); onDecide(focusedId, { action: 'defer' }); }
        else if (e.key === 'p') { e.preventDefault(); togglePinOnFocused(); }
        else if (e.key === 'Enter' && focusedId) { e.preventDefault(); fireRecommendedOnFocused(); }
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [lens, focusedId, selectedIds, helpOpen, compareOpen, pendingBatch,
      moveFocus, onToggle, onDecide, togglePinOnFocused, fireRecommendedOnFocused,
      clearSelection, performUndo]);

  // Lens counts
  const counts = useMemo(() => {
    // "Decided" = the operator has taken a triage action (keep/skip/defer).
    // TVE-only annotations don't count as a decision—they're a framework
    // override flag, not a triage commitment.
    const undecided = BRIEFS.filter(b => !decisions[b.id]?.action).length;
    const decidedCt = BRIEFS.length - undecided;
    // Active = total minus skip-decisioned (those are hidden by default
    // across queue/beach/gap; the show-skipped toggle restores them).
    // Beach + Gap counts mirror that so the tab badge tracks what's
    // actually visible inside the lens.
    const skippedCt = BRIEFS.filter(b => decisions[b.id]?.action === 'skip').length;
    const active = BRIEFS.length - skippedCt;
    return {
      queue: undecided,
      beach: active,
      gap:   active,
      log:   decidedCt,
      docs:  '',
    };
  }, [decisions]);

  // Trend match counts
  const trendHits = useMemo(() => {
    if (!trend.trim()) return null;
    const q = trend.toLowerCase();
    return BRIEFS.filter(b => {
      const hay = (b.topic + ' ' + (b.recommendedArticle||'') + ' ' + (b.topKeywords || []).map(k=>k.label).join(' ')).toLowerCase();
      return hay.includes(q);
    }).length;
  }, [trend]);

  // Telemetry counts—keep traffic KPIs (volume, kept), add engagement avg
  // as a placeholder for the Snowflake-blend metric.
  const tel = useMemo(() => {
    const tot = BRIEFS.length;
    const engAvg = Math.round(BRIEFS.reduce((s,b)=>s+engagementSignal(b),0) / Math.max(1,tot));
    return {
      tot,
      go: BRIEFS.filter(b=>window.briefRuntimeVerdict(b)==='high-opportunity').length,
      test: BRIEFS.filter(b=>window.briefRuntimeVerdict(b)==='worth-testing').length,
      monitor: BRIEFS.filter(b=>window.briefRuntimeVerdict(b)==='monitor').length,
      skip: BRIEFS.filter(b=>window.briefRuntimeVerdict(b)==='skip').length,
      // Audit 2026-05-04 H2: phantom-row guard. Pin-only / annotation-only
      // rows have no triage action; they shouldn't count as "decided" for
      // the velocity + telemetry KPIs (and shouldn't desync from the
      // undecided count which IS action-checked above).
      decided: Object.values(decisions).filter(d => !!d.action).length,
      kept: Object.values(decisions).filter(d=>d.action==='keep').length,
      engAvg,
    };
  }, [decisions]);

  // Decision-velocity footer (item 4.10). Walk decisions[] for entries inside
  // the last 7d AND in the prior 7d (8-14d ago). Display delta as up/down.
  const velocity = useMemo(() => {
    const now = Date.now();
    const D7 = 7 * 24 * 60 * 60 * 1000;
    let thisWk = 0, lastWk = 0;
    Object.values(decisions).forEach(d => {
      if (!d.action || !d.ts) return;
      const age = now - d.ts;
      if (age <= D7) thisWk += 1;
      else if (age <= 2 * D7) lastWk += 1;
    });
    const tot = BRIEFS.length;
    return { thisWk, lastWk, tot };
  }, [decisions]);

  // Apply ALL hard filters (collection + verdict + decision) at the lens-prop
  // level so every lens sees the same intersection. Per design spec: "All
  // filters AND together. Collection=Mind + State=undecided + Verdict=High Opportunity
  // shows the intersection." Previously only QueueLens honored verdict+decision;
  // Beach/GapMap/Log silently ignored them. Lifted here for consistency.
  const visibleBriefs = useMemo(() => {
    return BRIEFS.filter(b => {
      if (filters.collection !== 'all' && inferTHCollection(b) !== filters.collection) return false;
      if (filters.verdict !== 'all' && window.briefRuntimeVerdict(b) !== filters.verdict) return false;
      if (filters.decision !== 'all') {
        const action = decisions[b.id]?.action;
        if (filters.decision === 'undecided' && action) return false;
        if (filters.decision === 'kept' && action !== 'keep') return false;
        if (filters.decision === 'skipped' && action !== 'skip') return false;
        if (filters.decision === 'deferred' && action !== 'defer') return false;
      } else if (!showSkipped && lens !== 'log') {
        // Default: skip-decisioned briefs are hidden across the working
        // lenses (queue, beach, gap-map) so triaged-out items stop
        // cluttering the surfaces where you're choosing what to do next.
        // Recoverable via the show-skipped toggle. Explicit decision
        // filters (kept/skipped/deferred/undecided) also bypass this —
        // operator's pick wins. Decisions lens (lens === 'log') is the
        // exception: that lens IS where you go to review past decisions
        // including skips, so the default-hide actively breaks its
        // purpose. Show-skipped toggle still works there to suppress
        // the bucket if the operator wants the focused review.
        if (decisions[b.id]?.action === 'skip') return false;
      }
      // Persona-fit filter—content-aware fit against the currently selected persona
      if (filters.personaFit !== 'all') {
        const detail = personaFitDetail(b);
        const pid = tweaks.personaId || 'curious-optimizer';
        if (detail.fits[pid] !== filters.personaFit) return false;
      }
      return true;
    });
  }, [filters.collection, filters.verdict, filters.decision, filters.personaFit, decisions, tweaks.personaId, showSkipped, lens]);

  // Trend filter: soft—matched briefs highlight, unmatched dim but stay
  // visible. Computed once at App level and passed down so every lens
  // applies it consistently (was inconsistent: only QueueLens highlighted).
  const trendMatched = useMemo(() => {
    if (!trend.trim()) return null;
    const q = trend.toLowerCase();
    const hits = new Set();
    BRIEFS.forEach(b => {
      const hay = (b.topic + ' ' + (b.recommendedArticle || '') + ' ' +
                   (b.topKeywords || []).map(k => k.label).join(' ')).toLowerCase();
      if (hay.includes(q)) hits.add(b.id);
    });
    return hits;
  }, [trend]);

  // Persona-fit chip counts—memoized so the 4 inline filters don't re-run
  // personaFitDetail() across all BRIEFS on every parent render.
  const personaFitChipCounts = useMemo(() => {
    const pid = tweaks.personaId || 'curious-optimizer';
    const counts = { all: BRIEFS.length, high: 0, medium: 0, low: 0 };
    BRIEFS.forEach(b => {
      const lvl = personaFitDetail(b).fits[pid];
      if (counts[lvl] != null) counts[lvl] += 1;
    });
    return counts;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tweaks.personaId]);

  // Apply a saved preset (item 4.8).
  const applyPreset = useCallback((p) => {
    if (!p) return;
    setFilters({ ...DEFAULT_FILTERS, ...(p.filters || {}) });
    if (p.sort) setSort({ ...DEFAULT_SORT_FALLBACK, ...p.sort });
    setTrend('');
  }, []);
  const saveCurrentAsPreset = useCallback(() => {
    const name = (typeof window !== 'undefined' && window.prompt)
      ? window.prompt('Name this filter preset:') : null;
    if (!name) return;
    setPresets(prev => {
      const without = prev.filter(p => p.name !== name);
      return [...without, { name, filters: { ...filters }, sort: { ...sort } }];
    });
  }, [filters, sort]);
  const deletePreset = useCallback((name) => {
    setPresets(prev => prev.filter(p => p.name !== name));
  }, []);

  const lensProps = {
    briefs: visibleBriefs,
    decisions, expanded, onToggle, onDecide, onReorderPins, onJump,
    revenueOn: tweaks.revenueOverlay,
    engagementOn: tweaks.engagementOverlay,
    personaOn: tweaks.personaOverlay,
    personaId: tweaks.personaId || 'curious-optimizer',
    trend,
    trendMatched,
    sort,         // QueueLens consumes this; non-default overrides native sort
    setSort,      // QueueLens header renders the sort dropdown + dir toggle
    clearSort,    // QueueLens "clear sort" button inline with the dropdown
    sortDirty,    // disable the inline clear when sort is already at default
    // 4.x—UX upgrades
    selectedIds,
    setSelectedIds,
    toggleSelectOne,
    selectRange,
    clearSelection,
    focusedId,
    setFocusedId,
    registerVisibleIds,
    recommendedActionFor,
    outcomeStateTokenFor,
    // Item 7 / 8—multi-objective overlay + cohort-mode threading
    objective,
    cohortMode,
    target: objective,           // alias—lenses + tiles read either name
  };

  // Item 45—read-only per-brief view via #brief/<id> hash. Renders only
  // the brief's tile + drawer with no rail and no other lenses. All decision
  // surfaces are inert (no decision wiring is supplied to the BriefTile).
  // Esc / clicking the "back" link clears the hash and returns to normal view.
  if (readOnlyBriefId) {
    return <ReadOnlyBriefView briefId={readOnlyBriefId} onClear={() => setReadOnlyBriefId(null)} />;
  }

  return (
    <div className="app">
      {/* TOPBAR */}
      <header className="topbar">
        <div className="brand">
          <span className="brand-dot"></span>
          <b>Keywords Decisioning Console</b>
        </div>
        <div className="lenses" role="tablist">
          {LENSES.map(L => (
            <button key={L.id} className={'lens ' + (lens===L.id?'active':'')} onClick={()=>setLens(L.id)}>
              <span className="k">{L.key}</span>
              {L.label}
              <span className="count">{counts[L.id]}</span>
            </button>
          ))}
        </div>
        <div className="topbar-right">
          <button className="cmd-trigger" onClick={()=>setPalette(true)}>
            <span>⌕</span> jump to brief or lens…
            <span className="kbd">⌘K</span>
          </button>
        </div>
      </header>

      {/* MAIN */}
      <div className="main">
        <aside className="rail">
          {/* Filter clear button—top of rail. Sort lives inline with the
              lens header (it's a property of the displayed list, not a global
              filter), so this button only resets filter chips + trend search. */}
          <div className="rail-clear-row">
            <button
              className={'rail-clear ' + (filtersDirty ? 'dirty' : 'idle')}
              disabled={!filtersDirty}
              onClick={clearFilters}
              title="Reset all filters (state, verdict, collection, persona fit, trend search)"
            >
              clear filters
            </button>
          </div>

          {/* Item 7 / 8—objective overlay + cohort-mode dropdowns. Live at
              the top of the rail so they're discoverable as global lens-of-
              the-slate controls; both persist to localStorage. */}
          <div className="rail-objective-row">
            <label className="rail-objective-label">Objective</label>
            <select
              className="rail-objective-select"
              value={objective}
              onChange={e => setObjective(e.target.value)}
              title="Multi-objective weights—re-derives compositeScore via weightsFor(target). 'Balanced' is the calibrated default."
            >
              {OBJECTIVE_OPTIONS.map(o => (
                <option key={o.value} value={o.value}>{o.label}</option>
              ))}
            </select>
          </div>
          <div className="rail-objective-row">
            <label className="rail-objective-label">Cohort</label>
            <select
              className="rail-objective-select"
              value={cohortMode}
              onChange={e => setCohortMode(e.target.value)}
              title="Cohort scope for percentile re-ranking. Same vertical / Play / content type re-ranks against a tighter peer group."
            >
              {COHORT_MODE_OPTIONS.map(o => (
                <option key={o.value} value={o.value}>{o.label}</option>
              ))}
            </select>
          </div>

          {/* Saved filter presets (item 4.8)—drop-down + save-current. */}
          <FilterPresets
            presets={presets}
            onApply={applyPreset}
            onSaveCurrent={saveCurrentAsPreset}
            onDelete={deletePreset}
          />

          <h4>Trend filter <span className="dim">L1 input</span></h4>
          <div className="trend-input-wrap">
            <span className="glyph">→</span>
            <input className="trend-input"
              placeholder="paste a trend, e.g. 'sober sips after dark' or 'TV grazing snacks'"
              value={trend} onChange={e=>setTrend(e.target.value)} />
            {trend && <button className="trend-clear" onClick={()=>setTrend('')}>×</button>}
          </div>
          {trend.trim() && (
            <div className="trend-hits">
              {trendHits} hit{trendHits===1?'':'s'} · briefs matching <b>{trend.trim()}</b>
            </div>
          )}

          <h4>State</h4>
          <div className="chip-row">
            {[['all','any'],['undecided','undecided'],['kept','kept'],['skipped','skipped'],['deferred','deferred']].map(([v,l]) => (
              <button key={v} className={'chip ' + (filters.decision===v?'active':'')} onClick={()=>setFilters(f=>({...f, decision:v}))}>
                {v!=='all' && <span className="chip-dot" style={{background: v==='kept'?'var(--kept)':v==='skipped'?'var(--skipped)':v==='deferred'?'var(--deferred)':'var(--t-3)'}}></span>}
                {l}
              </button>
            ))}
          </div>
          {/* Show-skipped toggle (2026-05-04)—when state filter is "any",
              skip-decisioned briefs are hidden from every lens by default
              so triaged-out items don't clutter the working surface. Toggle
              to bring them back. Disabled when an explicit decision filter
              is active (the explicit filter already controls visibility). */}
          {filters.decision === 'all' && (
            <div className="chip-row" style={{marginTop:4}}>
              <button
                className={'chip ' + (showSkipped ? 'active' : '')}
                onClick={() => setShowSkipped(s => !s)}
                title={showSkipped
                  ? 'Skip-decisioned briefs are visible—toggle off to hide them'
                  : 'Skip-decisioned briefs hidden by default—toggle on to bring them back'}>
                <span className="chip-dot" style={{background:'var(--skipped)'}}></span>
                {showSkipped ? 'showing skipped' : 'show skipped'}
              </button>
            </div>
          )}

          <h4>Verdict</h4>
          <div className="chip-row">
            {[['all','all'],['high-opportunity','high opportunity'],['worth-testing','worth testing'],['monitor','monitor'],['skip','skip']].map(([v,l]) => (
              <button key={v} className={'chip ' + (filters.verdict===v?'active':'')} onClick={()=>setFilters(f=>({...f, verdict:v}))}>
                {v!=='all' && <span className="chip-dot" style={{background: v==='high-opportunity'?'var(--go)':v==='worth-testing'?'var(--test)':v==='monitor'?'var(--monitor)':'var(--skip)'}}></span>}
                {l}
              </button>
            ))}
          </div>

          {/* TH COLLECTION—primary editorial filter (was "Vertical" pre-pivot) */}
          <h4>Collection</h4>
          <div className="chip-row">
            <button className={'chip ' + (filters.collection==='all'?'active':'')} onClick={()=>setFilters(f=>({...f, collection:'all'}))}>
              all <span className="n">{BRIEFS.length}</span>
            </button>
            {Object.entries(TH_COLLECTIONS).map(([cid, m]) => {
              const n = BRIEFS.filter(b => inferTHCollection(b) === cid).length;
              return (
                <button
                  key={cid}
                  className={'chip coll-chip ' + (filters.collection===cid?'active':'')}
                  onClick={()=>setFilters(f=>({...f, collection: f.collection===cid?'all':cid}))}
                  title={m.desc}
                >
                  <span className="coll-glyph">{m.glyph}</span> {m.name.toLowerCase()} <span className="n">{n}</span>
                </button>
              );
            })}
          </div>

          <h4>Overlays</h4>
          <div className="chip-row">
            <button className={'chip ' + (tweaks.engagementOverlay?'active':'')} onClick={()=>setTweak({engagementOverlay: !tweaks.engagementOverlay})} title="Engagement signal—live Snowflake blend (time-on-page · engaged-session-rate · pvs-per-session · cluster-continuation · scroll-depth via Marfeel). Proxy fallback only when no article has published yet against the brief's keyword space.">
              <span className="chip-dot" style={{background:'var(--engagement)'}}></span> engagement
            </button>
            <button className={'chip ' + (tweaks.revenueOverlay?'active':'')} onClick={()=>setTweak({revenueOverlay: !tweaks.revenueOverlay})}>
              <span className="chip-dot" style={{background:'var(--kept)'}}></span> revenue
            </button>
            <button className={'chip ' + (tweaks.personaOverlay?'active':'')} onClick={()=>setTweak({personaOverlay: !tweaks.personaOverlay})}>
              <span className="chip-dot" style={{background:'var(--ct-orange)'}}></span> persona fit
            </button>
          </div>

          {/* Persona target—Curious Optimizer is always-default, but
              strategists can swap to compare fit against another persona.
              Persona-fit filter chips live directly under the dropdown so the
              filter and selection stay paired. */}
          {tweaks.personaOverlay && (
            <>
              <div className="persona-picker">
                <label className="dim">measuring fit against</label>
                <select
                  className="persona-select"
                  value={tweaks.personaId || 'curious-optimizer'}
                  onChange={e=>setTweak({personaId: e.target.value})}
                >
                  {Object.entries(PERSONAS).map(([pid, p]) => (
                    <option key={pid} value={pid} disabled={p.paused}>
                      {p.name}{p.primary ? ' (TH primary)' : ''}{p.paused ? '—paused' : ''}
                    </option>
                  ))}
                </select>
              </div>

              <h4 style={{marginTop:10}}>Persona fit <span className="dim mono" style={{fontSize:9, fontWeight:400, letterSpacing:'.04em', textTransform:'none'}}>· vs {(PERSONAS[tweaks.personaId] || PERSONAS['curious-optimizer']).name}</span></h4>
              <div className="chip-row">
                {(() => {
                  const counts = personaFitChipCounts;
                  return [['all','any'],['high','high'],['medium','medium'],['low','low']].map(([v,l]) => (
                    <button key={v} className={'chip ' + (filters.personaFit===v?'active':'')} onClick={()=>setFilters(f=>({...f, personaFit:v}))}>
                      {v!=='all' && <span className="chip-dot" style={{background: v==='high'?'var(--go)':v==='medium'?'var(--test)':'var(--t-3)'}}></span>}
                      {l} <span className="n">{counts[v]}</span>
                    </button>
                  ));
                })()}
              </div>
            </>
          )}
          <div className="dim" style={{fontSize:10, marginTop:6, fontFamily:'var(--font-mono)', letterSpacing:'.04em'}}>
            recipe coordinates always visible in drawer · revenue OFF by default · engagement signal is live Snowflake blend (proxy fallback only when no article published yet)
          </div>

          <h4>KPIs</h4>
          <div className="kpi">
            <div><div className="v go">{tel.go}</div><div className="l">high opportunity</div></div>
            <div><div className="v test">{tel.test}</div><div className="l">worth testing</div></div>
            {tel.monitor > 0 && <div><div className="v monitor">{tel.monitor}</div><div className="l">monitor</div></div>}
            <div><div className="v kept">{tel.kept}</div><div className="l">kept</div></div>
            <div><div className="v">{tel.tot - tel.decided}</div><div className="l">undecided</div></div>
          </div>
          {/* Engagement KPI—Snowflake-blend metric.
              v2 batting-average shipped 2026-05-18 (Marfeel ETL recurring +
              v2.10 model_tracker landed 3-gate dwell+scroll+volume). brief-
              outcomes precompute carries hit_count_v2 + v2_eligible_count +
              avg_article_hit_rate_v2 per brief (TRACKER_ENRICHED IS_HIT_V2 +
              ARTICLE_HIT_RATE_V2). engagementSignal composites Amplitude/
              Marfeel per content-type framework via engagementLiveFor with
              proxy fallback. */}
          <div className="kpi-engagement">
            <div className="ke-bar" style={{'--p': tel.engAvg + '%'}}></div>
            <div className="ke-row">
              <span className="ke-v">{tel.engAvg}</span>
              <span className="ke-l">avg engagement signal</span>
            </div>
            <div className="dim" style={{fontSize:10, marginTop:4, fontFamily:'var(--font-mono)'}}>
              composite: time-on-page · engaged-session-rate · repeat-view-rate · csa-cluster-continuation · scroll-depth (Marfeel)
            </div>
          </div>
        </aside>

        <main className="content">
          {lens === 'queue' && <QueueLens {...lensProps} />}
          {lens === 'beach' && <BeachLens {...lensProps} />}
          {lens === 'gap'   && <GapMapLens {...lensProps} />}
          {lens === 'log'   && <LogLens {...lensProps} />}
          {lens === 'docs'  && <DocsLens onJump={onJump} setLens={setLens} />}
        </main>
      </div>

      {/* FOOTER */}
      <footer className="footer">
        {/* Live ETRP provenance read off window.VERDICT_THRESHOLDS—surfaces
            both the calibration date AND the bucket-cut decision so any future
            re-calibration is visible without code-spelunking. */}
        <span style={{display:'inline-flex', alignItems:'center', gap:0}}>
          <span title={`Weights: ${VERDICT_THRESHOLDS_RUNTIME.calibrationSource}. Buckets: ${VERDICT_THRESHOLDS_RUNTIME.bucketsProvenance}.`}>
            etrp · {Math.round(VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct*100)}/{Math.round(VERDICT_THRESHOLDS_RUNTIME.worthTestingPct*100)}/{Math.round(100 - VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct*100 - VERDICT_THRESHOLDS_RUNTIME.worthTestingPct*100)} · 75/10/10/5 · {VERDICT_THRESHOLDS_RUNTIME.bucketsDecision === 'REFINED' ? 'cuts refined' : 'baseline'} {VERDICT_THRESHOLDS_RUNTIME.calibrationDate}
          </span>
          {window.TermHelp && <window.TermHelp termKey="footerEtrp" />}
        </span>
        <span className="dim">·</span>
        {/* Decision-velocity pill (item 4.10)—this-week vs last-week count. */}
        <span style={{display:'inline-flex', alignItems:'center', gap:0}}>
          <span className="velocity-pill"
                title={`Decisions made in the last 7 days vs the prior 7 days (8-14 days ago). 'Decided' = action ∈ {keep, skip, defer}.`}>
            {velocity.thisWk}/{velocity.tot} decided this week
            {velocity.thisWk > velocity.lastWk
              ? <span className="velocity-up"> ▲ from {velocity.lastWk}</span>
              : velocity.thisWk < velocity.lastWk
              ? <span className="velocity-down"> ▼ from {velocity.lastWk}</span>
              : <span className="velocity-flat"> · flat from {velocity.lastWk}</span>}
          </span>
          {window.TermHelp && <window.TermHelp termKey="footerVelocity" />}
        </span>
        <span className="dim">·</span>
        {/* SEMrush credit pill—reads live state from data/semrush-credits.js
            so the displayed numbers stay accurate across the monthly reset.
            Update that file by hand after notable pulls, or via
            calibration/scripts/00-check-credits.py. Falls back to a neutral
            string when the data file is missing. */}
        <span style={{display:'inline-flex', alignItems:'center', gap:0}}>
          {(() => {
            const c = (typeof window !== 'undefined' && window.SEMRUSH_CREDITS) || null;
            if (!c) {
              return (
                <span className="credit-pill" title="Team SEMrush allocation: 250K units per month. Resets on the 1st of each month.">
                  SEMrush · 250K / month · resets 1st
                </span>
              );
            }
            const fmtK = (n) => Math.round(n / 1000) + 'K';
            const remaining = Math.max(0, (c.budget || 0) - (c.spent_this_month || 0));
            const pct = c.budget ? Math.round(((c.spent_this_month || 0) / c.budget) * 100) : 0;
            return (
              <span
                className="credit-pill"
                title={`Team SEMrush allocation: ${fmtK(c.budget || 250000)} units per month. ${fmtK(c.spent_this_month || 0)} spent so far in ${c.month || 'this month'} (${pct}%). ${fmtK(remaining)} remaining. Last updated ${c.last_updated || '?'}. Resets on the 1st of each month.${c.note ? ' ' + c.note : ''}`}>
                SEMrush · ~{fmtK(c.spent_this_month || 0)} spent / {fmtK(c.budget || 250000)} · resets 1st of month
              </span>
            );
          })()}
          {window.TermHelp && <window.TermHelp termKey="footerSemrush" />}
        </span>
        <span className="dim">·</span>
        <span style={{display:'inline-flex', alignItems:'center', gap:0}}>
          <span className={'sync-pill ' + (decisionSyncStatus.error ? 'sync-err' : decisionSyncStatus.lastSyncedAt ? 'sync-ok' : 'sync-pending')}
                title={decisionSyncStatus.error
                  ? `Decisions sync error: ${decisionSyncStatus.error}. Local cache in use; will retry on next click.`
                  : decisionSyncStatus.lastSyncedAt
                  ? `Decisions persisted to D1 database. Last sync: ${new Date(decisionSyncStatus.lastSyncedAt).toLocaleString()}.`
                  : 'Decisions worker unconfigured—running in local-cache-only mode. Set DECISIONS_WORKER url + token in docs/data/worker-config.js after deploying the worker.'}>
            decisions {decisionSyncStatus.error ? 'offline (cached)' : decisionSyncStatus.lastSyncedAt ? 'synced' : 'local-only'}
          </span>
          {window.TermHelp && <window.TermHelp termKey="footerSync" />}
        </span>
        <span className="right" style={{display:'inline-flex', alignItems:'center', gap:0}}>
          <span>/ focus trend · 1–5 lens · ⌘K cmd · esc collapse · ? help</span>
          {window.TermHelp && <window.TermHelp termKey="footerKeys" />}
        </span>
      </footer>

      <CommandPalette open={palette}
        briefs={BRIEFS}
        onClose={()=>setPalette(false)}
        onJumpBrief={(id)=>onJump(lens, id)}
        onSetLens={(id)=>setLens(id)}
        lenses={LENSES} />

      {/* Floating batch-action bar (item 4.2) */}
      {selectedIds.size >= 2 && !pendingBatch && (
        <BatchActionBar
          count={selectedIds.size}
          onAction={(action) => setPendingBatch({ action })}
          onClear={clearSelection}
          onCompare={() => {
            if (selectedIds.size >= 2 && selectedIds.size <= 4) setCompareOpen(true);
          }}
          canCompare={selectedIds.size >= 2 && selectedIds.size <= 4}
        />
      )}

      {/* Pending batch—shared rationale prompt */}
      {pendingBatch && (
        <BatchRationaleModal
          action={pendingBatch.action}
          count={selectedIds.size}
          onConfirm={(note) => applyBatchAction(pendingBatch.action, note)}
          onCancel={() => setPendingBatch(null)}
        />
      )}

      {/* Side-by-side comparison modal (item 4.4) */}
      {compareOpen && (
        <CompareModal
          briefs={(window.BRIEFS || []).filter(b => selectedIds.has(b.id))}
          onClose={() => setCompareOpen(false)}
        />
      )}

      {/* Keyboard-shortcuts help (item 4.1 + ?) */}
      {helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}

      {/* Undo toast (item 4.11) */}
      {undoToast && (
        <div className="undo-toast" role="status" aria-live="polite">
          <span className="undo-toast-msg">{undoToast.msg}</span>
          <button className="undo-toast-dismiss" onClick={() => setUndoToast(null)}>×</button>
        </div>
      )}

      {/* Tweaks panel—registers with host */}
      {window.TweaksPanel && (
        <window.TweaksPanel title="Tweaks">
          <window.TweakSection title="Display">
            <window.TweakRadio label="Density" value={tweaks.density} onChange={v=>setTweak({density:v})}
              options={[{label:'comfortable',value:'comfortable'},{label:'compact',value:'compact'}]} />
            <window.TweakToggle label="Decision flash glow" value={tweaks.decisionFlash} onChange={v=>setTweak({decisionFlash:v})} />
          </window.TweakSection>
          <window.TweakSection title="Overlays">
            <window.TweakToggle label="Engagement signal" value={tweaks.engagementOverlay} onChange={v=>setTweak({engagementOverlay:v})} />
            <window.TweakToggle label="Revenue projection" value={tweaks.revenueOverlay} onChange={v=>setTweak({revenueOverlay:v})} />
            <window.TweakToggle label="Persona fit" value={tweaks.personaOverlay} onChange={v=>setTweak({personaOverlay:v})} />
            <window.TweakSelect
              label="Target persona"
              value={tweaks.personaId || 'curious-optimizer'}
              onChange={v=>setTweak({personaId:v})}
              options={Object.entries(PERSONAS)
                .filter(([,p])=>!p.paused)
                .map(([pid,p])=>({label: p.name + (p.primary?' (TH primary)':''), value: pid}))}
            />
          </window.TweakSection>
          {/* Item 6—Sensitivity testing. Operator-set composite weights +
              live verdict replay against the focused brief (or first brief
              when nothing is focused). Save / load named presets to
              localStorage. SensitivityTester degrades gracefully when
              window.weightsFor / scoreComponents are unavailable. */}
          {window.SensitivityTester && (
            <window.TweakSection title="Sensitivity testing">
              <window.SensitivityTester
                focusedBrief={
                  (focusedId && (window.BRIEFS || []).find(b => b.id === focusedId))
                  || (window.BRIEFS && window.BRIEFS[0])
                  || null
                }
              />
            </window.TweakSection>
          )}
          <window.TweakSection title="Reset">
            {/* Local-cache-only clear: drops the localStorage cache and resets
                in-memory decisions. Server-side D1 rows are untouched—the
                next page load re-hydrates from the worker. To actually delete
                decisions server-side, click Skip → empty rationale on each
                brief (worker normalizes empty payloads to a clear). */}
            <window.TweakButton label="Clear local cache only" onClick={()=>{ if(confirm('Clear local decision cache?\n\nThis only clears your browser cache. Server-side decisions stay intact and will re-hydrate on next page load.')) { localStorage.removeItem('dk-v4-decisions'); setDecisions({}); }}} />
          </window.TweakSection>
        </window.TweaksPanel>
      )}
    </div>
  );
}

// ──────── Filter presets dropdown (item 4.8) ────────
function FilterPresets({ presets, onApply, onSaveCurrent, onDelete }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="filter-presets">
      <div className="fp-head">
        <h4 style={{margin:0}}>Saved filters</h4>
        <button className="fp-toggle" onClick={() => setOpen(o => !o)} title="Show / hide saved filter presets">
          {open ? '▾' : '▸'}
        </button>
      </div>
      {open && (
        <div className="fp-body">
          {presets.length === 0 && <div className="dim mono fp-empty">no saved presets</div>}
          {presets.map(p => (
            <div key={p.name} className="fp-item">
              <button className="fp-apply" onClick={() => onApply(p)} title={`Apply preset: ${p.name}`}>
                <span className="fp-name">{p.name}</span>
                <span className="fp-meta dim mono">
                  {p.filters?.verdict !== 'all' && <span> v:{p.filters.verdict}</span>}
                  {p.filters?.decision !== 'all' && <span> s:{p.filters.decision}</span>}
                  {p.filters?.collection !== 'all' && <span> c:{p.filters.collection}</span>}
                </span>
              </button>
              <button className="fp-del" onClick={() => onDelete(p.name)} title="Delete preset">×</button>
            </div>
          ))}
          <button className="fp-save" onClick={onSaveCurrent} title="Save current filters + sort as a preset">
            + save current
          </button>
        </div>
      )}
    </div>
  );
}

// ──────── Floating batch-action bar (item 4.2) ────────
function BatchActionBar({ count, onAction, onClear, onCompare, canCompare }) {
  return (
    <div className="batch-bar" role="toolbar" aria-label="Batch actions">
      <span className="batch-count">{count} selected</span>
      <button className="batch-btn batch-keep" onClick={() => onAction('keep')}>Keep all</button>
      <button className="batch-btn batch-skip" onClick={() => onAction('skip')}>Skip all</button>
      <button className="batch-btn batch-defer" onClick={() => onAction('defer')}>Defer all</button>
      <button className="batch-btn batch-pin" onClick={() => onAction('pin')}>Pin to top</button>
      <button
        className="batch-btn batch-compare"
        onClick={onCompare}
        disabled={!canCompare}
        title={canCompare ? 'Side-by-side (c)' : 'Compare requires 2-4 selected'}>
        Compare
      </button>
      <button className="batch-btn batch-clear" onClick={onClear}>Clear</button>
    </div>
  );
}

// ──────── Shared-rationale prompt for batch actions (item 4.2) ────────
function BatchRationaleModal({ action, count, onConfirm, onCancel }) {
  const [note, setNote] = useState('');
  const inputRef = useRef(null);
  useEffect(() => { setTimeout(() => inputRef.current?.focus(), 10); }, []);
  const onKey = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); onConfirm(note); }
    else if (e.key === 'Escape') onCancel();
  };
  const label = action === 'pin'
    ? `Pin ${count} brief${count===1?'':'s'} to top`
    : `${action[0].toUpperCase() + action.slice(1)} ${count} brief${count===1?'':'s'}`;
  return (
    <div className="cmdk-bg" onClick={onCancel}>
      <div className="batch-modal" onClick={e => e.stopPropagation()}>
        <h3>{label}</h3>
        <p className="dim">Shared rationale (applied to every selected brief):</p>
        <textarea
          ref={inputRef}
          className="batch-note-input"
          placeholder="optional—paste reasoning, link, or owner here"
          value={note}
          onChange={e => setNote(e.target.value)}
          onKeyDown={onKey}
        />
        <div className="batch-modal-actions">
          <button className="batch-btn batch-cancel" onClick={onCancel}>Cancel</button>
          <button className={'batch-btn batch-' + action} onClick={() => onConfirm(note)}>
            {action === 'pin' ? 'Pin all' : `${action} all`}
          </button>
        </div>
        <div className="dim mono batch-modal-foot">⌘↵ to confirm · esc to cancel</div>
      </div>
    </div>
  );
}

// ──────── Side-by-side comparison modal (item 4.4) ────────
function CompareModal({ briefs, onClose }) {
  const fmt = (n) => (typeof fmtVol === 'function') ? fmtVol(n) : String(n);
  const fmtCpcLocal = (n) => (typeof fmtCpc === 'function') ? fmtCpc(n) : ('$' + (n||0).toFixed(2));
  const verdictLabel = (v) => v === 'high-opportunity' ? 'High Opportunity'
    : v === 'worth-testing' ? 'Worth Testing'
    : v === 'monitor' ? 'Monitor'
    : v === 'skip' ? 'Skip' : '—';
  const personaIdResolved = 'curious-optimizer';
  return (
    <div className="cmdk-bg" onClick={onClose}>
      <div className="compare-modal" onClick={e => e.stopPropagation()}>
        <div className="compare-head">
          <h3>Side-by-side · {briefs.length} brief{briefs.length===1?'':'s'}</h3>
          <button className="compare-close" onClick={onClose}>×</button>
        </div>
        <div className="compare-body">
          <table className="compare-table">
            <thead>
              <tr>
                <th className="compare-row-label">signal</th>
                {briefs.map(b => (
                  <th key={b.id} className="compare-col">
                    <div className="compare-col-topic">{b.topic}</div>
                    <div className="compare-col-meta dim mono">{b.id}</div>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              <tr>
                <td className="compare-row-label">total volume</td>
                {briefs.map(b => <td key={b.id}>{fmt(b.totalVolume || 0)}</td>)}
              </tr>
              <tr>
                <td className="compare-row-label">avg KD</td>
                {briefs.map(b => <td key={b.id}>{b.avgKD ?? '—'}</td>)}
              </tr>
              <tr>
                <td className="compare-row-label">avg CPC</td>
                {briefs.map(b => <td key={b.id}>{b.avgCPC != null ? fmtCpcLocal(b.avgCPC) : '—'}</td>)}
              </tr>
              <tr>
                <td className="compare-row-label">runtime verdict</td>
                {briefs.map(b => {
                  const v = window.briefRuntimeVerdict ? window.briefRuntimeVerdict(b) : null;
                  return <td key={b.id} className={'compare-verdict v-' + v}>{verdictLabel(v)}</td>;
                })}
              </tr>
              <tr>
                <td className="compare-row-label">persona-fit</td>
                {briefs.map(b => {
                  const fit = (typeof personaFitDetail === 'function')
                    ? (personaFitDetail(b).fits[personaIdResolved] || 'n/a')
                    : 'n/a';
                  return <td key={b.id}>{fit}</td>;
                })}
              </tr>
              <tr>
                <td className="compare-row-label">engagement</td>
                {briefs.map(b => {
                  const e = (typeof engagementSignal === 'function') ? engagementSignal(b) : null;
                  return <td key={b.id}>{e != null ? e : '—'}</td>;
                })}
              </tr>
              <tr>
                <td className="compare-row-label">revenue range</td>
                {briefs.map(b => {
                  const r = (typeof revenueRange === 'function') ? revenueRange(b) : null;
                  if (!r) return <td key={b.id} className="dim">—</td>;
                  const fmtMoney = (n) => n >= 1000 ? '$' + (n/1000).toFixed(n>=10000?0:1) + 'K' : '$' + Math.round(n);
                  return <td key={b.id}>{fmtMoney(r.min)}–{fmtMoney(r.max)}/mo</td>;
                })}
              </tr>
              <tr>
                <td className="compare-row-label">recommended type</td>
                {briefs.map(b => {
                  // Audit 2026-05-04 C2: window.recommendedContentType returns
                  // { contentType, rationale } object. Pull the string field;
                  // never render the raw object as a React child.
                  let ctLabel = '—';
                  if (typeof window.recommendedContentType === 'function') {
                    try {
                      const v = window.recommendedContentType(b);
                      if (v && typeof v === 'object') ctLabel = v.contentType || '—';
                      else if (typeof v === 'string') ctLabel = v;
                    } catch (e) { /* fall through to inferContentType */ }
                  }
                  if (ctLabel === '—' && typeof inferContentType === 'function') {
                    ctLabel = inferContentType(b) || '—';
                  }
                  return <td key={b.id}>{ctLabel}</td>;
                })}
              </tr>
            </tbody>
          </table>
        </div>
        <div className="compare-foot dim mono">esc to close</div>
      </div>
    </div>
  );
}

// ──────── Keyboard-shortcuts help overlay (item 4.1) ────────
function ShortcutsHelp({ onClose }) {
  const rows = [
    ['j', 'next brief'],
    ['k', 'previous brief'],
    ['e', 'expand / collapse drawer for focused brief'],
    ['↵', 'fire recommended action on focused brief'],
    ['y', 'Keep on focused brief'],
    ['n', 'Skip on focused brief'],
    ['d', 'Defer on focused brief'],
    ['p', 'toggle priority-pin on focused brief'],
    ['/', 'focus the trend filter input'],
    ['1–5', 'switch lens'],
    ['⌘K / ^K', 'open command palette'],
    ['⌘A / ^A', 'select all visible briefs in lens'],
    ['⌘Z / ^Z', 'undo last decision'],
    ['shift-click tile', 'range-select for batch ops'],
    ['c', 'open side-by-side compare (with 2-4 selected)'],
    ['?', 'show this help overlay'],
    ['esc', 'close drawer / clear selection / close modal'],
  ];
  return (
    <div className="cmdk-bg" onClick={onClose}>
      <div className="shortcuts-help" onClick={e => e.stopPropagation()}>
        <div className="shortcuts-head">
          <h3>Keyboard shortcuts</h3>
          <button className="compare-close" onClick={onClose}>×</button>
        </div>
        <table className="shortcuts-table">
          <tbody>
            {rows.map(([k, label]) => (
              <tr key={k}>
                <td className="shortcuts-key"><kbd>{k}</kbd></td>
                <td className="shortcuts-label">{label}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <div className="dim mono compare-foot">esc to close</div>
      </div>
    </div>
  );
}

// ──────── Read-only single-brief view (item 45) ────────
// Public per-brief link: when URL hash is `#brief/<briefId>` the App renders
// only this view—no rail, no other lenses, no decision wiring. All the
// mutation handlers below are noops; the BriefTile is opened expanded with
// every overlay on so the share recipient sees a richly-specced page without
// needing to click anything. Esc clears the hash; the back button is the
// other escape hatch.
function ReadOnlyBriefView({ briefId, onClear }) {
  const brief = useMemo(
    () => (window.BRIEFS || []).find(b => b.id === briefId) || null,
    [briefId]
  );
  const noop = useCallback(() => {}, []);
  const goBack = useCallback(() => {
    try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch {}
    if (typeof onClear === 'function') onClear();
  }, [onClear]);
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') goBack(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [goBack]);
  return (
    <div className="app app-readonly">
      <header className="topbar topbar-readonly">
        <div className="brand">
          <span className="brand-dot"></span>
          <b>Keywords Decisioning Console</b>
          <span className="dim mono" style={{marginLeft: 10, fontSize: 10, letterSpacing: '.08em'}}>
            read-only · per-brief share
          </span>
        </div>
        <div className="topbar-right">
          <button className="cmd-trigger" onClick={goBack} title="Return to full console">
            ← back to console
          </button>
        </div>
      </header>
      <main className="content content-readonly">
        {!brief && (
          <div className="empty">
            <div className="big">brief not found</div>
            <div>id <code>{briefId}</code> isn't in the current brief inventory.</div>
          </div>
        )}
        {brief && window.BriefTile && (
          <div className="readonly-brief-wrap" data-brief-id={brief.id}>
            <window.BriefTile
              brief={brief}
              decision={null}
              expanded={true}
              onToggle={noop}
              onDecide={noop}
              onJump={noop}
              revenueOn={true}
              engagementOn={true}
              personaOn={true}
              personaId={'curious-optimizer'}
              trendMatch={null}
              readOnly={true}
            />
          </div>
        )}
      </main>
    </div>
  );
}

// Top-level ErrorBoundary. Catches synchronous render-path crashes that the
// inline try/catches at consumer call sites can't see (e.g. a child component
// throws during a useMemo or render). Logs to console + renders a minimal
// fallback. Does NOT catch async errors (event handlers, fetch callbacks);
// those are handled by their own try/catch sites.
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(_error) {
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    console.error('ErrorBoundary caught:', error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      return (
        <div style={{padding:'40px 24px', fontFamily:'var(--font-sans, sans-serif)', maxWidth:600, margin:'40px auto'}}>
          <h2 style={{marginTop:0}}>Something broke</h2>
          <p style={{color:'#666'}}>The console hit an unrecoverable render error. Open the browser console for details, then reload the page.</p>
          <button onClick={() => window.location.reload()} style={{padding:'6px 14px', cursor:'pointer'}}>Reload</button>
        </div>
      );
    }
    return this.props.children;
  }
}

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