// Scheme guard for emitted-feed URLs. Anything we render into <a href={…}>
// from upstream JSON should pass through this—javascript:, data:, vbscript:
// schemes get downgraded to '#'. Allow http/https only.
function safeHref(url) {
  if (typeof url !== 'string') return '#';
  return /^https?:\/\//i.test(url) ? url : '#';
}

// Item 39—auto-flag underperforming Kept (cold + decision >28d old).
// Single source of truth for the BriefTile + BriefDrawer derivations.
// Inputs:
//   brief   —the brief object (needs brief.id)
//   decision—the decision record from the decisions map; may be null
//   outcomes—optional override for window.BRIEF_OUTCOMES.outcomes
function computeUnderperformingKept(brief, decision, outcomes) {
  if (!decision || decision.action !== 'keep') return false;
  const o = outcomes || (typeof window !== 'undefined' && window.BRIEF_OUTCOMES?.outcomes) || null;
  const auto = o ? o[brief && brief.id] : null;
  if (!auto || auto.best_position_state !== 'cold') return false;
  const decTs = decision.updatedAt || decision.createdAt || decision.timestamp;
  if (!decTs) return false;
  const t = new Date(decTs).getTime();
  if (!Number.isFinite(t)) return false;
  return ((Date.now() - t) / 86400000) > 28;
}

// Discreet (?) help icon—small, low-contrast circle next to a heading.
// Click pops a panel with a "what is this / how is it computed?" blurb.
// Looks up content from LENS_BLURBS (lens-level) or HELP_BUBBLES (drawer
// section-level) by id. Same component for both. Click outside or Esc
// to close. Defined here in brief.jsx because it loads before lenses.jsx,
// so both files can use it.
// Compute viewport-relative coordinates for a popover anchored to `iconEl`.
// Returns { top, left|right } chosen to keep the popover on-screen—left-
// anchored by default, right-anchored when left-anchoring would overflow
// the viewport's right edge. Used by both LensHelp + TermHelp.
function _popoverAnchorFor(iconEl, popoverWidth) {
  if (!iconEl) return null;
  const rect = iconEl.getBoundingClientRect();
  const margin = 16;
  const vw = window.innerWidth || document.documentElement.clientWidth;
  // Effective width after the popover's own max-width clamp matches the
  // CSS rule (min(460px, viewport - 32)). Use the smaller for fit math.
  const effectiveWidth = Math.min(popoverWidth, Math.max(0, vw - 2 * margin));
  const anchorRight = rect.left + effectiveWidth + margin > vw;
  return {
    top: Math.round(rect.bottom + 8),
    left: anchorRight ? null : Math.round(rect.left),
    right: anchorRight ? Math.round(vw - rect.right) : null,
  };
}

function LensHelp({ lensId, id, size }) {
  const helpId = id || lensId;
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState(null);
  const iconRef = React.useRef(null);
  const popoverRef = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    setPos(_popoverAnchorFor(iconRef.current, 460));
    const onClick = (e) => {
      if (iconRef.current && iconRef.current.contains(e.target)) return;
      if (popoverRef.current && popoverRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    // Close on scroll—fixed-positioned popovers don't track the icon
    // when the underlying tile scrolls. Reopening is one click.
    const onScroll = () => setOpen(false);
    document.addEventListener('mousedown', onClick);
    document.addEventListener('keydown', onKey);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onScroll);
    return () => {
      document.removeEventListener('mousedown', onClick);
      document.removeEventListener('keydown', onKey);
      window.removeEventListener('scroll', onScroll, true);
      window.removeEventListener('resize', onScroll);
    };
  }, [open]);
  const blurb = (typeof LENS_BLURBS !== 'undefined' && LENS_BLURBS[helpId])
             || (typeof HELP_BUBBLES !== 'undefined' && HELP_BUBBLES[helpId])
             || null;
  if (!blurb) return null;
  const sizeClass = size === 'sm' ? ' lens-help-sm' : '';
  const popover = open && pos && (
    <div ref={popoverRef}
         className="lens-help-popover lens-help-popover-portal"
         style={{
           top: pos.top + 'px',
           left: pos.left != null ? pos.left + 'px' : 'auto',
           right: pos.right != null ? pos.right + 'px' : 'auto',
         }}
         onClick={e => e.stopPropagation()}>
      <div className="lens-help-head">
        <span className="lens-help-purpose">{blurb.purpose || blurb.title}</span>
        <button className="lens-help-close" onClick={() => setOpen(false)} aria-label="Close">×</button>
      </div>
      <p className="lens-help-body">{blurb.body}</p>
      {blurb.useWhen && <p className="lens-help-when">{blurb.useWhen}</p>}
      {blurb.note && <p className="lens-help-when">{blurb.note}</p>}
    </div>
  );
  return (
    <span className={'lens-help' + sizeClass}>
      <button
        ref={iconRef}
        className={'lens-help-icon ' + (open ? 'active' : '')}
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
        title={`What is "${blurb.title}"?`}
        aria-label={`Help—${blurb.title}`}
      >
        ?
      </button>
      {popover && ReactDOM.createPortal(popover, document.body)}
    </span>
  );
}

// Copy-keywords button—copies the brief's ranked keyword list (one per line,
// labels only, no headers or other fields) to the system clipboard. Stakeholder
// hands off the list to a writer or sheet without re-typing or filtering.
function CopyKeywordsBtn({ keywords }) {
  const [state, setState] = React.useState('idle');
  const onClick = async (e) => {
    e.stopPropagation();
    const text = (keywords || []).map(k => k.label).join('\n');
    if (!text) return;
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(text);
      } else {
        // Fallback for older / non-secure-context browsers
        const ta = document.createElement('textarea');
        ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'absolute'; ta.style.left = '-9999px';
        document.body.appendChild(ta); ta.select();
        document.execCommand('copy'); document.body.removeChild(ta);
      }
      setState('copied');
      setTimeout(() => setState('idle'), 1800);
    } catch (err) {
      setState('error');
      setTimeout(() => setState('idle'), 2200);
    }
  };
  const label = state === 'copied' ? '✓ copied' : state === 'error' ? '✕ failed' : 'copy keywords';
  return (
    <button
      className={'btn-copy-kws ' + state}
      onClick={onClick}
      title={`Copy all ${(keywords || []).length} keywords (ranked best-to-worst by opportunity, one per line)`}
    >
      {label}
    </button>
  );
}

// Brief tile + drawer (atomic unit, used by every lens).
// 2026-04-28—TH-pivot pass:
//   • TH collection chip surfaces alongside content type
//   • Purple (L&E bridge) tiles render with "paused" treatment + tooltip
//   • Persona-fit framed against selected persona (default Curious Optimizer)
//   • Drawer references to L&E pubs render grayed/struck-through with a paused tag
//   • Engagement signal pill renders next to traffic metrics when overlay is on
const { useState, useEffect, useRef, useMemo, useCallback } = React;

const VERDICT_TIPS = {
  'high-opportunity':    'High Opportunity—top-cohort opportunity surface (volume × KD × CPC × residual). Cluster-scale commitment recommended. Hit rate is execution-bound; ETRP found scoring uncorrelated to anti-correlated with PV outcomes historically (-0.19 to -0.31 Spearman). Where to attack, not what will win.',
  'worth-testing': 'Worth Testing—1–3 articles batch via CSA; watch ~30d, expand or kill. Mid-cohort opportunity; outcome uncertain.',
  'skip':       'Skip—do not pursue. Hard auto-Skip rule fired or composite in bottom 20% of cohort.',
  'monitor':    'Monitor—a Trend Hunter trend that is velocity-validated but below the search-demand floor (pre-demand). Neither pursue nor skip: watch for demand to materialize. Editorial may keep on TH-momentum grounds; the keyword console flags demand as unproven.',
  'insufficient-data': 'Insufficient Data—too few signals to classify. Drawer lists what we would need to commit a verdict.',
};

// 1-line definitions for the (?) bubbles next to numerics on the tile and in
// the drawer keyword table. Keep tight—operators glance, don't read.
const TERM_HELP = {
  kd:           'Keyword difficulty (0-100). How hard to rank in the top 10. Lower is easier.',
  cpc:          'Cost per click—what advertisers pay. Proxy for commercial value.',
  ourPos:       "Our portfolio's best position on this keyword (1=top, null=not ranking).",
  volume:       'Monthly search volume—sum across keywords in the brief.',
  tier:         'TH tier classification: T1 (KD 0-29 longtail), T2 (30-49 midtail), T3 (50+ headtail).',
  personaFit:   'Match strength between the brief and the selected persona via affinity-term scoring.',
  tail:         'Tail-class—KD-band classification. Longtail (0-29) ranks fastest; headtail (50+) compounds slowest.',
  intent:       'Search-intent vector—informational / commercial / transactional / navigational mix from keyword phrasing.',
  trend:        '30-day movement of the underlying signal where time-series data is available.',
  scoreDecomp:  'Score-contribution bar—proportional widths show what share of the brief\'s composite score comes from each signal: blue=volume (75% weight), green=inverse KD (10%), orange=CPC (10%), purple=residual (5%). Hover any segment for its exact contribution.',
  intentMismatch: 'The dominant search intent for these keywords doesn\'t match the recommended content type. Pairings: informational↔entry-point/deepener/evergreen/compounder, commercial↔connector/bridge, transactional↔converter/bridge. Review before committing capacity.',
  revenueRange: 'Estimated monthly ad revenue at: (articles) × (PVs per article) × eCPM ÷ 1000, with ±30% range. Defaults: articles count derived from runtime verdict (3 / 2 / 1 for high-opportunity / worth-testing / monitor or skip), 8000 PVs/article baseline, brief\'s portfolio eCPM. Edit any field to override; doesn\'t persist across sessions.',
  verdictPill:  'Two parts. (1) The tier—High Opportunity / Worth Testing / Skip, plus Monitor for a TH-pipeline trend below the search-demand floor (velocity-validated, demand unproven)—set by where the brief\'s composite score lands in the cohort percentile distribution (Monitor overrides the cohort cut). (2) The confidence suffix—high / medium / low—set by the 90% bootstrap CI span on that score (tight CI < 0.15 → high confidence; 0.15-0.35 → medium; > 0.35 → low). Tier drives the suggested action: high-opp → keep, worth-testing → defer, skip → skip, monitor → no auto-fire (watch)—EXCEPT when confidence is low, the suggestion softens to defer (collect more data before committing or closing).',
  // Footer-status explainers
  footerEtrp:   'ETRP (Empirical Threshold Recalibration Protocol) provenance for the verdict tiers. Three numbers: top % = High Opportunity cut, next % = Worth Testing cut, bottom % = Skip cut. Then composite score weights (volume / inverse-KD / CPC / residual). Then "cuts refined" if the cuts came from a calibration run, "baseline" if defaulted. Date = last calibration run.',
  footerVelocity: 'Decision velocity. Numerator = briefs decisioned (kept/deferred/skipped) in the last 7 days; denominator = total briefs. Trend arrow compares to the prior 7 days (8–14 days ago). Tracks whether the team is keeping up with weekly inflow.',
  footerSemrush: 'Team SEMrush API allocation: 250K units per month, resets on the 1st. The "spent" estimate is read from docs/data/semrush-credits.js (manually updated after notable pulls, or by calibration/scripts/00-check-credits.py). Hover shows last-updated stamp + remaining units.',
  footerSync:   'Decisions persistence state. "synced" = decisions are reaching the D1-backed Cloudflare Worker, visible across all 5 stakeholders. "offline (cached)" = worker unreachable or rejecting; decisions are saved locally but NOT shared until the worker comes back. "local-only" = worker URL/token unconfigured (no shared state at all).',
  footerKeys:   'Keyboard shortcuts. "/" focuses the trend filter input. 1–5 jumps to lens (1 Today, 2 Beach, 3 Gap, 4 Decisions, 5 Docs). ⌘K opens the command palette (jump to brief, jump to lens). Esc collapses any open expanded tile. ? opens the full shortcuts overlay.',
};

// One-shot fetcher for data-headlines outcomes. Cached at module scope so
// the JSON loads once per session even when many drawers expand. Returns a
// promise that resolves to the parsed payload or null on failure (CORS,
// offline, 404—all degrade silently per spec 2.4).
let _HEADLINES_PROMISE = null;
function loadHeadlineOutcomes() {
  if (_HEADLINES_PROMISE) return _HEADLINES_PROMISE;
  // Audit 2026-05-04: data-headlines.pierce.tools is also gated by CF Access.
  // A cross-origin fetch from any origin OTHER than data-keywords.pierce.tools
  // (which has the CF Access cookie) trips a CORS error visible in the
  // console even though the JS catch fires. Skip the fetch entirely when
  // the page isn't being served from the production origin—local dev,
  // playwright tests, and any preview deploy all return null silently.
  const onProdOrigin = typeof window !== 'undefined'
    && window.location && window.location.host === 'data-keywords.pierce.tools';
  if (!onProdOrigin) {
    _HEADLINES_PROMISE = Promise.resolve(null);
    return _HEADLINES_PROMISE;
  }
  _HEADLINES_PROMISE = fetch('https://data-headlines.pierce.tools/data/headline-outcomes.json')
    .then(r => r.ok ? r.json() : null)
    .catch(() => null);
  return _HEADLINES_PROMISE;
}

// Local fallback for playSequence(). Spec 3.2 notes this may migrate to
// util.js. TODO: when window.playSequence lands, prefer it over this local
// version. Algorithm: longtail-low-KD first (foothold), then midtail
// authority-builders, then headtail gated on prior authority.
function _localPlaySequence(briefs, playId) {
  if (!Array.isArray(briefs) || briefs.length === 0) return [];
  const subset = playId
    ? briefs.filter(b => (b.thV5?.play_num === playId || b.thV5?.play_name === playId))
    : briefs.slice();
  const tier = (b) => {
    const tail = (typeof inferTailClass === 'function') ? inferTailClass(b) : null;
    if (tail === 'longtail') return 0;
    if (tail === 'midtail') return 1;
    return 2;
  };
  return subset
    .map(b => ({ b, t: tier(b), kd: b.avgKD || 99 }))
    .sort((a, c) => a.t - c.t || a.kd - c.kd)
    .map(x => x.b.id);
}
function getPlaySequence(briefs, playId) {
  if (typeof window !== 'undefined' && typeof window.playSequence === 'function') {
    try { return window.playSequence(briefs, playId); } catch { /* fall through */ }
  }
  return _localPlaySequence(briefs, playId);
}

