// Docs lens—comprehensive, three-part documentation.
//
// Part I —How this works (plain English, no jargon, accessible to anyone).
// Part II—Technical specs (architecture, files, cron, deployment).
// Part III—Calculations & weights glossary (every computed signal explained).
//
// Search input filters TOC entries live. Active item is highlighted as user
// scrolls. Cross-section anchor links via jump(id).

const DOCS_TOC = [
  // ───── PART I—How this works (plain English) ─────
  { id: 'p1', label: 'PART I—How this works', items: [
    { id: 'pivot-summary', label: '2026-04-27 pivot · read first' },
    { id: 'what-is', label: 'What this console is' },
    { id: 'who-uses', label: 'Who uses each lens' },
    { id: 'concepts-101', label: 'Concepts 101 · zero-knowledge primer' },
    { id: 'lens-cheatsheet', label: 'The five lenses (cheat sheet)' },
    { id: 'lens-queue', label: 'Today\'s briefs · daily triage' },
    { id: 'lens-beach', label: 'Beach · editorial workspace' },
    { id: 'lens-gap', label: 'Gap map · portfolio positioning' },
    { id: 'lens-log', label: 'Decisions · history + outcomes' },
    { id: 'brief-tile', label: 'Anatomy: the brief tile' },
    { id: 'brief-drawer', label: 'Anatomy: the brief drawer' },
    { id: 'brief-decide', label: 'Triage: Keep / Skip / Defer' },
    { id: 'engagement-pill-states', label: 'Engagement pill: live vs proxy' },
    { id: 'filters-sort', label: 'Filters, sort, focus mode' },
    { id: 'filter-recipes', label: 'Filter recipes (what each combo finds)' },
    { id: 'sort-recipes', label: 'Sort recipes (what each dimension surfaces)' },
    { id: 'reverting', label: 'How to revert / undo any decision' },
    { id: 'help-bubbles', label: 'The (?) help bubbles' },
    { id: 'decisions-persistence', label: 'Where your decisions live' },
    { id: 'auto-vs-manual', label: 'What auto-updates vs what\'s manual' },
    { id: 'troubleshooting', label: 'Troubleshooting' },
    { id: 'faq', label: 'FAQ' },
  ]},

  // ───── PART II—Technical specs ─────
  { id: 'p2', label: 'PART II—Technical specs', items: [
    { id: 'tech-architecture', label: 'Architecture overview' },
    { id: 'tech-data-sources', label: 'Data sources + refresh cadence' },
    { id: 'tech-file-map', label: 'File map (where everything lives)' },
    { id: 'tech-decisions-worker', label: 'Decisions worker + D1 database' },
    { id: 'tech-semrush', label: 'SEMrush pull pipeline' },
    { id: 'tech-snowflake-layers', label: 'Snowflake learning-loop layers' },
    { id: 'tech-cron', label: 'Cron schedules' },
    { id: 'tech-config', label: 'Configuration + secrets' },
    { id: 'tech-deployment', label: 'Deployment runbooks' },
    { id: 'tech-quality-gates', label: 'Quality gates' },
  ]},

  // ───── PART III—Glossary of every calculation + weight ─────
  { id: 'p3', label: 'PART III—Glossary: calculations & weights', items: [
    { id: 'gloss-vocab', label: 'Vocabulary (KD, CPC, vol, head/mid/long-tail)' },
    { id: 'gloss-tier-pill', label: 'Tier pill (T1 / T2 / T3 / down-arrow)' },
    { id: 'gloss-composite', label: 'Composite score (75/10/10/5)' },
    { id: 'gloss-volume', label: 'Volume normalization' },
    { id: 'gloss-invkd', label: 'Inv-KD (keyword difficulty)' },
    { id: 'gloss-cpc', label: 'CPC (vertical-relative)' },
    { id: 'gloss-cpc-norms', label: 'Per-vertical CPC norms (full table)' },
    { id: 'gloss-residual', label: 'Residual nudges' },
    { id: 'gloss-cuts', label: 'Cohort percentile cuts (30/50/20)' },
    { id: 'gloss-auto-skip', label: 'Hard auto-skip rules' },
    { id: 'gloss-kd-bands', label: 'KD bands (TH Framework)' },
    { id: 'gloss-web-roles', label: 'Web roles' },
    { id: 'gloss-cluster-opp', label: 'Cluster opportunity score' },
    { id: 'gloss-da-shift', label: 'DA-shift (per-pub authority)' },
    { id: 'gloss-ctr', label: 'CTR curve (KD-derived → GSC-observed)' },
    { id: 'gloss-revenue', label: 'Revenue projection' },
    { id: 'gloss-ecpm-table', label: 'eCPM table (static per-pub)' },
    { id: 'gloss-engagement', label: 'Engagement signal composite' },
    { id: 'gloss-engagement-pill', label: 'Engagement pill states (live vs proxy)' },
    { id: 'gloss-persona-fit', label: 'Persona-fit scoring' },
    { id: 'gloss-personas', label: 'Personas—full vocabularies' },
    { id: 'gloss-author', label: 'Author authority' },
    { id: 'gloss-lifecycle', label: 'Brief lifecycle states' },
    { id: 'gloss-production-mix', label: 'Production-mix target (60/30/10)' },
    { id: 'gloss-unlocks', label: 'Rolling unlock thresholds' },
    { id: 'gloss-tve', label: 'Trend Velocity Exception (TVE)' },
    { id: 'gloss-etrp', label: 'ETRP—empirical findings' },
    { id: 'gloss-content-types', label: 'Content types (Blue/Orange/Purple/Green)' },
    { id: 'gloss-th-collections', label: 'TH Collections (Mind & Body / etc)' },
    { id: 'gloss-confidence', label: 'Confidence: precise vs partial' },
    { id: 'gloss-our-pos', label: '"Our pos"—what 999 means' },
  ]},

  // ───── PART IV—Outcome reconciliation (item 2.3) ─────
  // Calibration surface—joins Decision Log + Layer 0 outcomes + Layer 10
  // revenues. Reads window.DECISIONS_OUTCOMES (precompute_decisions_outcomes.py).
  { id: 'p4', label: 'PART IV—Outcome reconciliation', items: [
    { id: 'recon-summary',     label: 'Summary stats' },
    { id: 'recon-attribution', label: 'How attribution works (explainer)' },
    { id: 'recon-by-action',   label: 'By decision action (Keep / Skip / Defer)' },
    { id: 'recon-calibration', label: 'Predicted-vs-actual calibration' },
    { id: 'recon-brier',       label: 'Brier score + reliability deciles' },
    { id: 'recon-residuals',   label: 'Residuals histogram' },
    { id: 'recon-by-play',     label: 'Per-Play calibration breakdown' },
    { id: 'recon-flips',       label: 'Verdicts that flipped this quarter' },
    { id: 'recon-table',       label: 'Per-decision table (with filters)' },
  ]},
];