// Tiny inline sparkline. Renders an SVG polyline scaled to a 24x12 box.
// Returns null when no usable data points (omits the sparkline entirely).
function Sparkline({ points, color }) {
  if (!Array.isArray(points) || points.length < 2) return null;
  const vals = points.map(p => typeof p === 'number' ? p : (p?.value ?? p?.y ?? null)).filter(v => v != null && Number.isFinite(v));
  if (vals.length < 2) return null;
  const min = Math.min(...vals);
  const max = Math.max(...vals);
  const range = max - min || 1;
  const w = 24, h = 12;
  const step = w / (vals.length - 1);
  const pts = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - ((v - min) / range) * h).toFixed(1)}`).join(' ');
  return (
    <svg width={w} height={h} className="brief-spark" style={{verticalAlign:'middle', marginLeft:3, opacity:0.7}}>
      <polyline points={pts} fill="none" stroke={color || 'currentColor'} strokeWidth="1" />
    </svg>
  );
}

// Inline (?) bubble for term help. Reuses the same .lens-help / .lens-help-sm /
// .lens-help-icon / .lens-help-popover classes as LensHelp so it inherits the
// existing dark-theme styling (text-transform: none—important here since
// some parent containers apply uppercase that would garble the body text),
// the right-side-anchor rule for popovers in .t2-side drawer columns
// (prevents right-edge clipping), and the established visual language across
// the app. Click-to-toggle; closes on outside click / Esc.
function TermHelp({ termKey }) {
  const text = TERM_HELP[termKey];
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState(null);
  const iconRef = React.useRef(null);
  const popoverRef = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    setPos(_popoverAnchorFor(iconRef.current, 460));
    const onDocClick = (e) => {
      if (iconRef.current && iconRef.current.contains(e.target)) return;
      if (popoverRef.current && popoverRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    const onScroll = () => setOpen(false);
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onKey);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onScroll);
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('keydown', onKey);
      window.removeEventListener('scroll', onScroll, true);
      window.removeEventListener('resize', onScroll);
    };
  }, [open]);
  if (!text) return null;
  const popover = open && pos && (
    <div ref={popoverRef}
         className="lens-help-popover lens-help-popover-portal"
         style={{
           top: pos.top + 'px',
           left: pos.left != null ? pos.left + 'px' : 'auto',
           right: pos.right != null ? pos.right + 'px' : 'auto',
         }}
         onClick={e => e.stopPropagation()}>
      <p className="lens-help-body" style={{margin:0}}>{text}</p>
    </div>
  );
  return (
    <span className="lens-help lens-help-sm">
      <button ref={iconRef}
              type="button"
              className={'lens-help-icon ' + (open ? 'active' : '')}
              aria-label={`Help: ${termKey}`} aria-expanded={open}
              onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}>?</button>
      {popover && ReactDOM.createPortal(popover, document.body)}
    </span>
  );
}

// Diff highlight wrapper. When `prior` is set and differs from `current` by
// more than 10% relative change, renders the value in a tinted span with an
// arrow + tooltip. Otherwise renders plain children.
// TODO(styles.css): add `.value-changed` rules—subtle bg tint + arrow.
function DiffValue({ current, prior, priorDate, format, children }) {
  const cur = Number(current);
  const pr = Number(prior);
  if (!Number.isFinite(cur) || !Number.isFinite(pr) || pr === 0) return <span>{children}</span>;
  const rel = Math.abs(cur - pr) / Math.abs(pr);
  if (rel <= 0.10) return <span>{children}</span>;
  const up = cur > pr;
  const pct = (rel * 100).toFixed(0);
  const fmt = format || ((n) => String(n));
  const dateStr = priorDate ? ` on ${priorDate}` : '';
  return (
    <span className="value-changed"
          style={{background:'rgba(255,200,100,0.12)', padding:'0 3px', borderRadius:2}}
          title={`was ${fmt(pr)} (${up ? '↑' : '↓'} ${pct}% since last decision${dateStr}).`}>
      {children}<span style={{marginLeft:2, fontSize:9}}>{up ? '▲' : '▼'}</span>
    </span>
  );
}

// 4-segment score-contributions bar for the tile. Hover shows percent share.
// TODO(styles.css): promote inline style to .brief-score-decomp class.
function ScoreDecompBar({ brief }) {
  // Try the new helper first; degrade to computeVerdictBreakdown components.
  let parts = null;
  if (typeof window.scoreContributions === 'function') {
    try { parts = window.scoreContributions(brief); } catch { /* fall through */ }
  }
  if (!parts && typeof computeVerdictBreakdown === 'function') {
    try {
      const bd = computeVerdictBreakdown(brief);
      const c = bd?.components;
      if (c) {
        parts = {
          volume:   (c.volNorm   || 0) * 0.75,
          kd:       (c.invKdNorm || 0) * 0.10,
          cpc:      (c.cpcNorm   || 0) * 0.10,
          residual: (c.residualNorm || 0) * 0.05,
        };
      }
    } catch (e) { console.warn('brief.jsx: window helper threw, falling back', e); }
  }
  if (!parts) return null;
  const total = (parts.volume || 0) + (parts.kd || 0) + (parts.cpc || 0) + (parts.residual || 0);
  if (total <= 0) return null;
  const pct = (n) => ((n || 0) / total * 100);
  const segs = [
    { key: 'volume',   pct: pct(parts.volume),   color: 'rgba(120,170,220,0.75)', label: 'volume' },
    { key: 'kd',       pct: pct(parts.kd),       color: 'rgba(150,200,150,0.75)', label: 'inverse KD' },
    { key: 'cpc',      pct: pct(parts.cpc),      color: 'rgba(220,180,120,0.75)', label: 'CPC' },
    { key: 'residual', pct: pct(parts.residual), color: 'rgba(180,150,200,0.75)', label: 'residual' },
  ];
  return (
    <div style={{display:'flex', alignItems:'center', gap:4, margin:'4px 0'}}>
      <div className="brief-score-decomp"
           style={{display:'flex', height:6, borderRadius:2, overflow:'hidden', flex:1, background:'rgba(255,255,255,0.04)'}}
           aria-label="Score contributions: volume (blue), inverse KD (green), CPC (orange), residual (purple)">
        {segs.map(s => (
          <div key={s.key}
               style={{width: s.pct.toFixed(2) + '%', background: s.color, transition:'width 200ms'}}
               title={`${s.label} contributes ${s.pct.toFixed(0)}% to the score`} />
        ))}
      </div>
      <TermHelp termKey="scoreDecomp" />
    </div>
  );
}

// Why-this-matters-now badge. Reads a brief's KD trajectory + volume
// trajectory from window.COMPOUNDING_CURVES if present. Returns null when
// no time-series data—the spec says skip the badge silently.
function WhyNowBadge({ brief }) {
  const cc = (typeof window !== 'undefined' && window.COMPOUNDING_CURVES) || null;
  const hist = cc?.briefs?.[brief.id]?.history;
  if (!Array.isArray(hist) || hist.length < 2) return null;
  // Each history entry is expected to have { kd, volume } at minimum.
  const first = hist[0]; const last = hist[hist.length - 1];
  const kd0 = Number(first?.kd); const kd1 = Number(last?.kd);
  const v0 = Number(first?.volume); const v1 = Number(last?.volume);
  if (![kd0, kd1, v0, v1].every(Number.isFinite)) return null;
  const dKd = kd1 - kd0;
  const dVolPct = v0 ? (v1 - v0) / v0 : 0;
  let state = 'open';
  let label = '→ open';
  let desc = 'KD steady, volume steady';
  if (dKd <= -5 && dVolPct >= -0.05) { state = 'opening'; label = '▲ opening'; desc = `KD dropped ${Math.abs(dKd).toFixed(0)}pts, volume holding`; }
  else if (dKd >= 5)                 { state = 'closing'; label = '▽ closing'; desc = `KD up ${dKd.toFixed(0)}pts—competition rising`; }
  else if (dKd <= -5 && dVolPct <= -0.30) { state = 'closed'; label = '□ closed'; desc = 'KD down but volume crashed'; }
  return (
    <span className={'why-now-badge why-now-' + state}
          title={`${desc} (last ${hist.length} obs)`}
          style={{display:'inline-block', marginLeft:6, padding:'1px 6px', fontSize:10, fontFamily:'var(--font-mono)', borderRadius:2, background:'rgba(255,255,255,0.05)', color:'var(--t-2)'}}>
      {label}
    </span>
  );
}

// Local search-intent inference. Returns four percentages summing to 1.
// Falls back to keyword-phrase heuristics when window.intentVector is absent.
function _localIntentVector(brief) {
  const kws = (brief?.topKeywords || []).map(k => (k.label || '').toLowerCase());
  if (kws.length === 0) return { informational: 1, commercial: 0, transactional: 0, navigational: 0 };
  let inf = 0, com = 0, txn = 0, nav = 0;
  for (const k of kws) {
    if (/(buy|price|cheap|deal|coupon|discount|order|shop)/.test(k)) txn++;
    else if (/(best|review|vs|compare|top \d|brands)/.test(k))         com++;
    else if (/(login|website|official|near me|address)/.test(k))       nav++;
    else                                                                inf++;
  }
  const total = kws.length;
  return {
    informational: inf / total,
    commercial:    com / total,
    transactional: txn / total,
    navigational:  nav / total,
  };
}
function getIntentVector(brief) {
  if (typeof window !== 'undefined' && typeof window.intentVector === 'function') {
    try { return window.intentVector(brief); } catch (e) { console.warn('brief.jsx: window.intentVector threw, falling back', e); }
  }
  return _localIntentVector(brief);
}

// Recommended content type → article structure recipe.
function getArticleStructure(ct) {
  const map = {
    'entry-point':    { words: '800-1200',  shape: 'listicle or definitional' },
    'deepener':       { words: '1500-2500', shape: 'narrative + supporting evidence' },
    'compounder':     { words: '2000-3500', shape: 'authoritative pillar with H2 sub-topics' },
    'bridge':         { words: '600-900',   shape: 'service or how-to with clear takeaway' },
    'evergreen':      { words: '1200-2000', shape: 'definitive guide, refresh quarterly' },
  };
  return map[ct] || { words: '1000-1500', shape: 'standard editorial' };
}

// Returns `{ contentType, rationale }` always—both code paths normalize.
// util.js's window.recommendedContentType returns the object shape; the
// inferContentType fallback returns just a string, which we wrap. Audit
// 2026-05-04 C2: prior version returned the bare string from the fallback
// branch, which crashed the Compare + EditorialBrief modals when they
// rendered the result as a React child.
function getRecommendedContentType(brief) {
  if (typeof window !== 'undefined' && typeof window.recommendedContentType === 'function') {
    try {
      const v = window.recommendedContentType(brief);
      if (v && typeof v === 'object' && 'contentType' in v) return v;
      if (typeof v === 'string') return { contentType: v, rationale: '' };
    } catch (e) {
      console.warn('window.recommendedContentType threw, falling back', e);
    }
  }
  const ct = (typeof inferContentType === 'function') ? inferContentType(brief) : 'entry-point';
  return { contentType: ct, rationale: '' };
}

// Returns `{ missing, complete }` always—both code paths normalize.
//
// util.js's window.dataAvailability returns `{ hasOutcomes, hasGsc,
// hasEngagement, hasRevenue, hasAuthor, score }` (signal-presence flags +
// 0..5 score). The JSX consumer below renders a list of MISSING signals,
// so the wrapper translates the util.js flags into a missing-labels array.
// Audit 2026-05-04: prior wrapper passed util.js's shape through verbatim,
// crashing `da.missing.map(...)` on every drawer mount where the brief was
// classified as insufficient-data (which is the path where this surface is
// actually rendered). Same family of bug as C2.
function getDataAvailability(brief) {
  if (typeof window !== 'undefined' && typeof window.dataAvailability === 'function') {
    try {
      const v = window.dataAvailability(brief);
      if (v && typeof v === 'object' && Array.isArray(v.missing)) {
        return v;  // already in JSX shape
      }
      if (v && typeof v === 'object') {
        // Translate util.js's signal-flag shape into the JSX consumer shape.
        const missing = [];
        if (v.hasOutcomes === false)   missing.push('published-article outcomes (Layer 0)');
        if (v.hasGsc === false)        missing.push('GSC authority (Layer 6)');
        if (v.hasEngagement === false) missing.push('engagement signal (Layer 7)');
        if (v.hasRevenue === false)    missing.push('revenue attribution (Layer 10)');
        if (v.hasAuthor === false)     missing.push('author E-E-A-T (Layer 9)');
        return { missing, complete: missing.length === 0, score: v.score };
      }
    } catch (e) {
      console.warn('window.dataAvailability threw, falling back', e);
    }
  }
  const missing = [];
  if (!brief?.totalVolume)            missing.push('total volume');
  if (!Array.isArray(brief?.topKeywords) || brief.topKeywords.length < 3) missing.push('keyword breadth (≥3 keywords)');
  if (!brief?.avgKD)                  missing.push('keyword difficulty');
  if (!brief?.avgCPC)                 missing.push('CPC data');
  if (!brief?.topCompetitors?.length) missing.push('competitor SERP map');
  if (!brief?.dataSource)             missing.push('source provenance');
  return { missing, complete: missing.length === 0 };
}

// Fallback for window.briefRuntimeTierLabel—surfaces 'insufficient-data'
// when the cohort recalc lacks signal. Defaults to runtime verdict label.
function getBriefRuntimeTierLabel(brief) {
  if (typeof window !== 'undefined' && typeof window.briefRuntimeTierLabel === 'function') {
    try {
      const v = window.briefRuntimeTierLabel(brief);
      if (v) return v;
    } catch (e) { console.warn('brief.jsx: window helper threw, falling back', e); }
  }
  // Heuristic: insufficient-data when key signals are missing. A below-floor TH
  // brief wins first—it sits below the demand floor by design and carries no
  // signal as its expected state, so it must not be masked as insufficient
  // (mirrors window.briefRuntimeTierLabel's below-floor-TH precedence: monitor,
  // the high-opportunity carve-out, or skip from a kd-ceiling all show through).
  const rv = (typeof briefRuntimeVerdict === 'function') ? briefRuntimeVerdict(brief) : null;
  if (rv === 'monitor') return 'monitor';
  const isTH = typeof window !== 'undefined' && typeof window.isTHTrend === 'function' && window.isTHTrend(brief);
  // Source the floor from the canonical threshold only—no hardcoded fallback. If
  // the threshold is unavailable (the same degraded path that reaches this
  // fallback at all), skip the below-floor-TH precedence rather than guess a value
  // that could disagree with the real minVolume.
  const floor = (typeof window !== 'undefined' && window.VERDICT_THRESHOLDS_RUNTIME && window.VERDICT_THRESHOLDS_RUNTIME.minVolume) || null;
  if (isTH && floor != null && (brief && (brief.totalVolume || 0)) < floor) return rv;
  const da = getDataAvailability(brief);
  if (da.missing.length >= 3) return 'insufficient-data';
  return rv;
}

// Verdict ladder—three-segment SVG with a marker triangle at the brief's
// position. Returns `{ segments: [{ name, width }], position, verdict?, score? }`.
//
// Audit 2026-05-04: util.js's `window.verdictLadder` returns segments with
// `{ label, lo, hi }` while the JSX consumer below uses `seg.name` +
// `seg.width`. The wrapper now normalizes BOTH shapes to the JSX shape so
// `seg.name.startsWith(...)` never crashes the drawer render. Same family
// of bug as C2 (recommendedContentType return-shape mismatch).
function getVerdictLadder(brief) {
  if (typeof window !== 'undefined' && typeof window.verdictLadder === 'function') {
    try {
      const v = window.verdictLadder(brief);
      if (v && Array.isArray(v.segments)) {
        // Normalize util.js's {label, lo, hi} → {name, width}.
        const segments = v.segments.map(s => ({
          name: s.name || s.label || '?',
          width: typeof s.width === 'number' ? s.width
               : (typeof s.hi === 'number' && typeof s.lo === 'number') ? Math.max(0, s.hi - s.lo)
               : 0,
          label: s.label || s.name || '?',
        }));
        return { ...v, segments };
      }
    } catch (e) {
      console.warn('window.verdictLadder threw, falling back', e);
    }
  }
  if (typeof computeVerdictBreakdown !== 'function') return null;
  let bd;
  try { bd = computeVerdictBreakdown(brief); } catch { return null; }
  if (!bd?.cohort) return null;
  const pos = bd.cohort.percentile || 0; // 0..1 along the ladder
  const verdictLabel = bd.computedVerdict || 'skip';
  // Segments: skip 0..0.6, worth 0.6..0.8, high-opp 0.8..1.0 (approximation).
  // The ladder is cohort-percentile GEOGRAPHY only—it has no 'monitor' band
  // because monitor is an advisory-floor OVERRIDE, not a cohort bucket. The
  // marker is placed by `position` (percentile); `verdict` is carried for
  // reference but the render site (BriefDrawer) uses only segments/position/
  // score, never ladder.verdict—so a 'monitor' value here is inert by design.
  return {
    segments: [
      { name: 'Skip',             width: 0.6, label: 'Skip' },
      { name: 'Worth Testing',    width: 0.2, label: 'Worth Testing' },
      { name: 'High Opportunity', width: 0.2, label: 'High Opportunity' },
    ],
    position: Math.max(0, Math.min(1, pos)),
    verdict: verdictLabel,
    score: bd.score,
  };
}

// Local fallback wrappers for new util.js helpers being added in parallel.
// Each returns null/empty when the global is undefined so the tile + drawer
// degrade gracefully if the helper hasn't loaded yet. Same pattern as
// getRecommendedContentType / getDataAvailability above.
function getCohortStability(brief) {
  if (typeof window !== 'undefined' && typeof window.cohortStability === 'function') {
    try { return window.cohortStability(brief); } catch (e) { console.warn('brief.jsx: window.cohortStability threw, falling back', e); }
  }
  return null;
}
function getClusterAuthorityCascade(brief, decisions) {
  if (typeof window !== 'undefined' && typeof window.clusterAuthorityCascade === 'function') {
    try { return window.clusterAuthorityCascade(brief, decisions); } catch (e) { console.warn('brief.jsx: window.clusterAuthorityCascade threw, falling back', e); }
  }
  return null;
}
function getAnomalyDetect(brief) {
  if (typeof window !== 'undefined' && typeof window.anomalyDetect === 'function') {
    try { return window.anomalyDetect(brief); } catch (e) { console.warn('brief.jsx: window.anomalyDetect threw, falling back', e); }
  }
  return null;
}
function getVerdictStabilityScore(briefId) {
  if (typeof window !== 'undefined' && typeof window.verdictStabilityScore === 'function') {
    try { return window.verdictStabilityScore(briefId); } catch (e) { console.warn('brief.jsx: window.verdictStabilityScore threw, falling back', e); }
  }
  return null;
}
function getRecencyWeightedScore(brief, kind) {
  if (typeof window !== 'undefined' && typeof window.recencyWeightedScore === 'function') {
    try { return window.recencyWeightedScore(brief, kind); } catch (e) { console.warn('brief.jsx: window.recencyWeightedScore threw, falling back', e); }
  }
  return null;
}

// Confidence-tightness label for a verdict (2026-05-04—symmetric labels).
// Used to decorate the tile pill (item 4). Three buckets, all explicit:
//   tight CI (≤0.15)       → "high confidence"
//   mid CI (0.15 to 0.35)  → "medium confidence"
//   wide CI (>0.35)        → "low confidence"
// Returns null only when bootstrap CI data isn't available (tile renders
// without the suffix in that case).
function ciTightnessLabel(brief) {
  if (typeof window === 'undefined' || typeof window.briefVerdictCI !== 'function') return null;
  let ci = null;
  try { ci = window.briefVerdictCI(brief, 200); } catch (e) { return null; }
  if (!ci || ci.lo == null || ci.hi == null) return null;
  const span = ci.hi - ci.lo;
  // Short codes match the tile's mono pill aesthetic. Tooltip on the
  // verdict-pill prefixes them with "90% CI confidence: " so the
  // shorthand reads correctly on hover.
  if (span > 0.35) return 'wide';
  if (span > 0.15) return 'med';
  return 'tight';
}

// Returns true when CI data exists AND the CI is wide (low confidence).
// Used by recommendedActionFor to soften the recommendation when the
// verdict isn't statistically nailed down.
function ciIsLowConfidence(brief) {
  if (typeof window === 'undefined' || typeof window.briefVerdictCI !== 'function') return false;
  let ci = null;
  try { ci = window.briefVerdictCI(brief, 200); } catch { return false; }
  if (!ci || ci.lo == null || ci.hi == null) return false;
  return (ci.hi - ci.lo) > 0.35;
}

// Per-objective verdict for a brief—computes verdict at each cohort target
// and returns the set of distinct labels seen. Used by the drift badge (item 9).
// Returns util.js verdict vocab ('high-opportunity'/'worth-testing'/'monitor'/'skip');
// the badge UI translates to display labels at render time.
function perObjectiveVerdicts(brief) {
  if (typeof window === 'undefined' || typeof window.cohortPercentilesFor !== 'function'
      || typeof VERDICT_THRESHOLDS_RUNTIME === 'undefined') return null;
  const targets = ['balanced', 'revenue', 'engagement', 'reach'];
  // Resolve each objective's verdict through the shared engine helper so a
  // below-floor TH trend reports 'monitor' here too (consistent with the pill),
  // and so this path can't drift from computeVerdictBreakdown.
  const baseSkipChecks = (typeof window.hardSkipChecks === 'function' && typeof window.authorityState === 'function')
    ? window.hardSkipChecks(brief, window.authorityState(brief)) : [];
  const verdictFromPct = (pct) => {
    if (pct == null) return null;
    if (typeof window.resolveCohortVerdict === 'function') {
      return window.resolveCohortVerdict(pct, baseSkipChecks, brief).verdict;
    }
    if (pct >= 1 - VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct) return 'high-opportunity';
    if (pct >= 1 - VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct - VERDICT_THRESHOLDS_RUNTIME.worthTestingPct) return 'worth-testing';
    return 'skip';
  };
  const out = {};
  for (const t of targets) {
    try {
      const cp = window.cohortPercentilesFor('all-active', t);
      const pct = cp?.lookup?.[brief.id];
      const v = verdictFromPct(pct);
      if (v) out[t] = v;
    } catch (e) { /* skip target */ }
  }
  return out;
}

// Display-label translation from util.js verdict vocab → UI text.
function _verdictDisplayLabel(v) {
  if (v === 'high-opportunity') return 'Keep';
  if (v === 'worth-testing') return 'Test';
  if (v === 'monitor') return 'Monitor';
  if (v === 'skip') return 'Skip';
  return v || '—';
}

// Parse "Apr 2026", "2026-04", "April 2026", or ISO from a dataSource string.
// Returns a Date or null. Used by the staleness badge (item 32).
function parseDataSourceDate(s) {
  if (!s || typeof s !== 'string') return null;
  // ISO YYYY-MM or YYYY-MM-DD anywhere in the string.
  const iso = s.match(/(\d{4})-(\d{1,2})(?:-(\d{1,2}))?/);
  if (iso) {
    const y = +iso[1], m = +iso[2] - 1, d = iso[3] ? +iso[3] : 1;
    const dt = new Date(y, m, d);
    if (!isNaN(dt)) return dt;
  }
  // "Apr 2026", "April 2026"
  const months = { jan:0,feb:1,mar:2,apr:3,may:4,jun:5,jul:6,aug:7,sep:8,oct:9,nov:10,dec:11,
                   january:0,february:1,march:2,april:3,june:5,july:6,august:7,september:8,october:9,november:10,december:11 };
  const m = s.match(/([A-Za-z]+)\s+(\d{4})/);
  if (m) {
    const mi = months[m[1].toLowerCase()];
    if (mi != null) {
      const dt = new Date(+m[2], mi, 1);
      if (!isNaN(dt)) return dt;
    }
  }
  return null;
}

// (Removed 2026-05-04: intentContentTypeMatch—formerly used by the
// intent-mismatch warning badge. Replaced by an in-recommender override in
// util.js's recommendedContentType: when dominant intent doesn't match the
// cascade-chosen content type, the recommender now swaps to a compatible
// type and notes the swap in the rationale, so the badge never had to fire.)

// Per-keyword intent-compat sort score. Lower = better fit. Used by item 24.
function keywordIntentSortKey(label, briefCt) {
  const l = (label || '').toLowerCase();
  let intent = 'inf';
  if (/(buy|price|cheap|deal|coupon|discount|order|shop)/.test(l)) intent = 'txn';
  else if (/(best|review|vs|compare|top \d|brands)/.test(l))         intent = 'com';
  else if (/(login|website|official|near me|address)/.test(l))       intent = 'nav';
  // Compatible-first ordering relative to brief content type.
  const compat = (
    (intent === 'inf' && (briefCt === 'entry-point' || briefCt === 'deepener' || briefCt === 'evergreen' || briefCt === 'compounder')) ||
    (intent === 'com' && (briefCt === 'connector' || briefCt === 'bridge')) ||
    (intent === 'txn' && (briefCt === 'converter' || briefCt === 'bridge'))
  );
  return compat ? 0 : 1;
}

function VerdictGlyph({ verdict }) {
  // Native title doesn't fire on touch devices—pair with role + aria-label
  // so screen readers and tap-targeted UAs surface the same explainer text.
  const titleText = VERDICT_TIPS[verdict] || verdict;
  return (
    <div className="verdict-glyph" title={titleText} role="img" aria-label={titleText}>
      <span className="vd"></span>
    </div>
  );
}

function BriefTile({ brief, decision, expanded, onToggle, onDecide, onJump, trendMatch, revenueOn, engagementOn, personaOn, personaId, playSequenceIndex, playSequenceTotal }) {
  const ct = inferContentType(brief);
  const ctMeta = CT_META[ct];
  const isPaused = !!ctMeta.paused;
  const tail = inferTailClass(brief);
  const dec = decision?.action;
  const coll = inferTHCollection(brief);
  const collMeta = TH_COLLECTIONS[coll];
  const engDetail = engagementSignalDetail(brief);
  const eng = engDetail.score;
  const fit = personaFit(brief, personaId);
  const lifecycle = briefLifecycleState(brief, decision);
  const ageDays = briefAgeDays(brief);
  // TH Framework v1—tier classification with DA bonus from GSC authority
  const tierShift = classifyTierWithDaBonus(brief);
  const tierInfo = tierShift.best;  // best tier across pubs (DA-bonused)
  const webRole = contentWebRoleFor(ct);
  // Runtime verdict (ETRP-validated cohort recalc) drives tile visual; hardcoded
  // brief.verdict no longer stored—drift badge retired 2026-05-04 since the
  // pull-time call has no canonical record.
  const runtimeVerdict = briefRuntimeVerdict(brief);
  const tierLabel = getBriefRuntimeTierLabel(brief);
  const isInsufficient = tierLabel === 'insufficient-data';

  // Time-series for sparklines (degrade silently when absent—spec 4.7).
  const compHist = (typeof window !== 'undefined' && window.COMPOUNDING_CURVES?.briefs?.[brief.id]?.history) || null;
  const gscHist  = (typeof window !== 'undefined' && window.GSC_AUTHORITY?.briefs?.[brief.id]?.history) || null;
  const sparkVol = Array.isArray(compHist) ? compHist.map(h => h?.volume).filter(v => Number.isFinite(v)) : null;
  const sparkKd  = Array.isArray(compHist) ? compHist.map(h => h?.kd).filter(v => Number.isFinite(v))     : null;
  const sparkCpc = Array.isArray(compHist) ? compHist.map(h => h?.cpc).filter(v => Number.isFinite(v))    : null;
  const sparkPos = Array.isArray(gscHist)  ? gscHist.map(h => h?.position).filter(v => Number.isFinite(v)) : null;

  // Diff-highlighting against the operator's prior decisionSnapshot (spec 4.13).
  const priorSnap = decision?.decisionSnapshot || null;
  const priorDate = decision?.decisionSnapshotDate || decision?.updatedAt || null;

  // Kept-action concrete next step (spec 3.1)—compute when dec === 'keep'.
  const keptActionText = (() => {
    if (dec !== 'keep') return null;
    // Article count by tile-cohort opportunity (rough proxy: composite via runtime verdict).
    // monitor intentionally shares skip's 1-article estimate (else-branch): a monitor
    // brief is pre-demand, so a single probe article is the right initial commitment.
    // Documented in the TERM_HELP "3 / 2 / 1 for high-opportunity / worth-testing / monitor or skip".
    const articleCount = runtimeVerdict === 'high-opportunity' ? 3 : runtimeVerdict === 'worth-testing' ? 2 : 1;
    // Targets: top opportunity keywords NOT already in our top-25.
    const ranked = (typeof rankKeywordsByOpportunity === 'function')
      ? rankKeywordsByOpportunity(brief.topKeywords || [])
      : (brief.topKeywords || []).slice();
    const targets = ranked.filter(k => !(k.ourPos && k.ourPos <= 25)).slice(0, 2).map(k => k.label);
    // Window: high-opp = 12w, worth-testing = 4w, baseline = 8w. monitor takes the
    // 8-week baseline watch window (else-branch), consistent with its 1-article
    // estimate above—a pre-demand probe checked at the standard cadence.
    const windowWeeks = runtimeVerdict === 'high-opportunity' ? 12 : runtimeVerdict === 'worth-testing' ? 4 : 8;
    return { articleCount, targets, windowWeeks };
  })();

  // Item 4—CI-driven verdict-pill suffix.
  const ciSuffix = ciTightnessLabel(brief);
  const verdictPillLabel = (() => {
    const base = runtimeVerdict === 'high-opportunity' ? 'High Opportunity'
               : runtimeVerdict === 'worth-testing' ? 'Worth Testing'
               : runtimeVerdict === 'monitor' ? 'Monitor'
               : runtimeVerdict === 'skip' ? 'Skip' : null;
    if (!base) return null;
    return ciSuffix ? `${base} · ${ciSuffix}` : base;
  })();

  // Item 9—per-objective verdict drift across {balanced, revenue, engagement, reach}.
  const objVerdicts = perObjectiveVerdicts(brief);
  const objVerdictDrift = (() => {
    if (!objVerdicts) return null;
    const entries = Object.entries(objVerdicts);
    const distinct = new Set(entries.map(e => e[1]));
    if (distinct.size < 2) return null;
    return entries;
  })();

  // Item 23—intent-vector vs recommended-content-type mismatch.
  const intentMismatch = (() => {
    try {
      const iv = getIntentVector(brief);
      const ctRec = getRecommendedContentType(brief)?.contentType;
      return !intentContentTypeMatch(iv, ctRec);
    } catch { return false; }
  })();

  // Item 32—data-staleness badge from brief.dataSource.
  const isDataStale = (() => {
    const dt = parseDataSourceDate(brief?.dataSource);
    if (!dt) return false;
    const daysOld = (Date.now() - dt.getTime()) / 86400000;
    return daysOld > 90;
  })();

  // Item 39—auto-flag underperforming Kept (cold + decision >28d old).
  // Shared with the drawer below—see computeUnderperformingKept().
  const underperformingKept = computeUnderperformingKept(brief, decision, null);

  const cls = [
    'brief',
    'v-' + (isInsufficient ? 'insufficient-data' : runtimeVerdict),
    'ct-' + ct,
    'coll-' + coll,
    'life-' + lifecycle,
    isPaused ? 'paused' : '',
    expanded ? 'expanded' : '',
    dec === 'keep' ? 'd-keep' : '',
    dec === 'skip' ? 'd-skip-decided' : '',
    dec === 'defer' ? 'd-defer' : '',
    trendMatch === 'hit' ? 'match-hit' : '',
    trendMatch === 'dim' ? 'match-dim' : '',
  ].filter(Boolean).join(' ');

  // Keyboard-operable tile: Enter/Space on the focused header fires the same
  // expand/collapse as a click. tabIndex=0 makes it focusable.
  const onKeyToggle = (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onToggle();
    }
  };
  return (
    <div className={cls} data-brief-id={brief.id} title={isPaused ? 'L&E bridge content—paused until the L&E data pipe is repaired' : undefined}>
      <div className="brief-rail"></div>
      <div className="brief-body">
        <div className="t1" onClick={onToggle} tabIndex={0} onKeyDown={onKeyToggle} role="button" aria-expanded={!!expanded}>
          <VerdictGlyph verdict={isInsufficient ? 'insufficient-data' : runtimeVerdict} />
          <div className="t1-topic">
            <div className="topic">
              {brief.topic}
              {lifecycle === 'fresh' && <span className="life-pill life-pill-fresh" title={`Added ${ageDays}d ago—new in this intake batch`}>new</span>}
              {lifecycle === 'serp-reverify' && <span className="life-pill life-pill-reverify" title={`Added ${ageDays}d ago—SERP landscape may have shifted; spot-check rankings before committing further capacity (90-day SERP re-verification window)`}>SERP re-verify · {ageDays}d</span>}
              {lifecycle === 'refresh-due' && <span className="life-pill life-pill-refresh" title={`Added ${ageDays}d ago—schedule a wholesale content refresh (~6-month cycle)`}>refresh due · {ageDays}d</span>}
              {isPaused && <span className="paused-inline" title="Paused—L&E data pipe broken; awaiting repair">paused</span>}
              <WhyNowBadge brief={brief} />
              {Number.isFinite(playSequenceIndex) && Number.isFinite(playSequenceTotal) && (
                <span className="tile-pill play-seq-pill" title={`Sequencing position within this Play—foothold longtails first, midtails next, headtails last`}>
                  #{playSequenceIndex + 1} of {playSequenceTotal} in this Play
                </span>
              )}
              {/* Item 23—intent vs recommended-content-type mismatch. */}
              {intentMismatch && (
                <span className="tile-pill tile-pill-warn intent-mismatch-badge"
                      title="Search intent vector doesn't align with the recommended content type—review before committing capacity.">
                  ⚠ intent mismatch
                </span>
              )}
              {/* Item 32—data-staleness badge from brief.dataSource >90d old. */}
              {isDataStale && (
                <span className="tile-pill tile-pill-stale stale-data-badge"
                      title={`Data source date is more than 90 days old (${brief.dataSource}). Refresh before scaling.`}>
                  ⏵ stale data
                </span>
              )}
              {/* Item 39—Auto-flag underperforming Kept (cold + decision >28d old). */}
              {underperformingKept && (
                <span className="tile-pill tile-pill-danger underperforming-badge"
                      title="Predicted Keep, observed cold—re-decide?">
                  ▽ underperforming
                </span>
              )}
            </div>
            {/* Item 22—sequencing rationale on Beach lens tiles only. */}
            {Number.isFinite(playSequenceIndex) && Number.isFinite(playSequenceTotal) && (() => {
              const seqRole = (() => {
                const slice = playSequenceTotal;
                if (playSequenceIndex === 0) return 'foothold';
                if (playSequenceIndex >= slice - 1) return 'authority';
                return 'midtail';
              })();
              const tailLbl = tail === 'longtail' ? 'longtail'
                            : tail === 'midtail' ? 'midtail'
                            : tail === 'headtail' ? 'headtail' : tail;
              const text = seqRole === 'foothold'
                ? `Foothold first—KD ${brief.avgKD ?? '—'}, ${tailLbl} establishes ranking territory before authority builds`
                : seqRole === 'authority'
                ? `Authority closer—KD ${brief.avgKD ?? '—'}, ${tailLbl} compounds on prior footholds in this Play`
                : `Building authority—KD ${brief.avgKD ?? '—'}, ${tailLbl} extends the foothold opened upstream`;
              return (
                <div className="play-seq-rationale dim mono"
                     style={{fontSize:10, marginTop:2, color:'var(--t-3)', lineHeight:1.3}}>
                  {text}
                </div>
              );
            })()}
            <ScoreDecompBar brief={brief} />
            <div className="meta">
              <span className="meta-chip">
                <span className={'tier-pill tier-' + tierInfo.tier}
                      title={tierShift.daShift
                        ? `Base tier T${tierShift.base.tier} → shifts to T${tierInfo.tier} on ${tierShift.bestPub} (DA bonus +${tierInfo.daBonus} KD points from GSC observed rank)`
                        : `${tierInfo.label}—${tierInfo.description} (KD ${tierInfo.kdMin}-${tierInfo.kdMax}, vol min ${tierInfo.volMin.toLocaleString()})`}>
                  T{tierInfo.tier}{!tierInfo.volumeQualifies ? '*' : ''}{tierShift.daShift ? '↓' : ''}
                </span>
                <TermHelp termKey="tier" />
              </span>
              <span className="meta-chip ct">{ctMeta.name}</span>
              <span className="meta-chip">
                <span className="coll-tag" title={collMeta.desc}><span className="coll-glyph">{collMeta.glyph}</span> {collMeta.name.toLowerCase()}</span>
              </span>
              <span className="meta-chip">
                {tailLabel(tail)}
                <TermHelp termKey="tail" />
              </span>
              <span className="meta-chip">
                KD <DiffValue current={brief.avgKD} prior={priorSnap?.avgKD} priorDate={priorDate} format={n=>'KD '+n}>{brief.avgKD}</DiffValue>
                <Sparkline points={sparkKd} />
                <TermHelp termKey="kd" />
              </span>
              <span className="meta-chip">
                <DiffValue current={brief.totalVolume} prior={priorSnap?.totalVolume} priorDate={priorDate} format={n=>n.toLocaleString()}>{fmtVol(brief.totalVolume)}</DiffValue> vol
                <Sparkline points={sparkVol} />
                <TermHelp termKey="volume" />
              </span>
              <span className="meta-chip">
                <DiffValue current={brief.avgCPC} prior={priorSnap?.avgCPC} priorDate={priorDate} format={n=>'$'+Number(n).toFixed(2)}>{fmtCpc(brief.avgCPC)}</DiffValue> cpc
                <Sparkline points={sparkCpc} />
                <TermHelp termKey="cpc" />
              </span>
              {engagementOn && (
                <span className="meta-chip">
                  <span className={'eng-pill eng-' + engDetail.source}
                        title={`${engDetail.framework}. ${engDetail.source === 'live' ? 'Live data from TRACKER_ENRICHED.' : 'Proxy fallback—live data lights up once an article publishes against this brief\'s keyword space.'}`}>
                    eng {eng}{engDetail.tentative ? '*' : ''}
                  </span>
                  <LensHelp id="engagement-pill-tile" size="sm" />
                </span>
              )}
              {/* R28 Tier C (2026-05-17)—pair the upstream volume / cpc
                  traffic estimate with the downstream dwell expectation, so
                  the row carries both "how many" and "how long" signals
                  side-by-side. Hides when no live data is matched yet for
                  the brief's keyword cluster (no defensible proxy). */}
              {engagementOn && (() => {
                const dw = typeof dwellExpectationFor === 'function' ? dwellExpectationFor(brief) : null;
                if (!dw) return null;
                // 90s threshold (not 60s) so the "minutes" rendering only
                // kicks in when the value is meaningfully into minute-scale.
                // "1.0m typical" felt more precise than "60s typical"; "1.5m"
                // is the first minutes display under the new threshold.
                const display = dw.seconds < 90
                  ? `${Math.round(dw.seconds)}s`
                  : `${(dw.seconds / 60).toFixed(1)}m`;
                return (
                  <span className="meta-chip">
                    <span className="eng-pill eng-live dwell-chip"
                          title={`Expected median time on page: ${Math.round(dw.seconds)}s (median across ${dw.n} matching tracker article${dw.n === 1 ? '' : 's'}). Pairs with the upstream volume/cpc estimate—high search volume + low typical dwell is a clickbait-risk signal.`}>
                      {display} typical
                    </span>
                    <LensHelp id="dwell-chip" size="sm" />
                  </span>
                );
              })()}
              {personaOn && fit !== 'n/a' && (
                <span className="meta-chip">
                  <span className={'fit-pill fit-' + fit} title={`Fit vs ${PERSONAS[personaId]?.name || 'persona'}`}>{fit} fit</span>
                </span>
              )}
              {/* Item 9—per-objective verdict drift badge. Hidden when all 4
                  agree. Compact display: 2 diverge → "X↔Y drift"; 3+ diverge
                  → "N-way drift". Tooltip carries full per-objective mapping
                  so the badge stays narrow enough to fit Beach rail-tiles. */}
              {objVerdictDrift && (() => {
                const labels = objVerdictDrift.map(([t, v]) => `${t}: ${_verdictDisplayLabel(v)}`);
                const tipText = `Verdict differs across cohort objectives—${labels.join(' · ')}. Open drawer to weigh which objective applies.`;
                const compact = objVerdictDrift.length === 2
                  ? `${objVerdictDrift[0][0]}↔${objVerdictDrift[1][0]} drift`
                  : `${objVerdictDrift.length}-way drift`;
                return (
                  <span className="meta-chip">
                    <span className="tile-pill tile-pill-soft obj-drift-badge"
                          title={tipText}>
                      ▲ {compact}
                    </span>
                  </span>
                );
              })()}
              {revenueOn && (() => {
                // Revenue lens—revenueRange() uses the static ECPM table, but
                // ecpmFor() inside it is now live-BURT-backed via util.js shim.
                // L&E pubs that were "paused" for engagement still get revenue
                // signal (BURT covers them independently of the broken Amplitude
                // L&E pipe). Surface live → revenue range works on all briefs;
                // append a recovered-from-pause marker so operators know that's
                // the source of revenue numbers on otherwise-paused briefs.
                const range = revenueRange(brief);
                if (!range) return isPaused
                  ? <span className="meta-chip dim rev-paused">rev paused</span>
                  : null;
                const fmt = (n) => n >= 1000 ? '$' + (n/1000).toFixed(n>=10000?0:1) + 'K' : '$' + Math.round(n);
                // Volatility staleness gate—surfaces when target outlet's 30d
                // eCPM diverges meaningfully from 90d (>0.25). Pulls from the
                // live precompute via ecpmVolatilityFor() helper in util.js.
                const vol = (typeof ecpmVolatilityFor === 'function')
                  ? ecpmVolatilityFor(range.bestPubKey || range.bestPub) : null;
                const unstable = vol != null && vol > 0.25;
                return (
                  <span className="meta-chip rev-range" title={`Best: ${range.bestPub} ≈ $${Math.round(range.bestRevenue).toLocaleString()}/mo. Range across ${range.count} portfolio pubs. Open drawer for full breakdown.${isPaused ? ' (Brief is paused for engagement signals; revenue continues via live BURT data.)' : ''}`}>
                    {fmt(range.min)}–{fmt(range.max)}/mo · {range.count} pubs{isPaused ? ' (rev live)' : ''}
                    {unstable && (
                      <span className="rev-unstable"
                            title={`30d eCPM on ${range.bestPub} diverges from 90d by ${(vol * 100).toFixed(0)}%—recent revenue may not reflect long-run baseline. Lean on PV/authority signals over revenue when committing capacity.`}>
                        ⚠
                      </span>
                    )}
                  </span>
                );
              })()}
            </div>
          </div>
          <div className="t1-end">
            {/* Item 4—verdict pill on the tile, with CI-tightness suffix.
                Renders only when there's no decision badge competing for the
                same slot and the verdict isn't insufficient-data. */}
            {!dec && !isInsufficient && verdictPillLabel && (
              <>
                <span className={'verdict-pill v-' + runtimeVerdict}
                      title={ciSuffix
                        ? `${VERDICT_TIPS[runtimeVerdict] || ''} · 90% CI confidence: ${ciSuffix}`
                        : (VERDICT_TIPS[runtimeVerdict] || '')}>
                  {verdictPillLabel}
                </span>
                <TermHelp termKey="verdictPill" />
              </>
            )}
            {isInsufficient && !dec && (
              <span className="decision-badge insufficient-data"
                    title="Insufficient Data—too few signals to classify. Open drawer to see what we'd need to know.">
                Insufficient Data
              </span>
            )}
            {dec && (
              <span className={'decision-badge ' + (dec==='keep'?'keep':dec==='skip'?'skip':'defer')}
                    title={dec === 'keep' && keptActionText && keptActionText.targets.length > 0
                      ? `Plan: ${keptActionText.articleCount} article${keptActionText.articleCount===1?'':'s'} · target ${keptActionText.targets.join(' + ')} · ${keptActionText.windowWeeks}w window. Open the drawer for the full breakdown.`
                      : undefined}>
                {dec}
              </span>
            )}
            {!dec && !isInsufficient && (
              <span className="dim mono open-hint">{expanded?'▾':'▸'}</span>
            )}
          </div>
        </div>

        {expanded && <BriefDrawer brief={brief} decision={decision} onDecide={onDecide} onJump={onJump} revenueOn={revenueOn} engagementOn={engagementOn} personaId={personaId} />}
      </div>
    </div>
  );
}

function VerdictBreakdown({ brief }) {
  const bd = computeVerdictBreakdown(brief);
  const verdictLabel = (v) => v === 'high-opportunity' ? 'High Opportunity' : v === 'worth-testing' ? 'Worth Testing' : v === 'monitor' ? 'Monitor' : 'Skip';
  const pctOf = (val, weight) => {
    // Weighted contribution toward composite (max possible from this weight).
    const contribution = val * weight;
    return { val, weight, contribution };
  };
  const v = pctOf(bd.components.volNorm,      0.75);
  const k = pctOf(bd.components.invKdNorm,    0.10);
  const c = pctOf(bd.components.cpcNorm,      0.10);
  const r = pctOf(bd.components.residualNorm, 0.05);
  const fmtPct = (n) => (n * 100).toFixed(0) + '%';
  const fmtScore = (n) => n.toFixed(2);

  return (
    <details className="verdict-breakdown">
      <summary>
        <span className="vb-summary-label">Verdict breakdown <span className="vb-summary-hint">(click to expand)</span></span>
        {bd.softened && <>
          <span className="vb-soft-badge">soft confidence</span>
          <LensHelp id="soft-confidence" size="sm" />
        </>}
        {bd.needsFootholdNote && <>
          <span className="vb-soft-badge">no SERP foothold yet</span>
          <LensHelp id="no-serp-foothold" size="sm" />
        </>}
      </summary>

      <div className="vb-grid">
        <div className="vb-section">
          <h4>Score components <LensHelp id="score-components" size="sm" /> <span className="vb-h-meta">75 / 10 / 10 / 5—ETRP-validated</span></h4>
          <table className="vb-tbl">
            <thead>
              <tr><th>signal</th><th>norm</th><th>weight</th><th>contrib</th></tr>
            </thead>
            <tbody>
              <tr>
                <td>Volume <span className="vb-detail">{(brief.totalVolume || 0).toLocaleString()} mo</span></td>
                <td>{fmtPct(v.val)}</td>
                <td>×0.75</td>
                <td className="vb-contrib">{fmtScore(v.contribution)}</td>
              </tr>
              <tr>
                <td>Inv-KD <span className="vb-detail">KD {brief.avgKD || '—'}</span></td>
                <td>{fmtPct(k.val)}</td>
                <td>×0.10</td>
                <td className="vb-contrib">{fmtScore(k.contribution)}</td>
              </tr>
              <tr>
                <td>CPC <span className="vb-detail">{brief.avgCPC ? '$' + brief.avgCPC.toFixed(2) : 'no data'} · {brief.verticalId}-pctile</span></td>
                <td>{fmtPct(c.val)}</td>
                <td>×0.10</td>
                <td className="vb-contrib">{fmtScore(c.contribution)}</td>
              </tr>
              <tr>
                <td>Residual <span className="vb-detail">conf={brief.confidence || '—'}</span></td>
                <td>{fmtPct(r.val)}</td>
                <td>×0.05</td>
                <td className="vb-contrib">{fmtScore(r.contribution)}</td>
              </tr>
              <tr className="vb-total-row">
                <td><b>Composite</b></td>
                <td colSpan="2"></td>
                <td className="vb-contrib"><b>{fmtScore(bd.score)}</b></td>
              </tr>
            </tbody>
          </table>
          <div className="vb-cuts">
            <b>Rank {bd.cohort.rank} / {bd.cohort.total}</b> in cohort
            ({(bd.cohort.percentile * 100).toFixed(0)}th percentile).
            Cuts: top <b>{(VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct * 100).toFixed(0)}%</b> High Opportunity ·
            mid <b>{(VERDICT_THRESHOLDS_RUNTIME.worthTestingPct * 100).toFixed(0)}%</b> Worth Testing ·
            bottom <b>{((1 - VERDICT_THRESHOLDS_RUNTIME.highOpportunityPct - VERDICT_THRESHOLDS_RUNTIME.worthTestingPct) * 100).toFixed(0)}%</b> Skip.
          </div>
        </div>

        <div className="vb-section">
          <h4>Hard auto-Skip checks <LensHelp id="hard-auto-skip" size="sm" /> <span className="vb-h-meta">any non-advisory fail = forced Skip</span></h4>
          <ul className="vb-checks">
            {bd.skipChecks.map(c => (
              <li key={c.name} className={c.passed ? 'pass' : c.advisory ? 'advisory' : 'fail'}>
                <span className="vb-check-icon">{c.passed ? '✓' : c.advisory ? '⚠' : '✗'}</span>
                <span className="vb-check-label">{c.label}</span>
                <span className="vb-check-detail">{c.detail}</span>
                {c.advisory && <span className="vb-gap-pill" title="Advisory for TH-pipeline trends—surfaces the gap but does not force a skip (routes to Monitor).">advisory</span>}
                {c.gap && <span className="vb-gap-pill" title="No automated signal yet—operator override path is the only signal.">gap</span>}
              </li>
            ))}
          </ul>
        </div>

        <div className="vb-section">
          <h4>Authority context <LensHelp id="authority-context" size="sm" /> <span className="vb-h-meta">from topKeywords[].ourPos</span></h4>
          <div className="vb-auth">
            <span className={'vb-auth-pill auth-' + bd.auth.state}>
              {bd.auth.state === 'in-zone' ? `#${bd.auth.minPos} in zone`
                : bd.auth.state === 'foothold' ? `#${bd.auth.minPos} foothold`
                : 'cold start'}
            </span>
            <span className="vb-auth-detail">
              {bd.auth.minPos != null
                ? `${bd.auth.top25Count} top-25 · ${bd.auth.top50Count} top-50 · ${bd.auth.tangentialPage1or2 ? 'has' : 'no'} tangential page-1/2`
                : 'no top-50 ranking detected on these keywords'}
            </span>
          </div>
        </div>

        {/* TH Framework v1—Tier + Content Web Role + DA shift + opportunity score */}
        {bd.tier && bd.webRole && (() => {
          const tierShift = classifyTierWithDaBonus(brief);
          const opp = clusterOpportunityScore(brief);
          // Reconciliation: the framework tier is a per-keyword KD-band classification.
          // When the cohort-percentile recalc overrules to Skip—or the cluster
          // opportunity check fails the Tier-1 minimum—the framework's "Go Now /
          // Build Into" descriptor is misleading. Mute the tier pill + descriptor and
          // surface explicit reconciliation so the panel doesn't visually contradict
          // the computed verdict at the bottom.
          const tierIsPositive = tierShift.best.tier <= 2;
          const cohortOverrules = tierIsPositive && (bd.computedVerdict === 'skip' || bd.computedVerdict === 'monitor');
          const clusterFails = tierShift.best.tier === 1 && !opp.clearsTier1Cluster;
          const superseded = cohortOverrules || clusterFails;
          return (
            <div className="vb-section">
              <h4>TH Framework v1 <LensHelp id="th-framework" size="sm" /> <span className="vb-h-meta">tier + web role + per-role thresholds + cluster opp</span></h4>
              <div className="vb-tier-row">
                <span className={'vb-tier-pill tier-' + tierShift.best.tier + (superseded ? ' superseded' : '')}>{tierShift.best.label}</span>
                {tierShift.daShift && (
                  <span className="vb-da-shift-pill" title={`Base tier T${tierShift.base.tier} (KD ${brief.avgKD}). With observed GSC authority on ${tierShift.bestPub}, effective KD shifts by -${tierShift.best.daBonus} → T${tierShift.best.tier}.`}>
                    DA-shifted from T{tierShift.base.tier} on {tierShift.bestPub}
                  </span>
                )}
                <span className="vb-tier-detail">
                  KD band {tierShift.best.kdMin}–{tierShift.best.kdMax} · vol min {tierShift.best.volMin.toLocaleString()} · production target {(tierShift.best.productionMixPct*100).toFixed(0)}%
                </span>
              </div>
              {superseded && (
                <div className="vb-reconcile-note">
                  Framework tier classification is per-keyword (KD band + vol floor); doesn't reflect cohort weakness or cluster scale.
                  {cohortOverrules && <> Computed verdict at bottom of this panel ({verdictLabel(bd.computedVerdict)}, {(bd.cohort.percentile * 100).toFixed(0)}th cohort pctile) overrules the framework tier descriptor.</>}
                  {clusterFails && !cohortOverrules && <> Cluster opportunity below Tier-1 minimum (need ≥10 sweet-spot kws + ≥10K combined vol).</>}
                </div>
              )}
              <div className={'vb-tier-desc' + (superseded ? ' superseded' : '')}>{tierShift.best.description}</div>
              <div className="vb-tier-row" style={{marginTop:8}}>
                <span className={'vb-webrole-pill role-' + bd.webRole.role}>
                  Web Role · {bd.webRole.rules.label}
                </span>
                <span className="vb-tier-detail">{bd.webRole.rules.description}</span>
              </div>
              <ul className="vb-checks" style={{marginTop:8}}>
                {bd.webRole.checks.map(c => (
                  <li key={c.name} className={c.passed ? 'pass' : 'fail'}>
                    <span className="vb-check-icon">{c.passed ? '✓' : '⚠'}</span>
                    <span className="vb-check-label">{c.label}</span>
                    <span className="vb-check-detail">{c.detail}</span>
                  </li>
                ))}
              </ul>
              {/* Cluster opportunity score (Section 4b sort) */}
              <div className="vb-opp-row">
                <span className="vb-opp-label">Cluster opportunity</span>
                <span className="vb-opp-detail">
                  <b>{opp.count}</b> sweet-spot kw{opp.count===1?'':'s'} (vol≥500 / KD≤29) · combined vol <b>{opp.volume.toLocaleString()}</b>
                  {opp.clearsTier1Cluster
                    ? <span className="vb-opp-clear pass">✓ clears Tier-1 cluster minimum (≥10 sweet-spot · ≥10K vol)</span>
                    : <span className="vb-opp-clear fail">⚠ below Tier-1 cluster minimum (need ≥10 sweet-spot kws + ≥10K vol)</span>}
                </span>
              </div>
              <div className="vb-tier-foot dim mono">
                Web-role checks are advisory (TVE override allowed); base hard auto-Skips above are gates. DA-shift uses GSC observed avg-position per pub.
              </div>
            </div>
          );
        })()}

        {bd.gaps.length > 0 && (
          <div className="vb-gaps-callout">
            <b>Signals not yet auto-classified:</b> {bd.gaps.join(', ')}.
            These default to "pass" in the runtime check but operator can still
            apply via Skip + rationale (highest-signal feedback per CLAUDE.md).
          </div>
        )}

        <div className="vb-final">
          <span className="vb-final-label">Computed verdict:</span>
          <span className={'vb-final-verdict v-' + bd.computedVerdict}>
            {verdictLabel(bd.computedVerdict)}
          </span>
          {bd.hardSkipFailed && (
            <span className="vb-final-detail">
              forced by failed check: <code>{bd.hardSkipFailed}</code>
            </span>
          )}
          {!bd.hardSkipFailed && (
            <span className="vb-final-detail">
              cohort rank {bd.cohort.rank}/{bd.cohort.total} · composite {fmtScore(bd.score)} · {(bd.cohort.percentile * 100).toFixed(0)}th pctile
            </span>
          )}
        </div>
      </div>
    </details>
  );
}