function DocsLens({ setLens }) {
  const [active, setActive] = React.useState('pivot-summary');
  const [search, setSearch] = React.useState('');
  const containerRef = React.useRef(null);

  // Sync active TOC item as user scrolls. rAF-throttled so the handler runs
  // at most once per frame regardless of scroll-event frequency.
  React.useEffect(() => {
    const root = containerRef.current;
    if (!root) return;
    let ticking = false;
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        const sections = root.querySelectorAll('section[data-anchor]');
        const top = root.scrollTop + 120;
        let cur = sections[0]?.dataset.anchor;
        sections.forEach(s => {
          if (s.offsetTop <= top) cur = s.dataset.anchor;
        });
        if (cur) setActive(cur);
        ticking = false;
      });
    };
    root.addEventListener('scroll', onScroll);
    return () => root.removeEventListener('scroll', onScroll);
  }, []);

  const jump = (id) => {
    const el = containerRef.current?.querySelector(`section[data-anchor="${id}"]`);
    if (el) {
      containerRef.current.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
      setActive(id);
    }
  };

  // TOC filtering by search input
  const q = search.trim().toLowerCase();
  const filteredToc = q ? DOCS_TOC.map(group => ({
    ...group,
    items: group.items.filter(it => it.label.toLowerCase().includes(q) || it.id.includes(q)),
  })).filter(g => g.items.length > 0) : DOCS_TOC;

  return (
    <div className="docs-shell">
      <nav className="docs-toc">
        <div className="toc-title">Documentation</div>
        <div className="toc-sub">v4 console · plain-English first · last updated 2026-04-29</div>
        <input
          className="toc-search"
          type="search"
          placeholder="Search…"
          value={search}
          onChange={e => setSearch(e.target.value)}
          autoFocus={false}
        />
        {filteredToc.length === 0 && <div className="toc-empty">No matches for <b>{search}</b></div>}
        {filteredToc.map(group => (
          <div key={group.id} className="toc-group">
            <div className="toc-group-label">{group.label}</div>
            {group.items.map(it => (
              <button
                key={it.id}
                className={'toc-item ' + (active === it.id ? 'active' : '')}
                onClick={() => jump(it.id)}
              >
                {it.label}
              </button>
            ))}
          </div>
        ))}
      </nav>

      <div className="docs-content" ref={containerRef}>
        <div className="docs-prose">

          {/* ═══════════════════════════════════════════════════════════════ */}
          {/* PART I—How this works                                          */}
          {/* ═══════════════════════════════════════════════════════════════ */}

          <div className="docs-part-banner">PART I—How this works</div>

          {/* ─── 2026-04-27 pivot ─── */}
          <section data-anchor="pivot-summary">
            <h1>2026-04-27 pivot · read first</h1>
            <p className="lede">
              The National team is going full-time on TrendHunter. Read this before
              working in the console for the first time—the shape of the work
              changed, even though the surfaces look familiar.
            </p>
            <p>What's actually different:</p>
            <ul>
              <li><b>One destination.</b> TH O&amp;O is the only place new National content ships. UsWeekly, Woman's World, and the rest of L&amp;E are paused until the L&amp;E data pipe is repaired.</li>
              <li><b>Three content collections.</b> Briefs are organized into <b>Mind &amp; Body</b>, <b>Experiences</b>, and <b>Everyday Living</b>—the TH relaunch IA.</li>
              <li><b>One target persona by default.</b> The <b>Curious Optimizer</b> is the always-on persona for fit scoring. Other personas are still available for cross-checking.</li>
              <li><b>Throughput re-baselined.</b> Ready-now target is <b>3–5 articles/wk</b> per active vertical (was 8–12).</li>
              <li><b>Engagement signal live.</b> Composite of time-on-page + pvs/session + scroll-depth (when derivable), per the BI-team framework. Replaces the prior placeholder.</li>
            </ul>
          </section>

          {/* ─── What this console is ─── */}
          <section data-anchor="what-is">
            <h1>What this console is</h1>
            <p>
              <code>data-keywords</code> is a private dashboard at <a href="https://data-keywords.pierce.tools" target="_blank" rel="noopener">data-keywords.pierce.tools</a>.
              Strategists open it before they commit any team capacity to a topic, and it answers the question:
              <em>should we pursue this keyword space, and if so, how?</em>
            </p>
            <p>Each opportunity gets a verdict (<b>High Opportunity</b>, <b>Worth Testing</b>, <b>Monitor</b>, or <b>Skip</b>), a recommended article, a strategic context, and—once articles publish—automatically tracked outcomes that flow back in so the system gets smarter over time. (<b>Monitor</b> applies to Trend-Hunter-pipeline trends that sit below the search-demand floor: velocity-validated by the trend system but with no proven search demand yet—neither pursue nor skip.)</p>
            <p>It exists so editorial decisions stop being made by gut alone and start being made with the structured signal of search demand, difficulty, authority, audience fit, and revenue potential standing right next to the gut.</p>
          </section>

          {/* ─── Who uses each lens ─── */}
          <section data-anchor="who-uses">
            <h1>Who uses each lens</h1>
            <table className="docs-table">
              <thead><tr><th>Role</th><th>Primary lens</th><th>What they do</th></tr></thead>
              <tbody>
                <tr><td>Content strategist</td><td>Today's briefs</td><td>Daily triage—Keep / Skip / Defer</td></tr>
                <tr><td>Content team lead</td><td>Beach</td><td>Weekly mix planning per collection</td></tr>
                <tr><td>Exec / strategist</td><td>Gap map</td><td>Monthly portfolio review</td></tr>
                <tr><td>Anyone post-publish</td><td>Decisions</td><td>Capture article outcomes (PV, position, headline grade)</td></tr>
                <tr><td>New team member</td><td>Docs</td><td>Read this. Then ask questions.</td></tr>
              </tbody>
            </table>
          </section>

          {/* ─── Concepts 101 ─── zero-knowledge primer ─── */}
          <section data-anchor="concepts-101">
            <h1>Concepts 101 · zero-knowledge primer</h1>
            <p className="lede">
              The console uses a handful of recurring terms—KD, CPC, volume, head/mid/long-tail, T1/T2/T3, persona, eCPM. Every glossary entry below assumes these are unfamiliar. Read this once and the rest will make sense.
            </p>

            <h3>Volume (vol)</h3>
            <p><b>What it is.</b> Estimated number of times a search query is typed into Google per month, globally or per country. SEMrush calls it "volume." When the console shows <code>vol 12,000</code> it means about 12,000 searches per month for that exact query.</p>
            <p><b>Why it matters.</b> Volume is the demand side of the keyword. No volume = no audience. High volume = lots of audience, but probably also lots of competing publishers.</p>
            <p><b>Source.</b> SEMrush <code>phrase_fullsearch</code> endpoint at brief-pull time. Refreshes monthly for Kept briefs.</p>

            <h3>KD (keyword difficulty)</h3>
            <p><b>What it is.</b> SEMrush's 0–100 score for how hard it is to rank on the first page of Google search results for a given keyword. <b>0 = trivial</b> (you'll rank if you publish anything competent). <b>100 = effectively impossible</b> (Wikipedia, NIH, and the New York Times own page 1, and they're not letting go).</p>
            <p><b>How it's computed (under the hood).</b> SEMrush combines: (1) authority of the domains currently ranking, (2) backlink counts of the current top-10 results, (3) on-page content depth, (4) topical-cluster strength, (5) some proprietary weighting. Operators don't need to compute it—SEMrush returns it as a finished number.</p>
            <p><b>Practical rules of thumb.</b></p>
            <ul>
              <li><b>KD 0–29:</b> "we can win this if we publish well." First-page rankable for a regional pub.</li>
              <li><b>KD 30–49:</b> "we can win this if we have some authority already." Need some site-level credibility on adjacent topics.</li>
              <li><b>KD 50–79:</b> "hard, but possible with a strong cluster." Multi-article cluster + internal linking + ongoing freshness.</li>
              <li><b>KD 80+:</b> "unrealistic for us." Entrenched competitors, forget it unless we already have an unusual foothold.</li>
            </ul>

            <h3>CPC (cost per click)</h3>
            <p><b>What it is.</b> What advertisers pay Google, on average, for a single click on a paid ad against this keyword. Reported in US dollars by SEMrush.</p>
            <p><b>Why it matters here.</b> We don't run paid ads—we use CPC as a <em>commercial-intent signal</em>. High CPC means advertisers see commercial value (e.g., people who search "best mattress for back pain" are about to spend money). High CPC keywords usually translate to higher organic ad revenue too, because the same advertisers are buying programmatic display next to our articles.</p>
            {/* docs-link: every cross-anchor link in this lens is a <button>,
                not an <a>. Reason: <a onClick> without an href isn't keyboard-
                accessible (Tab skips it, Enter doesn't fire). <button> is
                focusable + Enter-firing by default. The .docs-link CSS class
                inherits link styling so the visual stays the same. */}
            <p><b>Why it's vertical-relative.</b> $1.50 is high in entertainment but laughably low in financial services. The console normalizes CPC against per-vertical low/median/high anchors before scoring, so a $0.95 entertainment keyword can score the same as a $6.00 financial one if both sit at their vertical's median. See <button className="docs-link" onClick={() => jump('gloss-cpc-norms')}>per-vertical CPC norms table</button>.</p>

            <h3>Head, mid, longtail</h3>
            <p><b>Tail = how specific a search query is.</b> Imagine a curve: a few extremely common short phrases at the top (the "head"), a fat middle of moderately specific phrases ("midtail"), and a long tail of very specific multi-word phrases. The console classifies each keyword by word count + volume:</p>
            <table className="docs-table">
              <thead><tr><th>Class</th><th>Example</th><th>Volume</th><th>KD</th><th>Trait</th></tr></thead>
              <tbody>
                <tr><td><b>Head</b></td><td>"workouts"</td><td>500K+</td><td>Usually 70+</td><td>Generic, massive demand, brutal competition</td></tr>
                <tr><td><b>Midtail</b></td><td>"best workouts for back pain"</td><td>5K–80K</td><td>30–60</td><td>The sweet spot—meaningful demand, winnable competition</td></tr>
                <tr><td><b>Longtail</b></td><td>"best workouts for lower back pain in your 40s"</td><td>500–5K</td><td>0–30</td><td>Specific, low demand per query, trivially easy to rank, often higher conversion intent</td></tr>
              </tbody>
            </table>
            <p><b>Strategy implication.</b> A new pub with no authority should chase longtail first (winnable), build a foothold, then graduate to midtail. Headtail is for established publishers with topical authority—not us, not yet.</p>

            <h3>Tiers (T1 / T2 / T3)</h3>
            <p><b>What they are.</b> Trend Hunter Keyword Selection Framework v1's three difficulty tiers, defined by KD bands.</p>
            <ul>
              <li><b>T1 · Go Now</b>—KD 0–29, ≥500 vol/keyword. Build immediately; no preconditions. Targets <b>60% of production mix</b>.</li>
              <li><b>T2 · Build Into</b>—KD 30–49, ≥1,000 vol/keyword. Attack after T1 traction; benefits from DA-shift. Targets <b>30% of mix</b>.</li>
              <li><b>T3 · Long Game</b>—KD 50+, ≥5,000 vol/keyword. Compete only once topical authority is established. Targets <b>10% of mix</b>.</li>
            </ul>
            <p><b>The tier pill on the brief tile.</b> Shows the brief's classification—e.g., <code>T1</code>, <code>T2</code>, <code>T3</code>. When you see a small <b>down-arrow (↓)</b> after the tier number, that means the per-keyword tier didn't survive the cluster check (less than 10 sweet-spot keywords or under 10K combined sweet-spot volume)—the cluster is too thin to commit. See <button className="docs-link" onClick={() => jump('gloss-tier-pill')}>tier pill glossary</button>.</p>

            <h3>Persona</h3>
            <p><b>What it is.</b> A named audience archetype with an associated vocabulary of words and phrases that resonate with that audience. Not a real person—a curation pattern.</p>
            <p><b>How we use it.</b> The console scores each brief against each persona's vocabulary; the brief earns "high / medium / low" fit per persona based on how many vocabulary terms appear in the brief's topic + recommended article + keywords.</p>
            <p><b>Why one persona is "default."</b> The 2026-04-27 TH pivot named <b>Curious Optimizer</b> as the always-on B2C target—every brief is fit-scored against Curious Optimizer first. Other personas (Discover Browser, Watercooler Insider, etc.) remain available for cross-checking. See <button className="docs-link" onClick={() => jump('gloss-personas')}>full persona vocabularies</button>.</p>

            <h3>eCPM</h3>
            <p><b>What it is.</b> "Effective cost per mille"—revenue earned per 1,000 ad impressions. Reported in US dollars. If a pub's eCPM is $1.50, every 1,000 article views generate ~$1.50 in programmatic ad revenue.</p>
            <p><b>How we use it.</b> The Revenue projection panel multiplies expected page views (volume × CTR-at-position) by eCPM/1000 to project per-pub revenue.</p>
            <p><b>Important caveat: it's a static table for now.</b> Each pub has a single hardcoded eCPM number, refreshed manually every quarter (last refresh: 2026-04-09). It does not vary by section, time of year, ad density, content type, or recency. Direct-sold revenue is not modeled. The console nudges Pierce when the table is overdue. See <button className="docs-link" onClick={() => jump('gloss-ecpm-table')}>full eCPM table</button>.</p>

            <h3>SERP / SERP foothold</h3>
            <p><b>SERP.</b> "Search Engine Results Page"—the page Google returns when someone types a query. The first 10 results are "page 1," 11–20 is page 2, and so on. Most clicks go to page 1; almost no one scrolls to page 3+.</p>
            <p><b>SERP foothold.</b> When at least one of our portfolio pubs (Miami Herald, Kansas City Star, etc.) already ranks somewhere in the top 50 of a SERP for a given keyword. Even a position 47 ranking matters—it's Google's signal that our domain has some credibility on this topic, which we can build on.</p>
            <p><b>"No SERP foothold yet" badge</b> appears when a brief gets a High Opportunity verdict but <em>none</em> of our pubs rank in the top 50 for any of its keywords. Translation: opportunity is real but we'd be entering cold against entrenched competitors. The recommendation is to "stage the bet"—publish a few longtail-niche articles first, earn a foothold ranking, then scale.</p>

            <h3>"Our pos"</h3>
            <p><b>What it is.</b> The best (lowest-numbered) Google ranking position observed across all McClatchy National portfolio pubs for a given keyword. <b>Lower is better.</b> Position 3 means a portfolio pub already ranks #3 in Google for that keyword. Position 47 means somewhere on page 5.</p>
            <p><b>Position 999.</b> A sentinel value meaning "no portfolio pub appears in the top 50 for this keyword." Not literally rank 999—it's the console's way of marking "unranked from our perspective." The backfill cron pulls top-50 only; anything beyond rank 50 collapses to 999.</p>

            <h3>O&amp;O vs off-O&amp;O</h3>
            <p><b>O&amp;O = "owned and operated."</b> Pubs we own, where readers come directly to <em>our</em> domain. Page views on miamiherald.com are O&amp;O. These count toward Google rankings, our search authority, and our directly-monetized ad revenue.</p>
            <p><b>Off-O&amp;O = third-party syndication.</b> Apple News, SmartNews, NewsBreak, Yahoo, MSN—content surfaces on platforms we don't own. Doesn't count toward Google ranking authority but does count toward total reach + ad revenue (revenue split varies per platform).</p>

            <h3>GSC</h3>
            <p><b>Google Search Console.</b> Google's own webmaster tool that reports impression / click / position data for every keyword each domain ranks on. Connects via authenticated pull from Snowflake's <code>SEARCH_ANALYSIS_RPT</code> table (the data team's mirror of Google's GSC API).</p>

            <h3>Snowflake</h3>
            <p><b>What it is.</b> McClatchy's data warehouse—a SaaS database where all data-team pipelines land. The console reads from it nightly to refresh outcomes, engagement signals, and authority data. It's not user-facing; operators see the resulting JS data files committed to the repo.</p>

            <h3>Cohort</h3>
            <p><b>What it is.</b> The set of all briefs currently loaded into the console—typically all 43 V5 trend units. Verdict cuts (top 30% / mid 50% / bottom 20%) are computed against the cohort's composite-score distribution. So "High Opportunity" means "in the top 30% of <em>this</em> cohort," not "above some absolute threshold."</p>
            <p><b>Implication.</b> Adding new briefs reshuffles cohort percentiles. A brief that was High Opportunity yesterday may shift to Worth Testing if newly-pulled briefs score higher.</p>

            <h3>Other one-shot acronyms you'll see</h3>
            <ul>
              <li><b>TH</b>—Trend Hunter, the new always-on O&amp;O destination after the 2026-04-27 pivot.</li>
              <li><b>L&amp;E</b>—Lifestyle &amp; Entertainment publication group (Us Weekly, Woman's World, etc.) currently paused while the data pipe is repaired.</li>
              <li><b>TVE</b>—Trend Velocity Exception. A manual override for momentum that raw search volume hasn't caught up to. See <button className="docs-link" onClick={() => jump('gloss-tve')}>TVE glossary</button>.</li>
              <li><b>ETRP</b>—Empirical Threshold Refinement Pipeline. The calibration engine that validates verdict weights against historical PV outcomes. See <button className="docs-link" onClick={() => jump('gloss-etrp')}>ETRP findings</button>.</li>
              <li><b>DA</b>—Domain Authority. A pub's overall standing in Google's ranking system. The console uses GSC-observed average position as a proxy for per-keyword DA.</li>
              <li><b>E-E-A-T</b>—Experience, Expertise, Authoritativeness, Trustworthiness. Google's quality framework. Author authority is our proxy.</li>
              <li><b>CTR</b>—Click-through rate. Of people who see your result on a SERP, what fraction click. Position 1 ~28%, position 10 ~3%.</li>
              <li><b>D1</b>—Cloudflare's serverless SQLite database. Stores operator decisions (Keep / Skip / Defer + outcomes).</li>
            </ul>
          </section>

          {/* ─── Cheat sheet (auto-rendered from LENS_BLURBS) ─── */}
          <section data-anchor="lens-cheatsheet">
            <h1>The five lenses (cheat sheet)</h1>
            <p>Same blurbs surface via the <b>(?)</b> icon next to each lens title in the console. Click to pop a panel with the same content, in case you forget mid-flow.</p>
            <div className="lens-cheat-grid">
              {Object.entries(LENS_BLURBS).map(([id, b]) => (
                <div key={id} className="lens-cheat-card">
                  <div className="lens-cheat-name">{b.title}</div>
                  <div className="lens-cheat-purpose">{b.purpose}</div>
                  <p className="lens-cheat-body">{b.body}</p>
                  <p className="lens-cheat-when"><em>{b.useWhen}</em></p>
                  <p><button className="docs-cta" onClick={() => setLens(id)}>Open {b.title} →</button></p>
                </div>
              ))}
            </div>
          </section>

          {/* ─── Per-lens deep dives ─── */}
          <section data-anchor="lens-queue">
            <h1>Today's briefs · daily triage</h1>
            <p>A flat list, sorted with undecided <span className="dot go"></span> High Opportunity briefs first, then Worth Testing, then Skip. Decided briefs sink to the bottom but stay visible. The daily driver—open it, work top-down, hit the bottom, you're done.</p>
            <p>The left rail is your filter surface: trend search, decision state, verdict, collection, and the optional persona-fit overlay (off by default).</p>
            <p>Header has: a sort picker (primary + optional secondary key), a "focus on top 15" toggle (off by default—you see the full inventory), and the <b>(?)</b> help bubble.</p>
            <p><button className="docs-cta" onClick={() => setLens('queue')}>Open Today's briefs →</button></p>
          </section>

          <section data-anchor="lens-beach">
            <h1>Beach · editorial workspace</h1>
            <p>Briefs grouped by TH Collection (Mind &amp; Body / Experiences / Everyday Living). Within each collection, three difficulty rails:</p>
            <ul>
              <li><b className="rail-r">Ready now</b>—KD 0–29, longtail. Today's working surface. Target <b>3–5 articles/week</b> per active collection.</li>
              <li><b className="rail-y">Building toward</b>—KD 30–49, midtail. Locked until you've earned a foothold via Ready-now wins.</li>
              <li><b className="rail-d">Earned later</b>—KD 50+, headtail. Locked until cumulative wins + median position threshold.</li>
            </ul>
            <p>The lock isn't bureaucracy—it's the system saying "you don't have the authority to win these yet, spend that effort on the rail you can win."</p>
            <p>Production-mix tracker at the top shows progress against the <b>60% Ready / 30% Building / 10% Earned</b> target.</p>
            <p><button className="docs-cta" onClick={() => setLens('beach')}>Open Beach →</button></p>
          </section>

          <section data-anchor="lens-gap">
            <h1>Gap map · portfolio positioning</h1>
            <p>Volume × difficulty quadrants. Where a brief lands tells you what to do with it.</p>
            <ul>
              <li><b className="go-text">STRIKE</b> · high vol, low KD—winnable now, prioritize</li>
              <li><b className="test-text">STUDY</b> · high vol, high KD—find the longtail entry into the topic</li>
              <li><b className="kept-text">COMPOUND</b> · low vol, low KD—easy local wins, build authority</li>
              <li><b className="skip-text">SKIP</b> · low vol, high KD—not worth the effort</li>
            </ul>
            <p>Use it monthly+ for strategic review. Is the brief inventory shape-right? Are there empty quadrants signaling under-investment?</p>
            <p><button className="docs-cta" onClick={() => setLens('gap')}>Open Gap map →</button></p>
          </section>

          <section data-anchor="lens-log">
            <h1>Decisions · history + outcomes</h1>
            <p>Every decision lands here automatically—kept, skipped, or deferred. After an article ships, re-open the brief and fill in the outcome form (page views, final position, headline grade). Those numbers feed the Phase-5 learning loop that recalibrates the engine.</p>
            <p>Decisions persist across browsers, devices, and team members. Sync indicator in the footer (<b>● decisions synced</b>) confirms the worker is alive.</p>
            <p><button className="docs-cta" onClick={() => setLens('log')}>Open Decisions →</button></p>
          </section>

          {/* ─── Brief anatomy ─── */}
          <section data-anchor="brief-tile">
            <h1>Anatomy: the brief tile</h1>
            <p>The closed brief shows you everything you need to triage in one horizontal scan:</p>
            <ol>
              <li><b>Verdict glyph</b>—color-coded dot: green (High Opportunity) / amber (Worth Testing) / teal (Monitor — a TH-pipeline trend below the search-demand floor: velocity-validated but demand unproven) / red (Skip)</li>
              <li><b>Topic + lifecycle pill</b>—the brief name, plus a small badge if it's <em>new</em>, in <em>SERP re-verify</em>, or <em>refresh due</em></li>
              <li><b>Tier pill</b>—TH Framework v1 KD-band classification (T1 / T2 / T3)</li>
              <li><b>Content type</b>—Standard explainer / Bridge / Commerce / Discovery / Untyped</li>
              <li><b>Collection chip</b>—Mind &amp; Body / Experiences / Everyday Living</li>
              <li><b>Tail class</b>—head / mid / long</li>
              <li><b>KD / Volume / CPC</b>—keyword cluster aggregates</li>
              <li><b>Engagement pill</b> (when overlay on)—composite engagement score</li>
              <li><b>Persona-fit pill</b> (when overlay on)—high / medium / low vs current persona</li>
              <li><b>Revenue range</b> (when overlay on)—projected $/mo across portfolio pubs</li>
            </ol>
          </section>

          <section data-anchor="brief-drawer">
            <h1>Anatomy: the brief drawer</h1>
            <p>Click a brief or hit Enter on a focused tile to open the drawer. Layout:</p>
            <h3>Main column (left)</h3>
            <ol>
              <li><b>Verdict header</b>—the call (color-coded pill) + (?) bubble</li>
              <li><b>Why this verdict</b>—written rationale</li>
              <li><b>Recommended angle</b>—one paragraph on how to execute</li>
              <li><b>Verdict breakdown</b> (collapsed by default—click to expand)—score components, hard auto-skip checks, TH Framework v1, authority context</li>
              <li><b>Top keywords</b>—the actual search terms with vol / KD / CPC / our pos</li>
              <li><b>Decision area</b>—Trend Velocity Exception flag, Keep / Skip / Defer buttons, rationale note, outcome capture once kept</li>
            </ol>
            <h3>Side column (right)</h3>
            <ol>
              <li><b>Recipe coordinates</b>—TH Collection, Format, Persona (production handoff details collapsed)</li>
              <li><b>Persona fit</b> (when overlay on)—affinity scoring vs each persona</li>
              <li><b>Engagement signal</b>—composite engagement + per-metric breakdown</li>
              <li><b>Platform reach</b> (when data exists)—off-O&amp;O PVs from Apple News / SmartNews / NewsBreak / Yahoo / MSN</li>
              <li><b>Author authority</b> (when data exists)—top McClatchy authors with track record on this brief's keyword space</li>
              <li><b>Revenue projection</b> (when overlay on)—per-pub $/mo if articles rank at expected position</li>
              <li><b>Top competitors</b> (when data exists)—domains winning this SERP space</li>
            </ol>
            <p>Every section heading has a <b>(?)</b> bubble explaining what's shown and how it's computed.</p>
          </section>

          <section data-anchor="brief-decide">
            <h1>Triage: Keep / Skip / Defer</h1>
            <p>Three decision buttons, plain meaning:</p>
            <ul>
              <li><b className="go-text">Keep · commit pipeline</b>—the team is committing to producing content here. Adds the brief to the monthly SEMrush refresh scope.</li>
              <li><b className="skip-text">Skip · rationale required</b>—explicitly rejected. Excluded from refresh. Always include a one-sentence reason—that's the highest-signal feedback the engine gets.</li>
              <li><b>Defer</b>—parked for later. Excluded from refresh until you un-defer.</li>
            </ul>
            <p>You can change your mind by clicking another action. Decisions persist server-side instantly (sync pill in footer confirms)—no need to remember to save.</p>
            <p>Outcome capture (PV, position, headline grade) appears once a brief is Kept. Filling these in feeds the Phase-5 calibration loop.</p>
          </section>

          {/* ─── Engagement pill: live vs proxy ─── */}
          <section data-anchor="engagement-pill-states">
            <h1>Engagement pill: live vs proxy</h1>
            <p className="lede">
              The engagement pill on a brief tile shows up two ways: <b>full color</b> (live data) or <b>faded with a small asterisk</b> (proxy estimate). It's not a styling glitch—it tells you whether the number is observed or estimated. This is one of the more easily-missed signals; learn to read it.
            </p>

            <h3>Live (full color, no asterisk)</h3>
            <p><b>Meaning.</b> McClatchy has already published one or more articles whose keyword space matches this brief. Engagement metrics are pulled from <code>ARTICLE_ENGAGEMENT_SIGNALS</code> in Snowflake (Amplitude-derived) and joined to those matched articles. Numbers reflect <em>actual reader behavior</em>: time-on-page, pvs/session, scroll depth.</p>
            <p><b>How to read it.</b> Trust the score directly. A live engagement of 72 means real readers spent real time engaging. Higher = stronger.</p>

            <h3>Proxy (faded color + asterisk *)</h3>
            <p><b>Meaning.</b> No published McClatchy article matches this brief's keyword space yet—most V5 briefs are greenfield. The engagement pill in this state is a <em>directional placeholder</em>, derived from <code>function(KD, persona-fit, content-type)</code>. It's a guess, not an observation.</p>
            <p><b>How to read it.</b> Use it for relative ordering across briefs, not as ground truth. A proxy 60 vs a proxy 40 still tells you the first looks more promising—but both could land elsewhere when the article actually publishes.</p>

            <h3>Why both states exist on the same surface</h3>
            <p>Mixing live and proxy signals lets the operator triage greenfield briefs without losing the comparative shape. The fade + asterisk make the data quality visible at a glance—you can never accidentally treat a proxy as live, but you also don't lose the signal entirely just because no article exists yet.</p>

            <h3>What clears the proxy state</h3>
            <p>Once a McClatchy article publishes whose keywords overlap this brief's keyword cluster, the daily Layer-7 cron picks it up and the pill flips to live on the next page load. No operator action—happens automatically within ~24 hours of publication.</p>

            <h3>Where you'll see the same distinction in the drawer</h3>
            <p>The Engagement signal panel inside the drawer shows the same source flag, but with full per-metric breakdown. When live, you see actual time-on-page numbers per matched article. When proxy, you see the derivation reasoning (KD-X, persona-fit-Y, content-type-Z → score).</p>
          </section>

          {/* ─── Filters / sort / focus mode ─── */}
          <section data-anchor="filters-sort">
            <h1>Filters, sort, focus mode</h1>
            <p className="lede">Three orthogonal mechanisms for narrowing a long list. Filters cut briefs out, sort reorders what remains, focus mode caps the count. Combine freely.</p>

            <h3>Filters (left rail, AND-combined)</h3>
            <p>Each filter is independent and they AND together—Collection=Mind &amp; Body + State=undecided + Verdict=High Opportunity shows the intersection of all three.</p>

            <table className="docs-table">
              <thead><tr><th>Filter</th><th>Values</th><th>What it does</th><th>Implications</th><th>Revert</th></tr></thead>
              <tbody>
                <tr>
                  <td><b>Trend search</b></td>
                  <td>Free-text</td>
                  <td>Text match against topic + recommended article + keyword labels</td>
                  <td><b>Soft filter:</b> matched briefs highlight, unmatched dim but stay visible. Doesn't actually remove briefs—useful for "find this one quickly" without losing your place in the queue.</td>
                  <td>Clear the search box (X icon or empty the field).</td>
                </tr>
                <tr>
                  <td><b>Decision state</b></td>
                  <td>any · undecided · kept · skipped · deferred</td>
                  <td>Filter by what you've already done with the brief</td>
                  <td><b>Default = any.</b> Set to "undecided" for daily triage. Set to "kept" before a refresh week. Set to "skipped" to revisit rejection rationale during a calibration cycle.</td>
                  <td>Set back to "any" or use the lens's reset chip.</td>
                </tr>
                <tr>
                  <td><b>Verdict</b></td>
                  <td>all · high opportunity · worth testing · monitor · skip</td>
                  <td>Filter by computed verdict</td>
                  <td>Useful when you only have time for High Opportunity work today. Note: verdict is recomputed at runtime against current cohort, so a brief's verdict can shift between sessions if cohort composition changes.</td>
                  <td>Set back to "all."</td>
                </tr>
                <tr>
                  <td><b>Collection</b></td>
                  <td>all · Mind &amp; Body · Experiences · Everyday Living</td>
                  <td>Filter to one TH Collection</td>
                  <td>Use for collection-specific weekly planning. The Beach lens is collection-grouped natively; this filter is most useful in Today's briefs and Gap map.</td>
                  <td>Set back to "all."</td>
                </tr>
                <tr>
                  <td><b>Persona fit</b></td>
                  <td>any · high · medium · low</td>
                  <td>Filter by persona-fit level vs current persona</td>
                  <td><b>Only visible when Persona overlay is ON</b> (off by default, toggle in the operator panel). When on, set to "high" to see only briefs that resonate strongly with Curious Optimizer (or whichever persona is active).</td>
                  <td>Set back to "any"—or turn the overlay off entirely if you don't want persona-fit influencing triage.</td>
                </tr>
              </tbody>
            </table>

            <h3>Sort (lens header—two layered keys)</h3>
            <p>Sort has a <b>primary key</b> + optional <b>secondary tiebreaker key</b>. Each has its own asc/desc toggle. Default sort uses the lens's native triage ordering (verdict → vol for Today's briefs).</p>
            <p>Sort dimensions:</p>
            <ul>
              <li><b>default</b>—composite opportunity score desc (yields High Opportunity → Worth Testing → Skip tier order); Monitor briefs sort by their low composite, landing near Skip</li>
              <li><b>tier</b>—TH framework tier (1=best). Useful for committing to a production-mix target.</li>
              <li><b>volume</b>—raw monthly searches. Highest demand first.</li>
              <li><b>KD</b>—keyword difficulty. Asc = easiest first (where you can win); desc = hardest first.</li>
              <li><b>CPC</b>—vertical-relative commercial intent. Desc = highest commercial value first.</li>
              <li><b>engagement</b>—live or proxy engagement composite. Desc = strongest reader behavior first.</li>
              <li><b>persona fit</b>—vs current persona. high &gt; medium &gt; low.</li>
              <li><b>projected revenue</b>—max-pub revenue projection. Desc = biggest theoretical upside first.</li>
              <li><b>cluster opportunity</b>—sum of sweet-spot keyword volumes. Desc = richest cluster first.</li>
            </ul>
            <p>The secondary tiebreaker only kicks in when primary values tie—useful when many briefs share a tier or verdict.</p>

            <h3>Focus mode (lens header toggle)</h3>
            <p><b>Default: off.</b> Strategists see the full inventory of briefs (~43 V5 + intake). Toggle on to cap the visible queue at 15 (intake first, queue fills the rest).</p>
            <p><b>Use when:</b> the inventory is overwhelming and you want a "top 15 to triage today" surface. Pairs well with sort=default and decision-state=undecided.</p>
            <p><b>Revert:</b> toggle off to see the full inventory again. Toggling does not delete or modify any briefs—purely a display cap.</p>
          </section>

          {/* ─── Filter recipes ─── */}
          <section data-anchor="filter-recipes">
            <h1>Filter recipes—what each combo finds</h1>
            <p className="lede">Common filter combinations and what they surface. Use these as starting points; tune as needed.</p>

            <h3>"My triage list for today"</h3>
            <p><b>State=undecided · Verdict=any · Collection=any · Focus mode=on</b><br/>
            Shows the next 15 briefs you haven't decided on yet, with the strongest opportunity at the top. The daily-driver default.</p>

            <h3>"What needs Keep before the 1st-of-month refresh"</h3>
            <p><b>State=undecided · Verdict=high opportunity · Focus mode=off</b><br/>
            Surfaces every undecided High Opportunity brief—these are the candidates worth committing to so they enter the monthly SEMrush refresh scope.</p>

            <h3>"Re-examine my Skips"</h3>
            <p><b>State=skipped · Verdict=any · sort=primary tier · sort=secondary volume</b><br/>
            Surfaces every brief I've skipped, sorted by tier then volume—useful for a calibration-cycle review of "did I skip too aggressively?"</p>

            <h3>"This week's Mind &amp; Body plan"</h3>
            <p><b>Collection=Mind &amp; Body · State=any · sort=tier asc · sort=volume desc</b><br/>
            All Mind &amp; Body briefs, easiest tier first, highest volume within tier—the production-mix planning view. Better in Beach lens (which already groups by collection + tier rail).</p>

            <h3>"Find that brief I was just looking at"</h3>
            <p><b>Trend search="creatine"</b> (or whatever keyword)<br/>
            Soft-highlights matching briefs without filtering the rest out. Useful when you remember a topic but not the brief title.</p>

            <h3>"Best persona-fit High Opportunity briefs"</h3>
            <p><b>Persona overlay=ON · Persona fit=high · Verdict=high opportunity · State=undecided</b><br/>
            Shows undecided briefs that both score well by formula AND match persona vocabulary strongly. The "no-brainer" set.</p>

            <h3>"Briefs with the biggest projected revenue"</h3>
            <p><b>State=any · sort=projected revenue desc</b><br/>
            Sorted by max-pub theoretical revenue. Read with caution—projection assumes the article ranks at expected position, and ETRP found composite scores are uncorrelated to anti-correlated with actual PV.</p>
          </section>

          {/* ─── Sort recipes ─── */}
          <section data-anchor="sort-recipes">
            <h1>Sort recipes—what each dimension surfaces</h1>
            <p>Each sort dimension has a different "shape" of brief at the top. Knowing what surfaces helps you pick the right one.</p>

            <table className="docs-table">
              <thead><tr><th>Sort</th><th>What sits at the top (desc)</th><th>What sits at the top (asc)</th><th>Best paired with</th></tr></thead>
              <tbody>
                <tr><td><b>default</b></td><td>Undecided High Opportunity, highest volume</td><td>Skipped briefs, lowest volume</td><td>Daily triage with state=undecided</td></tr>
                <tr><td><b>tier</b></td><td>T3 (long game; rarely useful)</td><td><b>T1 first</b>—this is the natural use</td><td>Production-mix planning in Beach lens</td></tr>
                <tr><td><b>volume</b></td><td>Highest-demand head/midtail keywords</td><td>Smallest-volume longtail</td><td>"What's the biggest opportunity by raw demand?"</td></tr>
                <tr><td><b>KD</b></td><td>Hardest first (KD 80+, mostly auto-skip)</td><td><b>Easiest first</b>—natural Beach Ready-now ordering</td><td>"What can we win <em>this week</em>?"</td></tr>
                <tr><td><b>CPC</b></td><td>Highest commercial intent—health-wellness, financial-services dominate</td><td>Lowest commercial intent—entertainment, food</td><td>"Where's the ad money?"</td></tr>
                <tr><td><b>engagement</b></td><td>Live high-engagement (real reader behavior) or strong proxy</td><td>Faded proxies and weak live signals</td><td>"What do readers actually finish?"</td></tr>
                <tr><td><b>persona fit</b></td><td>High-fit Curious Optimizer briefs</td><td>Low-fit (not for this persona)</td><td>Persona-led editorial targeting</td></tr>
                <tr><td><b>projected revenue</b></td><td>Highest theoretical upside (vol × CTR × eCPM)</td><td>Smallest projection</td><td>Caveat: projection ≠ outcome (per ETRP)</td></tr>
                <tr><td><b>cluster opportunity</b></td><td>Richest sweet-spot clusters (10+ keywords with vol≥500 AND KD≤29)</td><td>Thinnest clusters</td><td>Cluster-scale content commitments</td></tr>
              </tbody>
            </table>
          </section>

          {/* ─── How to revert ─── */}
          <section data-anchor="reverting">
            <h1>How to revert / undo any decision</h1>
            <p className="lede">Every operator action in the console is reversible. Here's how to undo each one.</p>

            <h3>Revert a Keep / Skip / Defer</h3>
            <p>Open the brief drawer and click any other action button. Decision overwrites are instant—the new state is written to D1 and the previous one moves to the audit log (<code>decision_history</code>). To <em>fully clear</em> a decision (return to undecided): there's no UI button currently; operator can DELETE via API:</p>
            <pre className="docs-block">{`curl -X DELETE \\
  -H "X-Auth-Token: $WRITE_TOKEN" \\
  https://data-keywords-decisions.pierce-williams.workers.dev/decisions/<brief-id>`}</pre>
            <p>Audit log retains the full history; the live decisions row is removed.</p>

            <h3>Revert outcome data on a Kept brief</h3>
            <p>Open the brief drawer, scroll to the outcome capture form, edit values, save. Old values move to <code>decision_history</code>; new values become live. There's no "delete outcome only" path—clear all decision data via API DELETE if needed.</p>

            <h3>Revert a TVE flag</h3>
            <p>Open the brief drawer, click the TVE control, set to "off." The flag is part of the decision row; saving rewrites it. Audit log retains prior TVE state.</p>

            <h3>Revert a filter or sort selection</h3>
            <p>Click the filter chip's X, or set the dropdown back to "all" / "any." Filters are session-only—they don't persist across page reloads.</p>

            <h3>Revert focus mode</h3>
            <p>Toggle off in the lens header. Display-only setting, doesn't modify briefs.</p>

            <h3>Revert overlays (Persona / Revenue / Engagement)</h3>
            <p>Open the operator panel (<code>⚙</code> icon top-right), toggle the overlay off. Overlays change which pills appear on the tile and which panels appear in the drawer. Disabled overlays don't influence sort or filter availability.</p>

            <h3>Revert the entire console state</h3>
            <p>If something feels deeply wrong: open browser devtools console and run <code>localStorage.clear()</code> followed by hard-refresh. This wipes session UI state (filters, overlays, sort selections) but does NOT touch decisions—those live in D1, separate from browser state.</p>

            <h3>Revert a verdict change you don't agree with</h3>
            <p>Verdicts aren't operator-mutable. They recompute every page load against the current cohort. To override a verdict in practice, ignore it and Keep / Skip on your own judgment—your decision and rationale are the highest-signal feedback the engine gets and feed Phase-5 calibration.</p>
          </section>

          <section data-anchor="help-bubbles">
            <h1>The (?) help bubbles</h1>
            <p>Small circles next to most labels. Click to pop a panel with: what this is, how it's computed, an optional caveat note. Click outside or hit Esc to close.</p>
            <p>These exist on:</p>
            <ul>
              <li>Each lens title (top-level "what is this lens for")</li>
              <li>Brief drawer section headings (Verdict / Score components / TH Framework / Recipe coordinates / Persona fit / Engagement / Platform reach / Author authority / Revenue / Top keywords / Authority context)</li>
              <li>Verdict-breakdown badges (verdict drift / soft confidence / no SERP foothold yet)</li>
              <li>Trend Velocity Exception controls in the Decision area</li>
            </ul>
            <p>Same content surfaces in this Docs lens too—single source of truth in <code>util.js</code>, can't drift between surfaces.</p>
          </section>

          <section data-anchor="decisions-persistence">
            <h1>Where your decisions live</h1>
            <p>Click Keep / Skip / Defer → the decision is written to a <b>D1 database</b> running on Cloudflare. It persists across browsers, devices, and team members. Multiple operators can click concurrently without conflicts.</p>
            <p>The footer pill confirms sync state:</p>
            <ul>
              <li><b>● decisions synced</b>—last write succeeded; everything is current</li>
              <li><b>○ decisions local-only</b>—worker URL not configured (rare; only happens before first deploy)</li>
              <li><b>⚠ decisions offline (cached)</b>—worker unreachable; decisions are buffered locally and will retry on next click</li>
            </ul>
            <p>Audit trail: every change is appended to a <code>decision_history</code> table. Operators can query "who decided what when" via the worker; technical-spec section has the queries.</p>
          </section>

          <section data-anchor="auto-vs-manual">
            <h1>What auto-updates vs what's manual</h1>
            <h3>Automatic (you do nothing)</h3>
            <ul>
              <li><b>Brief outcomes</b>—every day at 8:13am Dallas. Joins keyword space against published articles in TRACKER_ENRICHED, computes auto-outcome stats per brief.</li>
              <li><b>GSC authority</b>—daily. Pulls 28-day window of search-impressions per pub for each brief's keywords.</li>
              <li><b>Engagement signal</b>—daily. Joins TRACKER_ENRICHED + ARTICLE_ENGAGEMENT_SIGNALS for time-on-page / pvs/session / scroll metrics.</li>
              <li><b>Author authority</b>—daily. Per-author engagement track record on each brief's keyword space.</li>
              <li><b>Decision sync</b>—instant on each click.</li>
              <li><b>Drift report</b>—weekly. Surfaces verdict drift between hardcoded and recomputed values.</li>
              <li><b>Monthly SEMrush refresh</b>—1st of each month at 8:13am Dallas. Refreshes competitors + ourPos for Kept briefs only.</li>
            </ul>
            <h3>Manual (operator action)</h3>
            <ul>
              <li><b>Adding new trend units / seeds</b>—operator edits the seed list and runs the SEMrush pull script.</li>
              <li><b>Tarrow XLSX upload</b>—weekly export from gsheets, dropped into <code>~/Downloads/</code>, then ingest script.</li>
              <li><b>Recalibration</b>—Phase-5 ETRP run after ≥3 months of decision data.</li>
              <li><b>Outcome capture</b>—operator fills PV / position / headline-grade once an article ships.</li>
            </ul>
          </section>

          {/* ─── Troubleshooting ─── */}
          <section data-anchor="troubleshooting">
            <h1>Troubleshooting</h1>

            <h3>Briefs aren't loading</h3>
            <ol>
              <li>Hard refresh the page (Cmd+Shift+R / Ctrl+Shift+R)—browser cache may be stale.</li>
              <li>Check that you're on the latest deploy—the footer shows <code>etrp · 30/50/20 · 75/10/10/5 · cuts refined</code> with a date. If it's missing, the Pages deploy may not be current.</li>
              <li>Open browser console (Cmd+Option+J on Mac)—JS errors will surface here.</li>
              <li>If <code>window.BRIEFS</code> is undefined, <code>data/briefs.js</code> failed to load. Network tab → check that file is loading.</li>
            </ol>

            <h3>Footer shows "decisions offline (cached)"</h3>
            <ol>
              <li>The Cloudflare Worker is unreachable. Your clicks are buffered locally and will retry on the next click.</li>
              <li>Check worker health: <code>curl https://data-keywords-decisions.pierce-williams.workers.dev/health</code></li>
              <li>If 5xx: check Cloudflare Dashboard → Workers logs for errors.</li>
              <li>Resolution is automatic—once the worker is back, the next click will sync.</li>
            </ol>

            <h3>Verdict shows the "no SERP foothold yet" badge</h3>
            <p>That brief got a High Opportunity verdict from the formula, but no portfolio pub ranks in the top 50 of any of its keywords. KD is also moderate-to-high. Translation: opportunity surface is real, but you'd be entering against entrenched competitors with no existing authority. Recommended path: publish a few longtail-niche articles first, earn a top-50 ranking somewhere, then scale up. Stage the bet rather than going in cold. Click the (?) next to the badge for more.</p>

            <h3>Drawer cards display wrong / overcrowded</h3>
            <p>Should be fixed by the container-query CSS update (2026-04-29). If you still see this, hard-refresh. The drawer auto-stacks vertically when the brief container is narrower than 720px.</p>

            <h3>"MSN data unavailable"</h3>
            <p>MSN platform reach is sourced from a weekly Tarrow XLSX export, not the daily Snowflake feed. When the latest Tarrow upload is missing, MSN data won't appear. The other off-O&amp;O platforms (Apple News, SmartNews, NewsBreak, Yahoo) come from Snowflake daily and aren't affected.</p>

            <h3>Engagement signal shows "proxy" badge</h3>
            <p>No published articles match this brief's keyword space yet, so the live engagement data doesn't exist. The console falls back to a proxy derived from KD + persona-fit + content-type as a directional placeholder. The proxy clears automatically when articles ship and TRACKER_ENRICHED has matches.</p>

            <h3>SEMrush credit budget concern</h3>
            <p>Footer shows running spend against the 250K monthly cap. Monthly refresh on the 1st burns ~3K per Kept brief. With 0 Kept briefs, monthly cron is a no-op. With ~10 Kept briefs, ~30K monthly burn—comfortable headroom.</p>
          </section>

          {/* ─── FAQ ─── */}
          <section data-anchor="faq">
            <h1>FAQ</h1>
            <h3>Can I override a verdict?</h3>
            <p>Yes, always. The verdict is a recommendation, not a gate. You can keep a Skip or skip a High Opportunity brief. Both decisions feed the engine equally; overrides with rationale are the highest-signal feedback we have.</p>

            <h3>Why doesn't the verdict reflect what I think it should?</h3>
            <p>The verdict is opportunity-surface assessment, not PV prediction. ETRP analysis on 1,062 historical articles found scoring is uncorrelated to anti-correlated with actual PV outcomes. Use the verdict to identify <em>where to attack</em>; execution + angle + timing determine hit rate.</p>

            <h3>Where do my decisions live?</h3>
            <p>In a Cloudflare D1 database, written on every click. Cross-browser, cross-device, cross-team-member. See <button className="docs-link" onClick={() => jump('decisions-persistence')}>Where your decisions live</button>.</p>

            <h3>Who owns this?</h3>
            <p>Pierce Williams, Director of Content Standards &amp; Pipeline Quality at McClatchy. Slack <code>#data-keywords</code> for questions / feedback / bugs.</p>

            <h3>How do I add a new brief?</h3>
            <p>Operator (Pierce) adds it to the seed list in <code>scripts/pull_th_v5.py</code> and runs the script. The new brief appears with full SEMrush data on next page load. See <button className="docs-link" onClick={() => jump('tech-semrush')}>SEMrush pull pipeline</button>.</p>

            <h3>Why is everything labeled "considering" / why aren't refreshes happening?</h3>
            <p>Briefs you haven't actively Kept aren't in the monthly refresh scope. Click Keep on briefs the team is committing to—they'll be auto-refreshed on the 1st of each month. Briefs you Skip or Defer never refresh until you un-do that decision.</p>
          </section>

          {/* ═══════════════════════════════════════════════════════════════ */}
          {/* PART II—Technical specs                                        */}
          {/* ═══════════════════════════════════════════════════════════════ */}

          <div className="docs-part-banner">PART II—Technical specs</div>

          <section data-anchor="tech-architecture">
            <h1>Architecture overview</h1>
            <p>Five distinct systems, glued together via daily/monthly crons + a real-time worker.</p>
            <pre className="docs-block">{`┌─────────────────┐  reads on load,  ┌──────────────────────┐
│  Console        │  writes on click │ Decisions Worker     │
│  (CF Pages)     │ ──────────────▶  │ (CF Worker + D1)     │
│  React + Babel  │                  │ pierce-williams.     │
│  CDN, no build  │ ◀──────────────  │   workers.dev        │
└────────┬────────┘  bootstrap state └──────────┬───────────┘
         │                                       │
         │ reads window.BRIEFS                   │ /decisions/kept
         ▼                                       ▼
┌────────────────┐    daily       ┌─────────────────────────┐
│ docs/data/     │ ◀─────────── ─ │ GitHub Actions cron     │
│ *.js (briefs,  │  bot commits   │ daily 8:13am Dallas     │
│ outcomes, etc) │                │ monthly 1st 8:13am      │
└────────┬───────┘                └────────┬────────────────┘
         │                                  │ uses
         │ via snowflake-tracker-sync       ▼
         ▼                          ┌──────────────────┐
┌─────────────────┐                 │ Snowflake        │
│ TRACKER_ENRICHED│ ◀───────────────│ MCC_PRESENTATION │
│ ARTICLE_ENG_SIG │                 └──────────────────┘
└─────────────────┘                 ┌──────────────────┐
                                    │ SEMrush API      │
                                    │ 250K units/month │
                                    └──────────────────┘`}</pre>
          </section>

          <section data-anchor="tech-data-sources">
            <h1>Data sources + refresh cadence</h1>
            <table className="docs-table">
              <thead><tr><th>File</th><th>Source</th><th>Refresh cadence</th><th>Cost</th></tr></thead>
              <tbody>
                <tr><td><code>briefs.js</code></td><td>SEMrush phrase_fullsearch + phrase_kdi + phrase_organic</td><td>Manual (initial seed) + monthly cron (Kept briefs only) for competitors + ourPos</td><td>~3K credits / Kept brief / month</td></tr>
                <tr><td><code>brief-outcomes.js</code></td><td>Snowflake TRACKER_ENRICHED</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>gsc-authority.js</code></td><td>Snowflake SEARCH_ANALYSIS_RPT (WEB, 28d window)</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>engagement-signals.js</code></td><td>TRACKER_ENRICHED + ARTICLE_ENGAGEMENT_SIGNALS</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>author-authority.js</code></td><td>TRACKER_ENRICHED + ARTICLE_ENGAGEMENT_SIGNALS (author-grouped)</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>brief-revenues.js</code></td><td>TRACKER_ENRICHED v2.7 (BURT_INTELLIGENCE_REV joined)</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>outlet-ecpm-live.js</code></td><td>TRACKER_ENRICHED v2.7 (BURT-derived per-domain eCPM)</td><td>Daily 8:13am Dallas</td><td>$0 SEMrush</td></tr>
                <tr><td><code>tarrow-syndication.js</code></td><td>Manual XLSX from Top Stories 2026 Syndication gsheet</td><td>Weekly (operator export)</td><td>$0 SEMrush</td></tr>
                <tr><td><code>verdict-thresholds.js</code></td><td>ETRP calibration script</td><td>Phase-5 (≥3 months of decision data)</td><td>~36K credits per ETRP run</td></tr>
                <tr><td><code>compounding-curves.js</code></td><td>ETRP Step 1 Snowflake pull</td><td>Quarterly (when ETRP re-runs)</td><td>$0 SEMrush</td></tr>
                <tr><td>D1 decisions</td><td>Operator clicks (Keep / Skip / Defer / outcome capture)</td><td>Real-time</td><td>$0 (D1 free tier)</td></tr>
              </tbody>
            </table>
          </section>

          <section data-anchor="tech-file-map">
            <h1>File map (where everything lives)</h1>
            <pre className="docs-block">{`data-keywords/
├── docs/                              # Cloudflare Pages site root
│   ├── index.html
│   ├── css/styles.css                 # all styling
│   ├── data/
│   │   ├── briefs.js                  # window.BRIEFS—single source of truth
│   │   ├── verdict-thresholds.js      # ETRP-validated weights + cuts
│   │   ├── compounding-curves.js      # historical curves from ETRP Step 1
│   │   ├── brief-outcomes.js          # Layer 0 daily output
│   │   ├── gsc-authority.js           # Layer 6 daily output
│   │   ├── engagement-signals.js      # Layer 7 daily output
│   │   ├── author-authority.js        # Layer 9 daily output
│   │   ├── brief-revenues.js          # Layer 10 daily output (live BURT)
│   │   ├── outlet-ecpm-live.js        # daily live BURT eCPM (replaces static)
│   │   ├── tarrow-syndication.js      # Layer 8 weekly manual
│   │   └── worker-config.js           # decisions worker URL + shared bearer (CF Access gated)
│   │   # (decisions.js placeholder + briefs-v3-archive.js moved to docs/_archive/ on 2026-05-04)
│   └── js/
│       ├── util.js                    # everything: helpers, scoring, registries
│       ├── brief.jsx                  # brief tile + drawer + LensHelp
│       ├── lenses.jsx                 # all 5 lenses + command palette
│       ├── docs.jsx                   # this file
│       ├── tweaks-panel.jsx           # operator tuning panel
│       └── app.jsx                    # app shell + state
├── scripts/                           # Python pull / processing scripts
│   ├── pull_th_v5.py                  # full V5 SEMrush pull
│   ├── backfill_competitors.py        # phrase_organic top-10 per kw
│   ├── backfill_positions.py          # phrase_organic top-50 per kw → ourPos
│   ├── ingest_tarrow_syndication.py   # weekly XLSX → tarrow-syndication.js
│   ├── precompute_brief_outcomes.py   # Layer 0
│   ├── precompute_gsc_authority.py    # Layer 6
│   ├── precompute_engagement.py       # Layer 7
│   ├── precompute_author_authority.py # Layer 9
│   ├── precompute_brief_revenue.py    # Layer 10 (live BURT $/brief)
│   ├── precompute_outlet_ecpm.py      # daily live BURT eCPM
│   ├── analyze_skip_patterns.py       # weekly skip-rationale analysis
│   ├── generate_drift_report.py       # weekly drift report
│   ├── extract_compounding_curves.py  # weekly curve refresh
│   ├── repull_weak_briefs.py          # multi-seed re-pull for weak briefs
│   └── check.sh                       # quality gate
├── calibration/                       # ETRP recalibration pipeline
│   ├── PIPELINE.md
│   ├── STATUS.md
│   └── scripts/
│       ├── 00-check-credits.py
│       ├── 01-pull-snowflake.py
│       ├── 02-tag-content-types.py
│       ├── 03-outcomes-confounds-keywords.py
│       ├── 05-semrush-pull.py
│       ├── 06-fit-ensemble.py
│       ├── 07-cv-validate.py
│       ├── 11-commit.py
│       └── recalibrate.py
├── workers/decisions/                 # Cloudflare Worker (D1 + REST API)
│   ├── src/index.js
│   ├── schema.sql
│   ├── wrangler.toml
│   ├── package.json
│   └── README.md
└── .github/workflows/
    ├── learning-loop.yml              # Layers 0/6/7/9 daily
    └── monthly-semrush-refresh.yml    # Kept briefs refresh on 1st of month`}</pre>
          </section>

          <section data-anchor="tech-decisions-worker">
            <h1>Decisions worker + D1 database</h1>
            <p>Cloudflare Worker bound to a D1 database. Single source of truth for operator decisions.</p>

            <h3>API endpoints</h3>
            <table className="docs-table">
              <thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Description</th></tr></thead>
              <tbody>
                <tr><td>GET</td><td><code>/health</code></td><td>none</td><td>Liveness</td></tr>
                <tr><td>GET</td><td><code>/decisions</code></td><td>none</td><td>Full decisions map</td></tr>
                <tr><td>GET</td><td><code>/decisions/kept</code></td><td>none</td><td>Brief IDs with action='keep' (used by cron)</td></tr>
                <tr><td>GET</td><td><code>/decisions/:briefId</code></td><td>none</td><td>Single decision</td></tr>
                <tr><td>POST</td><td><code>/decisions/:briefId</code></td><td>X-Auth-Token</td><td>Upsert decision</td></tr>
                <tr><td>DELETE</td><td><code>/decisions/:briefId</code></td><td>X-Auth-Token</td><td>Clear</td></tr>
              </tbody>
            </table>

            <h3>D1 schema</h3>
            <pre className="docs-block">{`CREATE TABLE decisions (
  brief_id TEXT PRIMARY KEY,
  action TEXT,                         -- 'keep' | 'skip' | 'defer' | null
  ts INTEGER,                          -- epoch ms of last update
  note TEXT,                           -- operator rationale
  tve TEXT,                            -- 'rising' | 'flat' | 'declining' | null
  outcome_pv INTEGER,
  outcome_position INTEGER,
  outcome_headline_grade TEXT,
  updated_by TEXT
);

CREATE TABLE decision_history (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  brief_id TEXT NOT NULL,
  ts INTEGER NOT NULL,
  action TEXT,
  -- ... (full audit log; append-only)
);`}</pre>

            <h3>Common queries</h3>
            <pre className="docs-block">{`# View current decisions
curl https://data-keywords-decisions.pierce-williams.workers.dev/decisions | jq

# Just kept brief IDs (what cron reads)
curl https://data-keywords-decisions.pierce-williams.workers.dev/decisions/kept | jq

# Audit log—last 50 changes
wrangler d1 execute data-keywords-decisions --remote \\
  --command "SELECT * FROM decision_history ORDER BY ts DESC LIMIT 50"

# Tail worker logs
wrangler tail`}</pre>
          </section>

          <section data-anchor="tech-semrush">
            <h1>SEMrush pull pipeline</h1>
            <p>Three SEMrush API endpoints used. Each call costs units that count against the 250K monthly cap.</p>

            <h3>Endpoint cost model</h3>
            <table className="docs-table">
              <thead><tr><th>Endpoint</th><th>Cost</th><th>Used by</th></tr></thead>
              <tbody>
                <tr><td><code>phrase_fullsearch</code></td><td>20 units / line returned (40 results = 800 units)</td><td>Initial brief seeding (volume + CPC + intent per keyword)</td></tr>
                <tr><td><code>phrase_kdi</code></td><td>50 units / keyword (15 keywords = 750 units)</td><td>Initial brief seeding (KD score per keyword)</td></tr>
                <tr><td><code>phrase_organic</code></td><td>10 units / line returned (10 results = 100 units; 50 results = 500 units)</td><td>Competitor + ourPos backfill</td></tr>
              </tbody>
            </table>

            <h3>Pull scripts</h3>
            <table className="docs-table">
              <thead><tr><th>Script</th><th>Replaces or merges</th><th>Cost per brief</th><th>Use when</th></tr></thead>
              <tbody>
                <tr><td><code>pull_th_v5.py</code></td><td>Full replace (briefs.js)</td><td>~1.5K (fullsearch + kdi)</td><td>Initial seeding, adding new TREND_UNITS, refreshing vol/KD drift</td></tr>
                <tr><td><code>backfill_competitors.py</code></td><td>Merge in place</td><td>~500 (phrase_organic top-10 × 5 keywords)</td><td>Refresh competitor domains</td></tr>
                <tr><td><code>backfill_positions.py</code></td><td>Merge in place</td><td>~2.5K (phrase_organic top-50 × 5 keywords)</td><td>Refresh ourPos (portfolio rankings)</td></tr>
                <tr><td><code>repull_weak_briefs.py</code></td><td>Merge in place</td><td>varies</td><td>Multi-seed re-pull for briefs whose initial seed underperformed</td></tr>
              </tbody>
            </table>

            <h3>Scope flags (backfill scripts)</h3>
            <pre className="docs-block">{`--scope=all                # default: refresh every brief in briefs.js
--scope=kept               # cron default: query worker, refresh only Kept briefs
--scope=brief:ID,ID,...    # ad-hoc operator-targeted refresh`}</pre>

            <p>Pre-flight discipline: validate seeds in SEMrush Keyword Magic Tool before any new pull. Bad seeds waste ~800 units per <code>phrase_fullsearch</code> call.</p>
          </section>

          <section data-anchor="tech-snowflake-layers">
            <h1>Snowflake learning-loop layers</h1>
            <p>Five-layer learning loop. Each layer is a Python script that runs on a schedule, reads from Snowflake, writes to a JS file the console picks up on next page load.</p>
            <table className="docs-table">
              <thead><tr><th>#</th><th>Layer</th><th>Cadence</th><th>Output</th><th>Snowflake table</th></tr></thead>
              <tbody>
                <tr><td>0</td><td>Brief outcomes</td><td>Daily 8:13am</td><td><code>brief-outcomes.js</code></td><td>TRACKER_ENRICHED (180d)</td></tr>
                <tr><td>1</td><td>Drift report</td><td>Mon 8:17am</td><td><code>reports/drift.md</code></td><td>(reads decision history)</td></tr>
                <tr><td>2</td><td>Skip-pattern analysis</td><td>Mon 8:17am</td><td><code>reports/skip-patterns.md</code></td><td>(reads decisions + outcomes)</td></tr>
                <tr><td>3</td><td>Recalibration trigger check</td><td>1st 9:17am</td><td>workflow signal</td><td>(reads decision count)</td></tr>
                <tr><td>4</td><td>Compounding-curve refresh</td><td>Mon 8:17am</td><td><code>compounding-curves.js</code></td><td>TRACKER_ENRICHED (180d, ETRP cohort)</td></tr>
                <tr><td>6</td><td>GSC authority</td><td>Daily 8:13am</td><td><code>gsc-authority.js</code></td><td>SEARCH_ANALYSIS_RPT WEB (28d)</td></tr>
                <tr><td>7</td><td>Engagement signals</td><td>Daily 8:13am</td><td><code>engagement-signals.js</code></td><td>TRACKER_ENRICHED + ARTICLE_ENGAGEMENT_SIGNALS (90d)</td></tr>
                <tr><td>8</td><td>Tarrow syndication</td><td>Manual weekly</td><td><code>tarrow-syndication.js</code></td><td>(XLSX, off-Snowflake)</td></tr>
                <tr><td>9</td><td>Author authority</td><td>Daily 8:13am</td><td><code>author-authority.js</code></td><td>TRACKER_ENRICHED + ARTICLE_ENGAGEMENT_SIGNALS (180d)</td></tr>
                <tr><td>10</td><td>Brief revenue (live BURT)</td><td>Daily 8:13am</td><td><code>brief-revenues.js</code></td><td>TRACKER_ENRICHED v2.7 (BURT_INTELLIGENCE_REV joined)</td></tr>
                <tr><td>—</td><td>Outlet eCPM live</td><td>Daily 8:13am</td><td><code>outlet-ecpm-live.js</code></td><td>TRACKER_ENRICHED v2.7 (BURT, per-domain)</td></tr>
              </tbody>
            </table>
            <p>Resilience: each precompute step in <code>learning-loop.yml</code> runs with <code>continue-on-error: true</code>. A single layer failing (Snowflake auth flicker, transient outage) does not kill the whole run—prior committed data stays in place; the console keeps rendering until the next clean cycle. Output validation gates the commit, so a malformed or empty JS file never reaches main.</p>
          </section>

          <section data-anchor="tech-cron">
            <h1>Cron schedules</h1>
            <p>All scheduled off-the-hour because GitHub silently drops top-of-hour scheduled runs. Times in UTC; Dallas equivalents in parens.</p>
            <table className="docs-table">
              <thead><tr><th>Workflow</th><th>UTC cron</th><th>Dallas time</th><th>Purpose</th></tr></thead>
              <tbody>
                <tr><td><code>learning-loop.yml</code> daily-outcomes</td><td><code>13 13 * * *</code></td><td>Mon-Sun 8:13am</td><td>Layers 0/6/7/9</td></tr>
                <tr><td><code>learning-loop.yml</code> weekly-drift</td><td><code>17 13 * * 1</code></td><td>Mon 8:17am</td><td>Layers 1/2/4</td></tr>
                <tr><td><code>learning-loop.yml</code> monthly-recalibration</td><td><code>17 14 1 * *</code></td><td>1st of month 9:17am</td><td>Layer 3 gate check</td></tr>
                <tr><td><code>monthly-semrush-refresh.yml</code></td><td><code>13 13 1 * *</code></td><td>1st of month 8:13am</td><td>Refresh competitors + ourPos for Kept briefs</td></tr>
              </tbody>
            </table>
            <p>Concurrency: each workflow uses a concurrency group to prevent overlap. Manual dispatch always available via <code>gh workflow run</code> or the Actions UI.</p>
            <p>DST: cron times are UTC-fixed; Dallas time shifts ±1h with DST changes. Adjust manually if needed.</p>
          </section>

          <section data-anchor="tech-config">
            <h1>Configuration + secrets</h1>
            <h3>Repository secrets (GitHub Actions)</h3>
            <table className="docs-table">
              <thead><tr><th>Secret</th><th>Used by</th><th>Source</th></tr></thead>
              <tbody>
                <tr><td><code>SEMRUSH_API_KEY</code></td><td>monthly-semrush-refresh.yml + manual scripts</td><td>SEMrush account dashboard</td></tr>
                <tr><td><code>SNOWFLAKE_RSA_KEY_B64</code></td><td>learning-loop.yml all jobs</td><td><code>base64 &lt; ~/.credentials/growth_strategy_service_rsa_key.p8</code></td></tr>
                <tr><td><code>SLACK_WEBHOOK_URL</code></td><td>both workflows (failure notifications)</td><td>Slack incoming-webhook URL—channel of operator's choice</td></tr>
              </tbody>
            </table>
            <h3>Worker secrets (Cloudflare)</h3>
            <table className="docs-table">
              <thead><tr><th>Secret</th><th>Worker</th><th>Source</th></tr></thead>
              <tbody>
                <tr><td><code>WRITE_TOKEN</code></td><td>data-keywords-decisions</td><td><code>openssl rand -hex 32</code></td></tr>
              </tbody>
            </table>
            <h3>Static config</h3>
            <ul>
              <li><code>docs/data/worker-config.js</code>—decisions worker URL + token (committed; CF Access protects access)</li>
              <li><code>workers/decisions/wrangler.toml</code>—D1 binding, deploy config</li>
              <li><code>scripts/pull_th_v5.py</code> top—TREND_UNITS list (operator edits to add/remove seeds)</li>
            </ul>
          </section>

          <section data-anchor="tech-deployment">
            <h1>Deployment runbooks</h1>
            <h3>Console (Cloudflare Pages)</h3>
            <p>Auto-deploys on push to <code>main</code>. No manual step. CF Pages reads <code>docs/</code> and serves it. Live within ~1 min of push.</p>
            <h3>Decisions worker</h3>
            <pre className="docs-block">{`cd workers/decisions
npm install
wrangler d1 create data-keywords-decisions    # one-time
# paste returned id into wrangler.toml
npm run db:init-remote
openssl rand -hex 32 | pbcopy
wrangler secret put WRITE_TOKEN  # paste at prompt
npm run deploy

# Update docs/data/worker-config.js with deployed URL + token
git add . && git commit -m "wire decisions worker" && git push`}</pre>
            <h3>Manual SEMrush pull</h3>
            <pre className="docs-block">{`export SEMRUSH_API_KEY=xxx

# Full V5 pull (initial seeding or seed-set update)
python3 scripts/pull_th_v5.py

# Backfill competitor domains
python3 scripts/backfill_competitors.py --scope=all
python3 scripts/backfill_competitors.py --scope=brief:rl-sleep-performance,wh-hormone-health
python3 scripts/backfill_competitors.py --scope=kept

# Backfill ourPos (portfolio SERP positions)
python3 scripts/backfill_positions.py --scope=all
python3 scripts/backfill_positions.py --scope=kept

# Then commit + push
git add docs/data/briefs.js && git commit -m "data: refresh" && git push`}</pre>
          </section>

          <section data-anchor="tech-quality-gates">
            <h1>Quality gates</h1>
            <p>Run before every commit. <code>scripts/check.sh</code> validates:</p>
            <ul>
              <li>JS / Python syntax</li>
              <li>Required v4 files present</li>
              <li>Anonymization rule deferred to /sync-repos Step 4.5 (fail-closed before push)</li>
              <li>HTML script-tag integrity</li>
            </ul>
            <p>Plus session-level checks via <code>/sync-repos</code> in ops-hub:</p>
            <ul>
              <li><b>Step 4.5 /anonymization</b>—denylist scan; transcript filename scan; verbatim content patterns; snapshot directory check</li>
              <li><b>Step 4.6 /conciseness</b>—projects.js structural lint; CONTEXT.md length; bloat phrases; session-log audit</li>
            </ul>
            <p>Both fail-closed: any hit blocks the push.</p>
          </section>

          {/* ═══════════════════════════════════════════════════════════════ */}
          {/* PART III—Glossary: every calculation + weight                  */}
          {/* ═══════════════════════════════════════════════════════════════ */}

          <div className="docs-part-banner">PART III—Glossary: every calculation, weight, and threshold</div>

          <section data-anchor="gloss-vocab">
            <h1>Vocabulary—KD, CPC, vol, head/mid/long-tail</h1>
            <p>Quick definitions of the recurring terms. The full primer with examples lives in <button className="docs-link" onClick={() => jump('concepts-101')}>Concepts 101</button>; this entry is the at-a-glance reference.</p>

            <table className="docs-table">
              <thead><tr><th>Term</th><th>Stands for</th><th>Range</th><th>Direction</th><th>Source</th></tr></thead>
              <tbody>
                <tr><td><b>vol</b></td><td>Monthly search volume</td><td>0 – ∞ (typical 100 – 1M)</td><td>Higher = more demand</td><td>SEMrush phrase_fullsearch</td></tr>
                <tr><td><b>KD</b></td><td>Keyword Difficulty</td><td>0 – 100</td><td><b>Lower = easier</b> to rank</td><td>SEMrush phrase_kdi</td></tr>
                <tr><td><b>CPC</b></td><td>Cost Per Click</td><td>$0.00 – $30+</td><td>Higher = more commercial intent</td><td>SEMrush phrase_fullsearch</td></tr>
                <tr><td><b>head</b></td><td>Headtail keyword</td><td>Single word or 2-word generic</td><td>Highest vol, brutal KD</td><td>Console classification (vol + word count)</td></tr>
                <tr><td><b>mid</b></td><td>Midtail keyword</td><td>3–4 word specific phrase</td><td>Moderate vol, winnable KD—sweet spot</td><td>Console classification</td></tr>
                <tr><td><b>long</b></td><td>Longtail keyword</td><td>5+ word very-specific phrase</td><td>Low vol, low KD, high conversion intent</td><td>Console classification</td></tr>
                <tr><td><b>our pos</b></td><td>Best portfolio rank for this keyword</td><td>1–50, or 999 = unranked top-50</td><td><b>Lower = better</b></td><td>SEMrush phrase_organic top-50</td></tr>
                <tr><td><b>eCPM</b></td><td>Effective cost per 1000 impressions</td><td>$0.50 – $10 typical</td><td>Higher = more revenue per pv</td><td><button className="docs-link" onClick={() => jump('gloss-ecpm-table')}>Static per-pub table</button></td></tr>
                <tr><td><b>CTR</b></td><td>Click-through rate at SERP position</td><td>0% – 28%</td><td>Position 1 = 28%, position 10 = ~3%</td><td>Industry CTR curve</td></tr>
              </tbody>
            </table>
          </section>

          <section data-anchor="gloss-tier-pill">
            <h1>Tier pill (T1 / T2 / T3 / down-arrow / DA-shifted)</h1>
            <p><b>What it signifies.</b> The small pill on every brief tile that summarizes the brief's difficulty classification per the TH Keyword Selection Framework v1. Three states (T1 / T2 / T3) plus two suffixes (down-arrow, DA-shifted) that modify how to read the tier. At-a-glance answer to "how hard is this brief and should we attack it now?"</p>

            <table className="docs-table">
              <thead><tr><th>Pill</th><th>Meaning</th><th>What to do</th></tr></thead>
              <tbody>
                <tr><td><b>T1</b></td><td>Tier 1 · Go Now. Avg KD 0–29. Production-mix target 60%.</td><td>Today's working surface—write these. Easy SERPs we can win immediately.</td></tr>
                <tr><td><b>T2</b></td><td>Tier 2 · Build Into. Avg KD 30–49. Target 30%.</td><td>Locked in Beach lens until rolling-unlock fires (≥6 keeps in the collection in last 60d). Once unlocked, midtail wins that compound authority.</td></tr>
                <tr><td><b>T3</b></td><td>Tier 3 · Long Game. Avg KD 50+. Target 10%.</td><td>Locked until higher rolling-unlock threshold (≥15 keeps). Stretch SERPs that need cluster-scale commitment + freshness loops.</td></tr>
                <tr><td><b>T1 ↓</b> (down-arrow)</td><td>Per-keyword tier is T1 but the cluster check failed—fewer than 10 sweet-spot keywords (vol ≥ 500 AND KD ≤ 29) OR combined sweet-spot volume &lt; 10K.</td><td>Don't treat as a true T1—cluster is too thin for cluster-scale commitment. You might still write the one strong article, but don't plan a cluster around it.</td></tr>
                <tr><td><b>T2 ↓</b></td><td>Same superseded-by-cluster pattern at T2—the per-keyword math says T2 but cluster richness doesn't support cluster-scale work.</td><td>Treat as a candidate for narrow-execution rather than cluster-scale commitment.</td></tr>
                <tr><td>Tier with <b>"DA-shifted from X"</b> tag</td><td>Effective tier improved on a specific pub thanks to GSC-observed authority. E.g. "T2 (DA-shifted from T2 on miamiherald.com)" means Miami Herald has enough adjacent ranking authority to attack the brief like a T1.</td><td>Route the brief through the named pub—they have the foothold the math needs.</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> Computed at runtime from the brief's avg KD via <code>classifyTier()</code> in <code>util.js</code>. Cluster check (the down-arrow trigger) runs <code>clusterOpportunityScore()</code> on the keyword cluster. DA-shift (the suffix tag) reads GSC authority data via <code>daBonusFromGsc()</code> + <code>classifyTierWithDaBonus()</code>. All three live in <code>util.js</code>; thresholds in <code>TIER_RULES</code> + <code>TIER_1_CLUSTER_MIN</code>.</p>

            <p><b>Why it matters.</b> Tier is the single most actionable summary the engine produces. Without the down-arrow + DA-shift modifiers, a tier label can be misleading—a T1 brief whose cluster won't support cluster-scale commitment looks like a slam-dunk but isn't, and a T2 brief that a specific pub can win like a T1 looks like "wait for unlock" when in reality "send to that pub today." The pill format compresses three correlated facts (raw tier, cluster richness, per-pub authority bump) into a single glance so triage is fast and accurate.</p>

            <p><b>How to interpret it.</b> Read T1 / T2 / T3 as the base. Down-arrow = "the per-keyword math says this tier but the cluster is too thin to commit cluster-scale." DA-shifted = "this pub specifically can attack a tier above what raw KD suggests." Drawer's TH Framework v1 panel shows the full breakdown—cluster keyword count, sweet-spot volume, per-pub authority detail. Use the pill for fast triage; use the panel when you want to understand the underlying judgment.</p>
          </section>

          <section data-anchor="gloss-composite">
            <h1>Composite score</h1>
            <p><b>What it signifies.</b> A single number between 0 and 1 attached to every brief. Think of it as a "how attractive does this opportunity look on paper?" number. Higher means the engine sees more raw opportunity (lots of search demand, low difficulty, decent commercial intent). Lower means the opposite. It is <em>not</em> a forecast of how many page views the article will get—it's a pre-commitment scoring signal.</p>
            <p><b>How it's sourced.</b> Computed at runtime from four ingredient signals, weighted into a single number:</p>
            <pre className="docs-block">{`composite = volume_norm   × 0.75
          + inv_kd_norm   × 0.10
          + cpc_norm      × 0.10
          + residual_norm × 0.05`}</pre>
            <p>Volume = monthly searches (log-normalized so it doesn't blow out the scale). Inv-KD = 1 minus keyword-difficulty (so easier KDs score higher). CPC = cost-per-click as a commercial-intent proxy, normalized against per-vertical anchors. Residual = small confidence + persona-fit nudges. Weights live in <code>docs/data/verdict-thresholds.js</code> → <code>weights.default.weights</code>; can be re-calibrated by the ETRP pipeline. Surfaces in the verdict-breakdown panel inside the brief drawer (Score components table) plus drives the verdict label on the tile.</p>
            <p><b>Why it matters.</b> The composite is the single number that determines whether a brief gets a High Opportunity, Worth Testing, or Skip verdict (cohort percentile cuts at 30/50/20; a below-floor TH-pipeline trend gets Monitor instead). It's the engine's editorial vote—"if you only have time for the strongest opportunities, work down from the top of this list." Without it the operator has to weigh four signals manually for every brief, which doesn't scale to a 43-brief inventory.</p>
            <p><b>How to interpret it.</b> Read it as a "where to attack" prioritizer, not a PV predictor. ETRP validation against 1,062 historical McClatchy articles found that the composite score had a slight <em>negative</em> Spearman correlation with actual page views (−0.19 to −0.31)—meaning historically ambitious high-score picks <em>underperformed</em> lower-score picks, almost certainly because high-score topics drew stiffer SERP competition. So: trust the composite to tell you where the surface looks promising, but don't let it predict outcomes. Execution + angle + timing determine the actual hit rate. See <button className="docs-link" onClick={() => jump('gloss-etrp')}>ETRP findings</button>.</p>
          </section>

          <section data-anchor="gloss-volume">
            <h1>Volume normalization</h1>
            <p><b>What it signifies.</b> The "volume" component of the composite score. It takes the raw monthly-search-volume number from SEMrush (e.g. 12,000 searches/month) and converts it to a 0-to-1 score so it can be combined with the other ingredients. Without normalization, a single 800,000-search head term would drown out every other signal in the composite.</p>
            <p><b>How it's sourced.</b> SEMrush <code>phrase_fullsearch</code> returns a monthly-volume number per keyword at brief-pull time. The console aggregates that across the brief's keyword cluster, then runs it through this transform:</p>
            <pre className="docs-block">{`if volume < 500: volume_norm = 0      # below auto-skip floor
else:            volume_norm = min(1, log10(volume / 500) / log10(200))
                                # 500 monthly searches → 0
                                # ~7,000 → ~0.5
                                # 100,000 → 1.0 (saturation cap)
                                # 800,000 → still 1.0 (no double-credit for outliers)`}</pre>
            <p><b>Why it matters.</b> Search volume is heavy-tailed—most keywords sit in the hundreds-to-low-thousands range while a few head terms sit at 100K+. If we used the raw number, a single mega-volume keyword would dominate the composite score regardless of difficulty or relevance. The log transform compresses the tail so a 7K-vol midtail keyword gets a meaningful 0.5 score while a 100K-vol headtail keyword gets a 1.0—penalizing diminishing returns. Below 500/month is treated as zero opportunity (matches the hard auto-skip volume floor).</p>
            <p><b>How to interpret it.</b> If you see the score-components panel showing volume_norm = 0.5, that brief sits roughly at "decent midtail demand" (~7K monthly searches). 1.0 means "saturated head-term demand" (≥100K). 0 means "below the noise floor of 500/month—not worth pursuing on volume grounds alone." A higher number on its own doesn't mean the brief is winnable—it just means the demand exists. Always read it alongside KD.</p>
          </section>

          <section data-anchor="gloss-invkd">
            <h1>Inv-KD (inverse keyword difficulty)</h1>
            <p><b>What it signifies.</b> The "how easy is this to rank for?" component of the composite. KD itself runs 0 (trivial) to 100 (effectively impossible). We invert it so that <em>higher means easier</em>—which keeps every composite ingredient pointed in the same direction (higher = better opportunity).</p>
            <p><b>How it's sourced.</b> SEMrush returns a KD number per keyword via the <code>phrase_kdi</code> endpoint at brief-pull time (50 SEMrush units per keyword). KD itself is a SEMrush proprietary composite of: domain authority of the sites currently ranking, backlink counts of top-10 results, on-page content depth, topical-cluster strength, and some additional weighting. The console takes the brief-level average KD and converts it linearly:</p>
            <pre className="docs-block">{`inv_kd_norm = max(0, 1 − KD/100)
              # KD 0   → 1.0  (trivially rankable; we'll definitely win)
              # KD 25  → 0.75 (easy; longtail territory)
              # KD 50  → 0.5  (moderate; midtail)
              # KD 80  → 0.2  (hard; would need real authority)
              # KD 100 → 0    (impossible; entrenched competitors own SERP)`}</pre>
            <p><b>Why it matters.</b> Demand without winnability is noise. A 100,000-search keyword is worthless to us if the top 10 results are Wikipedia, NIH, and the New York Times—we'll never break in. Inv-KD is the "sanity check" against volume: it shaves the composite down for opportunities that look big on paper but are practically un-rankable. The 10% weight in the composite is deliberately modest—KD is a constraint, not the primary driver—but it actively pulls down briefs we couldn't realistically execute on.</p>
            <p><b>How to interpret it.</b> KD &lt; 30 = "we can win this if we publish well"—Tier 1 territory. KD 30–49 = "we can win this if we already have some adjacent authority"—Tier 2. KD 50–79 = "hard but possible with cluster + freshness"—Tier 3. KD 80+ = "unrealistic for us" and triggers the hard auto-Skip rule unless we already rank top-50 on a sibling keyword (the tangential-foothold exemption). When you see inv_kd_norm at 0.2 in the score-components panel, the brief's average KD is around 80—read that as "not winnable cold."</p>
          </section>

          <section data-anchor="gloss-cpc">
            <h1>CPC (vertical-relative)</h1>
            <p><b>What it signifies.</b> The "is there money here?" component of the composite. CPC is what advertisers pay Google per click for ads against this keyword. We don't run ads ourselves—we use CPC as an indirect signal that the topic has commercial intent. Readers searching high-CPC queries are more likely to be in spending mode, which translates to higher organic ad revenue on our pages too (same advertisers buying programmatic display next to our articles).</p>
            <p><b>How it's sourced.</b> SEMrush returns a per-keyword CPC in dollars via <code>phrase_fullsearch</code> at brief-pull time. The console averages it across the brief's keyword cluster, then normalizes against the brief's <em>vertical's</em> low/median/high CPC anchors (not absolute numbers):</p>
            <pre className="docs-block">{`cpc_norm = piecewise_linear(cpc, vertical.lo, vertical.mid, vertical.hi)
           # at vertical.lo  → 0.25 (low commercial intent for this vertical)
           # at vertical.mid → 0.50 (typical for this vertical)
           # at vertical.hi  → 0.75 (high for this vertical)
           # > vertical.hi   → log-asymptote toward 1.0`}</pre>
            <p>Vertical anchors live in <code>VERTICAL_CPC_NORMS</code> in <code>util.js</code>; the full table is at <button className="docs-link" onClick={() => jump('gloss-cpc-norms')}>per-vertical CPC norms</button>.</p>
            <p><b>Why it matters.</b> CPC ranges are wildly different across verticals—$1.50 is high for entertainment but laughably low for financial services. Without per-vertical normalization, financial-services briefs would always dominate the CPC signal and entertainment briefs would never score, even when the ent brief is at the top of its own vertical's CPC distribution. The per-vertical anchor approach makes the signal comparable across verticals so editorial decisions aren't biased toward whichever vertical happens to advertise most expensively.</p>
            <p><b>How to interpret it.</b> A cpc_norm of 0.50 means "this keyword sits at its vertical's median commercial intent—typical for the space." 0.75+ means "above-typical intent—the topic is commercially hot for its vertical." Below 0.25 means "low intent for this vertical—readers aren't searching with spending intent." The 10% weight is deliberately modest—CPC is a tiebreaker between similarly-volume-and-difficulty briefs, not a primary driver.</p>
          </section>

          <section data-anchor="gloss-cpc-norms">
            <h1>Per-vertical CPC norms (full table)</h1>
            <p><b>What it signifies.</b> The lookup table of "what counts as low / typical / high CPC" within each vertical we operate in. Each row gives three dollar anchors—low (~25th percentile), mid (~50th), high (~75th)—observed in real CPC distributions. The console uses these anchors to normalize each brief's CPC against its own vertical, so a $0.95 entertainment keyword and a $6.00 financial-services keyword can score equivalently when both sit at their vertical's median.</p>

            <p><b>How it's sourced.</b> Per-vertical CPC distributions originated from data-team commentary on real observed CPC ranges across McClatchy verticals. Lives in <code>VERTICAL_CPC_NORMS</code> in <code>util.js</code>. Manual refresh—revisit when adding a new vertical or when observed distributions shift materially. The <code>_default</code> row is the fallback for unrecognized verticals.</p>

            <table className="docs-table">
              <thead><tr><th>Vertical</th><th>lo (~25th %ile)</th><th>mid (~50th)</th><th>hi (~75th)</th></tr></thead>
              <tbody>
                <tr><td>entertainment</td><td>$0.30</td><td>$0.95</td><td>$2.00</td></tr>
                <tr><td>sports</td><td>$0.40</td><td>$1.10</td><td>$2.50</td></tr>
                <tr><td>fashion</td><td>$0.50</td><td>$1.50</td><td>$4.00</td></tr>
                <tr><td>tech</td><td>$0.80</td><td>$2.00</td><td>$6.00</td></tr>
                <tr><td>travel</td><td>$0.50</td><td>$1.50</td><td>$4.50</td></tr>
                <tr><td>food-recipes</td><td>$0.30</td><td>$0.85</td><td>$2.00</td></tr>
                <tr><td>health-wellness</td><td>$0.80</td><td>$2.50</td><td>$8.00</td></tr>
                <tr><td>home</td><td>$0.60</td><td>$1.80</td><td>$5.00</td></tr>
                <tr><td>parenting</td><td>$0.40</td><td>$1.20</td><td>$3.50</td></tr>
                <tr><td>financial-services</td><td>$2.00</td><td>$6.00</td><td>$30.00</td></tr>
                <tr><td><em>_default</em> (unrecognized vertical)</td><td>$0.50</td><td>$1.50</td><td>$4.00</td></tr>
              </tbody>
            </table>

            <p><b>Why it matters.</b> CPC ranges differ wildly across verticals. $1.50 is high for entertainment but laughably low for financial services. Without per-vertical normalization, financial-services briefs would always dominate the CPC dimension of the composite score and entertainment briefs would always lose—even when the entertainment brief is at the top of its own vertical's CPC distribution. Per-vertical anchors keep the signal comparable across verticals so editorial decisions aren't biased toward whichever vertical happens to advertise most expensively.</p>

            <p><b>How to interpret it.</b> Find the brief's vertical row → check where the brief's avg CPC sits between lo / mid / hi. Below lo = bottom-quarter commercial intent for this vertical → cpc_norm ~0.25. Around mid = typical. Around hi = above-typical commercial intent for this vertical. Above hi = exceptional commercial intent (asymptotes toward 1.0). When a vertical isn't listed, the engine falls back to the <code>_default</code> anchors—that's a hint to add the vertical to the table once observation data lets you anchor it accurately.</p>
          </section>

          <section data-anchor="gloss-residual">
            <h1>Residual nudges</h1>
            <p><b>What it signifies.</b> A small "everything else" adjustment to the composite score that catches signals not captured by volume / KD / CPC. Specifically two things: how confident we are in the underlying SEMrush data for this brief, and how well the brief matches our default audience persona. Acts as a tiebreaker between briefs that score similarly on the main three signals.</p>
            <p><b>How it's sourced.</b> Computed at runtime from two flags already attached to the brief:</p>
            <pre className="docs-block">{`residual_norm = 0.5 baseline (neutral)
                + 0.10 if confidence == 'precise'
                − 0.15 if confidence == 'partial'
                + 0.08 if persona fit (vs Curious Optimizer) == 'high'
                − 0.05 if persona fit == 'low'
                clamped to [0, 1]`}</pre>
            <p>Confidence comes from the SEMrush pull: "precise" if we got ≥10 keywords with full vol/KD/CPC coverage, "partial" if the pull was thin or the KD coverage had gaps. Persona fit comes from the same vocabulary-matching logic that drives the Persona-fit panel—see <button className="docs-link" onClick={() => jump('gloss-persona-fit')}>persona-fit scoring</button>.</p>
            <p><b>Why it matters.</b> Two briefs with identical vol / KD / CPC numbers can still differ in real-world value: one might be backed by sketchy SEMrush data (so the 75/10/10 portion is over-confident) and one might be a poor fit for our default audience (so even if the search demand is real, we're not the right publisher to serve it). The residual lets the engine say "all else equal, this one's a slightly weaker bet" or "this one's a slightly stronger bet" without overhauling the main signals. The 5% weight is intentionally tiny—residual <em>nudges</em> the composite, never dominates.</p>
            <p><b>How to interpret it.</b> A residual_norm of 0.6 in the score-components panel means "small positive nudge—clean SEMrush data plus decent persona fit." 0.4 means "small negative nudge—partial data and/or weak persona fit." Practical effect: at most ±0.012 on the final composite (5% × ±0.23 swing range). It rarely flips a verdict on its own, but it can shift a borderline brief from Worth Testing into High Opportunity or vice versa. Phase-5 will tune the residual ingredients once Decision Log accumulates outcome data.</p>
          </section>

          <section data-anchor="gloss-cuts">
            <h1>Cohort percentile cuts (30/50/20)</h1>
            <p><b>What it signifies.</b> How the engine converts each brief's composite score (a number 0–1) into a verdict label (High Opportunity / Worth Testing / Monitor / Skip). Instead of fixed numeric thresholds ("composite ≥ 0.6 = High Opportunity"), the engine sorts the entire current cohort of briefs by composite, then slices the sorted list into three buckets by percentile. Two overrides sit on top of the cohort cut: a hard auto-Skip (volume floor / KD ceiling / intent mismatch), and—for a TH-pipeline trend below the volume floor whose only failing check is that advisory floor—a <b>Monitor</b> verdict instead of Skip.</p>
            <table className="docs-table">
              <thead><tr><th>Cut</th><th>Bucket</th><th>Verdict</th><th>Plain English</th></tr></thead>
              <tbody>
                <tr><td>Top 30%</td><td>composite ≥ 70th percentile</td><td><b>High Opportunity</b></td><td>Strongest bets in the current inventory</td></tr>
                <tr><td>Middle 50%</td><td>composite 20th–70th percentile</td><td><b>Worth Testing</b></td><td>Mid-cohort—probably batch a few articles to learn</td></tr>
                <tr><td>Bottom 20%</td><td>composite &lt; 20th percentile</td><td><b>Skip</b></td><td>Weakest 20% of the inventory</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Cut percentages live in <code>docs/data/verdict-thresholds.js</code> → <code>buckets.high_opportunity_pct</code> / <code>worth_testing_pct</code> / <code>skip_pct</code>. Recomputed at every page load against the current full cohort of briefs in <code>window.BRIEFS</code>—so adding new briefs reshuffles every brief's verdict if their composite ranking against the new cohort changes.</p>
            <p><b>Why it matters.</b> Percentile cuts force the engine to maintain a coherent triage shape: a roughly fixed proportion of briefs always sit in "do this first" vs "test these" vs "skip these," regardless of whether the cohort skews easy or hard overall. If we used absolute thresholds, a quarter where every brief had high KD would produce zero High Opportunity verdicts and the operator would freeze. The cut percentages were also empirically validated—ETRP ran a sweep over candidate cuts on 1,062 historical articles and found <b>30/50/20</b> beat the v2 baseline of 20/50/30 by:</p>
            <ul>
              <li><b>+12.8pp top-quartile-PV recall</b> in the High Opportunity bucket (32.0% vs 19.2%)—meaning briefs the engine called High Opportunity were 12.8 percentage points more likely to actually become top-quartile-PV winners</li>
              <li><b>+3.0pp bottom-quartile-PV precision</b> in the Skip bucket (26.5% vs 23.5%)—meaning briefs the engine called Skip were 3pp more likely to actually be bottom-quartile losers</li>
            </ul>
            <p>Both metrics beat baseline by ≥3pp—the threshold required to adopt a refinement.</p>
            <p><b>How to interpret it.</b> If a brief's verdict shifts between sessions without you doing anything, the cohort changed: someone added new briefs that scored higher and bumped this one down a percentile bucket, or vice versa. That's expected behavior, not a bug. Reading the verdict pill: "High Opportunity" doesn't mean "guaranteed winner"—it means "in the top 30% of <em>this</em> inventory by composite score." Always pair the verdict with the score-components breakdown to understand <em>why</em> it landed there. And remember the ETRP caveat: the verdict ranks opportunity surface, not predicted PVs.</p>
          </section>

          <section data-anchor="gloss-auto-skip">
            <h1>Hard auto-skip rules</h1>
            <p><b>What it signifies.</b> Three "hard no" rules that override the cohort-percentile verdict. The KD-ceiling and intent-mismatch gates force a Skip regardless of how the brief scored. The volume floor is a hard Skip for ordinary keyword briefs, but for TH-pipeline trends (briefs that carry a <code>thStatus</code>) it is <em>advisory</em>: below the floor they route automatically to a <b>Monitor</b> verdict (velocity-validated, search-demand unproven)—neither pursue nor skip—rather than a hard Skip. They're the engine's safety net for opportunities that look mathematically attractive but are practically un-winnable or ill-fitting.</p>
            <table className="docs-table">
              <thead><tr><th>Rule</th><th>Threshold</th><th>What it catches</th><th>Override path</th></tr></thead>
              <tbody>
                <tr><td>Volume floor</td><td>brief total volume &lt; 500/month</td><td>Briefs whose keyword cluster has too little aggregate search demand to bother spinning up an article</td><td>Automatic for TH-pipeline trends: a brief carrying a <code>thStatus</code> is advisory-floored → routes to <b>Monitor</b> (not Skip). The manual TVE "rising" flag is the operator equivalent for non-TH briefs.</td></tr>
                <tr><td>KD ceiling without tangential foothold</td><td>brief avg KD ≥ 80 <b>AND</b> no keyword has <code>ourPos ≤ 20</code></td><td>Briefs in extremely hard SERPs where we have no existing authority to leverage</td><td>If any keyword shows ourPos ≤ 20, the rule auto-bypasses (we have a foothold to build on); otherwise operator must Keep + leave rationale</td></tr>
                <tr><td>Search-intent mismatch</td><td>Advisory check—defaults to pass</td><td>Briefs where the formula thinks the topic fits but the actual SERP shows a different intent (e.g. transactional vs informational)</td><td>Operator-set; not auto-fired</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Thresholds live in <code>docs/data/verdict-thresholds.js</code> → <code>auto_skip.min_volume</code> + <code>auto_skip.max_kd_no_tangential</code>. Tangential-foothold check reads <code>topKeywords[].ourPos</code> on the brief (refreshed monthly via <code>backfill_positions.py</code>). All checks run after composite scoring—they take a "passed verdict" and override it to Skip when fired.</p>
            <p><b>Why it matters.</b> Without these gates, a brief with a single high-volume keyword (boosting volume_norm) on a hard SERP could mathematically score into High Opportunity territory while being completely unwinnable in practice. Volume below 500/month is genuinely too thin for the production-cycle math to work even at full hit-rate. KD ≥ 80 without a foothold means we're proposing to outrank Wikipedia from a cold start—not realistic. Both rules are deliberate "the engine refuses to recommend execution here" backstops.</p>
            <p><b>How to interpret it.</b> If the verdict-breakdown panel shows an auto-Skip badge fired, look at WHICH gate triggered: "vol &lt; 500" means the topic is too thin demand-side; "KD ≥ 80 + no foothold" means the SERP is too entrenched and we don't already rank anywhere; intent-mismatch means a domain expert flagged the brief manually. For a TH-pipeline trend the volume floor shows as an advisory ⚠ (not a ✗) and the brief resolves to Monitor automatically—external momentum is presumed by the TH pipeline that surfaced it, so no manual flag is needed. The manual TVE rising override remains the operator's tool for the volume floor on non-TH briefs—use it when external signal (Google Trends, social, news cycle) shows momentum that hasn't shown up in monthly SEMrush volume yet. The KD ceiling override requires explicit operator commitment via Keep + rationale; that decision becomes part of Phase-5 calibration training data.</p>
          </section>

          <section data-anchor="gloss-kd-bands">
            <h1>KD bands (TH Framework v1)</h1>
            <p><b>What it signifies.</b> Three difficulty tiers—T1, T2, T3—that classify each brief by its keyword-difficulty profile. They group briefs into "win this now," "build into this later," and "long-game stretch" buckets so the production pipeline can be staged: do the easy wins first to earn authority, then graduate into harder territory once the foothold exists. The Beach lens uses these tiers as the three difficulty rails inside each TH Collection.</p>
            <table className="docs-table">
              <thead><tr><th>Tier</th><th>KD range</th><th>Min keyword volume</th><th>Production-mix target</th><th>What it means</th></tr></thead>
              <tbody>
                <tr><td><b>Tier 1 · Go Now</b></td><td>KD 0–29</td><td>500</td><td>60% of kept mix</td><td>Easy SERPs we can win immediately. Longtail-leaning. Today's working surface—write these.</td></tr>
                <tr><td><b>Tier 2 · Build Into</b></td><td>KD 30–49</td><td>1,000</td><td>30%</td><td>Moderate SERPs winnable once the team has some adjacent-topic authority. Midtail. Locked in Beach lens until rolling unlock fires.</td></tr>
                <tr><td><b>Tier 3 · Long Game</b></td><td>KD 50+</td><td>5,000</td><td>10%</td><td>Hard SERPs. Cluster-scale commitment required (multi-article + freshness). Locked until a higher rolling-unlock threshold.</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Defined in <code>TIER_RULES</code> in <code>util.js</code>. Pulled from content team lead's TH Keyword Selection Framework v1 (2026-03-16). Each brief is classified by its <em>average</em> KD across the keyword cluster, with the per-keyword min-volume threshold acting as a sanity check that the cluster carries enough demand for the tier. Tier appears as a small pill on every brief tile (T1 / T2 / T3, with a "↓" suffix when the cluster check fails—see <button className="docs-link" onClick={() => jump('gloss-tier-pill')}>tier pill glossary</button>).</p>
            <p><b>Why it matters.</b> Without explicit tiers, the team would be free to attack any KD level at any time, with no gating logic. That tends to produce churn—operators chase shiny high-volume head-terms, lose against entrenched SERPs, and get demoralized. The framework's bet is that staged commitment (T1 wins → T2 unlocks → T3 unlocks) compounds authority faster than scattered effort. The 60/30/10 production-mix target is the explicit rule for "what shape should our weekly output look like" so we never starve the easy-win foundation.</p>
            <p><b>How to interpret it.</b> See a brief tagged T1 in the inventory? Top of the list to attack today—easy SERPs, you'll likely rank if the article is competent. T2 means "this is winnable but not yet—keep stacking T1 wins first, or move on." T3 means "respect the difficulty; only commit if you can do cluster-scale work." When a pub has demonstrated GSC-observed authority on the brief's keywords, the tier can improve for that pub specifically via DA-shift—see <button className="docs-link" onClick={() => jump('gloss-da-shift')}>DA-shift</button>. A "DA-shifted from T2 on miamiherald.com" tag means Miami Herald has enough adjacent ranking authority to attack the brief like a T1 even though raw KD says T2.</p>
          </section>

          <section data-anchor="gloss-web-roles">
            <h1>Web roles</h1>
            <p><b>What it signifies.</b> A per-brief role classification—Entry Point / Deepener / Connector / Converter—that says "what role does this content play in the broader site experience?" Each role has its own minimum thresholds (e.g. an Entry Point brief needs higher acquisition-velocity numbers than a Deepener; a Converter needs commercial intent). Maps each brief to the relevant set of advisory checks based on its content type.</p>
            <p><b>How it's sourced.</b> The role is derived from the brief's content type via <code>contentWebRoleFor()</code> in <code>util.js</code>. Mapping: Blue = Entry Point (acquisition), Orange = Deepener (typical wellness/lifestyle explainer), Purple = Bridge (L&amp;E, currently paused), Green = Converter (commerce/signups). Per-role threshold rules live in <code>WEB_ROLE_RULES</code>. Originated from the TH Keyword Selection Framework v1 along with the tier bands.</p>
            <p><b>Why it matters.</b> Different content roles need different success criteria. An Entry Point brief winning at 8% engaged-session-rate but driving lots of new visitors is doing its job; a Deepener brief with the same engagement number but no return-visit signal is failing. Without role-specific thresholds, every brief gets graded on the same "engagement composite" and we either over-credit acquisition pieces or under-credit deepeners. The role-aware approach lets the engine assess each brief against the standard appropriate to its function.</p>
            <p><b>How to interpret it.</b> Web-role checks are <em>advisory</em>, not gating—they surface in the verdict-breakdown panel as informational notes ("Deepener role: time-on-page below threshold") but do not auto-Skip a brief on their own. Read them as "the engine has noticed something off about this brief's expected behavior for its role"—useful as a reality-check before committing pipeline capacity. The TVE override path is available if external signals (e.g. trend velocity) say the brief should be pursued anyway.</p>
          </section>

          <section data-anchor="gloss-cluster-opp">
            <h1>Cluster opportunity score</h1>
            <p><b>What it signifies.</b> A "is this keyword cluster <em>actually</em> rich enough to commit cluster-scale production effort?" check. A cluster is rich if it has enough <em>sweet-spot keywords</em>—meaning keywords that combine meaningful demand (volume ≥ 500/month) with winnable difficulty (KD ≤ 29)—to support a multi-article content cluster around the topic. Not just one big keyword with a long tail of difficult or thin ones.</p>
            <p><b>How it's sourced.</b> Computed at runtime from the brief's <code>topKeywords[]</code> array (which comes from the SEMrush pull):</p>
            <pre className="docs-block">{`sweet_spot_keywords = topKeywords filtered to (vol ≥ 500 AND KD ≤ 29)
opportunity_score   = count(sweet_spot_keywords) × sum(sweet_spot.volume)
clears_tier_1       = count ≥ 10 AND combined_volume ≥ 10,000`}</pre>
            <p>The Tier 1 cluster threshold (≥ 10 sweet-spot keywords AND ≥ 10K combined volume) is from TH Framework v1 §4b. Surfaces in the TH Framework v1 panel inside the verdict breakdown.</p>
            <p><b>Why it matters.</b> A brief might pass the per-keyword Tier 1 check (avg KD &lt; 30, decent total volume) on the strength of one or two big keywords—but the engine still recommends cluster-scale commitment for Tier 1. If the cluster is actually thin (one fat keyword surrounded by hard ones), spinning up a 10-article cluster makes no sense; you'd write the one easy article and the rest would lose. The cluster-opportunity check catches this gap. When the brief is "T1 by KD bands but cluster fails," the tier label is marked "superseded"—meaning the engine doesn't actually trust the Tier 1 framing for this brief.</p>
            <p><b>How to interpret it.</b> See "T1 ↓" or "T1 (superseded)" on a brief? The per-keyword math says T1 but the cluster check says it can't support cluster-scale production. Treat as below-T1 for planning—you might still write the one strong article, but don't commit to a cluster around it. See "T1 (cluster: 12 keywords, 14.5K combined vol)" in the framework panel? Solid Tier 1—proceed with cluster commitment. The numbers shown in the framework panel let you eyeball cluster richness directly.</p>
          </section>

          <section data-anchor="gloss-da-shift">
            <h1>DA-shift (per-pub authority bonus)</h1>
            <p><b>What it signifies.</b> A per-publication "credit" we apply to the brief's keyword difficulty when one of our pubs already ranks well on the brief's keywords. Logic: if Miami Herald already ranks #4 in Google for some keyword in this brief's cluster, then Miami Herald has demonstrable topical authority there—KD 60 isn't really KD 60 for <em>that</em> pub, it's effectively easier. So Miami Herald might be able to attack this brief like a Tier 2 even though raw KD says Tier 3. "DA" = Domain Authority. "Shift" = we shift the effective tier downward (= easier).</p>
            <p><b>How it's sourced.</b> Layer 6 of the daily learning-loop cron pulls Google Search Console data from Snowflake (<code>SEARCH_ANALYSIS_RPT</code>, 28d window) for every brief × pub pair. The <code>avg_position</code> column tells us where that pub already ranks. The bonus scales with how strong the existing ranking is:</p>
            <pre className="docs-block">{`avg_rank ≤ 5  → DA bonus = 20 KD points  (very strong authority)
avg_rank ≤ 10 → DA bonus = 12
avg_rank ≤ 25 → DA bonus = 6
avg_rank ≤ 50 → DA bonus = 3
avg_rank > 50 → DA bonus = 0   (no observable authority)

effective_kd = max(0, brief.avgKD − DA_bonus)
tier         = classify_by_band(effective_kd)`}</pre>
            <p>Computed per pub × per brief, so a single brief can have different effective tiers on different pubs. Sources: <code>daBonusFromGsc</code> + <code>classifyTierWithDaBonus</code> in <code>util.js</code>.</p>
            <p><b>Why it matters.</b> Without DA-shift, the engine treats every brief as a cold start regardless of where in the portfolio we already have authority. That throws away one of our biggest competitive levers—the fact that some keywords have an existing top-50 ranking we could absolutely beat with a freshness pass or a cluster expansion. DA-shift makes that latent authority visible at the verdict-breakdown level so editorial decisions can route toward the pub best positioned to win.</p>
            <p><b>How to interpret it.</b> See a "DA-shifted from T2 on miamiherald.com" tag in the framework panel? Translation: this brief shows as Tier 2 by raw KD, but Miami Herald already has enough adjacent authority that effectively it's a Tier 1 attack for them specifically. Plan to send this brief through the Miami Herald flow—the math says they'll win it. No DA-shift tag = no portfolio pub has observable authority on this keyword space yet—treat as a cold start. The bonus only kicks in when GSC actually shows you ranking; it's not aspirational.</p>
          </section>

          <section data-anchor="gloss-ctr">
            <h1>CTR curve (KD-derived → GSC-observed)</h1>
            <p><b>What it signifies.</b> The conversion factor between "where this article ranks on Google's results page" and "what fraction of searchers actually click on it." If your article ranks #1 in a SERP, ~28% of people who search that query click on you; rank #10, only ~3%; rank #20, ~1.5%. The CTR curve is how the engine converts search-volume numbers into expected page-view numbers—multiply monthly searches × CTR-at-position to project monthly traffic.</p>
            <p><b>How it's sourced.</b> Industry-standard CTR curve from Advanced Web Ranking 2024 organic-SERP averages, baked into <code>CTR_CURVE</code> in <code>util.js</code>:</p>
            <pre className="docs-block">{`CTR by SERP rank (industry baseline):
  rank 1  → ~28%
  rank 2  → ~16%
  rank 5  → ~6%
  rank 10 → ~3%
  rank 20 → ~1%
  rank 50 → ~0.1%`}</pre>
            <p>Then we need to <em>predict</em> what rank our article would land at, since it doesn't exist yet. Two stages:</p>
            <p><b>Stage 1—KD-derived (fallback when no real ranking data exists).</b> Map KD into an expected rank using <code>expectedRankFromKD()</code>:</p>
            <pre className="docs-block">{`KD < 20  → expect rank ~5    (easy SERPs, top-5 likely)
KD < 30  → expect rank ~8    (longtail, top-10)
KD < 50  → expect rank ~18   (midtail, page 2)
KD < 70  → expect rank ~35   (hard, page 3-4)
KD ≥ 70  → expect rank ~60   (very hard, deep pages)`}</pre>
            <p><b>Stage 2—GSC-observed (when available).</b> Layer 6 of the daily learning-loop cron pulls <code>SEARCH_ANALYSIS_RPT</code> from Snowflake (28d window). When a portfolio pub has actually-observed <code>avg_position</code> for the brief's keywords, the engine uses that directly instead of the KD-derived guess. Higher accuracy because it's grounded in real ranking history rather than a SEMrush proxy.</p>
            <p><b>Why it matters.</b> Search volume on its own doesn't tell you traffic—a 100K-search query at rank 50 sends maybe 100 clicks. The CTR curve makes the volume number actionable by attaching a realistic capture rate. It's also the input to the Revenue projection panel—without expected PVs, you can't multiply by eCPM to estimate revenue. Stage-1 KD-derivation is a defensible baseline for greenfield briefs; Stage-2 GSC-observed is more accurate when authority data exists. Both are surfaced in the engine so the operator can see which stage drove the projection.</p>
            <p><b>How to interpret it.</b> Read CTR-projected PVs as <em>theoretical upside if we rank where the engine expects</em>—not as forecasts. Real outcomes vary widely (per ETRP's negative Spearman finding). When the Revenue panel shows "rank source: gsc-observed (pos 12)," it's grounded in actual McClatchy ranking history; when it shows "rank source: kd-estimated (KD 45 → rank ~18)," it's a defensible guess. Trust GSC-observed projections more than KD-estimated ones; treat KD-estimated as directional only.</p>
          </section>

          <section data-anchor="gloss-revenue">
            <h1>Revenue projection</h1>
            <p><b>What it signifies.</b> A "what's this brief potentially worth in dollars per month, per pub?" projection. The engine predicts how many page views the article would get if it ranked where the engine expects, then multiplies that by the pub's eCPM (revenue per 1,000 impressions). Surfaces as a dollar range across all portfolio pubs (e.g. "$240–$1,800/mo · 8 pubs") so you can see which pub is positioned to extract the most revenue from the brief.</p>
            <p><b>How it's sourced.</b> Multi-step computation, run per pub for the entire portfolio:</p>
            <pre className="docs-block">{`for each pub in portfolio:
  rank        = gsc_observed[pub] OR expected_rank_from_kd(brief.avgKD)
  ctr         = CTR_CURVE[rank]               # industry CTR curve, see CTR section
  monthly_pv  = brief.totalVolume × ctr        # search vol × click-through rate
  ecpm        = ECPM[pub]                      # static per-pub table (quarterly refresh)
  revenue     = (monthly_pv / 1000) × ecpm

range = { min, max, count, bestPub, bestRevenue }`}</pre>
            <p>Functions: <code>portfolioRevenue()</code>, <code>revenueRange()</code> in <code>util.js</code>; eCPM in <code>ECPM</code> map. Stage 1 uses KD-derived rank; Stage 2 uses GSC-observed rank when available (see <button className="docs-link" onClick={() => jump('gloss-ctr')}>CTR curve</button>).</p>
            <p><b>Why it matters.</b> Composite verdicts ("High Opportunity") tell you opportunity surface but say nothing about absolute dollar value. A High Opportunity brief that projects to $200/mo is a different decision than a Worth Testing brief that projects to $3K/mo per pub. The revenue lens turns the engine's verdict-side scoring into business-side scoring—useful for prioritizing under capacity constraints when you have to pick between briefs that all "look good." It's an overlay (off by default) because the projection is theoretical and over-emphasizing dollar projections can distort editorial decisions toward chasing inflated numbers.</p>
            <p><b>How to interpret it.</b> Treat the range as <em>theoretical upside if we rank where the engine expects</em>—not a forecast. Three reasons to be cautious: (1) the eCPM table is static per pub (no section / seasonal / direct-sold variation, see <button className="docs-link" onClick={() => jump('gloss-ecpm-table')}>eCPM table</button>); (2) KD-derived rank predictions are SEMrush proxies, not actual rankings; (3) ETRP found composite scores are uncorrelated to anti-correlated with PV outcomes historically, so even GSC-observed projections are subject to execution variance. Practical use: compare relative projections across briefs (which one looks bigger?) rather than treating any single number as a forecast. The "best pub" callout is more reliable than the absolute number—it reflects existing authority signals.</p>
          </section>

          <section data-anchor="gloss-ecpm-table">
            <h1>eCPM table (static per-pub)</h1>
            <p className="lede">
              <b>Important caveat:</b> the eCPM table is <b>static</b>—one number per pub, refreshed manually every quarter. It does not vary by section, time of year, content type, ad density, or recency. Direct-sold revenue is not modeled. The console nudges Pierce when the table approaches/passes the 90-day refresh deadline.
            </p>

            <p><b>What it signifies.</b> A flat lookup of "if an article published on this pub generates 1,000 ad impressions, how many dollars do we make?" One number per pub, in dollars. eCPM = effective cost per <em>mille</em> (Latin for thousand)—the standard advertising metric that converts impression count to revenue. The Revenue projection panel uses these numbers to multiply expected page views into projected dollars. Because the table is static, every brief on a given pub uses the same eCPM regardless of the brief's section / vertical / topic—a known coarse approximation.</p>

            <p><b>How it's sourced.</b> McClatchy ad-ops produces a quarterly eCPM export at the pub level. Pierce updates the <code>ECPM</code> map in <code>docs/js/util.js</code> + sets <code>ECPM_REFRESH_DATE</code> to the export's date. Staleness state—<code>ecpmStalenessState()</code> in <code>util.js</code>—flips to "due-soon" 15 days before the 90-day deadline and "overdue" past it. The console nudges visibly once stale.</p>

            <p><b>Why it matters.</b> Composite verdicts ("High Opportunity") tell you opportunity surface but say nothing about absolute dollar value. Multiplying expected PVs by eCPM converts the verdict into rough dollar projections—useful for prioritizing under capacity constraints. But "static per pub" means the number is a baseline programmatic-only estimate, not a forecast: ad sections / time of year / direct-sold mix all create real variation that's not captured. So the table matters as a <em>relative</em> ranking tool ("Miami Herald earns more per PV than Sacramento Bee—route revenue-hungry briefs there") more than as an absolute forecast.</p>

            <h3>Current values (last refresh 2026-04-09)</h3>
            <table className="docs-table">
              <thead><tr><th>Pub</th><th>Tier</th><th>eCPM</th><th>Notes</th></tr></thead>
              <tbody>
                <tr><td>TH O&amp;O</td><td>th-primary</td><td>$1.95</td><td>Post-pivot home—every new National article ships here</td></tr>
                <tr><td>Miami Herald</td><td>regional</td><td>$1.21</td><td></td></tr>
                <tr><td>Kansas City Star</td><td>regional</td><td>$1.10</td><td></td></tr>
                <tr><td>Charlotte Observer</td><td>regional</td><td>$1.06</td><td></td></tr>
                <tr><td>Sacramento Bee</td><td>regional</td><td>$0.92</td><td></td></tr>
                <tr><td>Raleigh News &amp; Observer</td><td>regional</td><td>$0.88</td><td></td></tr>
                <tr><td>The State</td><td>regional</td><td>$1.17</td><td></td></tr>
                <tr><td>Fort Worth Star-Telegram</td><td>regional</td><td>$0.98</td><td></td></tr>
                <tr><td>Lexington Herald-Leader</td><td>regional</td><td>$0.93</td><td></td></tr>
                <tr><td>Us Weekly</td><td>L&amp;E (paused)</td><td>$1.21</td><td>Paused until L&amp;E data pipe is repaired</td></tr>
                <tr><td>Woman's World</td><td>L&amp;E (paused)</td><td>$1.13</td><td>Paused</td></tr>
                <tr><td>Life &amp; Style</td><td>L&amp;E (paused)</td><td>$1.08</td><td>Paused</td></tr>
                <tr><td>First For Women</td><td>L&amp;E (paused)</td><td>$1.33</td><td>Paused</td></tr>
                <tr><td>In Touch Weekly</td><td>L&amp;E (paused)</td><td>$3.55</td><td>Paused · small sample (treat with caution)</td></tr>
                <tr><td>Closer Weekly</td><td>L&amp;E (paused)</td><td>$2.21</td><td>Paused · small sample</td></tr>
                <tr><td>Star Magazine</td><td>L&amp;E (paused)</td><td>$8.73</td><td>Paused · very small sample (treat as outlier)</td></tr>
                <tr><td>Soap Opera Digest</td><td>L&amp;E (paused)</td><td>$1.48</td><td>Paused</td></tr>
              </tbody>
            </table>

            <h3>Refresh cadence + nudge</h3>
            <ul>
              <li><b>Cadence:</b> quarterly, agreed 2026-04-23.</li>
              <li><b>Source:</b> McClatchy ad-ops export; documented in <code>REFERENCE.md</code> §"Authoritative site-level eCPM."</li>
              <li><b>Refresh deadline:</b> 90 days after last refresh date. Console state turns "due-soon" 15 days before deadline, "overdue" past deadline.</li>
              <li><b>To refresh:</b> request fresh eCPM export from ad-ops, update <code>ECPM</code> map in <code>util.js</code>, set <code>ECPM_REFRESH_DATE</code> to the export's date.</li>
            </ul>

            <h3>What's NOT in this number</h3>
            <ul>
              <li>Direct-sold premium ad revenue (separate channel; not captured)</li>
              <li>Section-specific variance (real estate / finance sections typically score higher)</li>
              <li>Seasonal swings (~35% monthly volatility—December strongest, January softest)</li>
              <li>Per-pub topical authority bonus (Stage 2 modeling, blocked on full GSC integration)</li>
              <li>Affiliate / commerce revenue (separate revenue line entirely)</li>
            </ul>

            <p><b>How to interpret it.</b> Use eCPM differences to rank pubs against each other for revenue-routing decisions: a $1.95 TH O&amp;O eCPM is roughly 2× a $0.92 Sacramento Bee eCPM, so the same article will earn ~2× more on TH for the same PV count. Don't read a single absolute eCPM number as a precise forecast—section / season / direct-sold can swing actual revenue by 30–50%. When you see the staleness pill ("eCPM table due in 12d" / "overdue 5d"), prompt for a fresh ad-ops export. Treat the L&amp;E pub eCPMs as currently academic—those pubs are paused until the L&amp;E data pipe is repaired. Per-pub authority bonuses (Stage 2) will eventually layer onto this for more accurate routing once GSC integration completes.</p>

            <p><b>Source.</b> <code>ECPM</code>, <code>ecpmFor()</code>, <code>ecpmStalenessState()</code> in <code>util.js</code>.</p>
          </section>

          <section data-anchor="gloss-engagement">
            <h1>Engagement signal composite</h1>
            <p><b>What it signifies.</b> A 0–100 score per brief that captures "how strongly are readers actually engaging with content like this?"—measured from real reader behavior on McClatchy articles whose keywords match the brief. Different from page views: a high-engagement article holds attention (long time-on-page, multi-page sessions, deep scroll) regardless of total volume; a low-engagement article gets the click but loses the reader. Different content types use different ingredients because they have different success criteria.</p>
            <p><b>How it's sourced.</b> Computed by the BI-team framework, weighted by content type (the role each piece plays):</p>
            <pre className="docs-block">{`Default / Deepener (Orange):  time_on_page × 0.40
                              + pvs_per_session × 0.30
                              + scroll_depth × 0.30
                              (when scroll_depth not derivable, weights renormalize)

Entry Point (Blue):           PVs ÷ new visitors  (acquisition velocity)

Connector:                    internal-link CTR + cluster-continuation rate

Converter (Green):            conversions + signups`}</pre>
            <p>The live data comes from <code>ARTICLE_ENGAGEMENT_SIGNALS</code> in Snowflake (Amplitude-derived: time-on-page, session length, pvs/session, bounce rate, engaged-session rate, etc.), joined to articles matching the brief's keyword space. Layer 7 of the daily learning-loop cron (8:13am Dallas) refreshes this. Functions: <code>engagementSignal()</code>, <code>engagementSignalDetail()</code>, <code>ENGAGEMENT_FRAMEWORK</code> in <code>util.js</code>.</p>
            <p><b>Why it matters.</b> Page views alone overrate acquisition pieces and underrate deepeners. An article that wins a viral moment but loses readers in 6 seconds is shipping low-quality reach; an article with modest PVs but 4-minute average time-on-page is actually serving the audience. The engagement signal lets the engine assess each brief on the dimension appropriate to its role—and surfaces "this brief's keyword space tends to engage poorly" or "...tends to engage well" warnings before pipeline capacity is committed. It's especially useful for greenfield decisions: "if we publish here, what's the historical engagement profile of similar articles?"</p>
            <p><b>How to interpret it.</b> A 70+ live score = strong reader behavior; readers actually engage with content like this. 40–60 = average. Below 30 = thin engagement; even if you publish, expect bounce. <b>Live data</b> (full color, no asterisk) is real reader behavior—trust the score directly. <b>Proxy</b> (faded color + asterisk *) means no published article matches yet, so the score is derived from KD + persona-fit + content-type—read it as a directional placeholder, not ground truth. The proxy auto-clears the next day after a matching article publishes (Layer-7 cron joins). See <button className="docs-link" onClick={() => jump('gloss-engagement-pill')}>engagement pill states</button> for the full visual-state explainer.</p>
          </section>

          <section data-anchor="gloss-engagement-pill">
            <h1>Engagement pill states (live vs proxy)</h1>
            <p><b>What it signifies.</b> The engagement pill on every brief tile renders in two visually distinct states—full-color or faded-with-asterisk—and the difference is load-bearing. Full color = real reader behavior pulled from a published McClatchy article whose keywords match this brief. Faded with an asterisk = no published article matches yet, so the score is a directional placeholder derived from KD + persona-fit + content-type. The visual distinction tells you at a glance whether the number is observed or estimated, so you never accidentally treat a proxy as ground truth.</p>

            <table className="docs-table">
              <thead><tr><th>State</th><th>Visual</th><th>Source</th><th>How to read</th></tr></thead>
              <tbody>
                <tr><td><b>Live</b></td><td>Full color, no asterisk</td><td>Snowflake <code>ARTICLE_ENGAGEMENT_SIGNALS</code> joined to articles matching the brief's keyword space</td><td>Trust as observed reader behavior. Higher = stronger.</td></tr>
                <tr><td><b>Proxy</b></td><td><em>Faded color + asterisk *</em></td><td>Derived: function(KD, persona-fit, content-type)</td><td>Directional only. Use for relative ordering across briefs, not as ground truth.</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> The pill state is decided by <code>engagementSignalDetail()</code> in <code>util.js</code>—returns <code>{`{ source: 'live'|'proxy', score, components, articleCount }`}</code>. Layer 7 of the daily learning-loop cron (8:13am Dallas) runs <code>scripts/precompute_engagement.py</code> which joins <code>TRACKER_ENRICHED</code> (Snowflake) against each brief's keyword cluster + reads <code>ARTICLE_ENGAGEMENT_SIGNALS</code> (also Snowflake, Amplitude-derived: time-on-page, pvs/session, bounce rate). When at least one matched article exists, the engine flips that brief's pill from proxy to live on the next page load. No operator action.</p>

            <p><b>Why it matters.</b> Two briefs with engagement scores of 70 mean very different things if one is live (real reader behavior says this topic engages well) and one is proxy (the engine guesses this topic should engage well based on heuristics). Without the visual distinction, the operator can't tell observation from estimate at the tile level—and proxy-as-truth would push wrong briefs to the top of triage. The fade + asterisk make data quality always visible, with no need to drill into the drawer.</p>

            <p><b>How to interpret it.</b> Live pills (full color): trust the score directly. A 72 means real readers spent real time engaging—actionable. Proxy pills (faded + asterisk): use only for relative ordering across briefs ("proxy 60 looks more promising than proxy 40")—both could land elsewhere when the article actually publishes. Greenfield V5 briefs default to proxy; the proxy clears within ~24 hours of a matching article publishing. The same source flag is mirrored in the drawer's Engagement signal panel with full per-metric breakdown—drill in there when you need to see <em>why</em> a brief scored as it did. See also: <button className="docs-link" onClick={() => jump('engagement-pill-states')}>plain-English Part-I explainer</button>.</p>
          </section>

          <section data-anchor="gloss-persona-fit">
            <h1>Persona-fit scoring</h1>
            <p><b>What it signifies.</b> How strongly a brief's topic + recommended article + keywords resonate with each named audience persona. Scored as <b>high / medium / low / n/a</b> per persona. Says: "if Curious Optimizer is the audience, does this brief speak to her?"—a brief about hormone-balancing supplements is a high fit; a brief about NFL draft prospects is a low fit. Used to flag briefs that mathematically score well by formula but are tonally wrong for the audience we're trying to grow.</p>
            <p><b>How it's sourced.</b> Vocabulary-matching against persona-specific term lists. Process:</p>
            <pre className="docs-block">{`for each persona:
  haystack = brief.topic + brief.recommendedArticle + brief.keywords
             (joined, lowercased)
  matches  = persona.affinity_terms ∩ haystack
  score    = Σ match.weight + content_type_prior_bonus

  fit = 'high'   if score is at or near the cohort's top
        'medium' if score is mid-range
        'low'    if score is below the noise floor (0.5)`}</pre>
            <p>Each persona has three weighted vocabulary categories—topic terms (weight 1.0, primary), format terms (0.6), tone terms (0.4). Plus a content-type prior bonus that adds <code>+0.5 / +0.2 / −0.2</code> depending on whether the brief's content type is canonically "high / medium / low" fit for that persona. Vocabularies live in <code>PERSONA_AFFINITY</code>; per-content-type matrix in <code>PERSONA_FIT_MATRIX</code>; matching logic in <code>personaFitDetail()</code>. All in <code>util.js</code>. Full vocabulary lists at <button className="docs-link" onClick={() => jump('gloss-personas')}>full persona vocabularies</button>.</p>
            <p><b>Why it matters.</b> The composite verdict ranks briefs by raw opportunity surface (volume × difficulty × commercial intent), but says nothing about audience fit. A high-composite brief about NFL draft prospects looks great by the math but isn't going to grow the Curious Optimizer audience that the post-pivot TH O&amp;O needs. Persona-fit catches that mismatch—it's the dimension that says "yes the math says go, but should we actually be the publisher serving this query?" Defaults to OFF (overlay toggle) because for most decisions raw opportunity is what matters; flip it on when you want persona-led prioritization.</p>
            <p><b>How to interpret it.</b> When the persona overlay is on, the tile shows "fit · high/medium/low" against the currently-active persona (defaults to Curious Optimizer). High = the brief's vocabulary strongly overlaps the persona's interests; this is squarely on-strategy. Medium = some overlap; the brief might serve adjacent interest. Low = the persona-fit floor wasn't cleared; this brief isn't really for this persona. n/a = persona's content-type prior says "not applicable" for this content type. The drawer's Persona-fit panel shows all five personas ranked + the matched-terms preview so you can see <em>why</em> the engine scored it that way.</p>
          </section>

          <section data-anchor="gloss-personas">
            <h1>Personas—full vocabularies</h1>
            <p className="lede">
              The five personas the console scores every brief against, with their <em>complete</em> affinity-term vocabularies. These are content team lead's editorially-validated lists from 2026-04. The full lists ship with the console (in <code>util.js</code>) so anyone can audit the matching logic.
            </p>

            <p><b>What it signifies.</b> Each persona is a named audience archetype (Curious Optimizer, Discover Browser, Watercooler Insider, Curious Explorer, Science Enthusiast) defined by a curated vocabulary of words and phrases the audience cares about. When a brief's topic + recommended article + keywords overlap heavily with a persona's vocabulary, the engine reads that as "this brief speaks to this audience." Defaults to Curious Optimizer (the always-on TH primary B2C audience) for fit overlay; other personas are available for cross-checking.</p>

            <p><b>How it's sourced.</b> Vocabularies live in <code>PERSONA_AFFINITY</code> in <code>util.js</code>; per-content-type fit matrix in <code>PERSONA_FIT_MATRIX</code>. Scoring logic in <code>personaFitDetail()</code>. Vocabularies originated from content team lead's editorially-validated term lists, 2026-04. To add new personas or terms, edit those constants—the console picks up changes on next page load.</p>

            <p><b>Why it matters.</b> Composite verdicts rank briefs by raw opportunity surface (volume × difficulty × commercial intent), but say nothing about whether <em>we</em> should be the publisher serving that query. A brief about NFL draft prospects might math-out as High Opportunity but isn't going to grow the Curious Optimizer audience the post-pivot TH O&amp;O needs. Persona fit catches that mismatch—it's the dimension that says "the math says go, but is this audience-on-strategy?" Showing the full vocabulary for every persona means anyone can audit how the engine decides fit, and propose vocabulary tweaks based on real outcomes.</p>

            <p><b>How to interpret it.</b> Each persona below has three weighted vocabulary categories—topic (weight 1.0, primary), format (0.6), tone (0.4). When a brief's haystack contains many topic terms from a persona's list, that persona scores high. When you see a brief flagged as "high fit · Curious Optimizer," skim the topic vocabulary below—those are the words the engine matched on. The drawer's Persona-fit panel shows the actual matched terms for that brief × persona, so you can see why the engine decided what it did.</p>

            <h3>How scoring works (mechanics)</h3>
            <p>For each persona, the console builds a "haystack" from the brief's <code>topic + recommendedArticle + topKeywords[].label</code>, lowercased and concatenated. It then walks each persona's three vocabulary categories (topic · weight 1.0, format · weight 0.6, tone · weight 0.4) and counts term matches, summing weighted scores. A small content-type prior bonus (<code>+0.5</code> high / <code>+0.2</code> medium / <code>−0.2</code> low) layers on top. The persona with the highest score wins; bucket boundaries (high / medium / low) are relative within the cohort, not absolute.</p>
            <p>If <em>no</em> persona scores above the floor (0.5), all return "low"—a "cold brief" the vocabulary doesn't match.</p>

            <h3>Curious Optimizer (TH primary B2C—the always-on default)</h3>
            <p><b>Profile.</b> Emerging trends, self-improvement, ahead-of-curve audience. Reads science-backed wellness, biohacking, productivity, longevity content. Looks for evidence-based "best of" / "how-to" / data-backed framings.</p>
            <p><b>Topic vocabulary (weight 1.0).</b> longevity, lifespan, aging, biohack, optimize, optimizing, productivity, focus, sleep, sleep quality, mindfulness, meditation, fitness, workout, nutrition, diet, protein, supplement, gut health, gut microbiome, fermented, fiber, probiotic, fasting, intermittent, metabolism, hormone, mental health, anxiety, stress, cortisol, mood, energy, habit, routine, morning routine, self-improvement, wellness, cognitive, brain health, memory, learn, glp-1, weight loss, recovery, mobility, strength, cardio.</p>
            <p><b>Format vocabulary (weight 0.6).</b> "best ", "how to", guide, what works, "top ", rated, science of, evidence, data-backed, research-backed, study shows.</p>
            <p><b>Tone vocabulary (weight 0.4).</b> data, evidence, study, research, science, expert, proven, tested, effective.</p>

            <h3>Discover Browser (Google Discover audience)</h3>
            <p><b>Profile.</b> Phone-scrollers swiping through Google Discover. Visual hooks, pattern-breaks, "see this!" energy. Listicles, before/afters, gallery-style content.</p>
            <p><b>Topic vocabulary.</b> photo, photos, picture, pictures, image, video, gallery, slideshow, before-and-after, before and after, transformation, makeover, reveal, jaw-dropping, unexpected, surprising, shocking, list, listicle, roundup, best photos, rare photos.</p>
            <p><b>Format vocabulary.</b> "photos:", "photos of", "in pictures", "see ", watch, gallery, " rated ", " ranked ", "top 10", "top 20", "top 25", "list of".</p>
            <p><b>Tone vocabulary.</b> must-see, jaw-dropping, amazing, incredible, shocking, stunning, breathtaking, won't believe.</p>

            <h3>Watercooler Insider (Entertainment &amp; Trending)</h3>
            <p><b>Profile.</b> Celebrity culture, viral moments, debunks, scandals. The audience that wants the gossip and the timeline.</p>
            <p><b>Topic vocabulary.</b> taylor swift, travis kelce, jenna ortega, aaron rodgers, kim kardashian, kanye, bianca censori, leonardo dicaprio, orlando bloom, eric dane, kelce, celebrity, celebrities, divorce, breakup, split, dating, engaged, wedding, feud, beef, scandal, cheating, affair, drama, red carpet, oscar, grammy, paparazzi, rumor, rumored, reportedly, allegedly, fact-check, debunk, hoax, gossip, tea, who is, spotted, caught.</p>
            <p><b>Format vocabulary.</b> "who is ", "what happened", "the truth about", timeline, "relationship timeline", "inside ", "behind the", "real story", "everything we know".</p>
            <p><b>Tone vocabulary.</b> shocking, bombshell, exclusive, inside, reveals, sources say.</p>

            <h3>Curious Explorer (Nature &amp; Discovery)</h3>
            <p><b>Profile.</b> Weird species, archaeological mysteries, hidden truths. Wants to feel small in the face of big nature / deep history.</p>
            <p><b>Topic vocabulary.</b> species, animal, creature, spider, bird, fish, mammal, reptile, amphibian, insect, wildlife, endangered, rare species, extinct, dinosaur, fossil, paleontology, archaeolog (matches archaeology / archaeological), ancient, ruins, tomb, pyramid, temple, lost city, expedition, jungle, rainforest, amazon, arctic, antarctic, cave, discovery, discovered, unearthed, uncovered, mystery, hidden, rare, natural wonder, national park.</p>
            <p><b>Format vocabulary.</b> discovered, "found in", unearthed, uncovered, revealed, "hidden ", "lost ", "ancient ", "rare ".</p>
            <p><b>Tone vocabulary.</b> wonder, mysterious, fascinating, remarkable, extraordinary, "never before seen", "first time".</p>

            <h3>Science Enthusiast (Wonder-Driven Science)</h3>
            <p><b>Profile.</b> Strange beautiful mechanisms, ocean trenches to exoplanets. Mechanism-curious—wants the "how it works."</p>
            <p><b>Topic vocabulary.</b> physics, quantum, particle, photon, electron, atom, biology, cell, dna, gene, genome, protein, chemistry, molecule, enzyme, reaction, compound, astronomy, galaxy, universe, star, planet, exoplanet, asteroid, comet, black hole, nebula, cosmos, space, ocean trench, deep sea, mariana, abyssal, hydrothermal, neuron, brain, synapse, consciousness, climate, geology, plate tectonic, volcano, earthquake, evolution, natural selection, ecosystem, biodiversity, microbe, bacterium, virus, mathematics, algorithm.</p>
            <p><b>Format vocabulary.</b> "how it works", "why ", "the science of", "the science behind", explained, "inside the", mechanism.</p>
            <p><b>Tone vocabulary.</b> fascinating, remarkable, extraordinary, mechanism, natural, phenomenon, observed, detected.</p>

            <h3>Maintenance</h3>
            <p>To add / remove vocabulary terms: edit <code>PERSONA_AFFINITY</code> in <code>util.js</code>. To add a new persona entirely: add a new top-level key to <code>PERSONA_AFFINITY</code> AND add the persona's prior matrix to <code>PERSONA_FIT_MATRIX</code> for each content type. The console picks up changes on next page load.</p>
            <p>Phase-5 plan: tune vocabularies based on Decision Log evidence—which personas actually engaged each brief space when articles published.</p>
          </section>

          <section data-anchor="gloss-author">
            <h1>Author authority (E-E-A-T)</h1>
            <p><b>What it signifies.</b> A ranked list of McClatchy authors who have a track record of writing engaging articles in this brief's keyword space—i.e. the people most likely to land a strong piece if assigned this topic. "E-E-A-T" is Google's quality framework—Experience, Expertise, Authoritativeness, Trustworthiness—and per-author engagement track record is the closest proxy we have to it without explicit credentialing data.</p>
            <p><b>How it's sourced.</b> Layer 9 of the daily learning-loop cron (<code>scripts/precompute_author_authority.py</code>) runs every day at 8:13am Dallas. For each brief, it finds every McClatchy author with ≥3 published articles whose keywords overlap the brief's keyword cluster within a 180-day window. Then it ranks those authors:</p>
            <pre className="docs-block">{`for each qualifying author:
  engagement_avg = mean(engagement_score) over the author's matched articles
  articles_count = count of matched articles
  score          = articles_count × engagement_avg
                   (sorted descending; top N surface in the drawer panel)`}</pre>
            <p>Author names are hashed to stable 6-character IDs (<code>a-XXXXXX</code>) before writing the data file—the lookup table lives off-repo in operator's local notes for anonymization compliance. Function: <code>authorAuthorityFor()</code> in <code>util.js</code>.</p>
            <p><b>Why it matters.</b> Without author-authority data, every brief is assigned blind to whoever has bandwidth—which means the writer who has covered this exact space three times before gets the same shot at the brief as the writer who's never touched it. That's a quality-and-engagement floor the team can't afford to give up. Author authority surfaces the "obvious right pick" so editorial assignment routes briefs to writers with proven engagement performance in the space. Especially valuable for cluster-scale commitments where one experienced author can carry 4-5 articles efficiently.</p>
            <p><b>How to interpret it.</b> Drawer panel shows top authors + their (anonymous ID, articles count, avg engagement) for the brief. Higher count × higher engagement = stronger signal. Use it as: "if we're committing to write this brief, route it to the author at the top of this list—they have demonstrated track record." When the panel shows "no qualifying authors," the brief is a true greenfield—no one in the org has covered this space recently. That's not a Skip signal on its own; it's a heads-up that whoever takes the brief is starting from cold and should bring the framework even more carefully. Operator's local hash → name table maps the 6-char IDs back to actual writer names for assignment.</p>
          </section>

          <section data-anchor="gloss-lifecycle">
            <h1>Brief lifecycle states</h1>
            <p><b>What it signifies.</b> A label that captures where each brief sits in its own life—from "just arrived, hasn't been triaged" through "actively in production" to "old enough that the SEMrush data probably went stale and we should re-check before continuing to invest." Drives the small lifecycle pill on the brief tile so operators know at a glance whether this is fresh inventory or aged inventory needing a refresh.</p>
            <table className="docs-table">
              <thead><tr><th>State</th><th>Trigger</th><th>Tile UI</th><th>What to do</th></tr></thead>
              <tbody>
                <tr><td><code>fresh</code></td><td>age ≤ 14d, undecided</td><td>"new" pill; surfaces in Recent Intake strip</td><td>Triage these first—newest signal, freshest data</td></tr>
                <tr><td><code>active</code></td><td>age 15–89d, undecided or kept</td><td>(no pill—normal state)</td><td>Standard handling; data still trustworthy</td></tr>
                <tr><td><code>serp-reverify</code></td><td>age 90–179d, kept</td><td>"SERP re-verify · Xd" pill</td><td>Before publishing more articles in this cluster, re-pull SEMrush positions to confirm SERPs haven't shifted</td></tr>
                <tr><td><code>refresh-due</code></td><td>age ≥ 180d, kept</td><td>"refresh due · Xd" pill</td><td>Full re-pull recommended—vol/KD/CPC numbers may be materially out of date</td></tr>
                <tr><td><code>decided</code></td><td>any decision action (skip/defer)</td><td>(decision badge replaces lifecycle)</td><td>Decision active; lifecycle state suspended until decision changes</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Computed at runtime from the brief's <code>addedAt</code> field (ISO date, set at SEMrush-pull time) and the current decision state. Thresholds: <code>FRESH_DAYS=14</code>, <code>SERP_REVERIFY_DAYS=90</code>, <code>REFRESH_DUE_DAYS=180</code> in <code>util.js</code>. Function: <code>briefLifecycleState()</code> + <code>briefAgeDays()</code>.</p>
            <p><b>Why it matters.</b> SEMrush data ages—keyword difficulty shifts as competitors invest, search volume swings seasonally, our own rankings move. A 6-month-old brief whose verdict said "High Opportunity" might actually be "Skip" today because the SERP filled in. Without lifecycle pills, operators have no way to know which briefs need a data refresh before continuing to commit pipeline capacity. The "fresh" pill also marks the new-inflow surface so weekly intake gets triaged before it ages into the main queue.</p>
            <p><b>How to interpret it.</b> See "new" on a brief? Recent SEMrush pull, treat the data as authoritative—triage today. No pill = standard handling. "SERP re-verify · 47d remaining" = brief is at day 43 of its 90-day fresh window; consider re-pulling positions before publishing additional cluster articles. "refresh due · 12d overdue" = the data is stale; the verdict shown may not reflect current reality. Run <code>backfill_competitors.py</code> + <code>backfill_positions.py</code> for that brief, or wait for the monthly cron on the 1st (kept briefs only).</p>
          </section>

          <section data-anchor="gloss-production-mix">
            <h1>Production-mix target (60/30/10)</h1>
            <p><b>What it signifies.</b> The target shape of the team's weekly output across the three difficulty tiers. <b>60%</b> Tier 1 (Go Now, easy SERPs), <b>30%</b> Tier 2 (Build Into, midtail), <b>10%</b> Tier 3 (Long Game, headtail). It's not a quota—it's a "this is the healthy ratio" target so the team is mostly winning easy stuff (foundation), some authority-building stuff (compounding), and a little ambitious stuff (stretch). Drift away from this shape signals the production pipeline is unbalanced.</p>
            <pre className="docs-block">{`Tier 1 · Go Now:        60% of kept briefs   (today's working surface)
Tier 2 · Build Into:    30%                  (where we're earning authority)
Tier 3 · Long Game:     10%                  (stretch / experimentation)`}</pre>
            <p><b>How it's sourced.</b> Defined in <code>TIER_RULES[N].productionMixPct</code> in <code>util.js</code>. Per TH Keyword Selection Framework v1. Target ratios are tracked in real time against the Kept-brief inventory: the Beach lens production-mix tracker (top strip) shows current actual vs target as a small bar chart with deltas.</p>
            <p><b>Why it matters.</b> Without an explicit mix target, the team gravitates toward the most exciting opportunities—usually high-volume midtail/headtail (T2/T3)—and starves the easy-win foundation that builds compounding authority. Result: lots of effort, few wins, frustrated team. The 60/30/10 forces capacity discipline: every five articles should be roughly three Tier 1, one-and-a-half Tier 2, half a Tier 3. Over-weighting T1 looks too easy and the team gets bored; under-weighting T1 starves the foundation. The framework's bet is that 60/30/10 is the sustainable ratio for compounding traffic over multiple quarters.</p>
            <p><b>How to interpret it.</b> Beach lens production-mix tracker shows current Kept-brief distribution vs target. Big tier-1 shortfall = team is over-investing in hard SERPs; correct by Keeping more easy-rail briefs. Big tier-3 over-investment = team is reaching too far for stretch wins; correct by deferring some T3s. Tracker is informational, not a hard gate—operator can override the mix when there's editorial reason to do so. The rolling-unlock mechanism (see <button className="docs-link" onClick={() => jump('gloss-unlocks')}>rolling unlocks</button>) is the harder constraint—it prevents the team from chasing T2/T3 work before earning the foothold.</p>
          </section>

          <section data-anchor="gloss-unlocks">
            <h1>Rolling unlock thresholds</h1>
            <p><b>What it signifies.</b> Per-collection gates that determine which difficulty rails are even <em>visible</em> in the Beach lens. The Ready-now rail (KD 0–29) is always available; the Building-toward rail (KD 30–49) is locked until the team has accumulated enough recent Keep decisions in this collection; the Earned-later rail (KD 50+) is locked until an even higher recent threshold. The collection only "unlocks" harder rails after demonstrating it can sustain wins on the easier ones.</p>
            <table className="docs-table">
              <thead><tr><th>Rail</th><th>Unlock condition</th><th>Window</th><th>Plain English</th></tr></thead>
              <tbody>
                <tr><td>Ready now (KD 0–29)</td><td>Always unlocked</td><td>—</td><td>You can always work the easy rail; no preconditions</td></tr>
                <tr><td>Building toward (KD 30–49)</td><td>≥ 6 keeps in this collection in last 60d</td><td>60d rolling</td><td>You have to be currently winning easy stuff to graduate to midtail</td></tr>
                <tr><td>Earned later (KD 50+)</td><td>≥ 15 keeps in this collection in last 60d</td><td>60d rolling</td><td>You have to be sustained-winning to attempt stretch SERPs</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Computed at runtime per collection from the Decision Log: count how many briefs have <code>action: 'keep'</code> in the last 60 days within this TH Collection. Compare against the threshold. Sources: <code>THROUGHPUT.buildingUnlock</code>, <code>THROUGHPUT.earnedUnlock</code>, <code>ROLLING_UNLOCK_DAYS=60</code>, <code>recentKeepCount()</code> in <code>util.js</code>.</p>
            <p><b>Why it matters.</b> Lifetime achievements are misleading—a collection that ran hot 6 months ago and has been quiet for the last quarter doesn't actually deserve "Earned" status today. The rolling 60-day window is an honest signal of "are we <em>currently</em> winning here?" If recent commits dried up, the harder rails re-lock—forcing the team back to the easy rail to rebuild momentum before stretching again. Prevents the team from coasting on past wins and chasing hard SERPs they don't have current authority for. Also creates explicit "earn it" feedback that compounds well: keep stacking T1 wins → T2 unlocks → keep stacking T2 wins → T3 unlocks.</p>
            <p><b>How to interpret it.</b> When you see a Beach rail showing "🔒 Locked—needs N more keeps" overlay, that collection hasn't met the threshold yet. The math is "active recent keeps in this collection within the last 60 days, regardless of what was happening before." If the rail just unlocked but you stop committing, it'll re-lock 60 days from your last keep. Practical: keep the easy rail busy across multiple collections to maintain unlocks. The tracker isn't punitive—it's a reflection of where you have current momentum and where you don't.</p>
          </section>

          <section data-anchor="gloss-tve">
            <h1>Trend Velocity Exception (TVE)</h1>
            <p><b>What it signifies.</b> A manual operator flag—set per brief—that says "I have external evidence of trend momentum here that SEMrush's monthly-search-volume number doesn't reflect yet." SEMrush data is monthly-aggregated and lagging; a topic trending hard on social media or in the news cycle this week may have very low SEMrush volume because the historical 30-day window hasn't caught up. TVE is the operator's tool for telling the verdict engine "the math says skip but I'm seeing velocity that the math doesn't see."</p>
            <table className="docs-table">
              <thead><tr><th>State</th><th>Effect on verdict</th><th>Use when</th></tr></thead>
              <tbody>
                <tr><td><b>off</b> (default)</td><td>None—verdict uses raw SEMrush volume</td><td>No external velocity signal; trust the SEMrush data</td></tr>
                <tr><td><b>rising</b></td><td><b>Bypasses the volume &lt; 500 hard auto-skip rule</b></td><td>External signal (Google Trends top/rising queries, social momentum, news-cycle event) shows the topic is gaining velocity even though SEMrush volume looks thin</td></tr>
                <tr><td><b>flat</b></td><td>None (informational marker)</td><td>You've confirmed the trend is steady—useful as a signal to future-you that you've already evaluated velocity for this brief</td></tr>
                <tr><td><b>declining</b></td><td>None (informational marker)</td><td>You see the trend fading; signal to deprioritize even if SEMrush still shows decent volume</td></tr>
              </tbody>
            </table>
            <p><b>How it's sourced.</b> Operator sets the flag manually in the brief drawer's TVE bar. State persists to the D1 database alongside the decision row (<code>decision.tve</code>). Reads back on every page load. Per TH Framework v1 §3 / §4c.</p>
            <p><b>Why it matters.</b> Without TVE, operators have no way to tell the engine "I see momentum the data doesn't see yet." Result: every breaking-trend opportunity gets auto-skipped because monthly-aggregated SEMrush volume hasn't caught up. That's a structural false-negative—we'd never act on news-cycle momentum. The "rising" flag explicitly carves out the bypass: once set, the engine respects the operator's external evidence and lets the brief be Kept despite low SEMrush volume. Flat / declining are informational-only—they don't change verdict logic but mark that velocity has been considered (so the next operator session knows the brief was triaged with that context).</p>
            <p><b>How to interpret it.</b> Default state is "off." Flip to "rising" only when you have specific external evidence (Google Trends rising queries, social-platform velocity, current news cycle) that contradicts SEMrush's volume number. Don't use it as a general "I want to keep this anyway" override—that's what the Keep + rationale path is for. Important limit: TVE rising bypasses the volume floor only—it does <em>not</em> override the KD ≥ 80 ceiling or the intent-mismatch check. Those still fire even with TVE rising set. Flat / declining are useful as session-to-session memory: setting "flat" tells future you "I checked velocity here, it's steady, no need to re-evaluate."</p>
          </section>

          <section data-anchor="gloss-etrp">
            <h1>ETRP—empirical findings</h1>
            <p><b>What it signifies.</b> ETRP stands for Empirical Threshold Refinement Pipeline—a calibration engine that tests whether the verdict-scoring rules are actually right by comparing them against real historical article outcomes. Instead of trusting that "75% volume / 10% inv-KD / 10% CPC / 5% residual" is the right weight mix because the data team picked those numbers, ETRP runs the math against 1,062 actual McClatchy articles and asks: did the engine's scoring predict which articles would win? If a fitted alternative weighting clearly beats the baseline on held-out data, we adopt it; if not, we keep baseline. First run: 2026-04-27.</p>

            <p><b>How it's sourced.</b> The pipeline lives in <code>calibration/scripts/</code> as a numbered sequence (00–11)—pulls Snowflake article data, tags content types, fits ensemble candidates, validates via held-out cross-validation + bootstrap CI gates, commits the surviving thresholds to <code>docs/data/verdict-thresholds.js</code>. Audit trail: <code>calibration/PIPELINE.md</code> (pre-registered methodology), <code>calibration/STATUS.md</code> (run-by-run state), <code>calibration/reports/*</code> (intermediate artifacts).</p>

            <p><b>Why it matters.</b> The verdict is the engine's editorial recommendation—High Opportunity / Worth Testing / Skip (plus Monitor for below-floor TH-pipeline trends). If the rules behind that recommendation aren't validated empirically, we're just dressing up someone's gut feel with formulas. ETRP is the discipline that says "before we ship a threshold, we test whether it actually predicts outcomes on real data"—and ships only the refinements that survive bootstrap-CI gates. It's also the mechanism that makes the deferred Phase-5 retest possible—once active-decision data accumulates in the Decision Log, the same pipeline runs again with that data instead of historical PV.</p>

            <h3>Plain-English methodology</h3>
            <p>The pipeline takes a historical sample of articles where we know both (a) what the engine <em>would have</em> scored each article based on its keywords, and (b) what actually happened—page views, ranking, engagement. Then it splits the sample into a "training" half (used to fit alternative weightings) and a "held-out" half (used to <em>test</em> whether those fits actually generalize). A fitted alternative only "wins" if it beats baseline on the held-out half by enough margin to clear bootstrap confidence-interval gates (≥ 0.05 Spearman improvement, with the confidence interval of the improvement excluding zero). This is statistical-rigor-by-default—bypasses the "we tuned the model and it looks great in training" trap.</p>
            <h3>What was tested</h3>
            <ul>
              <li>Composite weights (75/10/10/5) across all content-type cells with sufficient sample size (Untyped n=520, Orange n=144, Purple n=74, plus global n=810)</li>
              <li>Bucket cut percentages (v2 baseline 20/50/30 vs alternatives like 30/50/20, 25/50/25, etc.)</li>
              <li>KD bands (TH Framework v1—kept as-is, not directly tested)</li>
              <li>Hard auto-skip thresholds (volume floor 500, KD ceiling 80)</li>
            </ul>
            <h3>What survived</h3>
            <ul>
              <li><b>Weights 75/10/10/5: validated.</b> No fitted alternative beat baseline by enough margin to clear the bootstrap CI gate. Baseline ships unchanged.</li>
              <li><b>Bucket cuts: refined from 20/50/30 → 30/50/20.</b> The 30/50/20 cuts beat baseline by +12.8 percentage points on top-quartile-PV recall in the High Opportunity bucket, and +3.0 percentage points on bottom-quartile-PV precision in the Skip bucket. Both metrics cleared the 3pp adoption threshold. Cuts updated.</li>
              <li><b>KD bands: kept</b> (per framework, not directly tested by this pipeline).</li>
              <li><b>Hard auto-skips: kept.</b></li>
            </ul>
            <h3>The surprising sub-finding</h3>
            <p>The predicted composite score had a <b>negative Spearman correlation (−0.19 to −0.31)</b> with actual page-view outcomes across every cohort tested. (Spearman correlation = a stat that measures whether one variable's rank ordering matches another's; -1 = perfectly inverse; +1 = perfectly aligned; 0 = no relationship.) Translation: <em>articles the engine would have scored highest historically actually performed slightly worse than ones the engine would have scored lower</em>. For L&amp;E specifically: volume → PV Spearman −0.25 with p=0.028 (statistically significant—the 0.028 is the probability this could be a random coincidence; below 5% is the conventional bar).</p>
            <p><b>Hypothesis for why this happens.</b> Editorial selection bias. When the team had multiple briefs to choose from, ambitious high-volume picks were the ones that got greenlit—and those high-volume queries faced stiffer SERP competition from institutional sites (NIH, Healthline, NYT). Result: ambitious picks lost more SERP battles than the pragmatic lower-score picks that quietly over-performed. The composite score wasn't <em>wrong</em>; it just measured opportunity surface, not what would actually rank given competitive realities.</p>
            <p><b>How to interpret it.</b> Treat the verdict as opportunity-surface assessment, NOT a PV predictor. "High Opportunity" tells you "the demand and difficulty look good on paper"—it does NOT tell you "this article will get lots of page views." Hit rate is determined by execution + angle + timing, not by the score. Use the verdict to identify <em>where to attack</em>; trust your editorial judgment + the cluster framework + the author-authority signal to determine <em>how</em>.</p>
            <p>Phase 5 (the deferred next ETRP run) will re-test this finding using active-decision data—i.e. once the team has been making Keep/Skip decisions through the console for ~3 months, we'll have real outcome data tied to specific decisions and can rebuild the calibration on that. The historical-data sub-finding may not persist once we control for editorial selection.</p>
            <p><b>Total spend on the first run.</b> ~6 hours wall-clock · ~36K SEMrush units (1.9% of the 1.93M unit balance available at the time) · zero blockers.</p>
            <p><b>Audit trail.</b> Full pipeline source at <code>calibration/PIPELINE.md</code>, run-by-run state at <code>calibration/STATUS.md</code>, intermediate reports at <code>calibration/reports/</code>.</p>
          </section>

          <section data-anchor="gloss-content-types">
            <h1>Content types (Blue / Orange / Purple / Green / Untyped)</h1>
            <p><b>What it signifies.</b> A coarse classification of "what shape of article should this brief produce"—the role the article plays in the broader site experience. Different content types optimize for different success metrics (acquisition velocity for entry points, time-on-page for explainers, conversions for commerce). Surfaces as a small colored chip on the brief tile + full type label in the drawer's recipe-coordinates panel.</p>

            <table className="docs-table">
              <thead><tr><th>Type</th><th>Color chip</th><th>What it is</th><th>How it's measured</th></tr></thead>
              <tbody>
                <tr><td><b>Blue</b></td><td>Blue</td><td><b>Entry Point</b>—acquisition-driven content. Brings new visitors in from search / discover / social. Often listicle-y, hooky, optimized for first-impression conversion (visit ➝ engaged session).</td><td>PVs ÷ new visitors (acquisition velocity)</td></tr>
                <tr><td><b>Orange</b></td><td>Orange</td><td><b>Standard Explainer / Deepener</b>—the default for wellness / lifestyle / evergreen explainers. The "trade attention for understanding" piece readers actually finish.</td><td>time-on-page + pvs/session + scroll-depth composite</td></tr>
                <tr><td><b>Purple</b></td><td>Purple</td><td><b>Bridge / L&amp;E</b>—celebrity / entertainment / drama-adjacent content historically run on L&amp;E publications (Us Weekly, Woman's World). <b>Currently paused</b>—post-2026-04-27 pivot, L&amp;E pubs are frozen until the L&amp;E data pipe is repaired.</td><td>Default Deepener composite (pending pipe repair)</td></tr>
                <tr><td><b>Green</b></td><td>Green</td><td><b>Converter</b>—commerce / signups / affiliate. Built to drive a specific conversion event, not just engagement.</td><td>conversions + signups</td></tr>
                <tr><td><b>Untyped</b></td><td>(no chip)</td><td>Brief has no explicit content_type assignment. Falls back to Orange-style measurement and recommendations.</td><td>Default Deepener composite</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> <code>inferContentType()</code> in <code>util.js</code> uses several signals: the brief's vertical, the persona-fit shape, format hints in the topic + recommended-article text (e.g. "best of" → Green, "what is" → Orange), and any explicit content-type override set on the brief. Untyped briefs are common in V5 because the seed data didn't always carry a content-type assignment; future versions may default-tag at pull time.</p>

            <p><b>Why it matters.</b> Different content roles need different success criteria. Without content typing, an Entry Point brief ("Best Sleep Aids 2026") gets graded on the same time-on-page metric as a Deepener brief ("How Cortisol Affects Sleep")—and the Entry Point will look bad even though it's doing its job (driving new visitors). Content typing lets the engine match each brief against the standard appropriate to its function: acquisition velocity for Blue, deep engagement for Orange, conversions for Green. Also drives format suggestions in the recipe-coordinates panel.</p>

            <p><b>How to interpret it.</b> See a Green chip on a brief? The engagement signal is being measured against conversion / signup metrics—read accordingly (a Green brief with low time-on-page can still be doing great if it's converting). Blue chip = acquisition-velocity grading; the brief should pull in new visitors. Orange chip = standard depth-of-engagement grading. Purple chip = paused—drawer will show a paused banner; do not commit pipeline capacity until L&amp;E data pipe is repaired. No chip (Untyped) = engine is using fallback Orange grading; consider setting an explicit type on the brief if behavior should differ.</p>
          </section>

          <section data-anchor="gloss-th-collections">
            <h1>TH Collections (Mind &amp; Body / Experiences / Everyday Living)</h1>
            <p><b>What it signifies.</b> The three top-level content collections that organize the post-pivot Trend Hunter site. Every new article lands in exactly one of these collections—they're the IA (information architecture) Trend Hunter is launching with. The console mirrors the same IA so editorial decisions in the tool match the structure readers will encounter on TH itself. Replaces the prior 9-vertical taxonomy that was built for the old multi-pub world.</p>

            <table className="docs-table">
              <thead><tr><th>Collection</th><th>Covers</th><th>Example briefs</th></tr></thead>
              <tbody>
                <tr><td><b>Mind &amp; Body</b></td><td>Health, fitness, wellness, mental health, nutrition, sleep, longevity, supplements—anything serving readers' bodies and minds</td><td>Sleep performance, hormone health, cardio recovery, GLP-1, fasting protocols</td></tr>
                <tr><td><b>Experiences</b></td><td>Travel, food, dining, events, escapes, cultural moments—what readers <em>do</em> outside their daily routine</td><td>Wellness retreats, regional food finds, weekend escapes</td></tr>
                <tr><td><b>Everyday Living</b></td><td>Home, family, productivity, finance-adjacent, daily-life optimization—the texture of the day-to-day</td><td>Cleaning routines, time management, kid sleep schedules</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> <code>inferTHCollection()</code> in <code>util.js</code> maps each brief to one collection by matching the brief's vertical / topic / recommended-article terms against per-collection vocabulary. V5 trend-unit seeds are pre-assigned at pull time, so most briefs don't need runtime inference. Future state: editorial sets the collection explicitly on each brief; <code>inferTHCollection()</code> remains as fallback.</p>

            <p><b>Why it matters.</b> Without a coherent IA, weekly editorial planning becomes "pick from 43 random briefs and hope the mix lands well across the site." With the three-collection IA, the team can balance output across collections deliberately—a quarter that ships 90% Mind &amp; Body content under-serves the Everyday Living readership and starves the Experiences pillar. The Beach lens makes this balance visible by grouping briefs into 3 columns by collection so production planning happens at the right altitude. Today's briefs and Gap map both have a Collection filter so the operator can drill into one area at a time.</p>

            <p><b>How to interpret it.</b> Brief tile shows a small collection chip—the colored glyph next to the topic. Drawer's recipe-coordinates panel shows the full collection label + description. In the Beach lens, each collection becomes its own column with three difficulty rails inside; production-mix tracker (top of lens) shows how the kept inventory distributes across the three. If you're filtering for editorial planning, set Collection = Mind &amp; Body (or whichever) to focus on one pillar at a time. The default unfiltered view shows all collections.</p>
          </section>

          <section data-anchor="gloss-confidence">
            <h1>Confidence: precise vs partial</h1>
            <p><b>What it signifies.</b> A flag—"precise" or "partial"—that grades how trustworthy the SEMrush data behind a brief actually is. Set at the moment the SEMrush pull runs. "Precise" means the pull returned a full, healthy keyword cluster with complete vol / KD / CPC coverage. "Partial" means the pull came back thin or sparse—fewer keywords than expected, or KD coverage gaps. Surfaces as a <em>soft-confidence</em> badge in the verdict-breakdown panel and shifts the residual nudge that feeds into the composite score.</p>

            <table className="docs-table">
              <thead><tr><th>Confidence</th><th>Trigger</th><th>Effect on scoring</th></tr></thead>
              <tbody>
                <tr><td><b>precise</b></td><td>Initial SEMrush pull returned ≥10 keywords with full vol / KD / CPC coverage</td><td><b>+0.10 to residual nudge</b> (bumps composite up slightly—we trust the math behind this brief)</td></tr>
                <tr><td><b>partial</b></td><td>Pull returned &lt; 10 keywords OR significant KD-coverage gaps OR SEMrush API timeouts dropped data</td><td><b>−0.15 to residual nudge</b>; "soft confidence" badge appears in the brief drawer's verdict-breakdown panel</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> The pull script (<code>scripts/pull_th_v5.py</code>) inspects each brief's API response after running <code>phrase_fullsearch</code> + <code>phrase_kdi</code>. Counts returned keywords + checks KD-coverage completeness. Sets <code>brief.confidence</code> to <code>'precise'</code> or <code>'partial'</code> in <code>briefs.js</code> at write time. Read by <code>residualNudge()</code> in <code>util.js</code> at runtime to compute the composite.</p>

            <p><b>Why it matters.</b> Two briefs can score identically on the composite math but be backed by very different data quality. A brief whose composite was computed from 12 keywords with full coverage is genuinely more trustworthy than one built on 4 keywords with KD gaps. The confidence flag makes that data-quality dimension visible to the operator and bakes a small penalty into the composite for partial briefs (so "thin-data" briefs don't crowd into the top of triage just because they got lucky on the few keywords they had).</p>

            <p><b>How to interpret it.</b> A "soft confidence" badge in the verdict breakdown means the brief's verdict is directional, not definitive—the math is doing its best with thin SEMrush data. Action: run <code>scripts/repull_weak_briefs.py</code> against this brief's ID. The script uses a multi-seed strategy (tries the primary seed, then 1–2 fallback seeds derived from the brief's topic, picks whichever yields the richest cluster). If the re-pull comes back precise, the badge clears on next page load. If it stays partial after re-pull, the keyword space genuinely lacks rich SEMrush coverage—that's a structural property of the topic, not a fixable data issue. Treat partial briefs as "score directionally, validate with editorial judgment before commitment."</p>
          </section>

          <section data-anchor="gloss-our-pos">
            <h1>"Our pos"—and what 999 means</h1>
            <p><b>What it signifies.</b> The best (lowest-numbered) Google ranking position one of our portfolio pubs already holds for a keyword. <b>Lower is better.</b> Position 3 means a portfolio pub already ranks #3 in Google for that exact query—strong existing authority. Position 47 means somewhere on page 5—minimal but real foothold. Position 999 is a special <em>sentinel</em> value meaning "none of our pubs rank in the top 50 for this keyword"—not a literal rank, just the engine's way of marking unranked.</p>

            <table className="docs-table">
              <thead><tr><th>Value</th><th>Position meaning</th><th>What this signals</th></tr></thead>
              <tbody>
                <tr><td><b>1–10</b></td><td>Page 1 ranking</td><td>Strong existing authority—we already win this query. Defend / refresh when freshness window expires.</td></tr>
                <tr><td><b>11–25</b></td><td>Page 2 ranking</td><td>Leverageable foothold. Fresh content + cluster expansion realistically pushes us into page 1.</td></tr>
                <tr><td><b>26–50</b></td><td>Pages 3-5</td><td>Minimal authority but a ranking <em>exists</em>—Google has decided we're at least relevant on this query. Foundation we can build on.</td></tr>
                <tr><td><b>999</b></td><td><b>Sentinel: no portfolio pub in top 50</b></td><td>Cold start—we have no existing authority. Greenfield decision. Backfill cron only pulls top-50; anything beyond collapses to 999 (not a literal "rank 999").</td></tr>
              </tbody>
            </table>

            <p><b>How it's sourced.</b> SEMrush <code>phrase_organic</code> endpoint returns the top-50 ranking domains per keyword. The <code>backfill_positions.py</code> script runs this for every Kept brief's keywords against the portfolio pub list and stores the best-observed rank as <code>topKeywords[].ourPos</code> in <code>briefs.js</code>. The monthly cron (`monthly-semrush-refresh.yml`, fires 1st of month at 8:13am Dallas) auto-refreshes Kept briefs. Manual refresh: <code>python3 scripts/backfill_positions.py --scope=kept</code> (or <code>--scope=brief:ID,ID</code> for targeted refresh).</p>

            <p><b>Why it matters.</b> Existing authority is one of the most valuable inputs to a verdict—and one of the most often overlooked. A brief with KD 65 looks hard until you see "our pos: 14 on miamiherald.com"—meaning Miami Herald already ranks page 2 for that keyword and is very likely the right pub to run the cluster through. The DA-shift mechanism (see <button className="docs-link" onClick={() => jump('gloss-da-shift')}>DA-shift</button>) uses this data to bump effective tier per pub. The KD ≥ 80 hard auto-Skip rule also has a "tangential-foothold" exemption: if <em>any</em> keyword has <code>ourPos ≤ 20</code>, the auto-Skip is bypassed and the operator can Keep the brief. So an "our pos: 17" on even a single keyword can flip a brief's verdict from auto-Skip to actionable.</p>

            <p><b>How to interpret it.</b> Drawer's keywords table shows the per-keyword <code>ourPos</code> column. Look for any value ≤ 25—those are foothold opportunities. All-999 columns mean we'd be entering the SERP cold; combine with KD to gauge difficulty. When you see "our pos: 7" alongside a KD-50+ keyword, that's a cluster-expansion opportunity (we already win one corner of the topic; more articles can extend the win). When you see all 999s + KD ≥ 80, the auto-Skip will fire—and rightfully—unless a tangential-foothold keyword exists that the engine missed.</p>
          </section>

          {/* ─────────────────────────────────────────────────────────────── */}
          {/* PART IV—Outcome reconciliation (item 2.3)                    */}
          {/* ─────────────────────────────────────────────────────────────── */}
          <div className="docs-part-banner">PART IV—Outcome reconciliation</div>
          <OutcomeReconciliation />
        </div>
      </div>
    </div>
  );
}

// ─── Outcome reconciliation component ────────────────────────────────────────
// Reads window.DECISIONS_OUTCOMES (emitted by precompute_decisions_outcomes.py).
// Surfaces calibration: how often did Kept briefs actually outperform median?
// What's the distribution of outcomes by decision action? Predicted-vs-actual
// scatter. Per-decision table. The Phase-5 ETRP self-trust surface—without
// standing up a separate dashboard.
function OutcomeReconciliation() {
  const data = window.DECISIONS_OUTCOMES || null;
  if (!data || !Array.isArray(data.rows) || data.rows.length === 0) {
    return (
      <section data-anchor="recon-summary">
        <h1>Summary stats</h1>
        <p className="lede">
          No reconciliation data yet. <code>scripts/precompute_decisions_outcomes.py</code>{' '}
          emits <code>docs/data/decisions-with-outcomes.js</code> by joining the Decision Log
          (D1 worker) with Layer 0 outcomes + Layer 10 revenues. Either it hasn't run yet, or no
          decisions have been recorded. Once you've Kept/Skipped/Deferred at least one brief and
          the daily cron has fired, this section populates.
        </p>
      </section>
    );
  }

  const rows = data.rows;
  const kept = rows.filter(r => r.action === 'keep');
  const skipped = rows.filter(r => r.action === 'skip');
  const deferred = rows.filter(r => r.action === 'defer');

  const matched = (set) => set.filter(r => (r.outcome?.matched_articles || 0) > 0);
  const inZone = (set) => matched(set).filter(r => r.outcome?.best_position_state === 'in_zone');
  const cold   = (set) => matched(set).filter(r => r.outcome?.best_position_state === 'cold');

  const pct = (n, d) => d > 0 ? `${(100 * n / d).toFixed(0)}%` : '—';

  return (
    <>
      <ExportDecisionsButton rows={rows} />
      <section data-anchor="recon-summary">
        <h1>Summary stats</h1>
        <p className="lede">
          As of <code>{data.asof}</code>, {rows.length} decisions on record. Outcomes joined from
          Layer 0 (TRACKER_ENRICHED articles published since the brief's attribution floor) and
          Layer 10 (live BURT revenue attribution).
        </p>
        <table className="docs-table">
          <thead><tr><th>Action</th><th>N</th><th>With matches</th><th>In-zone</th><th>Cold</th><th>Total PVs (share-adjusted)</th></tr></thead>
          <tbody>
            {[['Keep', kept], ['Skip', skipped], ['Defer', deferred]].map(([label, set]) => (
              <tr key={label}>
                <td><b>{label}</b></td>
                <td>{set.length}</td>
                <td>{matched(set).length} ({pct(matched(set).length, set.length)})</td>
                <td>{inZone(set).length} ({pct(inZone(set).length, matched(set).length)})</td>
                <td>{cold(set).length} ({pct(cold(set).length, matched(set).length)})</td>
                <td>{Math.round(set.reduce((s, r) => s + (r.outcome?.total_pvs_share_adjusted || 0), 0)).toLocaleString()}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <p>
          <b>Read this:</b> the headline number is "Keep · in-zone %." That's the calibration
          test. If &lt;30% of Kept briefs land in-zone, the verdict thresholds are too loose.
          If &gt;70%, they're too tight (we're skipping briefs that would have worked). The ETRP-
          validated 30/50/20 cuts predict ~50% in-zone for Keeps; the rest is split between
          foothold (still useful) and cold (genuine misses).
        </p>
      </section>

      <section data-anchor="recon-attribution">
        <h1>How attribution works</h1>
        <p className="lede">Four moving parts decide which published article credits which brief—and how much credit it gets. Read this once; the rest of Part IV makes more sense after.</p>
        <h2>1. Keyword-overlap matching</h2>
        <p>Each brief carries a keyword set (the SEMrush seeds plus expansions). Each published article carries its own extracted keyword set. An article credits a brief when the two sets intersect on at least one keyword. One article can credit multiple briefs.</p>
        <h2>2. Attribution-floor logic</h2>
        <p>A brief's <code>addedAt</code> timestamp is when it entered the queue; its <code>attributionFloor</code> is the earliest publish date eligible to credit it (default = <code>addedAt</code>; can be back-dated when a brief replaces an older one). Articles published before the floor never credit the brief—they predate the decision.</p>
        <h2>3. Share-adjusted vs full-credit PV</h2>
        <p>When an article credits N briefs, full-credit PV gives the entire pageview total to each—useful for "did this brief produce anything?" Share-adjusted divides PVs evenly across the N briefs—the right number when summing across briefs without double-counting traffic.</p>
        <h2>4. Recency-weighted AVC</h2>
        <p>Article-vs-co-median is weighted by <code>0.95^weeks_since_publish</code>, then averaged. Floor: needs ≥3 articles before the recency-weighted figure displays—fewer and the average is too noisy to act on.</p>
      </section>

      <section data-anchor="recon-by-action">
        <h1>By decision action</h1>
        <p>Outcome distribution per action class. Columns sum to the action's matched-articles total.</p>
        <table className="docs-table">
          <thead><tr><th>Action</th><th>N matched</th><th>In-zone</th><th>Foothold</th><th>Cold</th><th>Avg AVC</th><th>Avg AVC (recency-weighted)</th></tr></thead>
          <tbody>
            {[['Keep', kept], ['Skip', skipped], ['Defer', deferred]].map(([label, set]) => {
              const m = matched(set);
              if (m.length === 0) {
                return <tr key={label}><td><b>{label}</b></td><td colSpan={6} className="dim">no matches yet</td></tr>;
              }
              const inz = m.filter(r => r.outcome?.best_position_state === 'in_zone').length;
              const fh  = m.filter(r => r.outcome?.best_position_state === 'foothold').length;
              const cd  = m.filter(r => r.outcome?.best_position_state === 'cold').length;
              const avc = m.map(r => r.outcome?.avg_article_vs_co_median).filter(v => typeof v === 'number');
              const avcRw = m.map(r => r.outcome?.avg_avc_recency_weighted).filter(v => typeof v === 'number');
              const avg = (a) => a.length > 0 ? (a.reduce((s, v) => s + v, 0) / a.length).toFixed(2) : '—';
              return (
                <tr key={label}>
                  <td><b>{label}</b></td>
                  <td>{m.length}</td>
                  <td>{inz} ({pct(inz, m.length)})</td>
                  <td>{fh} ({pct(fh, m.length)})</td>
                  <td>{cd} ({pct(cd, m.length)})</td>
                  <td>{avg(avc)}</td>
                  <td>{avg(avcRw)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
        <p className="dim">
          <b>Avg AVC</b> is the article-vs-co-median proxy averaged across matched articles.
          ≥1.0 means typical article in this brief outperforms the company median. The recency-
          weighted version decays older articles by 0.95 per week and floors at 3 articles.
        </p>
      </section>

      <section data-anchor="recon-calibration">
        <h1>Predicted-vs-actual calibration</h1>
        <p>Each Kept brief plotted against its observed AVC. The diagonal line is "perfect calibration"—predictions track outcomes. Points above the line outperformed; below underperformed. (Chart renders client-side via inline SVG.)</p>
        <CalibrationScatter rows={kept.filter(r => (r.outcome?.matched_articles || 0) >= 3)} />
      </section>

      <section data-anchor="recon-residuals">
        <h1>Residuals histogram</h1>
        <p>Distribution of (predicted AVC − observed AVC) for Kept briefs with ≥3 matches. Green bars near zero = well-calibrated; red bars in the tails = systematic over- or under-prediction.</p>
        <ResidualsHistogram rows={kept.filter(r => (r.outcome?.matched_articles || 0) >= 3)} />
      </section>

      <section data-anchor="recon-brier">
        <h1>Brier score + reliability deciles</h1>
        <p>Brier = mean squared error between verdict-implied probability and the in-zone outcome (1 = in_zone, 0.5 = foothold, 0 = cold). Lower is better. Reliability curve bins predicted probability into deciles and plots observed in-zone rate per bin against the diagonal of perfect calibration.</p>
        <CalibrationCurve rows={kept.filter(r => r.outcome?.best_position_state)} />
      </section>

      <section data-anchor="recon-by-play">
        <h1>Per-Play calibration breakdown</h1>
        <p>Calibration sliced by <code>thV5.play_num</code>. Plays with Brier &gt; 0.25 (red) are where the model is most miscalibrated—investigate before trusting verdicts there.</p>
        <PerPlayBreakdown rows={kept.filter(r => r.outcome?.best_position_state)} />
      </section>

      <section data-anchor="recon-flips">
        <h1>Verdicts that flipped this quarter</h1>
        <p>Briefs whose recorded action changed across the quarter—a Skip that became a Keep, a Keep that became a Defer. Useful for spotting unstable verdicts and tracing what changed in the snapshot when the call moved.</p>
        <VerdictFlips rows={rows} />
      </section>

      <section data-anchor="recon-table">
        <h1>Per-decision table</h1>
        <p>Every decision in the log, joined with its current outcome state. Filter chips narrow to a slice; sortable columns; click a brief id to jump.</p>
        <ReconciliationTableWithFilters rows={rows} />
      </section>
    </>
  );
}

function CalibrationScatter({ rows }) {
  if (!rows || rows.length === 0) {
    return <p className="dim">No Kept briefs with ≥3 matched articles yet—chart needs sample size.</p>;
  }
  // X-axis: brief's snapshot.kd (proxy for difficulty; lower = predicted easier).
  // Y-axis: observed avg_article_vs_co_median (1.0 = on-median; >1 = outperforming).
  const points = rows.map(r => ({
    id: r.brief_id,
    x: Number(r.snapshot?.kd ?? 50),
    y: Number(r.outcome?.avg_article_vs_co_median ?? 1.0),
  })).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
  if (points.length === 0) return <p className="dim">Insufficient data to plot.</p>;
  const W = 600, H = 280, M = 32;
  const xs = points.map(p => p.x), ys = points.map(p => p.y);
  const xMin = Math.min(...xs, 0), xMax = Math.max(...xs, 100);
  const yMin = Math.min(...ys, 0.3), yMax = Math.max(...ys, 1.7);
  const sx = (x) => M + (W - 2 * M) * (x - xMin) / (xMax - xMin || 1);
  const sy = (y) => H - M - (H - 2 * M) * (y - yMin) / (yMax - yMin || 1);
  return (
    <svg width={W} height={H} role="img" aria-label="predicted vs actual scatter" style={{maxWidth: '100%'}}>
      <line x1={M} y1={sy(1.0)} x2={W - M} y2={sy(1.0)} stroke="#888" strokeDasharray="4 3" />
      <text x={W - M} y={sy(1.0) - 6} fontSize="10" textAnchor="end" fill="#888">on-median</text>
      <line x1={M} y1={H - M} x2={W - M} y2={H - M} stroke="#444" />
      <line x1={M} y1={M} x2={M} y2={H - M} stroke="#444" />
      <text x={W / 2} y={H - 4} fontSize="11" textAnchor="middle" fill="#aaa">avgKD at decision time →</text>
      <text x={10} y={H / 2} fontSize="11" textAnchor="middle" fill="#aaa" transform={`rotate(-90 10 ${H / 2})`}>observed avg AVC →</text>
      {points.map(p => (
        <circle key={p.id} cx={sx(p.x)} cy={sy(p.y)}
          r="4"
          fill={p.y >= 1.0 ? "#4ea88f" : "#a86a4e"}
          opacity="0.78">
          <title>{p.id} · KD {p.x.toFixed(0)} · AVC {p.y.toFixed(2)}</title>
        </circle>
      ))}
    </svg>
  );
}

function ReconciliationTable({ rows }) {
  const [sortKey, setSortKey] = React.useState('decision_ts');
  const [dir, setDir] = React.useState('desc');
  const sorted = React.useMemo(() => {
    const copy = rows.slice();
    copy.sort((a, b) => {
      const av = sortKey.includes('.') ? sortKey.split('.').reduce((o, k) => o?.[k], a) : a[sortKey];
      const bv = sortKey.includes('.') ? sortKey.split('.').reduce((o, k) => o?.[k], b) : b[sortKey];
      const cmp = (av ?? -Infinity) < (bv ?? -Infinity) ? -1 : (av ?? -Infinity) > (bv ?? -Infinity) ? 1 : 0;
      return dir === 'asc' ? cmp : -cmp;
    });
    return copy.slice(0, 200); // Cap at 200 to keep DOM manageable
  }, [rows, sortKey, dir]);
  const sh = (key) => () => { if (sortKey === key) setDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setDir('desc'); } };
  const sortIcon = (key) => sortKey === key ? (dir === 'asc' ? ' ▲' : ' ▼') : '';
  return (
    <table className="docs-table">
      <thead>
        <tr>
          <th onClick={sh('brief_id')} style={{cursor: 'pointer'}}>Brief{sortIcon('brief_id')}</th>
          <th onClick={sh('action')} style={{cursor: 'pointer'}}>Action{sortIcon('action')}</th>
          <th onClick={sh('decision_iso')} style={{cursor: 'pointer'}}>When{sortIcon('decision_iso')}</th>
          <th onClick={sh('outcome.matched_articles')} style={{cursor: 'pointer'}}>Articles{sortIcon('outcome.matched_articles')}</th>
          <th onClick={sh('outcome.total_pvs_share_adjusted')} style={{cursor: 'pointer'}}>PVs (adj){sortIcon('outcome.total_pvs_share_adjusted')}</th>
          <th onClick={sh('outcome.avg_article_vs_co_median')} style={{cursor: 'pointer'}}>Avg AVC{sortIcon('outcome.avg_article_vs_co_median')}</th>
          <th onClick={sh('outcome.best_position_state')} style={{cursor: 'pointer'}}>State{sortIcon('outcome.best_position_state')}</th>
          <th>Note</th>
        </tr>
      </thead>
      <tbody>
        {sorted.map(r => (
          <tr key={r.brief_id}>
            <td><code>{r.brief_id}</code></td>
            <td>{r.action || '—'}</td>
            <td className="dim">{r.decision_iso?.slice(0, 10) || '—'}</td>
            <td>{r.outcome?.matched_articles ?? 0}</td>
            <td>{r.outcome?.total_pvs_share_adjusted != null ? Math.round(r.outcome.total_pvs_share_adjusted).toLocaleString() : '—'}</td>
            <td>{r.outcome?.avg_article_vs_co_median?.toFixed(2) ?? '—'}</td>
            <td>{r.outcome?.best_position_state || '—'}</td>
            <td className="dim" style={{maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>{r.note || ''}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// ─── Verdict-implied probability helper ──────────────────────────────────────
// Maps the verdict bucket (or composite snapshot score, when present) into a
// [0,1] probability for Brier scoring + reliability binning.
function predictedProb(r) {
  const composite = r.snapshot?.composite ?? r.snapshot?.composite_score;
  if (typeof composite === 'number' && Number.isFinite(composite)) {
    return Math.max(0, Math.min(1, composite > 1 ? composite / 100 : composite));
  }
  const verdict = r.snapshot?.verdict || r.verdict || '';
  if (verdict === 'high-opportunity') return 0.85;
  if (verdict === 'worth-testing')   return 0.5;
  if (verdict === 'monitor')         return 0.35;
  if (verdict === 'skip')            return 0.2;
  return 0.5;
}

function actualOutcome(r) {
  const s = r.outcome?.best_position_state;
  if (s === 'in_zone')  return 1;
  if (s === 'foothold') return 0.5;
  if (s === 'cold')     return 0;
  return null;
}

// ─── Brier score + reliability-decile chart ──────────────────────────────────
function CalibrationCurve({ rows }) {
  const usable = (rows || []).filter(r => actualOutcome(r) !== null);
  if (usable.length < 10) {
    return <p className="dim">Need ≥10 Kept rows with recorded outcomes—currently {usable.length}.</p>;
  }
  const pairs = usable.map(r => ({ p: predictedProb(r), a: actualOutcome(r) }));
  const brier = pairs.reduce((s, x) => s + (x.p - x.a) * (x.p - x.a), 0) / pairs.length;
  const interp = brier < 0.15 ? 'well-calibrated' : brier < 0.25 ? 'fair' : 'miscalibrated';
  const interpColor = brier < 0.15 ? '#4ea88f' : brier < 0.25 ? '#c0a050' : '#a86a4e';

  // Bin into 10 deciles by predicted_prob
  const bins = Array.from({ length: 10 }, () => ({ ps: [], as: [] }));
  pairs.forEach(x => {
    const idx = Math.min(9, Math.max(0, Math.floor(x.p * 10)));
    bins[idx].ps.push(x.p); bins[idx].as.push(x.a);
  });
  const points = bins.map((b, i) => {
    if (b.ps.length === 0) return null;
    const meanP = b.ps.reduce((s, v) => s + v, 0) / b.ps.length;
    const meanA = b.as.reduce((s, v) => s + v, 0) / b.as.length;
    return { i, meanP, meanA, n: b.ps.length };
  }).filter(Boolean);

  const W = 360, H = 280, M = 36;
  const sx = (p) => M + (W - 2 * M) * p;
  const sy = (p) => H - M - (H - 2 * M) * p;
  return (
    <div>
      <p>
        <b>Brier:</b> <span style={{color: interpColor}}>{brier.toFixed(3)}</span> · {interp}
        <span className="dim"> (&lt;0.15 well-calibrated · 0.15–0.25 fair · &gt;0.25 miscalibrated · n={pairs.length})</span>
      </p>
      <svg width={W} height={H} role="img" aria-label="reliability curve" style={{maxWidth: '100%'}}>
        <line x1={M} y1={H - M} x2={W - M} y2={H - M} stroke="#444" />
        <line x1={M} y1={M} x2={M} y2={H - M} stroke="#444" />
        <line x1={sx(0)} y1={sy(0)} x2={sx(1)} y2={sy(1)} stroke="#888" strokeDasharray="4 3" />
        <text x={W - M} y={sy(1) - 6} fontSize="10" textAnchor="end" fill="#888">perfect</text>
        <text x={W / 2} y={H - 6} fontSize="11" textAnchor="middle" fill="#aaa">predicted prob →</text>
        <text x={12} y={H / 2} fontSize="11" textAnchor="middle" fill="#aaa" transform={`rotate(-90 12 ${H / 2})`}>observed in-zone rate →</text>
        {points.length > 1 && (
          <polyline
            fill="none" stroke="#4ea88f" strokeWidth="2"
            points={points.map(p => `${sx(p.meanP)},${sy(p.meanA)}`).join(' ')}
          />
        )}
        {points.map(p => (
          <circle key={p.i} cx={sx(p.meanP)} cy={sy(p.meanA)} r={Math.min(8, 3 + Math.sqrt(p.n))} fill="#4ea88f" opacity="0.85">
            <title>decile {p.i + 1} · n={p.n} · predicted {p.meanP.toFixed(2)} · observed {p.meanA.toFixed(2)}</title>
          </circle>
        ))}
      </svg>
    </div>
  );
}

// ─── Residuals histogram ─────────────────────────────────────────────────────
function ResidualsHistogram({ rows }) {
  const pts = (rows || [])
    .map(r => {
      const predAvc = 1 + (predictedProb(r) - 0.5) * 1.4; // map 0..1 → ~0.3..1.7
      const actAvc = Number(r.outcome?.avg_article_vs_co_median);
      if (!Number.isFinite(actAvc)) return null;
      return predAvc - actAvc;
    })
    .filter(v => v !== null && Number.isFinite(v));
  if (pts.length === 0) {
    return <p className="dim">No matched Kept rows with AVC yet.</p>;
  }
  // Buckets at 0.2 from -2 to +2 → 20 buckets
  const STEP = 0.2, MIN = -2, MAX = 2;
  const nBuckets = Math.round((MAX - MIN) / STEP);
  const counts = Array.from({ length: nBuckets }, () => 0);
  pts.forEach(r => {
    const clamped = Math.max(MIN, Math.min(MAX - 1e-9, r));
    const idx = Math.floor((clamped - MIN) / STEP);
    counts[idx]++;
  });
  const maxC = Math.max(...counts, 1);
  const W = 600, H = 220, M = 32;
  const bw = (W - 2 * M) / nBuckets;
  const colorFor = (centerResidual) => {
    const a = Math.abs(centerResidual);
    if (a < 0.3) return '#4ea88f';
    if (a < 0.7) return '#c0a050';
    return '#a86a4e';
  };
  return (
    <svg width={W} height={H} role="img" aria-label="residuals histogram" style={{maxWidth: '100%'}}>
      <line x1={M} y1={H - M} x2={W - M} y2={H - M} stroke="#444" />
      <line x1={M} y1={M} x2={M} y2={H - M} stroke="#444" />
      {counts.map((c, i) => {
        const center = MIN + (i + 0.5) * STEP;
        const x = M + i * bw;
        const h = (H - 2 * M) * (c / maxC);
        return (
          <rect key={i} x={x + 1} y={H - M - h} width={Math.max(1, bw - 2)} height={h} fill={colorFor(center)} opacity="0.85">
            <title>{`residual ${center.toFixed(1)} · n=${c}`}</title>
          </rect>
        );
      })}
      <line x1={M + (0 - MIN) / (MAX - MIN) * (W - 2 * M)} y1={M} x2={M + (0 - MIN) / (MAX - MIN) * (W - 2 * M)} y2={H - M} stroke="#888" strokeDasharray="3 3" />
      <text x={W / 2} y={H - 6} fontSize="11" textAnchor="middle" fill="#aaa">predicted − observed AVC →</text>
      <text x={12} y={H / 2} fontSize="11" textAnchor="middle" fill="#aaa" transform={`rotate(-90 12 ${H / 2})`}>count →</text>
    </svg>
  );
}

// ─── Per-Play breakdown ──────────────────────────────────────────────────────
function PerPlayBreakdown({ rows }) {
  const [sortKey, setSortKey] = React.useState('brier');
  const [dir, setDir] = React.useState('desc');
  const grouped = React.useMemo(() => {
    const map = new Map();
    (rows || []).forEach(r => {
      const play = r.snapshot?.thV5?.play_num ?? r.thV5?.play_num ?? null;
      if (play == null) return;
      if (!map.has(play)) map.set(play, []);
      map.get(play).push(r);
    });
    const out = [];
    map.forEach((set, play) => {
      const inz = set.filter(r => r.outcome?.best_position_state === 'in_zone').length;
      const avcs = set.map(r => r.outcome?.avg_article_vs_co_median).filter(v => typeof v === 'number');
      const avgAvc = avcs.length ? avcs.reduce((s, v) => s + v, 0) / avcs.length : null;
      const briers = set.map(r => {
        const a = actualOutcome(r);
        if (a === null) return null;
        const p = predictedProb(r);
        return (p - a) * (p - a);
      }).filter(v => v !== null);
      const brier = briers.length ? briers.reduce((s, v) => s + v, 0) / briers.length : null;
      out.push({ play, n: set.length, inzone_rate: set.length ? inz / set.length : 0, avg_avc: avgAvc, brier });
    });
    return out;
  }, [rows]);
  const sorted = React.useMemo(() => {
    const copy = grouped.slice();
    copy.sort((a, b) => {
      const av = a[sortKey], bv = b[sortKey];
      const cmp = (av ?? -Infinity) < (bv ?? -Infinity) ? -1 : (av ?? -Infinity) > (bv ?? -Infinity) ? 1 : 0;
      return dir === 'asc' ? cmp : -cmp;
    });
    return copy;
  }, [grouped, sortKey, dir]);
  if (grouped.length === 0) {
    return <p className="dim">No Plays recorded on Kept rows yet.</p>;
  }
  const sh = (k) => () => { if (sortKey === k) setDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(k); setDir('desc'); } };
  const ic = (k) => sortKey === k ? (dir === 'asc' ? ' ▲' : ' ▼') : '';
  return (
    <table className="docs-table">
      <thead>
        <tr>
          <th onClick={sh('play')} style={{cursor:'pointer'}}>Play{ic('play')}</th>
          <th onClick={sh('n')} style={{cursor:'pointer'}}>N{ic('n')}</th>
          <th onClick={sh('inzone_rate')} style={{cursor:'pointer'}}>In-zone %{ic('inzone_rate')}</th>
          <th onClick={sh('avg_avc')} style={{cursor:'pointer'}}>Avg AVC{ic('avg_avc')}</th>
          <th onClick={sh('brier')} style={{cursor:'pointer'}}>Brier{ic('brier')}</th>
        </tr>
      </thead>
      <tbody>
        {sorted.map(row => {
          const flag = row.brier != null && row.brier > 0.25;
          return (
            <tr key={row.play} style={flag ? { color: '#a86a4e', fontWeight: 600 } : undefined}>
              <td>{row.play}</td>
              <td>{row.n}</td>
              <td>{(row.inzone_rate * 100).toFixed(0)}%</td>
              <td>{row.avg_avc != null ? row.avg_avc.toFixed(2) : '—'}</td>
              <td>{row.brier != null ? row.brier.toFixed(3) : '—'}</td>
            </tr>
          );
        })}
      </tbody>
    </table>
  );
}

// ─── Verdict flips this quarter ──────────────────────────────────────────────
function VerdictFlips({ rows }) {
  const flips = React.useMemo(() => {
    const byBrief = new Map();
    (rows || []).forEach(r => {
      if (!byBrief.has(r.brief_id)) byBrief.set(r.brief_id, []);
      byBrief.get(r.brief_id).push(r);
    });
    const out = [];
    byBrief.forEach((set) => {
      if (set.length < 2) return;
      const sorted = set.slice().sort((a, b) => (a.decision_ts || 0) - (b.decision_ts || 0));
      const distinctActions = new Set(sorted.map(s => s.action).filter(Boolean));
      if (distinctActions.size < 2) return;
      const prior = sorted[sorted.length - 2];
      const curr = sorted[sorted.length - 1];
      if (prior.action === curr.action) return;
      const days = prior.decision_ts && curr.decision_ts
        ? Math.round((curr.decision_ts - prior.decision_ts) / 86400)
        : null;
      const volDelta = (curr.snapshot?.total_volume ?? null) != null && (prior.snapshot?.total_volume ?? null) != null
        ? curr.snapshot.total_volume - prior.snapshot.total_volume : null;
      const kdDelta = (curr.snapshot?.kd ?? null) != null && (prior.snapshot?.kd ?? null) != null
        ? curr.snapshot.kd - prior.snapshot.kd : null;
      out.push({
        brief_id: curr.brief_id,
        topic: curr.snapshot?.topic || curr.topic || curr.brief_id,
        prior: prior.action, curr: curr.action,
        days, volDelta, kdDelta,
      });
    });
    return out;
  }, [rows]);
  if (flips.length === 0) {
    return <p className="dim">No verdict flips recorded this quarter.</p>;
  }
  const fmtDelta = (v, suffix = '') => v == null ? '—' : `${v > 0 ? '+' : ''}${typeof v === 'number' ? (Math.abs(v) >= 100 ? Math.round(v) : v.toFixed(1)) : v}${suffix}`;
  return (
    <table className="docs-table">
      <thead>
        <tr><th>Brief</th><th>Flip</th><th>Days</th><th>Δ vol</th><th>Δ KD</th></tr>
      </thead>
      <tbody>
        {flips.map(f => (
          <tr key={f.brief_id}>
            <td><code>{f.brief_id}</code> <span className="dim">{f.topic}</span></td>
            <td>{f.prior} → <b>{f.curr}</b></td>
            <td>{f.days != null ? `${f.days}d` : '—'}</td>
            <td>{fmtDelta(f.volDelta)}</td>
            <td>{fmtDelta(f.kdDelta)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// ─── Filter chips + filtered table wrapper ───────────────────────────────────
function ReconciliationTableWithFilters({ rows }) {
  const [filter, setFilter] = React.useState('all');
  const chips = React.useMemo(() => {
    const f = {
      all: () => true,
      kept_cold: (r) => r.action === 'keep' && (r.outcome?.best_position_state === 'cold' || (r.outcome?.matched_articles || 0) === 0),
      skip_outperformed: (r) => r.action === 'skip' && (r.outcome?.avg_article_vs_co_median ?? 0) > 1.0,
      inconsistent: (r) => {
        const v = r.snapshot?.verdict || r.verdict;
        const s = r.outcome?.best_position_state;
        return (v === 'high-opportunity' && s === 'cold') || (r.action === 'skip' && s === 'in_zone');
      },
      pending: (r) => r.action === 'keep' && (r.outcome?.matched_articles || 0) === 0,
    };
    return [
      { id: 'all',                label: 'All decisions',                       count: rows.filter(f.all).length,                fn: f.all },
      { id: 'kept_cold',          label: 'Kept-but-cold',                       count: rows.filter(f.kept_cold).length,          fn: f.kept_cold },
      { id: 'skip_outperformed',  label: 'Skipped-but-outperformed',            count: rows.filter(f.skip_outperformed).length,  fn: f.skip_outperformed },
      { id: 'inconsistent',       label: 'Outcomes inconsistent with predicted', count: rows.filter(f.inconsistent).length,       fn: f.inconsistent },
      { id: 'pending',            label: 'Pending outcomes',                    count: rows.filter(f.pending).length,            fn: f.pending },
    ];
  }, [rows]);
  const active = chips.find(c => c.id === filter) || chips[0];
  const filtered = rows.filter(active.fn);
  return (
    <div>
      <div style={{display:'flex', flexWrap:'wrap', gap:6, marginBottom:10}}>
        {chips.map(c => {
          const isActive = c.id === filter;
          return (
            <button
              key={c.id}
              onClick={() => setFilter(c.id)}
              style={{
                padding: '4px 10px',
                fontSize: 12,
                borderRadius: 12,
                border: '1px solid ' + (isActive ? '#4ea88f' : '#444'),
                background: isActive ? '#1f3b34' : 'transparent',
                color: isActive ? '#cde9df' : '#bbb',
                cursor: 'pointer',
              }}
              title={`${c.count} matching`}
            >
              {c.label} <span className="dim">({c.count})</span>
            </button>
          );
        })}
      </div>
      <ReconciliationTable rows={filtered} />
    </div>
  );
}

// ─── CSV export with date-range picker ───────────────────────────────────────
function ExportDecisionsButton({ rows }) {
  const [open, setOpen] = React.useState(false);
  // Default range: current quarter
  const today = new Date();
  const qStart = new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1);
  const [start, setStart] = React.useState(qStart.toISOString().slice(0, 10));
  const [end, setEnd] = React.useState(today.toISOString().slice(0, 10));

  const doExport = () => {
    const startMs = new Date(start + 'T00:00:00Z').getTime();
    const endMs = new Date(end + 'T23:59:59Z').getTime();
    const filtered = (rows || []).filter(r => {
      const t = r.decision_ts ? r.decision_ts * (r.decision_ts > 1e12 ? 1 : 1000) : null;
      const ms = t || (r.decision_iso ? new Date(r.decision_iso).getTime() : null);
      return ms != null && ms >= startMs && ms <= endMs;
    });
    const esc = (v) => {
      if (v == null) return '';
      const s = String(v);
      return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
    };
    const cols = [
      'brief_id','action','decision_iso','note',
      'matched_articles','total_pvs_share_adjusted','avg_article_vs_co_median',
      'avg_avc_recency_weighted','best_position_state','revenue',
    ];
    const lines = [cols.join(',')];
    filtered.forEach(r => {
      const o = r.outcome || {};
      lines.push([
        r.brief_id, r.action || '', r.decision_iso || '', r.note || '',
        o.matched_articles ?? '', o.total_pvs_share_adjusted ?? '',
        o.avg_article_vs_co_median ?? '', o.avg_avc_recency_weighted ?? '',
        o.best_position_state || '', r.revenue ?? '',
      ].map(esc).join(','));
    });
    const csv = '﻿' + lines.join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `dk-decisions-${start}_to_${end}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    // Defer revoke so the click event has time to start the download.
    setTimeout(() => URL.revokeObjectURL(url), 100);
    setOpen(false);
  };

  return (
    <div style={{margin:'4px 0 12px', display:'flex', gap:8, alignItems:'center', flexWrap:'wrap'}}>
      <button
        onClick={() => setOpen(o => !o)}
        style={{padding:'4px 10px', fontSize:12, borderRadius:6, border:'1px solid #4ea88f', background:'transparent', color:'#cde9df', cursor:'pointer'}}
      >
        ↓ Export Q{Math.floor(today.getMonth() / 3) + 1} decisions to CSV
      </button>
      {open && (
        <span style={{display:'inline-flex', gap:6, alignItems:'center'}}>
          <label style={{fontSize:12, color:'#aaa'}}>start <input type="date" value={start} onChange={e => setStart(e.target.value)} style={{marginLeft:4}} /></label>
          <label style={{fontSize:12, color:'#aaa'}}>end <input type="date" value={end} onChange={e => setEnd(e.target.value)} style={{marginLeft:4}} /></label>
          <button onClick={doExport} style={{padding:'4px 10px', fontSize:12, borderRadius:6, border:'1px solid #4ea88f', background:'#1f3b34', color:'#cde9df', cursor:'pointer'}}>download</button>
          <button onClick={() => setOpen(false)} style={{padding:'4px 8px', fontSize:12, color:'#888', background:'transparent', border:'none', cursor:'pointer'}}>cancel</button>
        </span>
      )}
    </div>
  );
}

Object.assign(window, { DocsLens });