function BriefDrawer({ brief, decision, onDecide, onJump, revenueOn, engagementOn, personaId }) {
  const recipe = recipeCoordinates(brief);
  const collMeta = TH_COLLECTIONS[recipe.thCollection];
  const ct = recipe.contentType;
  const isPaused = !!recipe.paused;
  const drawerRuntimeVerdict = briefRuntimeVerdict(brief);
  const drawerVerdictLabel = drawerRuntimeVerdict === 'high-opportunity'
    ? 'High Opportunity'
    : drawerRuntimeVerdict === 'worth-testing'
      ? 'Worth Testing'
      : drawerRuntimeVerdict === 'monitor'
        ? 'Monitor'
        : 'Skip';
  const drawerTierLabel = getBriefRuntimeTierLabel(brief);
  const drawerInsufficient = drawerTierLabel === 'insufficient-data';
  const personaIdResolved = personaId || 'curious-optimizer';
  const persona = PERSONAS[personaIdResolved];
  const fit = personaFit(brief, personaIdResolved);
  const engDetail = engagementSignalDetail(brief);
  const eng = engDetail.score;
  const [note, setNote] = useState(decision?.note || '');
  // Editorial brief modal (spec 2.1)—opens after Keep, doesn't gate Keep.
  const [editorialOpen, setEditorialOpen] = useState(false);
  // Printable brief card modal (spec 2.8)—opens via "Brief card" button.
  const [briefCardOpen, setBriefCardOpen] = useState(false);
  // Revenue assumptions override (spec 2.7). null = use auto defaults.
  const [revOverride, setRevOverride] = useState(null);
  // Competitor SERP segment toggle (spec 2.5).
  const [activeCompetitor, setActiveCompetitor] = useState(null);
  // Headline outcomes from data-headlines (spec 2.4)—async load.
  const [headlines, setHeadlines] = useState(null);
  // Annotations editor state—keyword → note (spec 4.16).
  const [annotEditing, setAnnotEditing] = useState(null);    // label currently being edited
  const [annotDraft, setAnnotDraft] = useState('');
  // Column visibility (spec 4.15).
  const DEFAULT_KW_COLS = { volume: true, kd: true, cpc: false, ourPos: true, intent: false, trend: false };
  const [kwCols, setKwCols] = useState(() => {
    try {
      const saved = JSON.parse(localStorage.getItem('dk-v4-keyword-cols') || 'null');
      return saved && typeof saved === 'object' ? { ...DEFAULT_KW_COLS, ...saved } : DEFAULT_KW_COLS;
    } catch { return DEFAULT_KW_COLS; }
  });
  const [colsMenuOpen, setColsMenuOpen] = useState(false);
  // Item 24—column-sort state. null = default (opportunity rank);
  // 'intent' = compatibility-with-content-type ascending.
  const [kwSort, setKwSort] = useState(null);
  // Item 39—underperforming-Kept gate (shared with BriefTile via
  // computeUnderperformingKept).
  const underperformingKept = computeUnderperformingKept(brief, decision, null);
  useEffect(() => {
    try { localStorage.setItem('dk-v4-keyword-cols', JSON.stringify(kwCols)); } catch {}
  }, [kwCols]);

  // Fetch headline outcomes once when drawer mounts. Cached at module scope.
  useEffect(() => {
    let alive = true;
    loadHeadlineOutcomes().then((data) => { if (alive) setHeadlines(data); });
    return () => { alive = false; };
  }, []);
  // Field names mirror the D1 schema (`outcome_pv`, `outcome_position`,
  // `outcome_headline_grade`) so client state, push payload, and DB row are
  // the same shape—no rename hop. Bug 2026-04-29: prior `headline` →
  // `headlineGrade` mismatch silently dropped grade writes.
  const [outcome, setOutcome] = useState(decision?.outcome || { pv:'', position:'', headlineGrade:'' });
  const [noteSaveState, setNoteSaveState] = useState('idle');     // 'idle' | 'pending' | 'saved'
  const [outcomeSaveState, setOutcomeSaveState] = useState('idle');

  // Latest decision held in a ref so debounced timers always read the freshest
  // value (avoids stale-closure writes after a Keep / Skip click happens
  // mid-typing).
  const decisionRef = useRef(decision);
  useEffect(() => { decisionRef.current = decision; }, [decision]);

  // audit-2026-05-10 R3: the R2 race fix attempted to guard pending-debounce
  // fires by comparing decisionRef.current?.briefId !== targetBriefId. That
  // guard was dead code—`briefId` is not a field on the decision object
  // (see app.jsx:312-326—decisions are stored under their id key with
  // shape {ts, action?, note?, tve?, outcome?, priorityPin?}). The guard's
  // first conjunct (decisionRef.current?.briefId) is always undefined →
  // falsy → guard short-circuits before the inequality check.
  //
  // The correct fix tracks the drawer's CURRENT brief.id in a ref and
  // compares against the captured target inside the timer fire.
  const currentBriefIdRef = useRef(brief.id);
  useEffect(() => { currentBriefIdRef.current = brief.id; }, [brief.id]);

  // Sync local note + outcome state when the decision prop changes
  // (e.g., bootstrap landed after mount, or another tab updated the row).
  // useState initializers only run once at mount, so without this the local
  // state stays stuck at original even after the server returns canonical
  // data. Watches a deep key on outcome via JSON.stringify.
  useEffect(() => {
    setNote(decision?.note || '');
    setOutcome(decision?.outcome || { pv:'', position:'', headlineGrade:'' });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [decision?.id, decision?.note, JSON.stringify(decision?.outcome)]);

  // Auto-save note on every keystroke (600ms debounce). Saves even before a
  // decision exists—the worker accepts a row with action: null + note. No
  // explicit save button—user types, gets persisted. Compare against the
  // ref (latest prop) instead of the captured `decision` param to avoid the
  // stale-prop blanking server-side rationale.
  //
  // audit-2026-05-10 R2: include `brief.id` in deps + capture brief.id at
  // schedule-time so a brief switch mid-debounce doesn't fire onDecide for
  // the WRONG brief. Without these guards: type in brief A's note, switch
  // to B within 600ms → timer fires with brief B's id closure but brief A's
  // note text → wrong brief gets the note.
  useEffect(() => {
    const latestNote = decisionRef.current?.note || '';
    if (note === latestNote) { setNoteSaveState('idle'); return; }
    setNoteSaveState('pending');
    const targetBriefId = brief.id;
    const t = setTimeout(() => {
      // audit-2026-05-10 R3: guard via currentBriefIdRef (the drawer's
      // active brief.id), not via a non-existent decisionRef.current.briefId.
      // If the drawer has moved on, drop the pending save—the next
      // keystroke on the new brief will trigger its own debounce.
      if (currentBriefIdRef.current !== targetBriefId) return;
      onDecide(targetBriefId, { ...(decisionRef.current || {}), note });
      setNoteSaveState('saved');
      setTimeout(() => setNoteSaveState('idle'), 1200);
    }, 600);
    return () => clearTimeout(t);
  }, [note, brief.id]); // eslint-disable-line react-hooks/exhaustive-deps

  // Auto-save outcome on every keystroke (600ms debounce). Same model;
  // compares against ref to dodge stale-prop reads. Same brief-switch
  // guard as the note useEffect above.
  useEffect(() => {
    const cur = decisionRef.current?.outcome || { pv:'', position:'', headlineGrade:'' };
    if (outcome.pv === cur.pv && outcome.position === cur.position && outcome.headlineGrade === cur.headlineGrade) {
      setOutcomeSaveState('idle'); return;
    }
    setOutcomeSaveState('pending');
    const targetBriefId = brief.id;
    const t = setTimeout(() => {
      // audit-2026-05-10 R3: same guard as the note useEffect above.
      if (currentBriefIdRef.current !== targetBriefId) return;
      onDecide(targetBriefId, { ...(decisionRef.current || { action: 'keep' }), note, outcome });
      setOutcomeSaveState('saved');
      setTimeout(() => setOutcomeSaveState('idle'), 1200);
    }, 600);
    return () => clearTimeout(t);
  }, [outcome.pv, outcome.position, outcome.headlineGrade, brief.id]); // eslint-disable-line react-hooks/exhaustive-deps

  const decide = (action) => {
    onDecide(brief.id, { action, note, outcome });
    // Spec 2.1—Keep triggers the editorial brief modal. Modal is non-
    // blocking; Keep persists first, then the modal opens for reference.
    if (action === 'keep') setEditorialOpen(true);
  };

  return (
    <div className={'t2 ' + (isPaused ? 't2-paused' : '')}>
      <div className="t2-main">
        {isPaused && (
          <div className="paused-banner">
            <b>L&amp;E bridge—paused.</b> This brief targeted UsW / Woman's World as bridge content. After the 2026-04-27 pivot, the National team is full-time on TrendHunter. Bridge content is on hold until the L&amp;E data pipe is repaired and L&amp;E numbers can be trusted. Drawer left navigable for retro-tagging audits—do not place.
          </div>
        )}

        {/* Verdict header—leads with the actual call so the operator
            sees what the verdict IS before reading why. The (?) bubble
            still explains the methodology. The full breakdown sits below
            (collapsed by default). When tier is 'insufficient-data', the
            entire breakdown is replaced by a "What we'd need to know"
            section listing missing signals. */}
        <h3 className={'section-title verdict-header v-' + (drawerInsufficient ? 'insufficient-data' : drawerRuntimeVerdict)}>
          Verdict <span className="verdict-header-pill">{drawerInsufficient ? 'Insufficient Data' : drawerVerdictLabel}</span>
          <LensHelp id="verdict-breakdown" size="sm" />
        </h3>
        <div className="why">{brief.verdictReason}</div>
        {!drawerInsufficient && window.briefRuntimeVerdict(brief) === 'worth-testing' && (
          <div className="verdict-operational">
            <b>Worth Testing operationally</b> = 1–3 articles batch via CSA. Watch performance ~30 days; expand or kill.
          </div>
        )}
        {window.briefRuntimeVerdict(brief) === 'monitor' && (
          <div className="verdict-operational v-monitor">
            <b>Monitor operationally</b> = TH-validated by momentum{brief.thHeatIndex != null ? ` (Heat Index ${brief.thHeatIndex})` : ''}, search demand unproven. Single probe article on TH grounds, or hold for demand to surface; re-check when SEMrush volume materializes.
          </div>
        )}

        {drawerInsufficient ? (() => {
          const da = getDataAvailability(brief);
          return (
            <div className="vb-gaps-callout" style={{borderLeft:'3px solid #888', padding:10, marginTop:8}}>
              <h4 style={{margin:'0 0 6px'}}>What we'd need to know</h4>
              <ul style={{margin:0, paddingLeft:18}}>
                {da.missing.map(m => <li key={m}>{m}</li>)}
                {da.missing.length === 0 && <li>data appears complete—verdict pending recompute</li>}
              </ul>
              <div className="dim mono" style={{marginTop:6, fontSize:11}}>
                Once these signals land, the brief will reclassify automatically. Skip + rationale is still the highest-signal manual override.
              </div>
            </div>
          );
        })() : (
          <>
            {/* You-are-here ladder (spec 4.18)—replaces the bare composite-score line
                with a horizontal ladder showing where the brief sits across the three
                verdict bands. */}
            {(() => {
              const ladder = getVerdictLadder(brief);
              if (!ladder) return null;
              const W = 240, H = 18;
              const cumWidths = [];
              let acc = 0;
              for (const seg of ladder.segments) { cumWidths.push({ ...seg, x: acc, w: seg.width * W }); acc += seg.width * W; }
              const markerX = ladder.position * W;
              return (
                <div className="verdict-ladder" style={{display:'flex', alignItems:'center', gap:10, margin:'4px 0 8px'}}
                     title={`Composite ${ladder.score?.toFixed?.(2) ?? '—'} · ${(ladder.position * 100).toFixed(0)}th percentile of cohort`}>
                  <svg width={W} height={H + 8} style={{display:'block'}}>
                    {cumWidths.map((seg, i) => {
                      const fill = seg.name.startsWith('Skip') ? 'rgba(180,80,80,0.35)'
                                 : seg.name.startsWith('Worth') ? 'rgba(220,180,80,0.35)'
                                 : 'rgba(120,180,120,0.45)';
                      return <rect key={i} x={seg.x} y={4} width={seg.w} height={H - 8} fill={fill} />;
                    })}
                    {/* divider lines */}
                    {cumWidths.slice(0, -1).map((seg, i) => (
                      <line key={'d-'+i} x1={seg.x + seg.w} y1={4} x2={seg.x + seg.w} y2={H - 4} stroke="rgba(255,255,255,0.15)" />
                    ))}
                    {/* marker triangle */}
                    <polygon points={`${markerX - 4},${H + 6} ${markerX + 4},${H + 6} ${markerX},${H - 2}`}
                             fill="var(--t-1, #fff)" stroke="var(--bg-1, #000)" strokeWidth="0.5" />
                  </svg>
                  <span className="dim mono" style={{fontSize:11}}>
                    {(ladder.position * 100).toFixed(0)}th pctile · score {ladder.score != null ? ladder.score.toFixed(2) : '—'}
                  </span>
                </div>
              );
            })()}

            {/* Verdict breakdown—runtime application of all calibrated rules.
                Shows score components, hard-skip checks, authority state, drift. */}
            <VerdictBreakdown brief={brief} />

            {/* Item 5—counterfactual sidecar. Other briefs whose snapshot
                composite scored within ±0.05 of this brief's compositeScore.
                Hidden when DECISIONS_OUTCOMES isn't loaded or no comparable rows. */}
            {(() => {
              const feed = (typeof window !== 'undefined' && window.DECISIONS_OUTCOMES) || null;
              const rows = Array.isArray(feed?.rows) ? feed.rows : null;
              if (!rows || rows.length === 0) return null;
              let myScore = null;
              try {
                if (typeof computeVerdictBreakdown === 'function') {
                  myScore = computeVerdictBreakdown(brief)?.score;
                }
              } catch { /* ignore */ }
              if (!Number.isFinite(myScore)) return null;
              const window05 = 0.05;
              const comparable = rows.filter(r => {
                if (r.brief_id === brief.id) return false;
                const s = r?.snapshot?.score;
                return Number.isFinite(s) && Math.abs(s - myScore) <= window05;
              });
              if (comparable.length === 0) return null;
              // Take the most recent 5 by updatedAt or matching field if present.
              const sorted = comparable.slice().sort((a, b) => {
                const ta = new Date(a.decided_at || a.updatedAt || a.timestamp || 0).getTime() || 0;
                const tb = new Date(b.decided_at || b.updatedAt || b.timestamp || 0).getTime() || 0;
                return tb - ta;
              }).slice(0, 5);
              const tally = { in_zone: 0, foothold: 0, cold: 0 };
              for (const r of sorted) {
                const o = r?.outcome_state || r?.outcome?.state || r?.state;
                if (o && tally[o] != null) tally[o]++;
              }
              const summaryParts = [];
              if (tally.in_zone) summaryParts.push(`${tally.in_zone} in_zone`);
              if (tally.foothold) summaryParts.push(`${tally.foothold} foothold`);
              if (tally.cold)    summaryParts.push(`${tally.cold} cold`);
              return (
                <div className="counterfactual-sidecar"
                     style={{margin:'10px 0', padding:'8px 10px', background:'rgba(150,200,150,0.06)', borderLeft:'2px solid rgba(150,200,150,0.5)', borderRadius:3, fontSize:12, lineHeight:1.5}}>
                  <div className="dim mono" style={{fontSize:10, letterSpacing:'.06em', textTransform:'uppercase', marginBottom:4}}>
                    Briefs that scored like this <span title={`±${window05} composite-score window around ${myScore.toFixed(2)}`} style={{opacity:0.6}}>(?)</span>
                  </div>
                  <div style={{marginBottom:4}}>
                    Last {sorted.length} brief{sorted.length===1?'':'s'} that scored like this performed: {summaryParts.length ? summaryParts.join(', ') + '.' : 'no recorded outcomes.'}
                  </div>
                  <ul style={{margin:0, paddingLeft:18, fontSize:11}}>
                    {sorted.map((r, i) => {
                      const state = r?.outcome_state || r?.outcome?.state || r?.state || '—';
                      const topic = r?.topic || r?.brief_topic || r?.brief_id || '—';
                      const score = r?.snapshot?.score;
                      return (
                        <li key={i}>
                          {topic} <span className="dim mono">— {state}{Number.isFinite(score) ? ` · score ${score.toFixed(2)}` : ''}</span>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              );
            })()}

            {/* Gaps to upgrade (spec 3.7)—what would flip this brief's verdict
                up one tier. Reads window.gapsToUpgrade; degrades silently if
                no gaps surface (already at top tier or helper unavailable). */}
            {(() => {
              if (typeof window.gapsToUpgrade !== 'function') return null;
              let gaps = [];
              try { gaps = window.gapsToUpgrade(brief) || []; } catch (e) { console.warn('brief.jsx: window.gapsToUpgrade threw, gaps unavailable', e); }
              if (!Array.isArray(gaps) || gaps.length === 0) return null;
              return (
                <div className="vb-gaps" style={{margin:'10px 0', padding:'8px 10px', background:'rgba(120,170,220,0.06)', borderLeft:'2px solid rgba(120,170,220,0.5)', borderRadius:3}}>
                  <div className="dim mono" style={{fontSize:10, letterSpacing:'.06em', textTransform:'uppercase', marginBottom:4}}>What would upgrade this verdict</div>
                  <ul style={{margin:0, paddingLeft:18, fontSize:12, lineHeight:1.5}}>
                    {gaps.slice(0, 3).map((g, i) => (
                      <li key={i}>{g.what || String(g)}{g.distance != null ? <span className="dim"> · {g.distance}</span> : null}</li>
                    ))}
                  </ul>
                </div>
              );
            })()}

            {/* Confidence interval (spec 1.2)—bootstrap CI on the brief's
                cohort percentile. Tells the operator how robust the call is.
                Reads window.briefVerdictCI; degrades silently if absent or
                returns no point/lo/hi. */}
            {(() => {
              if (typeof window.briefVerdictCI !== 'function') return null;
              let ci = null;
              try { ci = window.briefVerdictCI(brief, 200); } catch (e) { console.warn('brief.jsx: window.briefVerdictCI threw, CI unavailable', e); }
              if (!ci || ci.point == null) return null;
              const span = (ci.hi != null && ci.lo != null) ? ((ci.hi - ci.lo) * 100).toFixed(0) : null;
              const tightness = span == null ? 'unknown'
                              : Number(span) <= 15 ? 'tight'
                              : Number(span) <= 35 ? 'moderate'
                              : 'wide';
              return (
                <div className="vb-ci" style={{margin:'8px 0', fontSize:12, lineHeight:1.5}}>
                  <span className="dim mono" style={{fontSize:10, letterSpacing:'.06em', textTransform:'uppercase'}}>Confidence interval</span>{' '}
                  point <b>{(ci.point * 100).toFixed(0)}th</b>
                  {ci.lo != null && ci.hi != null && (
                    <> · 90% CI [{(ci.lo * 100).toFixed(0)}, {(ci.hi * 100).toFixed(0)}] <span className="dim">({tightness})</span></>
                  )}
                </div>
              );
            })()}

            {/* Search intent vector (spec 1.7)—informational / commercial /
                transactional / navigational mix derived from keyword phrasing. */}
            {(() => {
              const iv = getIntentVector(brief);
              const order = [
                { key: 'informational', color: 'rgba(120,170,220,0.85)', label: 'informational' },
                { key: 'commercial',    color: 'rgba(220,180,120,0.85)', label: 'commercial' },
                { key: 'transactional', color: 'rgba(180,120,200,0.85)', label: 'transactional' },
                { key: 'navigational',  color: 'rgba(150,200,150,0.85)', label: 'navigational' },
              ];
              const top = order.slice().sort((a, b) => (iv[b.key] || 0) - (iv[a.key] || 0))[0];
              const interpretation = (() => {
                if (!top || !iv[top.key]) return null;
                const pct = (iv[top.key] * 100).toFixed(0);
                if (top.key === 'informational') return `This brief skews informational (${pct}%)—best fit for explainer or how-to format.`;
                if (top.key === 'commercial')    return `This brief skews commercial (${pct}%)—best fit for review, comparison, or recommendation format.`;
                if (top.key === 'transactional') return `This brief skews transactional (${pct}%)—best fit for product-roundup or buyer-guide format.`;
                if (top.key === 'navigational')  return `This brief skews navigational (${pct}%)—best fit for landing page or directory format.`;
                return null;
              })();
              return (
                <div className="search-intent-panel" style={{marginTop:10}}>
                  <h3 className="section-title">Search intent <TermHelp termKey="intent" /></h3>
                  <div style={{display:'flex', flexDirection:'column', gap:4}}>
                    {order.map(o => {
                      const v = iv[o.key] || 0;
                      const pct = (v * 100).toFixed(0);
                      return (
                        <div key={o.key} style={{display:'flex', alignItems:'center', gap:8, fontSize:11}}
                             title={`${o.label}: ${pct}% of keywords`}>
                          <span style={{width:90, color:'var(--t-2)'}}>{o.label}</span>
                          <div style={{flex:'1 1 auto', height:8, background:'rgba(255,255,255,0.05)', borderRadius:2, overflow:'hidden'}}>
                            <div style={{width: pct + '%', height:'100%', background: o.color, transition:'width 200ms'}} />
                          </div>
                          <span className="mono" style={{width:36, textAlign:'right'}}>{pct}%</span>
                        </div>
                      );
                    })}
                  </div>
                  {interpretation && <div className="dim" style={{fontSize:11, marginTop:6, fontStyle:'italic'}}>{interpretation}</div>}
                </div>
              );
            })()}
          </>
        )}


        <h3 className="section-title">Recommended angle</h3>
        <div className="angle">{brief.recommendedAngle}</div>

        {/* Authority context (in-zone / foothold / cold-start) is rendered
            inside the verdict-breakdown panel above. The standalone version
            here was duplicating that data in the main column. Kept inside
            the breakdown so it surfaces alongside its sibling signals. */}

        {(() => {
          const rankedKwsBase = rankKeywordsByOpportunity(brief.topKeywords || []);
          // Item 24—apply intent-compatibility sort when toggled.
          const rankedKws = (() => {
            if (kwSort !== 'intent') return rankedKwsBase;
            const briefCt = (typeof inferContentType === 'function') ? inferContentType(brief) : null;
            return rankedKwsBase.slice().sort((a, b) => {
              const ka = keywordIntentSortKey(a.label, briefCt);
              const kb = keywordIntentSortKey(b.label, briefCt);
              return ka - kb;
            });
          })();
          const annotations = (decision?.annotations) || [];
          const annotMap = {};
          for (const a of annotations) { if (a && a.label) annotMap[a.label] = a.note || ''; }
          const saveAnnotation = (label, noteText) => {
            // Spec 4.16: persist via decision.annotations array. The worker
            // accepts the new field per migration 0002; we degrade locally
            // via onDecide with the merged decision.
            const next = (annotations || []).filter(a => a.label !== label);
            if (noteText && noteText.trim()) next.push({ label, note: noteText.trim(), updatedAt: new Date().toISOString() });
            onDecide(brief.id, { ...(decision || {}), annotations: next });
          };
          // Available column flags + their labels.
          const COL_DEFS = [
            { key: 'volume', label: 'Volume' },
            { key: 'kd',     label: 'KD' },
            { key: 'cpc',    label: 'CPC' },
            { key: 'ourPos', label: 'Our pos' },
            { key: 'intent', label: 'Intent' },
            { key: 'trend',  label: 'Trend slope' },
          ];
          const inferIntent = (label) => {
            const l = (label || '').toLowerCase();
            if (/(buy|price|cheap|deal|coupon|discount|order|shop)/.test(l)) return 'txn';
            if (/(best|review|vs|compare|top \d|brands)/.test(l))             return 'com';
            if (/(login|website|official|near me|address)/.test(l))           return 'nav';
            return 'inf';
          };
          return (
            <>
              <div className="kwtbl-head" style={{display:'flex', alignItems:'center', gap:8, flexWrap:'wrap'}}>
                <h3 className="section-title" style={{flex:'1 1 auto'}}>Top keywords ({brief.topKeywords.length}, ranked) <LensHelp id="top-keywords" size="sm" /></h3>
                <div style={{position:'relative'}}>
                  <button className="btn-copy-kws" onClick={(e) => { e.stopPropagation(); setColsMenuOpen(o => !o); }}
                          title="Choose columns">Columns ▾</button>
                  {colsMenuOpen && (
                    <div className="kwcols-menu"
                         style={{position:'absolute', right:0, top:'calc(100% + 4px)', background:'var(--bg-2, #1c1c1e)', border:'1px solid var(--line)', borderRadius:3, padding:8, zIndex:20, minWidth:160}}
                         onClick={e => e.stopPropagation()}>
                      <div className="dim mono" style={{fontSize:10, marginBottom:4}}>visible columns</div>
                      <div style={{fontSize:11, marginBottom:4, color:'var(--t-3)'}}>Label · always shown</div>
                      {COL_DEFS.map(c => (
                        <label key={c.key} style={{display:'block', fontSize:11, padding:'2px 0', cursor:'pointer'}}>
                          <input type="checkbox" checked={!!kwCols[c.key]}
                                 onChange={e => setKwCols(prev => ({ ...prev, [c.key]: e.target.checked }))}
                                 style={{marginRight:6}} />
                          {c.label}
                        </label>
                      ))}
                      <div className="dim mono" style={{fontSize:10, marginTop:6}}>saved per-browser</div>
                    </div>
                  )}
                </div>
                <CopyKeywordsBtn keywords={rankedKws} />
              </div>
              <table className="kwtbl">
                <thead>
                  <tr>
                    <th className="kw-rank">#</th>
                    <th>keyword</th>
                    {kwCols.volume && <th>vol</th>}
                    {kwCols.kd     && <th>kd</th>}
                    {kwCols.cpc    && <th>cpc</th>}
                    {kwCols.ourPos && <th>our pos</th>}
                    {kwCols.intent && (
                      <th title={kwSort === 'intent' ? 'Sorted by intent compatibility—click to reset to opportunity rank' : 'Click to sort by intent compatibility with this brief\'s content type'}
                          onClick={(e) => { e.stopPropagation(); setKwSort(kwSort === 'intent' ? null : 'intent'); }}
                          style={{cursor:'pointer', userSelect:'none'}}>
                        intent{kwSort === 'intent' ? ' ↓' : ''}
                      </th>
                    )}
                    {kwCols.trend  && <th title="30-day trend slope (where available)">trend</th>}
                    <th title="Pin a note for this keyword">📌</th>
                  </tr>
                </thead>
                <tbody>
                  {rankedKws.slice(0,8).map((k,i) => {
                    const note = annotMap[k.label];
                    const trendRaw = (typeof window !== 'undefined' && window.COMPOUNDING_CURVES?.keywords?.[k.label]?.history) || null;
                    const trend = Array.isArray(trendRaw) ? trendRaw : null;
                    return (
                      <tr key={k.label} title={note ? `pin: ${note}` : undefined}>
                        <td className="kw-rank">{i+1}</td>
                        <td className="kw">{k.label}</td>
                        {kwCols.volume && <td>{fmtVol(k.volume)}</td>}
                        {kwCols.kd     && <td>{k.kd}</td>}
                        {kwCols.cpc    && <td>{fmtCpc(k.cpc)}</td>}
                        {kwCols.ourPos && <td className={k.ourPos===999?'pos999':k.ourPos<=50?'posLow':''}>{k.ourPos===999?'absent':k.ourPos ?? '—'}</td>}
                        {kwCols.intent && <td className="dim mono">{inferIntent(k.label)}</td>}
                        {kwCols.trend  && <td>{trend ? <Sparkline points={trend.map(h => h?.volume).filter(Number.isFinite)} /> : <span className="dim">—</span>}</td>}
                        <td>
                          <button className="kw-pin-btn" title={note ? 'Edit pin note' : 'Pin a note for this keyword'}
                                  onClick={(e) => { e.stopPropagation(); setAnnotEditing(k.label); setAnnotDraft(note || ''); }}
                                  style={{background:'transparent', border:'none', cursor:'pointer', fontSize:12, opacity: note ? 1 : 0.4}}>
                            📌
                          </button>
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
              {annotEditing && (
                <div className="kw-pin-editor"
                     style={{margin:'6px 0', padding:8, border:'1px solid var(--line)', borderRadius:3, background:'rgba(120,170,220,0.06)'}}>
                  <div className="dim mono" style={{fontSize:11, marginBottom:4}}>Pin a note for <b>{annotEditing}</b></div>
                  <textarea value={annotDraft} onChange={e => setAnnotDraft(e.target.value)}
                            placeholder="Why does this keyword matter? Worth flagging?"
                            style={{width:'100%', minHeight:48, fontSize:12, padding:6, fontFamily:'inherit'}} />
                  <div style={{display:'flex', gap:6, marginTop:6}}>
                    <button className="btn" onClick={() => { saveAnnotation(annotEditing, annotDraft); setAnnotEditing(null); setAnnotDraft(''); }}>save</button>
                    <button className="btn" onClick={() => { setAnnotEditing(null); setAnnotDraft(''); }}>cancel</button>
                    {annotMap[annotEditing] && (
                      <button className="btn skip-btn" onClick={() => { saveAnnotation(annotEditing, ''); setAnnotEditing(null); setAnnotDraft(''); }}>clear pin</button>
                    )}
                  </div>
                </div>
              )}
              {rankedKws.length > 8 && (
                <p className="kwtbl-foot">Showing top 8 of {rankedKws.length}. Copy button grabs all, ranked.</p>
              )}
            </>
          );
        })()}

        <h3 className="section-title">Decision</h3>
        <div className="decide">
          {/* TH Framework v1—Trend Velocity Exception (Section 3 / 4c).
              Operator can flag rising-velocity briefs to override volume-floor
              auto-skips. Persisted as decision.tve so subsequent breakdowns
              can show the override path. */}
          <div className="tve-bar">
            <span className="tve-label">Trend Velocity Exception <LensHelp id="tve" size="sm" /></span>
            <div className="tve-options">
              {[['none','off','Clear—no velocity flag set (default)'],['rising','rising','Flag as rising velocity (overrides auto-skip)'],['flat','flat','Flag as flat velocity'],['declining','declining','Flag as declining velocity']].map(([v,l,t]) => (
                <button key={v} title={t}
                  className={'tve-chip ' + ((decision?.tve || 'none') === v ? 'active' : '')}
                  onClick={() => onDecide(brief.id, { ...(decision||{}), tve: v === 'none' ? null : v })}>
                  {l}
                </button>
              ))}
            </div>
          </div>
          <div className="decide-actions">
            <button className={'btn keep ' + (decision?.action==='keep'?'active':'')} onClick={()=>decide('keep')} disabled={isPaused} title={isPaused ? 'Cannot commit—L&E placement is paused' : ''}>
              <span className="gl"></span> Keep · commit pipeline
            </button>
            <button className={'btn skip-btn ' + (decision?.action==='skip'?'active':'')} onClick={()=>decide('skip')}>
              <span className="gl"></span> Skip · rationale required
            </button>
            <button className={'btn defer ' + (decision?.action==='defer'?'active':'')} onClick={()=>decide('defer')}>
              <span className="gl"></span> Defer
            </button>
          </div>
          <textarea
            className="note-input"
            placeholder={decision?.action==='skip' ? 'Rationale required—why pass on this?' : 'Optional note: format, owner, target week…'}
            value={note}
            onChange={e=>setNote(e.target.value)}
          />
          <div className={'autosave-status autosave-' + noteSaveState}>
            {noteSaveState === 'pending' ? 'saving…'
              : noteSaveState === 'saved' ? 'saved · synced to db'
              : 'auto-saves on edit'}
          </div>
          {decision?.action === 'keep' && (() => {
            // Auto-outcome panel—daily-refreshed from TRACKER_ENRICHED via
            // scripts/precompute_brief_outcomes.py. Operator does nothing.
            // Manual outcome inputs below pre-fill with auto data when empty;
            // operator overrides take precedence (their note is authoritative).
            const auto = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES &&
              window.BRIEF_OUTCOMES.outcomes && window.BRIEF_OUTCOMES.outcomes[brief.id]) || null;
            const hasAutoMatch = auto && auto.matched_articles > 0;
            const asof = window.BRIEF_OUTCOMES?.asof;
            return (
              <>
                <h3 className="section-title" style={{marginTop:14}}>
                  Outcome capture
                  {hasAutoMatch
                    ? <span className="auto-sync-badge inline" title={`auto-matched as of ${asof}`}>auto-synced</span>
                    : <span className="dim mono">after publish</span>}
                  {/* Item 17—attribution-floor explainer tooltip on the panel header. */}
                  {hasAutoMatch && auto.attribution_floor && (
                    <span className="aop-attrib-help"
                          title={`${auto.matched_articles} article${auto.matched_articles===1?'':'s'} · attribution floor: ${auto.attribution_floor} (matched articles published since this date)`}
                          style={{display:'inline-block', marginLeft:6, fontSize:10, opacity:0.6, cursor:'help', verticalAlign:'super'}}>
                      (?)
                    </span>
                  )}
                </h3>
                {/* Item 39—re-decide prompt for cold-state Kept briefs decided >28d ago. */}
                {hasAutoMatch && underperformingKept && (
                  <div className="reconsider-prompt"
                       style={{margin:'6px 0', padding:'6px 10px', borderLeft:'3px solid rgba(220,80,80,0.7)', background:'rgba(220,80,80,0.06)', fontSize:12, lineHeight:1.4}}
                       title="This brief was Kept >28 days ago, but observed performance is cold.">
                    <b>predicted Keep, observed cold—re-decide?</b>
                    <span className="dim mono" style={{marginLeft:6, fontSize:10}}>auto-flagged · cold + decision &gt;28d old</span>
                  </div>
                )}
                {hasAutoMatch && (() => {
                  // Item 15—share-adjusted PVs as primary; full credit shown
                  // as parenthetical when both are present.
                  const pvsAdj = auto.total_pvs_share_adjusted;
                  const pvsFull = auto.total_pvs;
                  const pvsPrimary = Number.isFinite(pvsAdj) ? Math.round(pvsAdj) : (pvsFull ?? 0);
                  // Item 16—count of shared articles + cross-brief topics.
                  const sharedCount = Number(auto.shared_articles) || 0;
                  // Walk other outcomes for cluster-overlap topics.
                  const allOutcomes = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES?.outcomes) || {};
                  const myClusters = new Set(Array.isArray(auto.cluster_ids) ? auto.cluster_ids : []);
                  const sharedBriefTopics = (() => {
                    if (sharedCount <= 0 || myClusters.size === 0) return [];
                    const briefs = (typeof window !== 'undefined' && Array.isArray(window.BRIEFS)) ? window.BRIEFS : [];
                    const labelOf = (id) => briefs.find(b => b.id === id)?.topic || id;
                    const matchingIds = [];
                    for (const [otherId, otherOut] of Object.entries(allOutcomes)) {
                      if (otherId === brief.id) continue;
                      const otherClusters = Array.isArray(otherOut?.cluster_ids) ? otherOut.cluster_ids : [];
                      if (otherClusters.some(c => myClusters.has(c))) matchingIds.push(otherId);
                    }
                    return matchingIds.map(labelOf);
                  })();
                  return (
                    <div className="auto-outcome-panel">
                      <div className="aop-row">
                        <span className={'aop-state aop-' + auto.best_position_state}>
                          {auto.best_position_state === 'in_zone' ? 'in zone' :
                           auto.best_position_state === 'foothold' ? 'foothold' :
                           auto.best_position_state === 'cold' ? 'cold' : auto.best_position_state}
                        </span>
                        <span className="aop-stat"><b>{auto.matched_articles}</b> matched articles</span>
                        <span className="aop-stat"
                              title={Number.isFinite(pvsAdj) && Number.isFinite(pvsFull)
                                ? `Share-adjusted (this brief's PV credit) shown as primary. Unadjusted full credit: ${pvsFull.toLocaleString()}.`
                                : 'Share-adjusted PVs unavailable; showing full-credit total.'}>
                          <b>{pvsPrimary.toLocaleString()}</b> total PVs
                          {Number.isFinite(pvsAdj) && Number.isFinite(pvsFull) && pvsFull !== pvsAdj && (
                            <span className="dim mono" style={{marginLeft:4, fontSize:10}}>
                              (unadjusted: {pvsFull.toLocaleString()})
                            </span>
                          )}
                        </span>
                        {auto.hit_count > 0 && <span className="aop-stat"><b>{auto.hit_count}</b> top-25 hits</span>}
                        {auto.avg_article_vs_co_median != null && (
                          <span className="aop-stat" title="avg article-vs-company-median performance ratio">
                            <b>{auto.avg_article_vs_co_median}×</b> co-median
                          </span>
                        )}
                      </div>
                      {/* Item 16—shared_articles surfaced. */}
                      {sharedCount > 0 && (
                        <div className="aop-shared dim mono"
                             style={{fontSize:11, marginTop:4}}
                             title="shared_articles = articles also matched by other briefs (cluster overlap)">
                          {auto.matched_articles} matched articles, {sharedCount} of which {sharedCount === 1 ? 'is' : 'are'} shared
                          {sharedBriefTopics.length > 0 && (
                            <> with brief{sharedBriefTopics.length === 1 ? '' : 's'} {sharedBriefTopics.slice(0, 3).join(' / ')}{sharedBriefTopics.length > 3 ? ` +${sharedBriefTopics.length - 3}` : ''}</>
                          )}.
                        </div>
                      )}
                      {auto.sample_url && (
                        <div className="aop-sample dim mono">
                          best match: <a href={safeHref(auto.sample_url)} target="_blank" rel="noopener noreferrer">{auto.sample_story_id || 'view'}</a>
                          {auto.sample_pvs ? ` · ${auto.sample_pvs.toLocaleString()} PVs` : ''}
                          {auto.latest_publish_date ? ` · latest ${auto.latest_publish_date}` : ''}
                        </div>
                      )}
                      <div className="aop-foot dim mono">
                        auto-precomputed from TRACKER_ENRICHED · {window.BRIEF_OUTCOMES?.window_days || 180}d window · operator overrides below take precedence
                      </div>
                    </div>
                  );
                })()}
                {/* Layer 10—per-brief revenue attribution (live BURT, daily refresh).
                    Only rendered when the brief has a real revenue number. Same window
                    + matching rule as outcomes; revenue rolls up from
                    ARTICLE_PROGRAMMATIC_REVENUE_LIVE × matched articles. */}
                {(() => {
                  const rev = (typeof window !== 'undefined' && window.BRIEF_REVENUES &&
                    window.BRIEF_REVENUES.revenues && window.BRIEF_REVENUES.revenues[brief.id]) || null;
                  if (!rev || !rev.total_revenue_live) return null;
                  const volatile = rev.avg_outlet_volatility != null && rev.avg_outlet_volatility > 0.25;
                  return (
                    <div className="auto-outcome-panel" style={{marginTop:8, borderLeft:'3px solid #5a8a3a'}}>
                      <div className="aop-row">
                        <span className="aop-state" style={{background:'#5a8a3a', color:'#fff'}}>revenue</span>
                        <span className="aop-stat"><b>${rev.total_revenue_live.toLocaleString()}</b> total $ attributed</span>
                        <span className="aop-stat"><b>{rev.articles_with_revenue}</b> of {rev.matched_articles} articles</span>
                        {rev.avg_outlet_ecpm_90d != null && (
                          <span className="aop-stat" title="rolling 90-day OMP eCPM, avg across matched outlets">
                            avg eCPM <b>${rev.avg_outlet_ecpm_90d.toFixed(2)}</b>
                          </span>
                        )}
                        {volatile && (
                          <span className="aop-stat" title={`30d eCPM diverges from 90d by ${(rev.avg_outlet_volatility * 100).toFixed(0)}%—recent revenue may not reflect long-run baseline`}
                                style={{color:'#c66'}}>
                            ⚠ unstable
                          </span>
                        )}
                      </div>
                      {rev.max_article_revenue && (
                        <div className="aop-sample dim mono">
                          top earner: ${rev.max_article_revenue.toLocaleString()} · avg ${(rev.avg_revenue_per_matched_article ?? rev.avg_revenue_per_article ?? 0).toLocaleString()}/article
                          {rev.avg_pv_share_of_domain_month != null && rev.avg_pv_share_of_domain_month > 0.005 ? ` · ${(rev.avg_pv_share_of_domain_month * 100).toFixed(2)}% domain-month share` : ''}
                        </div>
                      )}
                      <div className="aop-foot dim mono">
                        auto-precomputed from TRACKER_ENRICHED v2.7 · live BURT-driven · {window.BRIEF_REVENUES?.window_days || 180}d window
                      </div>
                    </div>
                  );
                })()}
                {/* R28 Tier C (2026-05-17)—7d engagement panel. Surfaces the
                    new precompute_brief_outcomes 7d fields (avg_time_on_page_7d_s,
                    avg_bounce_delta_7d, engagement_n_7d) so operators can
                    inspect verdict quality against engagement, not just PV.
                    Hides when no qualifying signal in the 7d window.
                    Gated on `engagementOn` to respect the operator's lens
                    setting (matches every other engagement surface in this
                    file—tile eng pill, tile dwell chip, drawer engagement
                    panel below). */}
                {engagementOn && (() => {
                  if (!hasAutoMatch) return null;
                  const dwell = auto.avg_time_on_page_7d_s;
                  const bDelta = auto.avg_bounce_delta_7d;
                  const n7 = auto.engagement_n_7d || 0;
                  // Gate dwell on > 0 (not just != null): a literal 0s
                  // mean dwell across n>0 articles is incoherent—belt-
                  // and-suspenders against an upstream regression. bDelta
                  // can legitimately be 0 (article exactly at baseline)
                  // so it stays !== null.
                  const hasDwell  = dwell != null && dwell > 0;
                  const hasBDelta = bDelta != null;
                  if ((!hasDwell && !hasBDelta) || n7 === 0) return null;
                  const dwellDisplay = !hasDwell ? null
                    : dwell < 90 ? `${Math.round(dwell)}s`
                                 : `${(dwell / 60).toFixed(1)}m`;
                  // Sign convention: bDelta < 0 = readers stickier than the
                  // pub baseline (good). bDelta exactly 0 = at baseline,
                  // neutral. Color-code accordingly. Hex literals match
                  // the existing revenue-panel sibling above (line ~1985)
                  // for visual parity across auto-outcome-panel variants.
                  const bDeltaDisplay = !hasBDelta ? null
                    : `${bDelta >= 0 ? '+' : ''}${(bDelta * 100).toFixed(1)}pp`;
                  const bDeltaColor = !hasBDelta ? null
                    : bDelta < 0 ? '#5a8a3a' : bDelta > 0 ? '#c66' : null;
                  return (
                    <div className="auto-outcome-panel" style={{marginTop:8, borderLeft:'3px solid #4a7a8a'}}>
                      <div className="aop-row">
                        <span className="aop-state" style={{background:'#4a7a8a', color:'#fff'}}>engagement</span>
                        {dwellDisplay && (
                          <span className="aop-stat"
                                title="Mean of per-article median time on page across matched articles published in the trailing 7 days. Source: TRACKER_ENRICHED engagement signals (Amplitude-derived).">
                            <b>{dwellDisplay}</b> avg dwell
                          </span>
                        )}
                        {bDeltaDisplay && (
                          <span className="aop-stat"
                                title="Article bounce rate minus publication-average bounce rate, averaged across matched in-7d articles. Absolute percentage-point delta (not a relative lift). Negative = readers stickier than the publication baseline."
                                style={{color: bDeltaColor}}>
                            <b>{bDeltaDisplay}</b> bounce vs pub
                          </span>
                        )}
                        <span className="aop-stat dim mono" style={{fontSize:10}}>
                          n={n7}
                        </span>
                      </div>
                      <div className="aop-foot dim mono">
                        7d engagement window · TRACKER_ENRICHED (Amplitude-derived) · NULL when no Amplitude coverage
                      </div>
                    </div>
                  );
                })()}
                <div className="outcome-form">
                  <div>
                    <label>page views</label>
                    <input type="text" inputMode="numeric" value={outcome.pv}
                      onChange={e=>setOutcome({...outcome, pv:e.target.value})}
                      placeholder={hasAutoMatch ? `auto: ${(auto.total_pvs ?? 0).toLocaleString()}` : 'e.g. 12,400'} />
                  </div>
                  <div>
                    <label>final position</label>
                    <input type="text" inputMode="numeric" value={outcome.position}
                      onChange={e=>setOutcome({...outcome, position:e.target.value})}
                      placeholder={hasAutoMatch && auto.hit_count > 0 ? 'auto: top-25 hit' : 'e.g. 7'} />
                  </div>
                  <div>
                    <label>headline grade</label>
                    <input type="text" value={outcome.headlineGrade} onChange={e=>setOutcome({...outcome, headlineGrade:e.target.value})} placeholder="A / B / C" />
                  </div>
                </div>
                <div className={'autosave-status autosave-' + outcomeSaveState}>
                  {outcomeSaveState === 'pending' ? 'saving…'
                    : outcomeSaveState === 'saved' ? 'saved · synced to db'
                    : 'auto-saves on edit'}
                </div>
              </>
            );
          })()}
        </div>

        <div className="xlinks">
          <button className="xlink" onClick={()=>onJump('beach', brief.id)}>see on beach</button>
          <button className="xlink" onClick={()=>onJump('gap', brief.id)}>see on gap map</button>
          {decision && <button className="xlink" onClick={()=>onJump('log', brief.id)}>open in log</button>}
          {decision && (
            <button className="xlink" onClick={() => setBriefCardOpen(true)} title="Open the printable, ready-to-send brief card">
              Brief card (printable)
            </button>
          )}
          {decision?.action === 'keep' && (
            <button className="xlink" onClick={() => setEditorialOpen(true)} title="Reopen the editorial brief generator">
              Editorial brief
            </button>
          )}
        </div>
      </div>

      <aside className="t2-side">
        <div className="side-block">
          <h3 className="section-title">Recipe coordinates <LensHelp id="recipe-coordinates" size="sm" /></h3>
          <dl className="kv">
            <dt>th collection</dt>
            <dd>
              <span className="coll-tag big" title={collMeta.desc}><span className="coll-glyph">{collMeta.glyph}</span> {collMeta.name}</span>
              <div className="dim" style={{fontFamily:'var(--font-sans)', fontSize:11, marginTop:4, color:'var(--t-3)', letterSpacing:0, textTransform:'none'}}>{collMeta.desc}</div>
            </dd>
            <dt>format</dt><dd>{recipe.format}</dd>
            <dt>persona</dt>
            <dd>
              {persona.name}
              {persona.primary && <span className="pill primary-tag" style={{marginLeft:6}}>TH primary</span>}
              <span className={'pill fit-pill fit-' + fit} style={{marginLeft:6}}>fit · {fit}</span>
              <span className="dim mono" style={{fontSize:10, marginLeft:6}}>see breakdown ↓</span>
            </dd>
          </dl>
          {/* Downstream-production handoff details (platforms / canonical /
              diff) live behind a collapsed disclosure—not load-bearing for
              the keep/skip/defer decision, but useful when assigning + scoping
              the article. */}
          <details className="recipe-extra">
            <summary>production handoff details</summary>
            <dl className="kv">
              <dt>platforms</dt>
              <dd>
                {recipe.platforms.map((p)=>{
                  const isLE = /UsWeekly|WomansWorld|L&E/i.test(p);
                  return <span key={p} className={'pill ' + (isLE?'pill-paused':'')} title={isLE?'L&E placement paused—L&E data pipe broken; awaiting repair':''}>{p}{isLE && ' · paused'}</span>;
                })}
              </dd>
              <dt>canonical</dt><dd style={{fontFamily:'var(--font-sans)', fontSize:12, color: isPaused?'var(--t-3)':'var(--t-2)', textTransform:'none', letterSpacing:0}}>{recipe.canonical}</dd>
              <dt>diff</dt><dd style={{fontFamily:'var(--font-sans)', fontSize:12, color: isPaused?'var(--t-3)':'var(--t-2)', textTransform:'none', letterSpacing:0}}>{recipe.differentiation}</dd>
            </dl>
          </details>
        </div>

        {/* Persona fit breakdown—content-aware. Shows all 5 personas ranked
            with their match count + which affinity terms hit. */}
        <div className="side-block">
          <h3 className="section-title">Persona fit <LensHelp id="persona-fit" size="sm" /> <span className="vb-h-meta">content-aware</span></h3>
          {(() => {
            const detail = personaFitDetail(brief);
            return (
              <>
                <div className="pf-prose">
                  Top match: <b>{PERSONAS[detail.topPersona]?.name || detail.topPersona}</b>
                  {' '}with {detail.scores[detail.topPersona].matchCount} affinity term{detail.scores[detail.topPersona].matchCount === 1 ? '' : 's'}.
                  Scored against topic + recommended article + keywords.
                </div>
                <table className="pf-tbl">
                  <thead><tr><th>persona</th><th>fit</th><th title="Affinity-term matches">matches</th><th title="Score = matched terms × weight + content-type prior">score</th></tr></thead>
                  <tbody>
                    {detail.ranked.map(pid => {
                      const meta = PERSONAS[pid];
                      const s = detail.scores[pid];
                      const lvl = detail.fits[pid];
                      const isSelected = pid === personaIdResolved;
                      return (
                        <tr key={pid} className={'pf-row pf-' + lvl + (isSelected ? ' pf-selected' : '')}>
                          <td>
                            {meta?.name || pid}
                            {meta?.primary && <span className="pf-primary-tag">TH primary</span>}
                            {isSelected && <span className="pf-selected-tag">selected</span>}
                          </td>
                          <td><span className={'pill fit-pill fit-' + lvl}>{lvl}</span></td>
                          <td className="pf-matches" title={s.matches.length ? s.matches.slice(0, 12).join(', ') + (s.matches.length > 12 ? `, +${s.matches.length-12} more` : '') : 'no affinity terms matched'}>
                            {s.matchCount > 0
                              ? <span className="pf-match-count">{s.matchCount}</span>
                              : <span className="dim">—</span>}
                            {s.matches.length > 0 && (
                              <span className="pf-match-sample">
                                {' '}{s.matches.slice(0, 3).join(', ')}{s.matches.length > 3 ? '…' : ''}
                              </span>
                            )}
                          </td>
                          <td className="pf-score">{s.total.toFixed(1)}</td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
                <div className="pf-foot dim mono">
                  Score = matches × weight + content-type prior. Tune from Decision Log when Phase-5 outcome data accumulates.
                </div>
              </>
            );
          })()}
        </div>

        {/* Platform Reach panel—off-O&O syndication PVs from
            TRACKER_ENRICHED (Apple News, SmartNews, NewsBreak, Yahoo) +
            manual Tarrow XLSX ingest (MSN gap + supplementary detail). */}
        {(() => {
          const reach = platformReachFor(brief.id);
          if (!reach || reach.total === 0) return null;
          const PLATFORM_LABELS = {
            apple_news:               'Apple News',
            apple_news_notifications: 'Apple News · push',
            smartnews:                'SmartNews',
            newsbreak:                'NewsBreak',
            yahoo:                    'Yahoo',
            yahoo_video:              'Yahoo video',
            msn:                      'MSN',
            msn_video:                'MSN video',
            backlink:                 'External backlink',
          };
          const fmt = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(n>=1e4?0:1)+'K' : String(n);
          const sortedPlatforms = Object.entries(reach.platforms)
            .sort((a, b) => (b[1].pvs || 0) - (a[1].pvs || 0));
          return (
            <div className="side-block">
              <h3 className="section-title">
                Platform reach · off-O&amp;O <LensHelp id="platform-reach" size="sm" />
                <span className="pr-source-badges">
                  {reach.sources.snowflake && <span className="pill pr-src-pill" title="Snowflake TRACKER_ENRICHED—daily-fresh, automated">SF daily</span>}
                  {reach.sources.tarrow && <span className="pill pr-src-pill" title={`Tarrow XLSX manual ingest—${reach.sources.tarrowAsof || 'last upload'}`}>Tarrow {reach.sources.tarrowAsof || ''}</span>}
                </span>
              </h3>
              <div className="pr-total dim mono">
                Total platform PVs (off-O&amp;O): <b>{reach.total.toLocaleString()}</b>
              </div>
              <table className="pr-tbl">
                <thead><tr><th>platform</th><th>PVs</th><th>top article</th></tr></thead>
                <tbody>
                  {sortedPlatforms.map(([p, agg]) => (
                    <tr key={p} className={'pr-row src-' + agg.source}>
                      <td>
                        {PLATFORM_LABELS[p] || p}
                        <span className={'pr-src-tag src-' + agg.source}>{agg.source}</span>
                      </td>
                      <td className="pr-pvs">{fmt(agg.pvs)}</td>
                      <td className="pr-top dim" title={agg.topTitle ? `${agg.topTitle}${agg.topBrand ? ' · ' + agg.topBrand : ''}` : ''}>
                        {agg.topTitle ? (agg.topTitle.length > 32 ? agg.topTitle.slice(0, 32) + '…' : agg.topTitle) : '—'}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="pr-foot dim mono">
                Snowflake source = TRACKER_ENRICHED platform PV columns (daily). Tarrow = manual weekly XLSX ingest (fills MSN gap + adds top-article detail).
              </div>
            </div>
          );
        })()}

        {/* Author Authority panel—Layer 9. Per-brief × per-writer E-E-A-T
            signal from TRACKER_ENRICHED.AUTHOR + engagement. Writers
            identified by initials (2-4 chars) per the published-bylines
            carve-out for the 6-person content team. Renders a "pending"
            state until the precompute has been run against fresh Snowflake. */}
        {(() => {
          const meta = authorAuthorityMeta();
          const auth = authorAuthorityFor(brief.id);
          // Hide entirely when no live data—no "pending" placeholder. The
          // (?) bubble in the Decisions/Docs surfaces explains what author
          // authority will show once data lands.
          if (!meta?.asof || !auth) return null;
          return (
            <div className="side-block">
              <h3 className="section-title">
                Author authority · E-E-A-T <LensHelp id="author-authority" size="sm" />
                <span className="pill aa-live-pill" title={`Layer 9 daily precompute · ${meta.window_days}d window · refreshed ${meta.asof}`}>live · {meta.asof}</span>
              </h3>
              <div className="aa-summary dim mono">
                <b>{auth.n_authors_qualifying}</b> author{auth.n_authors_qualifying===1?'':'s'} with ≥{(meta.min_articles_per_author||2)} matched articles · <b>{auth.n_matched_articles}</b> total articles in window
              </div>
              <table className="aa-tbl">
                <thead>
                  <tr>
                    <th title="Writer initials (2-4 chars from byline)">writer</th>
                    <th title="Articles by this writer matching the brief's keywords">arts</th>
                    <th title="Team-internal: writer's brief-avg PVs ÷ team's whole-window avg. 1.00 = team average; 2.00× = double team average; 0.50× = half.">vs team</th>
                    <th title="Genre-stratified: writer's articles that beat the team's (domain × format) median. Apples-to-apples within the team's own genres.">genre hits</th>
                    <th title="Pub-wide: legacy hit_rate against pub-wide median across ALL pub content (news, sports, etc.). Structurally biased low for the team's TH-format/bridge niche. Kept for legacy comparison.">vs pub</th>
                    <th>top PVs</th>
                  </tr>
                </thead>
                <tbody>
                  {auth.authors.map((a, i) => (
                    <tr key={a.initials} className={i === 0 ? 'aa-top' : ''}>
                      <td className="aa-hash">{a.initials}</td>
                      <td>{a.n_articles}</td>
                      <td className={(a.team_relative_pv ?? 0) >= 1 ? 'aa-hit' : ''}>
                        {a.team_relative_pv != null ? a.team_relative_pv + '×' : '—'}
                      </td>
                      <td className={(a.genre_hit_rate ?? 0) > 0 ? 'aa-hit' : ''}>
                        {a.genre_benchmarkable
                          ? `${a.genre_hit_count}/${a.genre_benchmarkable}`
                          : '—'}
                      </td>
                      <td className="dim">
                        {a.hit_count > 0 ? `${a.hit_count}` : '—'}
                      </td>
                      <td className="aa-pvs">{a.top_article_pvs.toLocaleString()}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="aa-foot dim mono">
                Writer initials are stable across byline drift (whitespace + case normalized; hyphens split). <b>vs team</b> + <b>genre hits</b> are team-internal baselines (apples-to-apples within the 6-writer content team); <b>vs pub</b> is the legacy pub-wide signal kept for comparison—structurally biased low because the team's tracked output is a niche genre slice of any pub's broader pool.
              </div>
            </div>
          );
        })()}

        {engagementOn && (() => {
          // Engagement panel—content strategist's content-type-aware framework. Shows
          // per-metric breakdown with live/proxy source badge per row.
          const live = engagementLiveFor(brief.id);
          const liveMeta = (typeof window !== 'undefined') ? window.ENGAGEMENT_SIGNALS : null;
          const fmtMetric = (key) => {
            const labels = {
              time_on_page: 'Time on page',
              pvs_per_session: 'PVs/session',
              scroll_depth: 'Scroll depth',
              pvs_per_new_visitor: 'PVs/new visitor',
              new_visitor_rate: 'New visitor rate',
              conversions_per_pv: 'Conversions/PV',
              newsletter_signups: 'Newsletter signups',
            };
            return labels[key] || key;
          };
          const fmtRaw = (key) => {
            if (!live || !live.raw) return null;
            const r = live.raw;
            if (key === 'time_on_page' && r.time_on_page_median_s != null) return `${r.time_on_page_median_s}s median`;
            if (key === 'pvs_per_session' && r.pvs_per_session_median != null) return `${r.pvs_per_session_median} median`;
            if (key === 'pvs_per_new_visitor' && r.pvs_per_new_visitor_raw != null) return `${r.pvs_per_new_visitor_raw} ratio`;
            if (key === 'new_visitor_rate' && live.new_visitor_rate != null) return `${(live.new_visitor_rate * 100).toFixed(0)}%`;
            if (key === 'conversions_per_pv' && r.conversions_total != null) return `${r.conversions_total} conv`;
            if (key === 'newsletter_signups' && r.newsletter_signups_total != null) return `${r.newsletter_signups_total} signups`;
            return null;
          };
          return (
            <div className="side-block">
              <h3 className="section-title">
                Engagement signal <LensHelp id="engagement-signal" size="sm" />
                <span className={'pill eng-source-pill eng-' + engDetail.source} style={{marginLeft:8}}
                      title={engDetail.source === 'live' ? `Live from TRACKER_ENRICHED · ${liveMeta ? liveMeta.window_days + 'd window · refreshed ' + liveMeta.asof : 'auto-precomputed'}` : 'Proxy fallback used when no article has published yet for this brief\'s keyword space. Asterisks mark proxy values.'}>
                  {engDetail.source === 'live' ? 'live' : 'proxy*'}
                </span>
              </h3>
              <div className="eng-bar-wrap">
                <div className="eng-bar" style={{'--p': eng + '%'}}></div>
                <div className="eng-row">
                  <span className="eng-v">{eng}</span>
                  <span className="dim" style={{fontFamily:'var(--font-mono)', fontSize:11}}>· of 100</span>
                </div>
              </div>
              <div className="eng-framework-label">
                {engDetail.framework}
              </div>
              <table className="eng-components">
                <thead><tr><th>signal</th><th>value</th><th>weight</th></tr></thead>
                <tbody>
                  {Object.entries(engDetail.components).map(([key, c]) => (
                    <tr key={key} className={'src-' + c.source}>
                      <td>
                        {fmtMetric(key)}
                        {c.source === 'proxy' && <span className="eng-asterisk">*</span>}
                      </td>
                      <td title={fmtRaw(key) || `${(c.value * 100).toFixed(0)}% normalized`}>
                        {(c.value * 100).toFixed(0)}{c.source === 'proxy' ? '*' : ''}
                      </td>
                      <td className="dim">×{(c.weight * 100).toFixed(0)}%</td>
                    </tr>
                  ))}
                  {engDetail.missing.length > 0 && engDetail.missing.map(m => (
                    <tr key={'miss-' + m} className="src-missing">
                      <td>{fmtMetric(m)} <span className="eng-gap">gap</span></td>
                      <td colSpan="2" className="dim mono">not derivable</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="eng-foot dim mono">
                {engDetail.source === 'live'
                  ? `Live · ${live.n_articles} matched articles · ${(live.total_pvs ?? 0).toLocaleString()} PVs in window`
                  : `Proxy fallback (no article published yet)—derived from KD + persona-fit + content-type. * = proxy value.`}
              </div>
            </div>
          );
        })()}

        {revenueOn && (() => {
          // Stage 1 (2026-04-28)—full-portfolio revenue ranking.
          // Replaces the prior "first suggested pub only" reductive view.
          // Operator can see where this brief earns most + use that for placement.
          // Math: volume × CTR(expectedRank from KD) × eCPM ÷ 1000.
          // Caveats footer explains what this excludes (direct-sold, section
          // variance, search-driven DA bonus pending GSC = Stage 2).
          const rows = portfolioRevenue(brief);
          const expectedRank = expectedRankFromKD(brief.avgKD);
          const ctr = ctrAtPosition(expectedRank);
          const staleness = (typeof ecpmStalenessState === 'function') ? ecpmStalenessState() : null;

          // Group rows by tier for visual structure
          const thOO = rows.filter(r => r.tier === 'th-primary');
          const regional = rows.filter(r => r.tier === 'regional');
          const le = rows.filter(r => r.tier === 'le');

          // GSC authority signal—count pubs with observed rank for this brief
          const gscPubs = rows.filter(r => r.rankSource === 'gsc-observed');
          const gscMeta = (typeof window !== 'undefined' && window.GSC_AUTHORITY) || null;

          const renderRow = (r) => {
            const fmtRank = (n) => n < 10 ? '#' + n.toFixed(1) : '#' + Math.round(n);
            const rankCell = r.rankSource === 'gsc-observed'
              ? <span className="rank-gsc" title={`GSC last 28d: ${r.gsc.impressions.toLocaleString()} impressions, ${r.gsc.clicks.toLocaleString()} clicks, ${r.gsc.queryCount} queries. Top: "${r.gsc.topQuery}" (${r.gsc.topQueryImpressions.toLocaleString()} impr).`}>
                  {fmtRank(r.expectedRank)} <span className="rank-src">obs</span>
                </span>
              : <span className="rank-kd" title={`No GSC observed rank for this brief × pub. Estimated from KD ${brief.avgKD} → rank #${r.expectedRank}.`}>
                  {fmtRank(r.expectedRank)} <span className="rank-src">est</span>
                </span>;
            return (
              <tr key={r.pubKey} className={r.paused ? 'row-paused' : ''}>
                <td className="kw">
                  {r.name}
                  {r.paused && <span className="pill pill-paused" style={{marginLeft:6}}>paused</span>}
                  {r.smallSample && <span className="pill" style={{marginLeft:6, fontSize:9, color:'var(--t-3)'}}>small N</span>}
                </td>
                <td>${r.ecpm.toFixed(2)}</td>
                <td className="rev-rank-cell">{rankCell}</td>
                <td className="dim mono" style={{fontSize:10}}>{r.pv.toLocaleString()}</td>
                <td style={{color: r.paused ? 'var(--t-3)' : 'var(--kept)', textDecoration: r.paused ? 'line-through' : 'none', fontWeight: r === rows[0] && !r.paused ? 600 : 400}}>
                  ${Math.round(r.revenue).toLocaleString()}
                </td>
              </tr>
            );
          };

          return (
            <div className="side-block">
              <h3 className="section-title">
                Revenue · portfolio projection <LensHelp id="revenue-projection" size="sm" />
                {gscPubs.length > 0 && (
                  <span className="pill gsc-pill" style={{marginLeft:8}}
                        title={`${gscPubs.length} pubs have GSC-observed ranking data for this brief's keywords (last ${gscMeta?.window_days || 28}d, refreshed ${gscMeta?.asof || 'recently'}). Other pubs fall back to KD-estimated rank.`}>
                    {gscPubs.length} pub{gscPubs.length === 1 ? '' : 's'} · GSC-observed
                  </span>
                )}
                {staleness && staleness.state !== 'fresh' && (
                  <span className={'pill ecpm-staleness ' + staleness.state} style={{marginLeft:6}}
                        title={`eCPM table refreshed ${staleness.refreshDate}; quarterly cadence`}>
                    {staleness.state === 'overdue'
                      ? `eCPM refresh OVERDUE by ${-staleness.daysUntilOverdue}d`
                      : `eCPM refresh due in ${staleness.daysUntilOverdue}d`}
                  </span>
                )}
              </h3>

              <div className="rev-assumption-bar">
                <span>
                  KD <b>{brief.avgKD}</b> → KD-estimated rank ≈ <b>#{expectedRank}</b> ({(ctr*100).toFixed(1)}% CTR fallback).
                  Pubs with GSC-observed rank use observed avg position instead.
                </span>
              </div>

              <table className="kwtbl rev-portfolio" style={{marginBottom:0}}>
                <colgroup>
                  <col className="col-pub" />
                  <col className="col-ecpm" />
                  <col className="col-rank" />
                  <col className="col-pv" />
                  <col className="col-rev" />
                </colgroup>
                <thead>
                  <tr>
                    <th>publication</th>
                    <th>eCPM</th>
                    <th title="Expected ranking position. 'obs' = GSC observed avg position last 28d. 'est' = KD-derived fallback.">rank</th>
                    <th title="Projected monthly PVs = volume × CTR(rank)">PV/mo</th>
                    <th>$ / mo</th>
                  </tr>
                </thead>
                <tbody>
                  {thOO.length > 0 && (<>
                    <tr className="rev-section-row"><td colSpan="5" className="dim mono">— TH primary —</td></tr>
                    {thOO.map(renderRow)}
                  </>)}
                  {regional.length > 0 && (<>
                    <tr className="rev-section-row"><td colSpan="5" className="dim mono">— regional portfolio —</td></tr>
                    {regional.map(renderRow)}
                  </>)}
                  {le.length > 0 && (<>
                    <tr className="rev-section-row"><td colSpan="5" className="dim mono">— L&amp;E (paused—data pipe broken) —</td></tr>
                    {le.map(renderRow)}
                  </>)}
                </tbody>
              </table>

              <details className="rev-caveats">
                <summary>What this number excludes</summary>
                <ul>
                  <li><b>Direct-sold revenue.</b> This eCPM is OMP (programmatic) only. Sections with active direct-sold demand (real estate, finance) earn meaningfully more per PV.</li>
                  <li><b>Section variance.</b> Same pub, same eCPM number—but real-estate, premium-vertical sections trade higher than the site-blended average shown here.</li>
                  <li><b>Per-pub topical authority—Stage 2 LIVE.</b> Pubs marked <code>obs</code> use GSC-observed avg position from the last 28d; pubs marked <code>est</code> fall back to KD-derived rank because we don't have observed ranking data for those queries on those pubs (yet).</li>
                  <li><b>~35% monthly volatility</b> (holidays strong, January softer). Number is a stable-state baseline, not a spot value.</li>
                  <li><b>CPC isn't part of this number.</b> CPC sits in the verdict score (10% weight, vertical-percentile normalized) as a commercial-intent proxy. Not a direct revenue input.</li>
                </ul>
              </details>

              <div className="dim mono rev-footer">
                eCPM: 2026-04-09 quarterly export · CTR: industry curve · GSC: SEARCH_ANALYSIS_RPT WEB last {gscMeta?.window_days || 28}d{gscMeta?.asof ? ` · refreshed ${gscMeta.asof}` : ''} · L&amp;E paused until data pipe is repaired
              </div>
            </div>
          );
        })()}

        <div className="side-block">
          <h3 className="section-title">Source · {(brief.dataSource || 'unknown').split('—')[0]}</h3>
          <div className="dim" style={{fontSize:11, fontFamily:'var(--font-mono)'}}>{brief.dataSource || 'unknown'}</div>
        </div>

        {/* Revenue range with toggleable assumptions (spec 2.7)—three
            editable inline inputs. State per-session (no persistence). */}
        {(() => {
          const range = (typeof revenueRange === 'function') ? revenueRange(brief) : null;
          // Auto defaults—derive from runtime verdict for articles count,
          // a reasonable PV-per-article baseline, and the brief's portfolio eCPM.
          const verdict = (typeof briefRuntimeVerdict === 'function') ? briefRuntimeVerdict(brief) : null;
          const autoArticles = verdict === 'high-opportunity' ? 3 : verdict === 'worth-testing' ? 2 : 1;
          const autoPv = 8000;
          const autoEcpm = range && range.bestRevenue && range.count
            ? Math.max(1, Math.round((range.bestRevenue / Math.max(1, autoPv * autoArticles)) * 1000) / 1) || 2.50
            : 2.50;
          const eff = revOverride || { articles: autoArticles, pv: autoPv, ecpm: autoEcpm };
          const computed = (eff.articles || 0) * (eff.pv || 0) * (eff.ecpm || 0) / 1000;
          const min = computed * 0.7, max = computed * 1.3;
          const setField = (k, v) => setRevOverride({ ...eff, [k]: Number(v) || 0 });
          const num = (v) => Number.isFinite(v) ? v : '';
          return (
            <div className="side-block">
              <h3 className="section-title">Revenue range · assumptions <TermHelp termKey="revenueRange" /></h3>
              <div style={{fontSize:12, lineHeight:1.6}}>
                <input type="number" min="0" step="1" value={num(eff.articles)} onChange={e => setField('articles', e.target.value)}
                       style={{width:48, fontSize:12, textAlign:'right',
                               background:'var(--bg-1, #0d1117)', color:'var(--t-1)',
                               border:'1px solid var(--line, rgba(255,255,255,0.20))',
                               borderRadius:3, padding:'2px 4px'}}
                       aria-label="articles" />
                <span> articles × </span>
                <input type="number" min="0" step="100" value={num(eff.pv)} onChange={e => setField('pv', e.target.value)}
                       style={{width:72, fontSize:12, textAlign:'right',
                               background:'var(--bg-1, #0d1117)', color:'var(--t-1)',
                               border:'1px solid var(--line, rgba(255,255,255,0.20))',
                               borderRadius:3, padding:'2px 4px'}}
                       aria-label="PVs each" />
                <span> PVs each × $</span>
                <input type="number" min="0" step="0.10" value={num(eff.ecpm)} onChange={e => setField('ecpm', e.target.value)}
                       style={{width:64, fontSize:12, textAlign:'right',
                               background:'var(--bg-1, #0d1117)', color:'var(--t-1)',
                               border:'1px solid var(--line, rgba(255,255,255,0.20))',
                               borderRadius:3, padding:'2px 4px'}}
                       aria-label="eCPM" />
                <span> eCPM</span>
                <div style={{marginTop:6, color:'var(--kept)', fontWeight:600}}>
                  = ${Math.round(min).toLocaleString()}–${Math.round(max).toLocaleString()} / mo
                </div>
                {revOverride && (
                  <button className="btn" style={{marginTop:6, fontSize:11, padding:'2px 8px'}}
                          onClick={() => setRevOverride(null)}>reset to auto</button>
                )}
              </div>
              <div className="dim mono" style={{fontSize:10, marginTop:6}}>
                per-session · doesn't persist · auto = {autoArticles}a × {autoPv} PV × ${autoEcpm.toFixed(2)} eCPM
              </div>
            </div>
          );
        })()}

        {/* Writers with track record (spec 2.6)—surfaces when AUTHOR_AUTHORITY
            data exists. Visible regardless of decision state, but emphasized
            when the brief is Kept. The existing Author Authority panel above
            already renders when data is live; this is a Keep-time emphasis card
            that calls out top-3 writers with initials + n_articles +
            team-internal metrics + a "copy initials" button per row. */}
        {(() => {
          const authData = (typeof window !== 'undefined' && window.AUTHOR_AUTHORITY?.briefs?.[brief.id]) || null;
          if (!authData || !Array.isArray(authData.authors) || authData.authors.length === 0) return null;
          const top3 = authData.authors.slice(0, 3);
          const isKept = decision?.action === 'keep';
          const copyInitials = async (initials) => {
            try {
              if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(initials);
              else {
                const ta = document.createElement('textarea'); ta.value = initials;
                ta.style.position='absolute'; ta.style.left='-9999px';
                document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
              }
            } catch (e) { console.warn('brief.jsx: window helper threw, silent fallback', e); }
          };
          return (
            <div className="side-block" style={isKept ? {borderLeft:'3px solid var(--kept)', paddingLeft:8} : null}>
              <h3 className="section-title">
                Authors with track record on this topic
                {isKept && <span className="pill" style={{marginLeft:6, fontSize:9, background:'var(--kept)', color:'#fff'}}>kept</span>}
              </h3>
              <table className="aa-tbl" style={{fontSize:11}}>
                <thead>
                  <tr>
                    <th>writer</th>
                    <th title="Articles by this writer matching the brief's keywords">arts</th>
                    <th title="Team-internal: writer's brief-avg PVs ÷ team's whole-window avg. >1.00 = above team average.">vs team</th>
                    <th title="Genre-stratified: hits vs (domain × format) team-median.">genre</th>
                    <th></th>
                  </tr>
                </thead>
                <tbody>
                  {top3.map(a => (
                    <tr key={a.initials}>
                      <td className="aa-hash mono">{a.initials}</td>
                      <td>{a.n_articles}</td>
                      <td>{a.team_relative_pv != null ? a.team_relative_pv + '×' : '—'}</td>
                      <td>{a.genre_benchmarkable ? `${a.genre_hit_count}/${a.genre_benchmarkable}` : '—'}</td>
                      <td>
                        <button onClick={(e) => { e.stopPropagation(); copyInitials(a.initials); }}
                                title="Copy initials to clipboard"
                                style={{background:'transparent', border:'1px solid var(--line)', borderRadius:2, fontSize:9, cursor:'pointer', padding:'1px 5px', color:'var(--t-2)'}}>
                          copy
                        </button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="dim mono" style={{fontSize:10, marginTop:4}}>
                Team-internal baselines: <b>vs team</b> &gt; 1.0× and <b>genre</b> hits = stronger track record within the 6-writer content team. Pub-wide hit_rate is hidden here (structurally biased low for the team's niche genre).
              </div>
            </div>
          );
        })()}

        {/* Competitor SERP visual map (spec 2.5)—replaces the flat list with a
            horizontal stacked bar. Segments are proportional to how many of the
            brief's top-5 keywords each competitor appears in. Click a segment to
            see which keywords that competitor owns. Falls back to a degraded
            list if shape doesn't include keywords-per-competitor coverage. */}
        {brief.topCompetitors && brief.topCompetitors.length > 0 && (() => {
          // Synthesize per-competitor keyword coverage from per-keyword
          // serp_competitors (when present)—this isn't always populated; we
          // degrade by treating the flat list as "covers all top-5".
          const top5 = (brief.topKeywords || []).slice(0, 5);
          const ourDomains = new Set(['heraldsun.com.au', 'mcclatchy.com', 'mcclatchydc.com',
            'kansascity.com', 'miamiherald.com', 'sacbee.com', 'star-telegram.com', 'newsobserver.com',
            'charlotteobserver.com', 'tri-cityherald.com', 'thestate.com', 'fresnobee.com',
            'theolympian.com', 'idahostatesman.com', 'thenewstribune.com', 'bnd.com', 'beaufortgazette.com']);
          const cov = {};
          for (const c of brief.topCompetitors) {
            cov[c.domain] = { count: top5.length || brief.topCompetitors.length, hits: [] };
          }
          // Refine when keyword-level competitors are present.
          for (const kw of top5) {
            if (Array.isArray(kw.competitors)) {
              for (const dom of kw.competitors) {
                if (!cov[dom]) cov[dom] = { count: 0, hits: [] };
                cov[dom].count = (cov[dom].count || 0) + 1;
                cov[dom].hits.push(kw.label);
              }
            }
          }
          const totalSlots = top5.length || brief.topCompetitors.length;
          const sorted = Object.entries(cov).sort((a, b) => b[1].count - a[1].count);
          const segs = sorted.map(([dom, v]) => ({ domain: dom, count: v.count, hits: v.hits, isOurs: ourDomains.has(dom) }));
          const totalCount = segs.reduce((s, x) => s + x.count, 0) || 1;
          return (
            <div className="side-block">
              <h3 className="section-title">Competitor SERP map</h3>
              <div className="serp-map-bar"
                   style={{display:'flex', height:18, borderRadius:3, overflow:'hidden', marginBottom:6, background:'rgba(255,255,255,0.04)'}}
                   aria-label="Competitor SERP coverage map">
                {segs.map((s, i) => {
                  const w = (s.count / totalCount) * 100;
                  const color = s.isOurs ? 'rgba(120,180,120,0.85)'
                              : ['rgba(120,170,220,0.7)','rgba(220,180,120,0.7)','rgba(180,120,200,0.7)','rgba(150,200,150,0.6)','rgba(220,140,140,0.7)','rgba(150,150,200,0.6)'][i % 6];
                  const isActive = activeCompetitor === s.domain;
                  return (
                    <div key={s.domain}
                         onClick={(e) => { e.stopPropagation(); setActiveCompetitor(isActive ? null : s.domain); }}
                         title={`${s.domain}: ${s.count}/${totalSlots} keywords${s.isOurs ? ' (our portfolio)' : ''}`}
                         style={{width: w.toFixed(2)+'%', background: color, cursor:'pointer', borderRight:'1px solid rgba(0,0,0,0.2)', boxShadow: isActive ? 'inset 0 0 0 2px rgba(255,255,255,0.4)' : 'none', transition:'box-shadow 150ms'}} />
                  );
                })}
              </div>
              <div style={{display:'flex', flexWrap:'wrap', gap:'4px 10px', fontSize:11}}>
                {segs.slice(0, 8).map(s => (
                  <span key={s.domain}
                        onClick={(e) => { e.stopPropagation(); setActiveCompetitor(activeCompetitor === s.domain ? null : s.domain); }}
                        style={{cursor:'pointer', color: s.isOurs ? 'var(--kept)' : 'var(--t-2)', fontWeight: activeCompetitor === s.domain ? 600 : 400}}>
                    {s.domain} <span className="dim mono">{s.count}/{totalSlots}</span>
                  </span>
                ))}
              </div>
              {activeCompetitor && cov[activeCompetitor]?.hits?.length > 0 && (
                <div className="dim mono" style={{marginTop:6, fontSize:11}}>
                  {activeCompetitor} owns: {cov[activeCompetitor].hits.join(', ')}
                </div>
              )}
              {activeCompetitor && (!cov[activeCompetitor]?.hits || cov[activeCompetitor].hits.length === 0) && (
                <div className="dim mono" style={{marginTop:6, fontSize:11}}>
                  Per-keyword competitor coverage not populated for this brief—segment widths are uniform across the top-5 SERP set.
                </div>
              )}
            </div>
          );
        })()}

        {/* Headlines that worked here (spec 2.4)—pulls from data-headlines
            outcomes JSON, matched on verticalId or topic. Degrades silently
            when the fetch fails (offline / CORS) per spec. */}
        {(() => {
          if (!headlines) return null;
          // Headlines payload shape: { items: [{ headline, score, verticalId, topic, ... }] } —
          // accept either array root or { items: [...] }.
          const items = Array.isArray(headlines) ? headlines : (headlines.items || headlines.headlines || []);
          if (!Array.isArray(items) || items.length === 0) return null;
          const lcTopic = (brief.topic || '').toLowerCase();
          const matches = items.filter(it => {
            const itTopic = (it.topic || '').toLowerCase();
            const itVert = it.verticalId || it.vertical_id;
            return (itVert && itVert === brief.verticalId) || (itTopic && lcTopic && (itTopic.includes(lcTopic) || lcTopic.includes(itTopic)));
          });
          const ranked = matches.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 3);
          if (ranked.length === 0) return null;
          const grade = (s) => {
            if (s == null) return '—';
            if (s >= 0.9) return 'A';
            if (s >= 0.75) return 'B';
            if (s >= 0.6) return 'C';
            if (s >= 0.4) return 'D';
            return 'F';
          };
          return (
            <div className="side-block">
              <h3 className="section-title">Headlines that worked here</h3>
              <ul style={{listStyle:'none', padding:0, margin:0, fontSize:12}}>
                {ranked.map((h, i) => (
                  <li key={i} style={{padding:'4px 0', borderBottom:'1px solid var(--line)'}}>
                    <span className="mono" style={{display:'inline-block', minWidth:22, color:'var(--t-2)'}}>[{h.grade || grade(h.score)}]</span>
                    <span style={{marginLeft:6}}>{h.headline || h.text || '—'}</span>
                  </li>
                ))}
              </ul>
              <div className="dim mono" style={{fontSize:10, marginTop:4}}>from data-headlines.pierce.tools · top {ranked.length} by score</div>
            </div>
          );
        })()}

        {/* Item 19—Cluster footprint panel. Reads outcome cluster_ids,
            cluster_count, cluster_avg_hit_rate. Hidden when no cluster data. */}
        {(() => {
          const auto = (typeof window !== 'undefined' && window.BRIEF_OUTCOMES?.outcomes?.[brief.id]) || null;
          if (!auto) return null;
          const ids = Array.isArray(auto.cluster_ids) ? auto.cluster_ids : [];
          const count = Number.isFinite(auto.cluster_count) ? auto.cluster_count : ids.length;
          const avgHit = Number.isFinite(auto.cluster_avg_hit_rate) ? auto.cluster_avg_hit_rate : null;
          if (!count && !ids.length) return null;
          const shown = ids.slice(0, 8);
          return (
            <div className="side-block">
              <h3 className="section-title">Cluster footprint</h3>
              <div style={{fontSize:12, lineHeight:1.5}}>
                <div><b>{count}</b> distinct cluster{count === 1 ? '' : 's'} matched
                  {avgHit != null && (
                    <> · avg hit rate <b>{(avgHit * 100).toFixed(0)}%</b></>
                  )}
                </div>
                {shown.length > 0 && (
                  <div className="dim mono" style={{fontSize:10, marginTop:4, wordBreak:'break-all'}}
                       title={ids.length > shown.length ? `+${ids.length - shown.length} more` : undefined}>
                    {shown.join(' · ')}{ids.length > shown.length ? ` +${ids.length - shown.length}` : ''}
                  </div>
                )}
              </div>
            </div>
          );
        })()}

        {/* Cross-brief synergy (spec 3.4)—find other briefs sharing >=2
            keywords or matching cluster_id. Click jumps to that brief.
            Item 20—quantify synergy as (shared kws / this brief's kw count) × 100. */}
        {(() => {
          const briefs = (typeof window !== 'undefined' && Array.isArray(window.BRIEFS)) ? window.BRIEFS : [];
          if (briefs.length === 0) return null;
          const myKwArr = (brief.topKeywords || []).map(k => (k.label || '').toLowerCase()).filter(Boolean);
          const myKws = new Set(myKwArr);
          const myCluster = brief.cluster_id || (typeof window !== 'undefined' && window.BRIEF_OUTCOMES?.outcomes?.[brief.id]?.cluster_id) || null;
          const synergies = [];
          for (const o of briefs) {
            if (o.id === brief.id) continue;
            const oKws = (o.topKeywords || []).map(k => (k.label || '').toLowerCase());
            const overlap = oKws.filter(k => myKws.has(k)).length;
            const sharedCluster = myCluster && (o.cluster_id === myCluster ||
              (typeof window !== 'undefined' && window.BRIEF_OUTCOMES?.outcomes?.[o.id]?.cluster_id === myCluster));
            if (overlap >= 2 || sharedCluster) {
              // Item 20—denominator is THIS brief's keyword count.
              const denom = Math.max(myKwArr.length, 1);
              const pct = Math.round((overlap / denom) * 100);
              synergies.push({ brief: o, overlap, sharedCluster, pct });
            }
          }
          if (synergies.length === 0) return null;
          // Item 20—sort by percentage desc; clusters without overlap fall to
          // the end (no quantifiable percentage).
          synergies.sort((a, b) => {
            if (b.pct !== a.pct) return b.pct - a.pct;
            return (b.sharedCluster ? 1 : 0) - (a.sharedCluster ? 1 : 0);
          });
          return (
            <div className="side-block">
              <h3 className="section-title">This brief reinforces</h3>
              <ul style={{listStyle:'none', padding:0, margin:0, fontSize:12}}>
                {synergies.slice(0, 5).map(s => (
                  <li key={s.brief.id} style={{padding:'3px 0', borderBottom:'1px solid var(--line)'}}>
                    <button className="xlink" onClick={() => onJump('queue', s.brief.id)}
                            style={{padding:0, fontSize:12, textAlign:'left'}}>
                      {s.brief.topic}
                    </button>
                    <span className="dim mono" style={{marginLeft:6, fontSize:10}}
                          title={`${s.overlap} of ${myKwArr.length} keywords overlap${s.sharedCluster ? ' · also shares a cluster' : ''}`}>
                      {s.pct}% overlap{s.sharedCluster ? ' · shared cluster' : ''}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          );
        })()}
      </aside>

      {/* Editorial brief modal (spec 2.1)—opens on Keep. Generated locally;
          no LLM. Operator can copy to clipboard for handoff. */}
      {editorialOpen && (
        <EditorialBriefModal brief={brief} onClose={() => setEditorialOpen(false)} />
      )}

      {/* Printable brief card modal (spec 2.8)—A4-shaped, white-bg,
          print-friendly. Visible whenever there's a decision. */}
      {briefCardOpen && (
        <BriefCardModal brief={brief} decision={decision} onClose={() => setBriefCardOpen(false)} />
      )}
    </div>
  );
}

// ──────── Editorial brief modal (spec 2.1) ────────────────────────────────
// Locally-generated 1-page editorial brief. No LLM. Markdown-styled.
// Renders top-5 keywords by opportunity, persona-vocabulary intersect,
// competitor coverage gaps, recommended article structure.
function EditorialBriefModal({ brief, onClose }) {
  const [copyState, setCopyState] = useState('idle');
  const ranked = (typeof rankKeywordsByOpportunity === 'function')
    ? rankKeywordsByOpportunity(brief.topKeywords || [])
    : (brief.topKeywords || []).slice();
  const top5 = ranked.slice(0, 5);
  // Persona-vocabulary intersect—uses personaFitDetail's matches list.
  const pfd = (typeof personaFitDetail === 'function') ? personaFitDetail(brief) : null;
  const topPersona = pfd?.topPersona;
  const vocab = (pfd?.scores?.[topPersona]?.matches || []).slice(0, 5);
  // Competitor coverage gaps—domains not in our portfolio.
  const ourDomains = new Set(['mcclatchy.com', 'mcclatchydc.com',
    'kansascity.com', 'miamiherald.com', 'sacbee.com', 'star-telegram.com', 'newsobserver.com',
    'charlotteobserver.com', 'tri-cityherald.com', 'thestate.com', 'fresnobee.com',
    'theolympian.com', 'idahostatesman.com', 'thenewstribune.com', 'bnd.com', 'beaufortgazette.com']);
  const competitorGaps = (brief.topCompetitors || []).filter(c => !ourDomains.has(c.domain)).map(c => c.domain).slice(0, 6);
  // Item 25—vocab-intersect quantification.
  // How many vocab terms appear inside the top-3 keywords' label text?
  const top3Labels = top5.slice(0, 3).map(k => (k.label || '').toLowerCase());
  const vocabCrossCount = vocab.reduce((acc, term) => {
    const t = (term || '').toLowerCase();
    if (!t) return acc;
    return acc + (top3Labels.some(l => l.includes(t)) ? 1 : 0);
  }, 0);
  const vocabFitLabel = (() => {
    if (vocab.length === 0) return null;
    const ratio = vocabCrossCount / vocab.length;
    if (ratio >= 0.6) return 'Strong fit';
    if (ratio >= 0.3) return 'Moderate fit';
    return 'Weak fit';
  })();
  const ctObj = getRecommendedContentType(brief);
  const ct = ctObj.contentType;
  const ctRationale = ctObj.rationale;
  const struct = getArticleStructure(ct);
  // Build the markdown text once (used for both render + copy).
  const mdText = [
    `# Editorial brief: ${brief.topic}`,
    ``,
    `**Recommended angle.** ${brief.recommendedAngle || '—'}`,
    ``,
    `## Top 5 keywords by opportunity`,
    ...top5.map((k, i) => `${i + 1}. ${k.label}—vol ${(k.volume || 0).toLocaleString()}, KD ${k.kd ?? '—'}, CPC $${(k.cpc ?? 0).toFixed(2)}`),
    ``,
    `## Persona vocabulary (${topPersona ? (typeof PERSONAS !== 'undefined' && PERSONAS[topPersona]?.name) || topPersona : 'no persona match'})`,
    vocab.length ? vocab.map(v => `- ${v}`).join('\n') : '- (no affinity-term matches in this brief)',
    vocabFitLabel
      ? `_Of the ${vocab.length} vocabulary term${vocab.length === 1 ? '' : 's'} that match, ${vocabCrossCount} appear${vocabCrossCount === 1 ? 's' : ''} in your top-3 keywords. ${vocabFitLabel}._`
      : null,
    ``,
    `## Competitor coverage gaps (own SERP, not in our portfolio)`,
    competitorGaps.length ? competitorGaps.map(d => `- ${d}`).join('\n') : '- (none surfaced)',
    ``,
    `## Article structure—${ct}`,
    ctRationale ? `_${ctRationale}_` : null,
    `- Length: **${struct.words} words**`,
    `- Shape: ${struct.shape}`,
    ``,
    `_Generated locally; no LLM. Sourced from ${brief.dataSource || 'brief metadata'}._`,
  ].filter(line => line !== null).join('\n');

  const onCopy = async () => {
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(mdText);
      } else {
        const ta = document.createElement('textarea');
        ta.value = mdText; ta.style.position = 'absolute'; ta.style.left = '-9999px';
        document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
      }
      setCopyState('copied'); setTimeout(() => setCopyState('idle'), 1800);
    } catch { setCopyState('error'); setTimeout(() => setCopyState('idle'), 2200); }
  };

  // Esc to close.
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  return (
    <div className="ed-brief-modal-bg"
         onClick={onClose}
         style={{position:'fixed', top:0, left:0, right:0, bottom:0, background:'rgba(0,0,0,0.6)', zIndex:9999, display:'flex', alignItems:'center', justifyContent:'center', padding:24}}>
      <div className="ed-brief-modal" onClick={e => e.stopPropagation()}
           style={{background:'var(--bg-1, #1a1a1c)', border:'1px solid var(--line)', borderRadius:6, maxWidth:680, width:'100%', maxHeight:'85vh', overflow:'auto', padding:24, position:'relative'}}>
        <div style={{display:'flex', alignItems:'center', justifyContent:'space-between', borderBottom:'1px solid var(--line)', paddingBottom:8, marginBottom:12}}>
          <div>
            <div className="dim mono" style={{fontSize:11}}>Brief generated</div>
            <h2 style={{margin:'2px 0 0', fontSize:18}}>Editorial brief: {brief.topic}</h2>
          </div>
          <div style={{display:'flex', gap:6}}>
            <button className="btn keep" onClick={onCopy}>
              {copyState === 'copied' ? '✓ copied' : copyState === 'error' ? '✕ failed' : 'Copy to clipboard'}
            </button>
            <button className="btn" onClick={onClose}>close</button>
          </div>
        </div>
        <div className="ed-brief-body" style={{fontSize:13, lineHeight:1.5}}>
          <p><b>Recommended angle.</b> {brief.recommendedAngle || '—'}</p>

          <h3 style={{marginTop:16, marginBottom:6}}>Top 5 keywords by opportunity</h3>
          <ol style={{margin:0, paddingLeft:20}}>
            {top5.map((k, i) => (
              <li key={k.label}><b>{k.label}</b> <span className="dim mono">— vol {(k.volume || 0).toLocaleString()}, KD {k.kd ?? '—'}, CPC ${(k.cpc ?? 0).toFixed(2)}</span></li>
            ))}
          </ol>

          <h3 style={{marginTop:16, marginBottom:6}}>Persona vocabulary {topPersona && typeof PERSONAS !== 'undefined' ? `(${PERSONAS[topPersona]?.name || topPersona})` : ''}</h3>
          {vocab.length ? <ul style={{margin:0, paddingLeft:20}}>{vocab.map(v => <li key={v}>{v}</li>)}</ul>
                       : <p className="dim">No affinity-term matches in this brief.</p>}
          {/* Item 25—vocab-intersect quantifier. */}
          {vocabFitLabel && (
            <p className="dim" style={{marginTop:6, fontSize:12, fontStyle:'italic'}}
               title={`Vocabulary terms cross-referenced against the top-3 keywords (${top3Labels.join(', ')})`}>
              Of the {vocab.length} vocabulary term{vocab.length === 1 ? '' : 's'} that match, {vocabCrossCount} appear{vocabCrossCount === 1 ? 's' : ''} in your top-3 keywords. <b>{vocabFitLabel}.</b>
            </p>
          )}

          <h3 style={{marginTop:16, marginBottom:6}}>Competitor coverage gaps</h3>
          {competitorGaps.length ? (
            <>
              <p className="dim" style={{marginTop:0}}>Own SERP, not in our portfolio:</p>
              <ul style={{margin:0, paddingLeft:20}}>{competitorGaps.map(d => <li key={d}>{d}</li>)}</ul>
            </>
          ) : <p className="dim">No competitor gaps surfaced from the keyword set.</p>}

          <h3 style={{marginTop:16, marginBottom:6}}>Recommended article structure—{ct}</h3>
          {ctRationale && <p className="dim" style={{marginTop:0, marginBottom:6, fontStyle:'italic'}}>{ctRationale}</p>}
          <ul style={{margin:0, paddingLeft:20}}>
            <li>Length: <b>{struct.words} words</b></li>
            <li>Shape: {struct.shape}</li>
          </ul>

          <p className="dim mono" style={{fontSize:10, marginTop:18, borderTop:'1px solid var(--line)', paddingTop:8}}>
            Generated locally · no LLM · sourced from {brief.dataSource || 'brief metadata'}
          </p>
        </div>
      </div>
    </div>
  );
}

// ──────── Printable brief card modal (spec 2.8) ────────────────────────────
// A4-shaped, white-background view for print or screenshot. Includes the
// brief metadata + decision + ranked keywords + competitor map snapshot.
function BriefCardModal({ brief, decision, onClose }) {
  const ranked = (typeof rankKeywordsByOpportunity === 'function')
    ? rankKeywordsByOpportunity(brief.topKeywords || [])
    : (brief.topKeywords || []).slice();
  const top5 = ranked.slice(0, 5);
  const verdict = (typeof briefRuntimeVerdict === 'function') ? briefRuntimeVerdict(brief) : null;
  const range = (typeof revenueRange === 'function') ? revenueRange(brief) : null;
  const ts = decision?.updatedAt || decision?.createdAt || decision?.timestamp || null;
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);
  return (
    <div className="brief-card-modal-bg"
         onClick={onClose}
         style={{position:'fixed', top:0, left:0, right:0, bottom:0, background:'rgba(0,0,0,0.65)', zIndex:9999, display:'flex', alignItems:'flex-start', justifyContent:'center', padding:24, overflow:'auto'}}>
      <div className="brief-card-modal" onClick={e => e.stopPropagation()}
           style={{background:'#fff', color:'#222', borderRadius:4, width:'8.5in', minHeight:'11in', padding:'0.6in 0.7in', boxShadow:'0 4px 32px rgba(0,0,0,0.5)', position:'relative', fontFamily:'Georgia, serif'}}>
        {/* Print + close—hidden when printing via @media print on body */}
        <div className="brief-card-controls"
             style={{position:'absolute', top:8, right:8, display:'flex', gap:6}}>
          <button onClick={() => window.print()}
                  style={{padding:'4px 10px', background:'#222', color:'#fff', border:'none', borderRadius:3, cursor:'pointer', fontSize:11}}>
            Print
          </button>
          <button onClick={onClose}
                  style={{padding:'4px 10px', background:'#888', color:'#fff', border:'none', borderRadius:3, cursor:'pointer', fontSize:11}}>
            close
          </button>
        </div>
        <h1 style={{margin:'0 0 4px', fontSize:24, fontWeight:600}}>{brief.topic}</h1>
        <div style={{fontSize:11, color:'#666', marginBottom:14}}>
          {brief.verticalId} · attribution floor {brief.attributionFloor || '—'}
        </div>
        <div style={{display:'flex', gap:14, marginBottom:14, paddingBottom:10, borderBottom:'1px solid #ccc'}}>
          <div>
            <div style={{fontSize:10, color:'#666', textTransform:'uppercase', letterSpacing:1}}>Verdict</div>
            <div style={{fontSize:16, fontWeight:600}}>
              {verdict === 'high-opportunity' ? 'High Opportunity' : verdict === 'worth-testing' ? 'Worth Testing' : verdict === 'monitor' ? 'Monitor' : verdict === 'skip' ? 'Skip' : '—'}
            </div>
          </div>
          {decision?.action && (
            <div>
              <div style={{fontSize:10, color:'#666', textTransform:'uppercase', letterSpacing:1}}>Decision</div>
              <div style={{fontSize:16, fontWeight:600, textTransform:'capitalize'}}>{decision.action}</div>
            </div>
          )}
          {range && (
            <div>
              <div style={{fontSize:10, color:'#666', textTransform:'uppercase', letterSpacing:1}}>Projected revenue</div>
              <div style={{fontSize:16, fontWeight:600}}>${Math.round(range.min).toLocaleString()}–${Math.round(range.max).toLocaleString()} / mo</div>
            </div>
          )}
        </div>
        {decision?.note && (
          <div style={{marginBottom:14}}>
            <div style={{fontSize:10, color:'#666', textTransform:'uppercase', letterSpacing:1, marginBottom:2}}>Rationale</div>
            <div style={{fontSize:13, lineHeight:1.5, fontStyle:'italic'}}>“{decision.note}”</div>
          </div>
        )}
        <h2 style={{fontSize:14, marginTop:14, marginBottom:6, borderBottom:'1px solid #ddd', paddingBottom:3}}>Recommended angle</h2>
        <p style={{margin:0, fontSize:12, lineHeight:1.5}}>{brief.recommendedAngle || '—'}</p>

        <h2 style={{fontSize:14, marginTop:14, marginBottom:6, borderBottom:'1px solid #ddd', paddingBottom:3}}>Top 5 keywords</h2>
        <table style={{width:'100%', fontSize:11, borderCollapse:'collapse'}}>
          <thead>
            <tr style={{background:'#f4f4f4'}}>
              <th style={{textAlign:'left', padding:'4px 6px', borderBottom:'1px solid #ccc'}}>#</th>
              <th style={{textAlign:'left', padding:'4px 6px', borderBottom:'1px solid #ccc'}}>keyword</th>
              <th style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #ccc'}}>vol</th>
              <th style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #ccc'}}>KD</th>
              <th style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #ccc'}}>CPC</th>
            </tr>
          </thead>
          <tbody>
            {top5.map((k, i) => (
              <tr key={k.label}>
                <td style={{padding:'4px 6px', borderBottom:'1px solid #eee'}}>{i + 1}</td>
                <td style={{padding:'4px 6px', borderBottom:'1px solid #eee'}}>{k.label}</td>
                <td style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #eee'}}>{(k.volume || 0).toLocaleString()}</td>
                <td style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #eee'}}>{k.kd ?? '—'}</td>
                <td style={{textAlign:'right', padding:'4px 6px', borderBottom:'1px solid #eee'}}>${(k.cpc ?? 0).toFixed(2)}</td>
              </tr>
            ))}
          </tbody>
        </table>

        {brief.topCompetitors?.length > 0 && (
          <>
            <h2 style={{fontSize:14, marginTop:14, marginBottom:6, borderBottom:'1px solid #ddd', paddingBottom:3}}>Competitor SERP map</h2>
            <p style={{fontSize:11, margin:0}}>{brief.topCompetitors.map(c => c.domain).join(' · ')}</p>
          </>
        )}

        <div style={{display:'flex', justifyContent:'space-between', borderTop:'1px solid #ccc', marginTop:24, paddingTop:8, fontSize:10, color:'#666'}}>
          <span>Decision timestamp: {ts ? new Date(ts).toLocaleString() : 'pending'}</span>
          <span>Operator: {decision?.operator || decision?.author || '—'}</span>
        </div>
        <div style={{textAlign:'center', fontSize:9, color:'#999', marginTop:6}}>
          data-keywords · brief card · printable view
        </div>
      </div>
    </div>
  );
}

// Memoize BriefTile—~43 tiles re-rendering on every keystroke in the
// trend filter is wasteful. Custom comparator: re-render only when the
// brief, its decision, or relevant tweak flags change. trendMatch is
// included because it gates the highlight/dim CSS class. Wrap and
// publish on window so lenses.jsx (loaded next) picks up the memoized
// version when it does <BriefTile> via the same global lookup chain.
const _BriefTileMemo = React.memo(BriefTile, (prev, next) => {
  if (prev.brief !== next.brief) return false;
  if (prev.brief?.id !== next.brief?.id) return false;
  if (prev.decision !== next.decision) return false;
  if (prev.expanded !== next.expanded) return false;
  if (prev.trendMatch !== next.trendMatch) return false;
  if (prev.revenueOn !== next.revenueOn) return false;
  if (prev.engagementOn !== next.engagementOn) return false;
  if (prev.personaOn !== next.personaOn) return false;
  if (prev.personaId !== next.personaId) return false;
  if (prev.onToggle !== next.onToggle) return false;
  if (prev.onDecide !== next.onDecide) return false;
  if (prev.onJump !== next.onJump) return false;
  if (prev.playSequenceIndex !== next.playSequenceIndex) return false;
  if (prev.playSequenceTotal !== next.playSequenceTotal) return false;
  return true;
});
// eslint-disable-next-line no-func-assign
BriefTile = _BriefTileMemo;

// TODO(lenses.jsx): when rendering BriefTile inside Beach lens, call
// getPlaySequence(briefs, playId) and pass playSequenceIndex + playSequenceTotal
// props so each tile can show "#1 of N in this Play". Without those props the
// tile is silent (no badge)—same memo-stable behavior as before.
// TODO(util.js): playSequence(briefs, playId) belongs in util.js as a primitive
//—when it lands, getPlaySequence() will prefer window.playSequence over the
// local fallback in this file.

Object.assign(window, {
  BriefTile: _BriefTileMemo,
  BriefDrawer,
  VerdictGlyph,
  VerdictBreakdown,
  // helpers exposed for cross-file use; safe to call from lenses.jsx if needed
  getPlaySequence,
  EditorialBriefModal,
  BriefCardModal,
  Sparkline,
  TermHelp,
  DiffValue,
  ScoreDecompBar,
  WhyNowBadge,
  TERM_HELP,
  ciIsLowConfidence,
});
