import { createRoot } from 'react-dom/client';
import React, { useState, useRef, useEffect, useCallback, useMemo, useContext, createContext } from 'react';
import {
  Zap, Loader2, Target, AlertTriangle, Calendar, Users, RefreshCw, ChevronRight, ChevronDown, ChevronUp, ChevronLeft, Link2, Link2Off,
  CheckCircle2, Undo2, Mail, BookOpen, Plus, Sparkles, Flag, Search, Clock, Crosshair, FileText, Download, ArrowRight, ExternalLink, ListChecks,
  Layers, X, Upload, Pencil, User, UserCheck, KeyRound, ListTree, Rss, Lock, Building2, RotateCcw, PlayCircle, Info, Archive, Circle, Trash2,
  Bookmark, Keyboard,
} from 'lucide-react';

// ---------------------------------------------------------------------------
// DEMO MODE (added per docs/demo-week-plan.md Day 1) — a single flag that
// swaps the seeded dataset (accounts/capabilities/partnerships/past work/
// Signal Map seed) for a self-contained, fully-sourced demo profile and
// hides frozen/non-demo areas (Manager mode, the Live feed's "View all
// sources" link) from navigation. Persisted under its own localStorage key,
// never the main app-state blob (STORAGE_KEY below) — so toggling it never
// touches a real session's saved data, and turning it off always restores
// the full app exactly as it was, same as the rule for every other toggle
// in this file. A `?demo=1`/`?demo=0` query param is also accepted (e.g.
// for a bookmarked demo link) and just writes the same flag before the
// reload that follows — there's no env-var mechanism available in this
// zero-build-step app, so a query param / settings toggle are the two
// options that actually fit, per the plan's own "whichever fits" framing.
const DEMO_MODE_KEY = 'gridline_demo_mode_v1';
function readDemoModeFlag() {
  try {
    const params = new URLSearchParams(window.location.search);
    if (params.has('demo')) {
      const on = params.get('demo') !== '0';
      window.localStorage.setItem(DEMO_MODE_KEY, on ? '1' : '0');
      return on;
    }
    return window.localStorage.getItem(DEMO_MODE_KEY) === '1';
  } catch (e) {
    return false;
  }
}
const DEMO_MODE = readDemoModeFlag();
function setDemoMode(on) {
  try {
    window.localStorage.setItem(DEMO_MODE_KEY, on ? '1' : '0');
  } catch (e) {
    // Private browsing / localStorage disabled — reload anyway; it'll just
    // land back in the same (non-demo) mode since the flag never wrote.
  }
  window.location.reload();
}

// Blocker next-step popup (added 2026-10-10) — a one-time-dismissible nag
// shown when the user selects a next step on a matched signal that has an
// unresolved Blocker gap, so moving it forward is a deliberate choice, not
// an accident of not having scrolled up to Fit & gaps. "Don't show again" is
// a plain localStorage flag, same never-touch-the-main-state-blob pattern as
// DEMO_MODE_KEY above — it's a UI nag preference, not account data.
const BLOCKER_POPUP_DISMISS_KEY = 'gridline_hide_blocker_popup_v1';
function blockerPopupDismissed() {
  try {
    return window.localStorage.getItem(BLOCKER_POPUP_DISMISS_KEY) === '1';
  } catch (e) {
    return false;
  }
}
function dismissBlockerPopupForever() {
  try {
    window.localStorage.setItem(BLOCKER_POPUP_DISMISS_KEY, '1');
  } catch (e) {
    // Private browsing / localStorage disabled — the popup just keeps
    // showing, which is the safe failure direction for a warning like this.
  }
}

// Shortcut-hint notice (added 2026-10-16) — a one-time-dismissible banner
// telling the user that key caps on buttons only appear while Ctrl is held
// (see .gl-kbd-hint/.gl-keys-held and ShortcutHint above), since that's not
// otherwise discoverable until someone happens to hold the key. Same
// never-touch-the-main-state-blob localStorage-flag pattern as the Blocker
// popup above.
const SHORTCUT_NOTICE_DISMISS_KEY = 'gridline_hide_shortcut_notice_v1';
function shortcutNoticeDismissed() {
  try {
    return window.localStorage.getItem(SHORTCUT_NOTICE_DISMISS_KEY) === '1';
  } catch (e) {
    return false;
  }
}
function dismissShortcutNoticeForever() {
  try {
    window.localStorage.setItem(SHORTCUT_NOTICE_DISMISS_KEY, '1');
  } catch (e) {
    // Private browsing / localStorage disabled — the notice just keeps
    // showing, which is the safe failure direction for a one-time tip.
  }
}

// ---------------------------------------------------------------------------
// MOCK DATA — standing in for a live news feed, a real CRM, and the firm's
// own knowledge base. ACCOUNTS_INITIAL is the one exception (real companies,
// as of 2026-10-03 — see claude.md §3.0 for why, and what was deliberately
// left mock/empty around it rather than fabricated for real firms). Swap the
// rest out for real integrations later; the app logic around it doesn't
// need to change.
// ---------------------------------------------------------------------------

// Real companies (added 2026-10-03) — these 8 are the single source of
// truth for Live feed company matching (§7.1); there used to be a second,
// separately-maintained list of the same names in News Scraper/
// sources.json's `watchlist`, now retired.
// Priorities below are generic, publicly-plausible sector priorities, not
// claimed facts about any of these companies — see claude.md §3 for why
// contacts and named past-client work were deliberately left off real
// accounts rather than inventing people or history for them.
// `aliases` is spelling variants a news item might actually use instead of
// (or alongside) `name` — migrated 2026-10-05 from News Scraper/sources.json's
// `watchlist` dict, which is retired now that matching lives here (§7.1).
// Optional on any account (falls back to matching just `name`); the only
// place these are authored going forward is this list, or the optional
// field on "Add account" (§5.1).
const REAL_ACCOUNTS_INITIAL = [
  { id: 'a1', name: 'Ørsted', aliases: ['Ørsted', 'Orsted'], sector: 'Offshore wind developer & operator', tier: 'Strategic', priorities: ['Rebuild offshore wind development pipeline after recent portfolio review', 'Improve capital discipline on projects under construction'], accountManager: '', stage: 'Identified' },
  { id: 'a2', name: 'Vestas', aliases: ['Vestas'], sector: 'Wind turbine manufacturer', tier: 'Strategic', priorities: ['Scale manufacturing capacity for next-generation turbine platforms', 'Protect service & warranty margins amid cost pressure'], accountManager: '', stage: 'Identified' },
  { id: 'a3', name: 'RWE', aliases: ['RWE'], sector: 'Multinational utility & renewables developer', tier: 'Strategic', priorities: ['Grow battery storage and flexible generation portfolio', 'Navigate permitting for onshore wind and solar pipeline'], accountManager: '', stage: 'Identified' },
  { id: 'a4', name: 'SSE', aliases: ['SSE Renewables', 'SSE plc', 'SSE'], sector: 'UK utility & renewables developer', tier: 'Growth', priorities: ['Deliver offshore wind projects on budget and schedule', 'Accelerate network investment ahead of grid-connection reform'], accountManager: '', stage: 'Identified' },
  { id: 'a5', name: 'Iberdrola', aliases: ['Iberdrola', 'ScottishPower'], sector: 'Multinational utility & renewables developer', tier: 'Growth', priorities: ['Expand grid and network investment in the UK and US', 'Grow offshore wind pipeline into new markets'], accountManager: '', stage: 'Identified' },
  { id: 'a6', name: 'NextEra Energy', aliases: ['NextEra', 'NextEra Energy'], sector: 'US utility & renewables developer', tier: 'Growth', priorities: ['Expand renewables and storage backlog', 'Navigate interconnection queue reform in US markets'], accountManager: '', stage: 'Identified' },
  { id: 'a7', name: 'Vattenfall', aliases: ['Vattenfall'], sector: 'European utility & renewables developer', tier: 'Prospect', priorities: ['Reassess offshore wind project economics amid cost inflation', 'Grow electrification and industrial heat business'], accountManager: '', stage: 'Identified' },
  { id: 'a8', name: 'CIP (Copenhagen Infrastructure Partners)', aliases: ['CIP', 'Copenhagen Infrastructure Partners'], sector: 'Renewable infrastructure fund manager', tier: 'Prospect', priorities: ['Deploy capital across new infrastructure fund vintages', 'Secure offtake and permitting for pipeline assets'], accountManager: '', stage: 'Identified' },
  // Fictional demo account (added 2026-10-08) — NOT a real company, unlike
  // the 8 above. Exists solely to carry a mock hydrogen-infrastructure
  // signal so generatePlan's gap-flagging rule (CAPABILITIES deliberately
  // excludes hydrogen, see §3.0 in data-model.md) has a live example to
  // fire on, without inventing a fabricated news event about a real firm
  // like CIP — see docs/open-items.md for why that distinction mattered
  // here. Both `name` and `sector` say "fictional" so it reads as such
  // everywhere it's shown, including inside AI prompts (`${account.sector}`
  // is interpolated directly into generatePlan/research prompts).
  { id: 'a9', name: 'Meridian H2 Partners (fictional demo account)', aliases: ['Meridian H2 Partners', 'Meridian H2'], sector: 'Fictional infrastructure fund — demo-only, not a real company', tier: 'Prospect', priorities: ['Illustrative only: expand hydrogen infrastructure investment'], accountManager: '', stage: 'Identified' },
];

// DEMO MODE dataset (added per docs/demo-week-plan.md Day 1) — see
// docs/demo-dataset.md for the full answer key (sources, retrieval dates,
// and the intended fit/gap per account). 6-8 real UK/European renewables
// developers, IPPs and storage players, chosen so that against the demo
// firm's profile below (DEMO_CAPABILITIES/DEMO_PARTNERSHIPS) they produce a
// deliberate mix of clear fits, partner-coverable gaps, and hard blockers —
// the gap-flagging fixture's replacement for the old Meridian H2 (a9)
// mechanism above, which stays in the *non-demo* dataset unchanged. Every
// priority here must have a real source cited in docs/demo-dataset.md.
const DEMO_ACCOUNTS_INITIAL = [
  { id: 'b1', name: 'Ørsted', aliases: ['Ørsted', 'Orsted'], sector: 'Offshore wind developer & operator', tier: 'Strategic', priorities: [
    'Deliver its 8.1 GW offshore wind construction programme across Europe, the US and Asia-Pacific on schedule and within planned costs',
    'Sharpen strategic focus on offshore wind by divesting its entire European onshore business (to CIP, ~EUR 1.44bn)',
    'Maintain a solid investment-grade credit rating as a core strategic pillar',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b2', name: 'SSE Renewables', aliases: ['SSE Renewables', 'SSE plc', 'SSE'], sector: 'UK utility & renewables developer', tier: 'Strategic', priorities: [
    'Complete construction of the 3.6 GW Dogger Bank Wind Farm',
    'Progress the 4.1 GW Berwick Bank offshore wind project toward a final investment decision expected in 2027',
    'Grow its secured UK/Ireland offshore wind development pipeline, now reported above 10 GW',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b3', name: 'Octopus Energy Generation', aliases: ['Octopus Energy Generation', 'Octopus Energy Generation Limited'], sector: 'Renewables investment & asset management arm of Octopus Energy Group', tier: 'Growth', priorities: [
    'Grow renewable assets under management — reached £8.5bn / 5.7 GW across 400+ sites in 21 countries',
    'Expand US clean-tech investment, targeting $2bn deployed into the energy transition by 2030',
    'Expand European onshore wind portfolio via acquisition (321 MW across France, Germany and Poland)',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b4', name: 'Low Carbon', aliases: ['Low Carbon'], sector: 'Private renewable energy investor & developer', tier: 'Growth', priorities: [
    'Bring a 3 GW portfolio of utility-scale solar, onshore wind, battery storage and co-located assets into operation, backed by a 16 GW development pipeline',
    'Grow presence in core markets (UK, Germany, Poland) using new capital from a ~£1.1bn CVC DIF investment',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b5', name: 'Zenobē', aliases: ['Zenobē', 'Zenobe'], sector: 'Battery storage & EV fleet infrastructure company', tier: 'Growth', priorities: [
    'Expand transmission-connected battery storage into Germany (acquired sdp energie, ~700 MWh across 23 ready-to-build projects)',
    'Scale fleet electrification internationally — entered the US electric-trucking market (acquired Revolv) and raised ~AU$400m for Australia/New Zealand',
    'Continue UK/Ireland electric bus rollout, targeting up to 5,000 electric buses by 2028 (~£980m financing secured)',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b6', name: 'Island Green Power', aliases: ['Island Green Power'], sector: 'UK solar & storage developer (owned by Macquarie Asset Management)', tier: 'Prospect', priorities: [
    'Transition from a pure development business into an independent power producer (IPP) that builds and operates its own assets',
    'Expand co-located solar-plus-storage NSIP pipeline — secured its fourth UK development consent of 2026',
    'Secure holdco-level debt financing to fund growth (£315m from Nomura and Barclays)',
  ], accountManager: '', stage: 'Identified' },
  { id: 'b7', name: 'CIP (Copenhagen Infrastructure Partners)', aliases: ['CIP', 'Copenhagen Infrastructure Partners'], sector: 'Renewable infrastructure fund manager', tier: 'Prospect', priorities: [
    'Restructure its EUR 3bn Energy Transition Fund I — the world’s largest dedicated green hydrogen fund — amid slower-than-expected hydrogen/power-to-X market development',
    'Grow its flagship credit strategy — raised EUR 1.3bn at first close for CI Green Credit Fund II, targeting EUR 2bn overall',
    'Expand into high-growth emerging markets — closed Growth Markets Fund II at ~USD 3bn',
  ], accountManager: '', stage: 'Identified' },
];

const ACCOUNTS_INITIAL = DEMO_MODE ? DEMO_ACCOUNTS_INITIAL : REAL_ACCOUNTS_INITIAL;

// Deliberately empty: these are now real companies, and inventing named
// individual contacts for them (specific people, titles, emails) would
// fabricate personal details about real firms rather than illustrate a
// feature. The UI already handles an empty address book as a normal state
// ("No contacts on file") — per §4, contacts are CRM-sourced in a real
// deployment anyway, never hand-authored mock data.
const CONTACTS_INITIAL = [];

// Our firm's thought leadership — things we can point to as credibility.
// No relatedSignals field: that used to link an insight to a specific mock
// NEWS_ITEMS id (removed §3.1/history #37 — real Live feed signals replaced
// the mock news entirely), and there's no stable id to link to on a live
// item. isRelevantTo() now only matches via accountId.
const REAL_FIRM_INSIGHTS = [
  { id: 't1', type: 'White paper', title: 'Clearing the Queue: lessons from three cluster-study reforms', date: 'Jun 2026', summary: 'What changed for developers under recent cluster-study reforms, and how to position projects.' },
  { id: 't2', type: 'Briefing', title: 'Standalone storage after the ITC guidance: a financing playbook', date: 'Jul 2026', summary: 'How the new eligibility rules change tax equity and construction debt for storage.' },
  { id: 't3', type: 'Webinar', title: 'Buyer coalitions for long-term offtake', date: 'May 2026', summary: 'Structuring multi-buyer PPAs, with case examples from wind and solar.' },
  { id: 't4', type: 'Report', title: 'Community solar administration benchmarks 2026', date: 'Apr 2026', summary: 'Cost and time benchmarks for subscriber management across state programs.' },
  { id: 't5', type: 'Market note', title: '2027 module pricing outlook', date: 'Jul 2026', summary: 'Where domestic manufacturing is taking module prices for 2027 deliveries.' },
];

// Previous work. accountId set = work done with that account directly —
// deliberately null for every entry now that accounts are real companies
// (§4/§6): claiming a specific past client relationship and outcome with a
// real, named firm would be a fabricated claim about them, not illustrative
// mock data. The anonymised clientLabel entries are unaffected — they never
// named a specific company to begin with.
const REAL_PAST_WORK = [
  { id: 'w1', accountId: null, clientLabel: 'A utility-scale solar developer', title: 'Grid-study scoping for a 200 MW solar portfolio', year: 2025, outcome: 'Cut expected study timeline by about four months' },
  { id: 'w2', accountId: null, clientLabel: 'A multi-state community solar operator', title: 'Subscriber admin software rollout across two states', year: 2024, outcome: 'Halved monthly billing reconciliation time' },
  { id: 'w3', accountId: null, clientLabel: 'A Midwest storage developer', title: 'Tax equity structuring for a 150 MW standalone BESS', year: 2025, outcome: 'Financing closed within ten weeks' },
  { id: 'w4', accountId: null, clientLabel: 'A Northeast offshore wind project', title: 'Multi-buyer PPA structuring with industrial offtakers', year: 2024, outcome: 'Signed four buyers under a common term sheet' },
  { id: 'w5', accountId: null, clientLabel: 'A regional utility', title: 'Interconnection queue triage and planning-tool pilot', year: 2025, outcome: 'Cleared a backlog of 40 requests in two quarters' },
];

// `let`, not `const` (added 2026-10-08): these used to be a fixed fixture
// with no way to edit them — see CapabilitiesControl below, the header
// widget that lets a user enter their own firm's capabilities/partnerships
// instead of this prototype's placeholder ones. Plain module-level mutable
// bindings, same pattern as PROMOTED_NEWS/PROMOTED_MATCHES above: every
// plain (non-component) function that builds an AI prompt — generatePlan,
// buildResearchPlanPrompt, etc. — reads these two identifiers directly by
// name at call time, so reassigning the binding here is picked up on the
// next call without threading the value through every function signature.
// Persisted via CapabilitiesControl's onChange callbacks into App's state
// (see hydrateCapabilitiesFromStorage below) — never silently reset.
const DEFAULT_CAPABILITIES = [
  'Interconnection queue strategy & grid studies',
  'Project finance advisory for storage & solar',
  'PPA / offtake structuring',
  'Regulatory & incentive compliance (ITC, state programs)',
  'Community solar program administration software',
];
const DEFAULT_PARTNERSHIPS = [
  'Financing partner: Meridian Capital Group (construction & tax equity)',
  'Technology partner: Voltaic Systems (BESS integration & controls)',
  'Legal partner: Ashworth & Cole (energy regulatory counsel)',
];
const DEFAULT_GEOGRAPHIES = ['United States'];

// DEMO MODE firm profile (added per docs/demo-week-plan.md Day 1b) — a
// fictional UK renewables advisory boutique, ~20 people. Capabilities and
// partnerships below are chosen *deliberately*, not copied from the
// defaults above, so that the 6-8 real DEMO_ACCOUNTS_INITIAL companies
// produce a genuine mix: clear fits (market intelligence, BD strategy,
// consenting/grid advisory — most accounts' stated priorities land here),
// partner-coverable gaps (hydrogen/e-fuels work routes to Fenwick H2
// Advisory; deep technical/OEM benchmarking routes to Calder Technical
// Partners), and hard blockers the firm has no partner for at all (EPC/
// construction delivery; project finance & debt arrangement; hands-on BESS
// engineering/system-integration delivery, as opposed to the technical
// *due diligence* Calder provides). See docs/demo-dataset.md for the full
// per-account answer key this is built against. This replaces the old a9
// "Meridian H2 Partners" hydrogen-gap fixture for demo purposes — a9 itself
// is untouched and still lives in the non-demo dataset above.
const DEMO_FIRM_NAME = 'Marlstone Energy Advisory';
const DEMO_GEOGRAPHIES = ['United Kingdom'];
const DEMO_CAPABILITIES = [
  'Market & competitor intelligence for renewables developers, utilities and infrastructure funds',
  'Account-based BD strategy and stakeholder mapping',
  'Offshore wind and solar planning & consenting strategy (non-legal)',
  'Grid connection queue strategy and regulatory positioning',
  'M&A target screening and origination support',
  'Stakeholder and community engagement strategy for contested projects',
];
const DEMO_PARTNERSHIPS = [
  'Legal partner: Harrowgate Energy Law (consenting, offtake & contract review)',
  'Technical partner: Calder Technical Partners (independent engineering due diligence & technology benchmarking)',
  'Specialist partner: Fenwick H2 Advisory (hydrogen & e-fuels project advisory)',
];
const DEMO_FIRM_INSIGHTS = [
  { id: 'dt1', type: 'White paper', title: 'Offshore wind consenting after the latest UK planning reforms', date: 'Jun 2026', summary: 'What changed for developers under the latest consenting reforms, and how to position projects.' },
  { id: 'dt2', type: 'Briefing', title: 'Grid connection queue reform: what developers should do now', date: 'Jul 2026', summary: 'Practical positioning for projects sitting in the reformed UK connections queue.' },
  { id: 'dt3', type: 'Webinar', title: 'Buyer coalitions for long-term UK offtake', date: 'May 2026', summary: 'Structuring multi-buyer PPAs, with case examples from wind and solar.' },
  { id: 'dt4', type: 'Report', title: 'Battery storage market outlook 2026', date: 'Apr 2026', summary: 'Where UK and European standalone storage economics are heading into 2027.' },
  { id: 'dt5', type: 'Market note', title: 'Capital deployment trends across European infrastructure funds', date: 'Jul 2026', summary: 'How fund managers are sequencing deployment across new vintages.' },
];
const DEMO_PAST_WORK = [
  { id: 'dw1', accountId: null, clientLabel: 'A European offshore wind developer', title: 'Grid connection queue strategy for a 400MW pipeline', year: 2025, outcome: 'Repositioned two projects ahead of a cluster-study deadline' },
  { id: 'dw2', accountId: null, clientLabel: 'A UK solar & storage developer', title: 'Consenting strategy for a contested solar-plus-storage site', year: 2025, outcome: 'Planning committee approval secured on first submission' },
  { id: 'dw3', accountId: null, clientLabel: 'An infrastructure fund manager', title: 'Target screening for a renewables M&A mandate', year: 2024, outcome: 'Shortlisted 6 targets from an initial list of 40' },
  { id: 'dw4', accountId: null, clientLabel: 'A battery storage developer', title: 'Independent technical benchmarking via partner due diligence', year: 2025, outcome: 'Benchmarked 3 OEM shortlist options ahead of financial close' },
  { id: 'dw5', accountId: null, clientLabel: 'A utility-scale wind developer', title: 'Stakeholder engagement strategy for a contested onshore project', year: 2024, outcome: 'Reduced objections at the second consultation round by half' },
];

let CAPABILITIES = DEMO_MODE ? DEMO_CAPABILITIES : DEFAULT_CAPABILITIES;
let PARTNERSHIPS = DEMO_MODE ? DEMO_PARTNERSHIPS : DEFAULT_PARTNERSHIPS;
// Your Company's own identity (added 2026-10-11, same page rename/expansion
// that turned "Your Capabilities" into "Your Company") — same
// module-level-mutable-plus-version-counter pattern as CAPABILITIES/
// PARTNERSHIPS above, for the same reason: plain prompt-building functions
// (generatePlan, evaluateResearchPlan, etc.) read these by identifier at
// call time, so reassigning the binding is picked up without threading the
// value through every function signature.
let COMPANY_NAME = DEMO_MODE ? DEMO_FIRM_NAME : '';
// Company website (added 2026-10-11, per direct feedback: "add a website
// field so the tool can get more background info on the user's
// capabilities") — same module-level-mutable pattern as COMPANY_NAME right
// above. Used two ways: passed as context on every generation prompt
// alongside COMPANY_NAME/GEOGRAPHIES, and as the anchor for the Your
// Company page's own "Research our company online" action (see
// CompanyWebsiteResearch below), which runs a real grounded web-search call
// against it to suggest competency rows — the same "AI-drafted judgment a
// human confirms" pattern suggestAccounts uses for prospect suggestions.
let COMPANY_WEBSITE = '';
let GEOGRAPHIES = DEMO_MODE ? DEMO_GEOGRAPHIES : DEFAULT_GEOGRAPHIES;
// P1 item 8 (feedback brief): a firm-level flag so a funding-call signal can
// surface "your team has bid-writing capability" where relevant — same
// module-level-mutable-plus-version-counter pattern as CAPABILITIES/
// PARTNERSHIPS above (see updateCapabilities/capabilitiesVersion), since the
// value is read by plain functions outside React (FundingBidEvaluation).
let BID_WRITING_CAPABLE = false;
const FIRM_INSIGHTS = DEMO_MODE ? DEMO_FIRM_INSIGHTS : REAL_FIRM_INSIGHTS;
const PAST_WORK = DEMO_MODE ? DEMO_PAST_WORK : REAL_PAST_WORK;

// "Manager mode" (added 2026-10-08) — a demo-only affordance letting a line
// manager/team lead delegate stage actions to a colleague and flag matched
// signals for one to review. Explicitly not required to be fully
// functional (no real user/auth system exists yet, see docs/architecture.md)
// — colleagues here are just names to pick from, not real accounts; nothing
// is actually notified or assigned anywhere outside this browser's
// localStorage. See NextMilestone (delegation) and MatchedSignalsPanel
// (flagging) below.
const MANAGER_COLLEAGUES = ['Avery Chen', 'Jordan Patel', 'Sam Osei', 'Riley Nakamura'];

const STAGES =['Identified', 'Researched', 'Contacted', 'Meeting', 'Proposal', 'Closed'];

// Phase banding over STAGES (design restyle, added 2026-10-16) — groups the
// six stages into three two-stage phases purely for the stage track's
// colouring and the Overview legend/column-header underline. This never
// replaces the stage name itself as the source of truth (the badge next to
// every track still reads the real stage) — colour is a second cue, not the
// only one, per CLAUDE.md's gap-flagging/honesty rule.
const PHASES = ['Research', 'Engage', 'Commercial'];
const PHASE_META = {
  Research: { range: '01–02', full: 'var(--ss-phase-research)', soft: 'var(--ss-phase-research-soft)' },
  Engage: { range: '03–04', full: 'var(--ss-phase-engage)', soft: 'var(--ss-phase-engage-soft)' },
  Commercial: { range: '05–06', full: 'var(--ss-phase-commercial)', soft: 'var(--ss-phase-commercial-soft)' },
};
const STAGE_PHASE = {
  Identified: 'Research',
  Researched: 'Research',
  Contacted: 'Engage',
  Meeting: 'Engage',
  Proposal: 'Commercial',
  Closed: 'Commercial',
};

// What the user has to confirm they've done to move out of each stage.
// The stage only advances when they confirm the action — never on its own.
const STAGE_ACTIONS = {
  Identified: { short: 'Research the account', confirm: 'Research done', activity: 'Research completed', hint: 'Choose below whether to run the research workflow or skip straight to outreach with your own notes.' },
  Researched: { short: 'Send first outreach', confirm: "I've sent the outreach", activity: 'Outreach sent', hint: 'Marking an email as sent in the composer below also completes this step — for whichever matched signal(s) it led with, or for the account as a whole if it led with their priorities instead.' },
  Contacted: { short: 'Hold a meeting', confirm: 'Meeting held', activity: 'Meeting held' },
  // confirm reworded 2026-10-13 (direct feedback: button should read "move
  // to proposal stage," not "confirm & send proposal") — the stage badge
  // right next to this card still honestly says "MEETING" (that's the
  // account/signal's real pipeline position); the button names the
  // transition it actually causes instead of implying the proposal itself
  // gets sent by this click.
  Meeting: { short: 'Send a proposal', confirm: 'Move to proposal stage', activity: 'Proposal sent' },
  Proposal: { short: 'Close the deal', confirm: 'Deal closed', activity: 'Deal closed' },
};
// Next-milestone labels (both NextMilestone's own card and
// OpportunitySummary's read-only Overview-row preview) read as plain
// "Next: {short}" statements, not questions — direct feedback 2026-10-12:
// "phrasing it as a question makes it confusing what step the user is on."

// "Point the user towards the generation actions before pushing them to
// confirm they've taken action — the generation resources available on the
// tool need to be clear" (2026-10-11 direct follow-up feedback). One link
// per stage, pointing at the page section that actually produces the thing
// this stage's confirm is about — NextMilestone renders it (see
// `generationPointer` prop) above its own confirm button. Shared by the
// account-baseline NextMilestone and every per-signal one (PlanPanel,
// MatchedSignalsPanel) so the mapping can't drift between the two. Meeting
// and Contacted intentionally share the same anchor — both are "Meeting
// prep & follow-up" (before/after the meeting), not two different tools.
function generationPointerForStage(stage) {
  switch (stage) {
    case 'Identified':
      return { label: 'Generate a research plan', anchorId: 'research-section' };
    case 'Researched':
      return { label: 'Generate the outreach email', anchorId: 'outreach-section' };
    case 'Contacted':
      return { label: 'Generate a meeting plan', anchorId: 'meeting-prep-section' };
    case 'Meeting':
      return { label: 'Add your meeting notes', anchorId: 'meeting-prep-section' };
    case 'Proposal':
      return { label: 'Review the proposal', anchorId: 'proposal-section' };
    default:
      return null;
  }
}

// Outcome, once an opportunity (the account's own baseline, or one matched
// signal's independent track) reaches Closed — see NextMilestone's "closed"
// branch below. Separate from `stage` itself: closing just means the
// confirm loop ended, not which way it went.
const DEAL_OUTCOMES = ['Won', 'Lost', 'Paused'];
const DEAL_OUTCOME_STYLE = {
  Won: 'text-[var(--ss-positive)] border-[var(--ss-positive)]/40 bg-[var(--ss-positive-wash)]',
  Lost: 'text-[var(--ss-danger)] border-[var(--ss-danger)]/40 bg-[var(--ss-danger-wash)]',
  Paused: 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]',
};

// Matched-signal stage tag (added 2026-10-11, direct feedback: "matched
// signals ... never change, even as the main page moves through outreach,
// meetings and so on"). A matched signal's own pipeline already reuses
// account.stage's underlying STAGES values/indices as its single source of
// truth (see setMatchStage/accountRollupStage) — every confirm/undo/Go-no-go
// path already depends on that shared vocabulary, so this doesn't fork the
// stored value into a second stage machine. What it does add is a signal-
// specific display label (an account is "Identified"; a matched signal
// reads clearer as "New") and a colored badge, so the two concepts never
// look like the same thing even though one field drives both.
const SIGNAL_STAGE_LABEL = {
  Identified: 'New',
  Researched: 'Researched',
  Contacted: 'Outreach sent',
  Meeting: 'Meeting',
  Proposal: 'Proposal sent',
  Closed: 'Closed',
};

// Jumps the main column to whichever existing section corresponds to a
// matched signal's current stage, and gives it a brief highlight so the
// click's effect is obvious (added 2026-10-11, direct feedback: "clicking
// a signal [should open] the main view at that signal's current stage").
// Research/outreach/meeting/proposal aren't split into per-signal main-view
// panels yet (fit & gaps and the Go/no-go proposal list are, today — see
// SignalGapGroup/GoNoGoCheck) — this targets whichever of those two already
// has this signal's content, falling back to the shared outreach section.
// Revisit once P1 gives every stage its own per-signal panel to target.
function scrollToSignalMain(newsId, stage, hasGaps) {
  const candidateIds = [];
  if (stage === 'Proposal' || stage === 'Closed') candidateIds.push(`proposal-signal-${newsId}`);
  if (hasGaps) candidateIds.push(`fitgap-signal-${newsId}`);
  candidateIds.push('outreach-section');
  for (const id of candidateIds) {
    const el = document.getElementById(id);
    if (!el) continue;
    el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    el.style.transition = 'box-shadow 0.3s ease';
    el.style.boxShadow = '0 0 0 2px var(--ss-border)';
    window.setTimeout(() => {
      el.style.boxShadow = '';
    }, 1400);
    return;
  }
}

const TIER_ORDER = ['Strategic', 'Growth', 'Prospect'];

// Lane copy for the Overview's "Group by: Tier" view (design restyle, added
// 2026-10-16) — kept separate from RESEARCH_DEPTH/PLAN_DEPTH so the words
// shown in a lane can be edited without touching the actual depth config
// those drive.
const TIER_META = {
  Strategic: { description: 'Named, bespoke pursuit. Every signal reviewed.', depthLabel: 'DEEP RESEARCH' },
  Growth: { description: 'Active, but on a lighter cadence.', depthLabel: 'STANDARD RESEARCH' },
  Prospect: { description: 'Watching. Act when a signal lands.', depthLabel: 'QUICK CHECK' },
};

// Ways to group the Overview tab's account list. 'custom' groups are
// user-defined (see App's customGroups/accountGroups state) rather than
// derived from a fixed field.
const GROUP_OPTIONS = [
  { key: 'none', label: 'No grouping' },
  { key: 'tier', label: 'Tier' },
  { key: 'sector', label: 'Company type' },
  { key: 'stage', label: 'BD status' },
  { key: 'custom', label: 'Custom groups' },
];

// Told apart by text only now (no colored pill) — Warm gets the one accent
// (positive, per the house-style mapping of "Warm"/success/relevant), Cold
// and Existing client stay neutral ink/muted.
const RELATIONSHIP_STYLE = {
  Warm: 'text-[var(--ss-positive)] border-[var(--ss-positive)]/40',
  Cold: 'text-[var(--ss-muted)] border-[var(--ss-border)]',
  'Existing client': 'text-[var(--ss-ink)] border-[var(--ss-border)]',
};

const GANTT_STAGE_META = [
  { label: 'Identified', sub: 'Wk 1' },
  { label: 'Researched', sub: 'Wks 1–2' },
  { label: 'Contacted', sub: 'Wks 2–3' },
  { label: 'Meeting', sub: 'Wks 3–4' },
  { label: 'Proposal', sub: 'Wks 4–6' },
  { label: 'Closed', sub: 'Wk 6+' },
];

const FRAMEWORK_STEPS = [
  'Open with the specific signal and why it was flagged for them.',
  "Confirm it still matches a priority they've stated — don't assume it does.",
  'Connect it to one relevant capability, not the full list.',
  "Name any capability gap plainly, and how you'd bridge it.",
  'Propose one concrete next step with a date attached.',
];

const TONES = {
  Warm: 'warm and personable, like a note to someone you respect',
  Direct: 'direct and brief, getting to the point in the first line',
  Formal: 'formal and polished, suitable for a senior executive',
};

// Talking points and gaps get a lighter or heavier treatment depending on
// how much attention the account is worth — a Prospect shouldn't read like
// a Strategic account got the same depth of research.
const PLAN_DEPTH = {
  Strategic: { points: 4, timelineSteps: 4 },
  Growth: { points: 3, timelineSteps: 3 },
  Prospect: { points: 2, timelineSteps: 2 },
};

// Research is scoped the same way: a Prospect gets a quick check, a
// Strategic account gets a proper half-day of digging.
const RESEARCH_DEPTH = {
  Strategic: { questions: 6, minutes: 120 },
  Growth: { questions: 4, minutes: 60 },
  Prospect: { questions: 3, minutes: 30 },
};

// Where the research plan is allowed to point people. Keeping it to source
// types (not URLs or specific documents) stops the model inventing links.
const RESEARCH_SOURCES = [
  'Company website, press releases and project pages',
  'Investor materials, annual reports or SEC filings',
  'ISO/RTO interconnection queue data',
  'State utility commission (PUC) dockets',
  'FERC filings',
  'Trade press and news archives',
  'LinkedIn and org charts',
  'Conference speaker lists and panel recordings',
  'Our CRM, address book and past project files',
  'Colleagues or partners with an existing relationship',
];

// Same field skin as the shared ssFieldClass primitive (defined later in
// this file, after the components that need it before inputClass is even
// declared — duplicated here rather than reordered) plus this app's
// `w-full` default and placeholder color, so every native form field in
// the file (the simple one-off <select>s this restyle is deliberately not
// converting to a themed dropdown included — see CLAUDE.md's cross-cutting
// rule) gets the house field skin for free.
const inputClass =
  'w-full bg-[var(--ss-surface)] border border-[var(--ss-border)] rounded-[var(--ss-radius)] text-[var(--ss-ink)] text-sm px-2.5 py-1.5 placeholder:text-[var(--ss-faint)] focus:outline-none focus:border-[var(--ss-frame)] focus:ring-1 focus:ring-[var(--ss-frame)]';

const sectionLabel = 'text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2';

function todayISO() {
  return new Date().toISOString().slice(0, 10);
}

// ---------------------------------------------------------------------------
// SMALL PRESENTATIONAL HELPERS
// ---------------------------------------------------------------------------

// Tier is told apart by the marker square, not a colored pill — this just
// pairs TierMarker with the mono tier name.
function TierBadge({ tier }) {
  return (
    <span className="inline-flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-text-2)]">
      <TierMarker tier={tier} />
      {tier}
    </span>
  );
}

// Opportunity numbers (added 2026-10-12, see assignOpportunityNumber in
// App) — one shared format/badge so every place that shows one (Overview
// row, the account page header, PDF exports) renders it identically.
function formatOpportunityNumber(n) {
  return `OPP-${n}`;
}

function OpportunityNumberBadge({ number }) {
  if (!number) return null;
  return (
    <span
      title="Opportunity number — reference this wherever you track this deal outside the tool"
      className="text-[11px] font-[var(--ss-font-mono)] tracking-wide px-2 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-muted)] bg-[var(--ss-surface)]"
    >
      {formatOpportunityNumber(number)}
    </span>
  );
}

function RelationshipBadge({ value }) {
  return (
    <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${RELATIONSHIP_STYLE[value] || RELATIONSHIP_STYLE.Cold}`}>
      {value}
    </span>
  );
}

// Display-only: the underlying value is still stored/passed as 0-100
// throughout (confidence/fit percentages) — ScoreBar just renders it as a
// 0-1 two-decimal read per the house style's score convention.
function ConfidenceBar({ value, className = '' }) {
  return <ScoreBar value={(value || 0) / 100} className={`w-[104px] ${className}`} />;
}

// A selectable option pill — same ink-filled/unfilled pattern as
// SegmentedControl's active state, just not joined into one row (used both
// for exclusive choices and independent multi-select toggles).
function TogglePill({ active, onClick, children }) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-pressed={active}
      className={`text-left text-[12px] px-2.5 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
        active ? 'border-[var(--ss-inverse)] bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]' : 'border-[var(--ss-border)] text-[var(--ss-text-2)] hover:border-[var(--ss-frame)]'
      }`}
    >
      {children}
    </button>
  );
}

// ---------------------------------------------------------------------------
// PROMOTED SIGNALS (added 2026-10-03) — a validated Live feed item, once the
// user explicitly adds it to the Signal Map (see "Add to Signal Map" in
// LiveFeedPromote), becomes a real news+match pair here. This is now the
// Signal Map's only data source: the hand-authored mock NEWS_ITEMS/MATCHES
// (6 illustrative items) were removed once real promoted signals existed to
// replace them (history #37) — accountMatches/newsById below are unchanged,
// so every existing reader (the Signal Map, the plan and research prompts)
// just sees fewer rows until something's promoted, not a different shape.
// Deliberately plain module-level arrays, not React state, so every reader
// picks up a promotion with no prop-threading anywhere; App bumps a plain
// `promotedVersion` counter after every promotion purely to trigger a
// re-render — the arrays themselves are read live, not via state. Same
// lifetime as everything else in this prototype: lost on refresh.
const PROMOTED_NEWS = [];
const PROMOTED_MATCHES = [];

function allNewsItems() {
  return PROMOTED_NEWS;
}

function allMatches() {
  return PROMOTED_MATCHES;
}

function accountMatches(accountId) {
  return allMatches()
    .filter((m) => m.accountId === accountId)
    // `stage` was added 2026-10-06 (each matched signal is its own
    // pipeline opportunity, see setMatchStage below) — older saved/seed
    // matches predate the field, so fall back to 'Identified' rather than
    // requiring a storage migration.
    .map((m) => ({ ...m, stage: m.stage || 'Identified', news: newsById(m.newsId) }))
    .sort((a, b) => b.confidence - a.confidence);
}

// Every matched signal is its own outreach opportunity, independent of the
// account's own baseline stage (which still exists and still drives the
// research workflow/intake gating — see isResearchStage in PlanPanel) and
// independent of every other matched signal on the same account. Mutates
// the PROMOTED_MATCHES entry in place, same module-level-array pattern as
// unlinkMatch; callers (App's recordAction/undoLastAction) bump
// promotedVersion afterward to trigger the re-render.
function setMatchStage(newsId, accountId, stage) {
  const m = PROMOTED_MATCHES.find((m) => m.newsId === newsId && m.accountId === accountId);
  if (m) m.stage = stage;
}

// "If there's only one matched signal, the overall company should move at
// the same pace" (2026-10-11 direct follow-up feedback) — shared by every
// per-track write (outreachResponses, meetingNotesReports,
// meetingNotesSkipped) that would otherwise only land on whichever
// track's UI the user happened to use. With exactly one non-archived
// matched signal there's no real ambiguity about which track a baseline
// gate (NextMilestone's own notesReady check, keyed by
// `account.id`) should also see — this returns that paired key, or null
// when there isn't a meaningful pairing (0 or 2+ matches, or already the
// paired key itself).
function pairedTrackKey(trackKey) {
  const accountId = trackKey.split(':')[0];
  const isSignalTrack = trackKey.includes(':');
  const soleMatches = allMatches().filter((m) => m.accountId === accountId && !m.archived);
  if (soleMatches.length !== 1) return null;
  const paired = isSignalTrack ? accountId : `${accountId}:${soleMatches[0].newsId}`;
  return paired !== trackKey ? paired : null;
}

// Meeting slots (see addMeetingSlot/meetingSlotCounts in App) — slot 1 is
// just the track's own key (unchanged, so every pre-existing single-
// meeting track's data reads the same as before); slot N>1 is a synthetic
// key with no ":" (so pairedTrackKey's colon-based signal-track check
// can't misparse it) and no collision risk with a real trackKey (which
// never contains "__").
function meetingSlotKey(trackKey, index) {
  return index <= 1 ? trackKey : `${trackKey}__meeting${index}`;
}

// Display-only rollup for the Overview row's at-a-glance Gantt bar: the
// furthest-along stage across the account's own baseline and every one of
// its matched-signal opportunities. Doesn't change account.stage itself —
// only what the Overview row's bar/label render — so research gating,
// STAGE_ACTIONS lookups, etc. (all keyed off the real account.stage) are
// unaffected.
function accountRollupStage(account) {
  let maxIdx = STAGES.indexOf(account.stage);
  accountMatches(account.id).forEach((m) => {
    const idx = STAGES.indexOf(m.stage);
    if (idx > maxIdx) maxIdx = idx;
  });
  return STAGES[maxIdx] ?? account.stage;
}

function newsById(id) {
  return allNewsItems().find((n) => n.id === id) || null;
}

// Promotes one Live feed item into a real signal for one account — the
// mechanism "Add to Signal Map" calls, live, from the UI, and (as of
// 2026-10-07) the one "Auto-match on scrape" also calls for every tracked
// account a freshly-loaded item mentions — see autoMatchNewLiveItems in
// App. `auto` distinguishes the two: an auto-added match starts
// `reviewed: false` so it can be flagged "Auto-matched" wherever matched
// signals are shown (OpportunitySummary, MatchedSignalsPanel) until the
// user actually looks at it (see reviewMatch below); a manually-added one
// needed no review step to begin with; it's the user's own explicit action,
// so it's just marked already-reviewed. Returns the newsId used, mainly so
// callers can log a sensible activity-feed entry.
function promoteLiveItemToMatch(item, accountId, { confidence, rationale, nextStep, auto = false, manual = false, stage = 'Identified' }) {
  const newsId = `live-${item.id}`;
  if (!PROMOTED_NEWS.some((n) => n.id === newsId)) {
    PROMOTED_NEWS.push({
      id: newsId,
      headline: item.headline,
      source: item.source,
      date: item.date,
      tag: item.tag || `${item.topics[0] || 'Energy'} · ${item.kind}`,
      summary: item.summary,
      sources: item.url ? [{ title: item.source || 'Source', url: item.url }] : [],
    });
  }
  // Guard against a duplicate (newsId, accountId) pair — without this, a
  // double-click, a double-submitted promote, or the module-level seed
  // examples re-running on every reload (see PERSISTENCE below) would each
  // add a second identical "Matched signal" row under the same account.
  if (!PROMOTED_MATCHES.some((m) => m.newsId === newsId && m.accountId === accountId)) {
    // Every promoted signal starts its own opportunity track, independent
    // of every other matched signal — see setMatchStage/accountRollupStage
    // above. `stage` defaults to 'Identified' but callers pass
    // initialMatchStage(account) instead whenever the account has already
    // been researched, so a brand-new signal never re-asks for a
    // company-wide action that's already done — see initialMatchStage's own
    // comment and cascadeResearchCompletion in App (the other half of this:
    // retroactively catching up signals matched *before* research was
    // confirmed).
    PROMOTED_MATCHES.push({ newsId, accountId, confidence, rationale, nextStep, stage, auto, manual, reviewed: !auto });
  }
  return newsId;
}

// What stage a brand-new matched signal should start at. Research is a
// company-wide fact ("have we researched this account"), not something
// specific to one news story, so a new signal should never make the user
// re-confirm it per signal if it's already true for the account — it starts
// at 'Researched' instead of 'Identified' when the account itself is
// already there or further along. Every later stage (Contacted, Meeting,
// Proposal...) stays genuinely per-signal (whether you've met/proposed
// about *this* opportunity specifically), so this only ever fast-forwards
// the one step that's really about the company, never further.
function initialMatchStage(account) {
  return STAGES.indexOf(account.stage) >= STAGES.indexOf('Researched') ? 'Researched' : 'Identified';
}

// Flips an auto-matched signal's `reviewed` flag once the user actually
// looks at it (MatchedSignalsPanel expanding its own milestone confirm) —
// see promoteLiveItemToMatch's auto/reviewed comment above. A no-op for a
// manually-added match (already reviewed: true from the start).
function reviewMatch(newsId, accountId) {
  const m = PROMOTED_MATCHES.find((m) => m.newsId === newsId && m.accountId === accountId);
  if (m) m.reviewed = true;
}

// Removes one account's connection to a promoted news signal — the inverse
// of promoteLiveItemToMatch, for when a signal turns out not to be
// relevant after all (either to this account specifically, or entirely).
// If that was the signal's last remaining match, the news record itself is
// dropped too, so an unlinked card doesn't linger on the Signal Map
// connected to nothing; the underlying Live feed item stays loaded either
// way and can be re-promoted at any time, same as any other loaded item.
function unlinkMatch(newsId, accountId) {
  // Removes every matching entry, not just the first — belt-and-suspenders
  // against any duplicate that predates the dedup guard in
  // promoteLiveItemToMatch, so "unlink" always fully clears the connection
  // rather than leaving a hidden duplicate behind.
  for (let i = PROMOTED_MATCHES.length - 1; i >= 0; i--) {
    const m = PROMOTED_MATCHES[i];
    if (m.newsId === newsId && m.accountId === accountId) PROMOTED_MATCHES.splice(i, 1);
  }
  if (!PROMOTED_MATCHES.some((m) => m.newsId === newsId)) {
    const newsIdx = PROMOTED_NEWS.findIndex((n) => n.id === newsId);
    if (newsIdx !== -1) PROMOTED_NEWS.splice(newsIdx, 1);
  }
}

// Which of our accounts this specific Live feed item has already been
// promoted to, read live off PROMOTED_MATCHES (see the comment above it).
function promotedAccountsForLiveItem(itemId, accounts) {
  const newsId = `live-${itemId}`;
  const ids = PROMOTED_MATCHES.filter((m) => m.newsId === newsId).map((m) => m.accountId);
  return accounts.filter((a) => ids.includes(a.id));
}

// ---------------------------------------------------------------------------
// PERSISTENCE (added 2026-10-05) — localStorage, not a backend/database. The
// prototype's actual work product (accounts, plans, research reports, email
// drafts, activity logs, Live feed items, Signal Map promotions) survives a
// refresh now; see docs/open-items.md for why localStorage and not a real
// store. Deliberately NOT persisted: transient in-flight state that can't
// meaningfully resume after a reload anyway (researchRuns, generatingId,
// error banners, the "New" live-item badge set) — these just reset to their
// empty defaults on load, same as before this change. The Anthropic API key
// (see setAnthropicApiKey below) is also deliberately excluded: it's kept in
// memory only, per the credential-handling story in docs/open-items.md, and
// must never be written to localStorage.
// ---------------------------------------------------------------------------
// Demo mode (see DEMO_MODE above) gets its own storage bucket, not this
// one — a separate localStorage key entirely, so a demo session's data
// never mixes with or overwrites a real session's, and toggling demo mode
// off always finds the real session exactly as it was left.
const STORAGE_KEY = DEMO_MODE ? 'gridline_demo_state_v1' : 'gridline_prototype_state_v1';

function loadSavedState() {
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch (e) {
    return null;
  }
}

function saveState(state) {
  try {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  } catch (e) {
    // Private browsing / quota exceeded / localStorage disabled — persistence
    // is a convenience on top of the in-memory app, not a requirement.
  }
}

// PROMOTED_NEWS/PROMOTED_MATCHES are plain module-level arrays (see above),
// not React state, so restoring saved promotions means mutating them in
// place once, before the first render — guarded so React's dev-mode double-
// invoke of lazy useState initializers can't push the same saved rows twice.
let promotedHydrated = false;
function hydratePromotedFromStorage(saved) {
  if (promotedHydrated) return;
  promotedHydrated = true;
  if (saved && Array.isArray(saved.promotedNews) && Array.isArray(saved.promotedMatches)) {
    // Merge, don't blindly concatenate: PROMOTED_NEWS/PROMOTED_MATCHES can
    // already hold the 5 seed example matches by the time this runs (see
    // the seed block further down, now gated to skip re-seeding once
    // anything has ever been saved — but this merge still guards against
    // any other overlap, e.g. restoring into a page that somehow already
    // promoted the same pair this load).
    saved.promotedNews.forEach((n) => {
      if (!PROMOTED_NEWS.some((existing) => existing.id === n.id)) PROMOTED_NEWS.push(n);
    });
    saved.promotedMatches.forEach((m) => {
      if (!PROMOTED_MATCHES.some((existing) => existing.newsId === m.newsId && existing.accountId === m.accountId)) {
        PROMOTED_MATCHES.push(m);
      }
    });
  }
  // Self-heal: collapse any duplicate (newsId, accountId) pair already
  // sitting in a user's saved state from before this fix existed, so it
  // doesn't keep showing as a doubled "Matched signal" forever. The next
  // save persists the cleaned result.
  const seenMatchKeys = new Set();
  for (let i = PROMOTED_MATCHES.length - 1; i >= 0; i--) {
    const m = PROMOTED_MATCHES[i];
    const key = `${m.newsId}::${m.accountId}`;
    if (seenMatchKeys.has(key)) {
      PROMOTED_MATCHES.splice(i, 1);
    } else {
      seenMatchKeys.add(key);
    }
  }
}

// Same guarded-once pattern as hydratePromotedFromStorage above, for the
// CAPABILITIES/PARTNERSHIPS mutable bindings (see CapabilitiesControl).
let capabilitiesHydrated = false;
function hydrateCapabilitiesFromStorage(saved) {
  if (capabilitiesHydrated) return;
  capabilitiesHydrated = true;
  if (saved && Array.isArray(saved.capabilities) && saved.capabilities.length) {
    CAPABILITIES = saved.capabilities;
  }
  if (saved && Array.isArray(saved.partnerships) && saved.partnerships.length) {
    PARTNERSHIPS = saved.partnerships;
  }
  if (saved && typeof saved.bidWritingCapable === 'boolean') {
    BID_WRITING_CAPABLE = saved.bidWritingCapable;
  }
  if (saved && typeof saved.companyName === 'string') {
    COMPANY_NAME = saved.companyName;
  }
  if (saved && typeof saved.companyWebsite === 'string') {
    COMPANY_WEBSITE = saved.companyWebsite;
  }
  if (saved && Array.isArray(saved.geographies) && saved.geographies.length) {
    GEOGRAPHIES = saved.geographies;
  }
}

// Default Overview sort: tier, then pipeline stage.
function compareAccounts(x, y) {
  const tierDiff = TIER_ORDER.indexOf(x.tier) - TIER_ORDER.indexOf(y.tier);
  if (tierDiff !== 0) return tierDiff;
  return STAGES.indexOf(x.stage) - STAGES.indexOf(y.stage);
}

// Severity key for the Overview's "Sort by gap severity" control — worse
// first: a Blocker outranks a Coverable-only account, which outranks a
// clean (checked, no gaps) account, which outranks one never checked at
// all (nothing to sort on, so it sinks to the bottom rather than sitting
// ambiguously at the top). Reads the same `fit: {fits,coverable,blockers}`
// rollup FitGapBadge already renders, so this can never disagree with what
// the row itself shows. The trailing counts are a tiebreak (two Blocker
// accounts; the one with more blockers sorts first).
function gapSeverityKey(fitGap) {
  if (!fitGap) return [-1, 0, 0];
  const { blockers, coverable } = fitGap.fit;
  const tier = blockers > 0 ? 2 : coverable > 0 ? 1 : 0;
  return [tier, blockers, coverable];
}

function compareGapSeverity(fitGaps) {
  return (x, y) => {
    const kx = gapSeverityKey(fitGaps[x.id]);
    const ky = gapSeverityKey(fitGaps[y.id]);
    for (let i = 0; i < kx.length; i++) {
      if (kx[i] !== ky[i]) return ky[i] - kx[i]; // descending — worse severity first
    }
    return 0;
  };
}

// Buckets accounts for the Overview tab's "Group by" control. 'none' is a
// single unlabeled bucket (today's flat list); every other mode returns
// one bucket per group, in a sensible order, each sorted by whatever the
// grouping dimension didn't already fix (so tier-grouped accounts are still
// ordered by stage within the group, and vice versa). `sortBySeverity`
// (the Overview's own toggle, independent of grouping) overrides the
// within-group ordering with compareGapSeverity above, falling back to the
// normal comparator as a tiebreak between accounts of equal severity.
function groupAccounts(accounts, groupBy, accountGroups, customGroupNames, fitGaps = {}, sortBySeverity = false) {
  const withSeverityFallback = (fallback) => {
    if (!sortBySeverity) return fallback;
    const bySeverity = compareGapSeverity(fitGaps);
    return (x, y) => bySeverity(x, y) || fallback(x, y);
  };

  if (groupBy === 'none') {
    return [{ key: null, label: null, accounts: [...accounts].sort(withSeverityFallback(compareAccounts)) }];
  }
  const keyFor = {
    tier: (a) => a.tier,
    stage: (a) => a.stage,
    sector: (a) => a.sector,
    custom: (a) => accountGroups[a.id] || 'Ungrouped',
  }[groupBy];
  const buckets = new Map();
  accounts.forEach((a) => {
    const k = keyFor(a);
    if (!buckets.has(k)) buckets.set(k, []);
    buckets.get(k).push(a);
  });

  let orderedKeys;
  if (groupBy === 'tier') orderedKeys = TIER_ORDER.filter((t) => buckets.has(t));
  else if (groupBy === 'stage') orderedKeys = STAGES.filter((s) => buckets.has(s));
  else if (groupBy === 'sector') orderedKeys = [...buckets.keys()].sort((a, b) => a.localeCompare(b));
  else orderedKeys = [...customGroupNames, 'Ungrouped']; // show every created group, even an empty one

  // Within a group, order alphabetically by name only — not by tier.
  // Grouping by anything other than tier (BD status, company type, a custom
  // group) used to still secondary-sort by TIER_ORDER (via compareAccounts
  // or an explicit tier comparator), so a "BD status" or "company type"
  // grouping still visually clustered Strategic/Growth/Prospect within each
  // bucket — the chosen grouping wasn't really the only thing driving
  // order. Flagged as unwanted; now the chosen grouping is genuinely the
  // only sort applied, tier or not.
  const compareWithin = withSeverityFallback((x, y) => x.name.localeCompare(y.name));

  return orderedKeys.map((k) => ({
    key: k,
    label: k,
    accounts: (buckets.get(k) || []).sort(compareWithin),
  }));
}

// A citation tag under a talking point or gap — traces the claim back to the
// specific signal it's grounded in, so it isn't just an unsourced assertion.
function Citation({ sourceId }) {
  const news = sourceId ? newsById(sourceId) : null;
  if (!news) return null;
  return (
    <div className="text-[11px] text-[var(--ss-muted)] mt-0.5 flex items-start gap-1">
      <Link2 size={10} className="shrink-0 mt-0.5" />
      <span>{news.headline} · {news.date}</span>
    </div>
  );
}

// Model output isn't always perfectly shaped — normalize talking points and
// gaps into { text, source } whether the model returned objects or (as a
// fallback) plain strings.
function normalizePoint(p) {
  if (typeof p === 'string') return { text: p, source: null };
  if (p && typeof p === 'object') return { text: p.text || '', source: p.source || null };
  return { text: String(p), source: null };
}

function normalizePlan(raw) {
  return {
    ...raw,
    talking_points: (raw.talking_points || []).map(normalizePoint),
    capability_gaps: (raw.capability_gaps || []).map(normalizePoint),
  };
}

// Structured fit/gap check (added per demo-week-plan.md Day 2a; redesigned
// 2026-10-10 per direct feedback — see docs/features.md for the full
// write-up of why). A gap is a signal-backed need our capabilities/
// partnerships/past work don't cover — but the redesign's whole point is
// that "gap" no longer means "blocker for the whole account." Three things
// changed:
// 1. A gap now names which PART of the opportunity it blocks (`scope`),
//    not the account as a whole — see opportunityParts/deriveOpportunityParts
//    below, which split the account's matched work into pieces so a hard
//    blocker on one piece doesn't read as a blanket "can't win this."
// 2. Every check now also recommends an entryPoint — the smallest credible
//    piece we can own now — instead of leaving "can we win this at all?"
//    as the only question the panel answers.
// 3. A gap's old single "mitigation" is now a MENU of 2-4 `moves` (team up,
//    subcontract, narrow the scope, change timing, lead with a strength, or
//    decline), so the user picks a real route forward instead of reading a
//    single canned suggestion. See generateFitGaps below for the prompt and
//    FitGapPanel for the render; selectedMoves (in App) is what carries a
//    chosen route through the proposal/email/go-no-go downstream of here
//    (point 4 of the redesign).
const GAP_SEVERITIES = ['Blocker', 'Coverable', 'Minor'];
// Model-authored moves only validate against these (see validateMove) — a
// 7th type, 'Custom', exists purely client-side (MOVE_TYPE_ICON below has
// its icon) for a user-typed override added via addOverrideMove in App
// (2026-10-10, direct feedback: "the user should select from the suggested
// blocker solutions (or an override)"); it's deliberately never something
// the model itself can claim.
const MOVE_TYPES = ['Team', 'Subcontract', 'Narrow scope', 'Timing', 'Lead with strength', 'Decline'];
const WHOLE_ENGAGEMENT = 'Whole engagement';

// PARTNERSHIPS entries read "<Role> partner: <Name> (<detail>)" — pull just
// the name out so a move's free-text "detail"/"partner" field can be
// checked against it (see validateMove below).
function partnerNamesFromPartnerships() {
  return PARTNERSHIPS.map((p) => {
    const m = /:\s*([^(]+)/.exec(p);
    return (m ? m[1] : p).trim();
  }).filter(Boolean);
}

// A short, stable-ish category slug for a gap ("hydrogen-delivery"), used
// to (a) key selectedMoves/confirmedMitigations so a route survives a
// re-check that doesn't actually touch this gap, and (b) group recurring
// gaps across accounts (see CrossAccountGapsPanel). Falls back to deriving
// one from the gap text itself if the model didn't give a usable tag.
function slugifyGapTag(s) {
  return String(s || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
    .slice(0, 40);
}

// A "Team"/"Subcontract" move that names a specific firm must name an
// actual listed partnership (via its `partner` field matching PARTNERSHIPS),
// or it's exactly the kind of invented coverage the gap-flagging rule
// exists to prevent (CLAUDE.md §5: "never softened"). Per the redesign's
// point 3 ("if none fits, name the type of firm to find, without inventing
// a company"), a move with no matching partner is still valid — it just
// can't claim an unverifiable specific name, so an unmatched `partner` is
// stripped rather than the whole move being dropped.
function validateMove(m) {
  if (!m || typeof m !== 'object') return null;
  const type = MOVE_TYPES.includes(m.type) ? m.type : 'Decline';
  const detail = String(m.detail || '').trim();
  if (!detail) return null;
  let partner = m.partner ? String(m.partner).trim() : null;
  if (partner && (type === 'Team' || type === 'Subcontract')) {
    const names = partnerNamesFromPartnerships();
    const matched = names.find((n) => detail.toLowerCase().includes(n.toLowerCase()) || partner.toLowerCase().includes(n.toLowerCase()));
    partner = matched || null;
    if (!matched) {
      return { type, detail: `${detail} (no matching partnership on file for "${m.partner}" — treat as a type of firm to find, not a named partner)`, partner: null };
    }
  } else {
    partner = null;
  }
  return { type, detail, partner };
}

function normalizeGap(raw, validSignalIds, partNames) {
  if (!raw || typeof raw !== 'object') return null;
  const gap = String(raw.gap || '').trim();
  if (!gap) return null;
  const severity = GAP_SEVERITIES.includes(raw.severity) ? raw.severity : 'Blocker';
  const evidence = (Array.isArray(raw.evidence) ? raw.evidence : [raw.evidence])
    .filter((id) => validSignalIds.includes(id));
  const tag = slugifyGapTag(raw.tag) || slugifyGapTag(gap) || `gap-${Math.random().toString(36).slice(2, 8)}`;
  const scope = partNames.includes(raw.scope) ? raw.scope : WHOLE_ENGAGEMENT;
  const moves = (Array.isArray(raw.moves) ? raw.moves : [])
    .map(validateMove)
    .filter(Boolean)
    .slice(0, 4);
  // Every gap needs at least one route, even if the model's own moves list
  // came back empty — "Decline, and say why" is the explicit last resort
  // the redesign calls for, not a silent dead end.
  if (!moves.length) moves.push({ type: 'Decline', detail: 'No credible route identified for this gap — treat as out of scope for now.', partner: null });
  const confidence = ['High', 'Medium', 'Low'].includes(raw.confidence) ? raw.confidence : 'Medium';
  return { gap, tag, severity, scope, evidence, moves, confidence };
}

// Derives each opportunity part's status from the gaps that reference it —
// never trusted from the model directly, same "derive, don't trust"
// principle normalizeFitGaps already applies to the fit/coverable/blocker
// counts below. A part with no referencing gap (or only gaps elsewhere) is
// a clean Fit by elimination.
function deriveOpportunityParts(gaps, partNames) {
  return partNames.map((name) => {
    const relevant = gaps.filter((g) => g.scope === name || g.scope === WHOLE_ENGAGEMENT);
    const status = relevant.some((g) => g.severity === 'Blocker')
      ? 'Blocked'
      : relevant.some((g) => g.severity === 'Coverable' || g.severity === 'Minor')
      ? 'Coverable'
      : 'Fit';
    return { name, status };
  });
}

// The "where you can win now" recommendation (redesign point 2): the
// opportunity part with the cleanest derived fit. Prefers the model's own
// pick IF it actually survives the derivation above as a clean Fit —
// otherwise falls through to picking one ourselves, so this can never
// recommend entering on a part this same check just flagged as blocked.
// Returns null only when every part has at least one unresolved blocker —
// an honest "no clean entry point yet" is more useful than inventing one.
function deriveEntryPoint(opportunityParts, modelPart, modelRationale) {
  const modelPick = modelPart ? opportunityParts.find((p) => p.name === modelPart) : null;
  if (modelPick && modelPick.status === 'Fit') {
    return { part: modelPick.name, rationale: String(modelRationale || '').trim() || 'Clean fit — no gaps block this part.' };
  }
  const clean = opportunityParts.find((p) => p.status === 'Fit');
  if (clean) return { part: clean.name, rationale: 'Clean fit — no gaps block this part.' };
  const coverable = opportunityParts.find((p) => p.status === 'Coverable');
  if (coverable) return { part: coverable.name, rationale: 'Coverable with the right teaming move first — see gaps below.' };
  return null;
}

function normalizeFitGaps(raw, signalIds) {
  const partNamesRaw = (Array.isArray(raw && raw.opportunity_parts) ? raw.opportunity_parts : [])
    .map((p) => String(p || '').trim())
    .filter(Boolean);
  const partNames = partNamesRaw.length ? partNamesRaw : [WHOLE_ENGAGEMENT];
  const gaps = (Array.isArray(raw && raw.gaps) ? raw.gaps : [])
    .map((g) => normalizeGap(g, signalIds, partNames))
    .filter(Boolean);
  // Fit count is derived, not model-reported: every evaluated signal that
  // no gap's evidence points to is a fit by elimination. Keeps the count
  // internally consistent instead of trusting a second, separately-stated
  // number that could drift from the gaps list itself.
  const gappedIds = new Set(gaps.flatMap((g) => g.evidence));
  const fits = signalIds.filter((id) => !gappedIds.has(id)).length;
  const blockers = gaps.filter((g) => g.severity === 'Blocker').length;
  const coverable = gaps.filter((g) => g.severity === 'Coverable' || g.severity === 'Minor').length;
  const opportunityParts = deriveOpportunityParts(gaps, partNames);
  const entryPoint = deriveEntryPoint(
    opportunityParts,
    raw && raw.entry_point && String(raw.entry_point.part || '').trim(),
    raw && raw.entry_point && raw.entry_point.rationale
  );
  return { gaps, signalIds, fit: { fits, coverable, blockers }, opportunityParts, entryPoint, checkedAt: new Date().toISOString() };
}

// Day 4 (demo-week-plan.md) — diffs two fitGaps results (same shape
// normalizeFitGaps returns) to tell "what changed" after a fresh re-check.
// Matches gaps by `tag` (a short category slug) rather than exact gap
// text — there's no stable id for a gap across separate generations (each
// run is a fresh model call), and tag is coarser/more likely to repeat
// than a full sentence whose wording can drift slightly between runs.
// Still not perfect (noted in docs/gap-eval.md) — a more robust match
// (e.g. by evidence overlap) would need a real eval run to judge against.
function diffFitGaps(previous, next) {
  const prevGaps = (previous && previous.gaps) || [];
  const prevTags = new Set(prevGaps.map((g) => g.tag));
  const nextTags = new Set(next.gaps.map((g) => g.tag));
  const newGaps = next.gaps.filter((g) => !prevTags.has(g.tag));
  const removedGaps = prevGaps.filter((g) => !nextTags.has(g.tag));
  const severityChanges = next.gaps
    .map((g) => {
      const was = prevGaps.find((pg) => pg.tag === g.tag);
      return was && was.severity !== g.severity ? { gap: g.gap, from: was.severity, to: g.severity } : null;
    })
    .filter(Boolean);
  return { changed: newGaps.length > 0 || removedGaps.length > 0 || severityChanges.length > 0, newGaps, removedGaps, severityChanges };
}

// DEMO RELIABILITY CACHE (added per demo-week-plan.md Day 5b) — real output
// captured from a live run on 2026-10-09 (see docs/gap-eval.md), not
// illustrative text. Used only as a fallback: fetchFitGaps/generateProposal
// below always try the live Claude call first, exactly as in non-demo mode,
// and only fall back to this cache if that call throws AND DEMO_MODE is on.
// This satisfies the plan's "demo must not depend on network or API
// latency" requirement without changing what a presenter sees on a normal
// run (a live call, same as always) — "live mode stays available" per the
// plan is true by construction, since the cache is never consulted unless
// the live path already failed.
// b7 (CIP) has two variants because the Day 4 trigger changes its real
// result: b7_pre is the baseline before the held-back signal is injected
// (2 gaps — same text as b7_post below, since the trigger only *adds* a
// third gap, it doesn't change the first two); b7_post is the full
// post-trigger result once HELD_BACK_DEMO_SIGNAL (live-dg-14-trigger) has
// been promoted. Picking between them (see demoCachedFitGapsFor) by
// whether that signal is currently matched to the account keeps the
// fallback honest about which moment in the demo it's standing in for,
// rather than always jumping straight to the post-trigger answer.
const DEMO_CACHED_FIT_GAPS = {
  b1: { gaps: [{ gap: 'No ongoing O&M/asset management support for newly commissioned offshore wind capacity.', severity: 'Blocker', evidence: ['live-dg-1'], mitigation: [{ type: 'Decline scope', detail: 'State O&M and asset operations support falls outside our service offering.' }], confidence: 'High' }], signalIds: ['live-dg-1', 'live-dg-2'], fit: { fits: 1, coverable: 0, blockers: 1 } },
  b2: { gaps: [{ gap: 'No capability for O&M base/port logistics and operational workforce planning as projects move to delivery', severity: 'Coverable', evidence: ['live-dg-3'], mitigation: [{ type: 'Partner', detail: 'Calder Technical Partners can assess O&M base engineering and technology readiness' }], confidence: 'Medium' }], signalIds: ['live-dg-3', 'live-dg-4'], fit: { fits: 1, coverable: 1, blockers: 0 } },
  b3: { gaps: [{ gap: 'No evidenced capability in carbon-removal technology evaluation or US clean-tech investment due diligence', severity: 'Blocker', evidence: ['live-dg-5'], mitigation: [{ type: 'Decline scope', detail: 'State carbon-removal/clean-tech investment screening falls outside our current offering.' }], confidence: 'High' }], signalIds: ['live-dg-5', 'live-dg-6'], fit: { fits: 1, coverable: 0, blockers: 1 } },
  b4: { gaps: [{ gap: 'No deal/transaction advisory or equity structuring support for a majority capital investment event', severity: 'Minor', evidence: ['live-dg-7'], mitigation: [{ type: 'Decline scope', detail: 'State plainly transaction structuring and investor relations advisory fall outside our offering.' }], confidence: 'Medium' }], signalIds: ['live-dg-7'], fit: { fits: 0, coverable: 1, blockers: 0 } },
  b5: {
    gaps: [
      { gap: "Germany market entry requires German-specific regulatory, grid and permitting expertise we don't evidence.", severity: 'Blocker', evidence: ['live-dg-8'], mitigation: [{ type: 'Decline scope', detail: 'State plainly German regulatory/consenting expertise falls outside what we offer.' }], confidence: 'Medium' },
      { gap: 'German BESS consenting and contract review needs local legal expertise beyond our UK-focused legal partner.', severity: 'Coverable', evidence: ['live-dg-8'], mitigation: [{ type: 'Partner', detail: 'Harrowgate Energy Law for consenting, offtake & contract review support.' }], confidence: 'Low' },
      { gap: "Scaling bus fleet delivery needs fleet operations/logistics capacity planning we don't list.", severity: 'Minor', evidence: ['live-dg-9'], mitigation: [{ type: 'Decline scope', detail: 'Note fleet operations scale-up planning falls outside our BD/intelligence remit.' }], confidence: 'Low' },
    ],
    signalIds: ['live-dg-8', 'live-dg-9'],
    fit: { fits: 0, coverable: 2, blockers: 1 },
  },
  b6: {
    gaps: [
      { gap: "Need holdco debt structuring/financing advisory expertise, which we don't offer", severity: 'Blocker', evidence: ['live-dg-11'], mitigation: [{ type: 'Decline scope', detail: 'State plainly that debt financing advisory falls outside what we offer.' }], confidence: 'High' },
      { gap: 'Need IPP transition capability: operations & asset management strategy, not just development', severity: 'Blocker', evidence: ['live-dg-11'], mitigation: [{ type: 'Decline scope', detail: 'Operational asset management strategy falls outside our listed capabilities.' }], confidence: 'Medium' },
    ],
    signalIds: ['live-dg-10', 'live-dg-11'],
    fit: { fits: 1, coverable: 0, blockers: 2 },
  },
  b7_pre: {
    gaps: [
      { gap: 'No fund restructuring, LP capital-return, or portfolio conversion advisory capability', severity: 'Blocker', evidence: ['live-dg-12'], mitigation: [{ type: 'Decline scope', detail: 'Fund restructuring and LP capital strategy fall outside what we offer.' }], confidence: 'High' },
      { gap: 'No evidenced local market entry or regulatory expertise across the 15 emerging markets targeted', severity: 'Minor', evidence: ['live-dg-13'], mitigation: [{ type: 'Hire/upskill', detail: 'Build emerging-market regulatory expertise as engagement scope requires.' }], confidence: 'Low' },
    ],
    signalIds: ['live-dg-12', 'live-dg-13'],
    fit: { fits: 0, coverable: 1, blockers: 1 },
  },
  b7_post: {
    gaps: [
      { gap: 'No fund restructuring, LP capital-return, or portfolio conversion advisory capability', severity: 'Blocker', evidence: ['live-dg-12'], mitigation: [{ type: 'Decline scope', detail: 'Fund restructuring and LP capital strategy fall outside what we offer.' }], confidence: 'High' },
      { gap: 'No data center site planning, market or technical conversion expertise for repurposing hydrogen asset', severity: 'Blocker', evidence: ['live-dg-14-trigger'], mitigation: [{ type: 'Decline scope', detail: 'Data center conversion feasibility falls outside what we offer.' }], confidence: 'Medium' },
      { gap: 'No evidenced local market entry or regulatory expertise across the 15 emerging markets targeted', severity: 'Minor', evidence: ['live-dg-13'], mitigation: [{ type: 'Hire/upskill', detail: 'Build emerging-market regulatory expertise as engagement scope requires.' }], confidence: 'Low' },
    ],
    signalIds: ['live-dg-12', 'live-dg-14-trigger', 'live-dg-13'],
    fit: { fits: 0, coverable: 1, blockers: 2 },
  },
};

// DEMO_CACHED_FIT_GAPS above predates the 2026-10-10 gap-flagging redesign
// (scope/moves/opportunity parts/entry point) — it's real output from a
// live 2026-10-09 run, captured under the OLD schema (a flat `mitigation`
// list, no tag/scope/moves/opportunity_parts). Rather than discard real
// captured output or let the new UI crash on missing fields, this maps
// each legacy gap into one best-effort "move" under the new shape: same
// type/detail, scope always "Whole engagement" (the old schema never split
// the opportunity into parts, so there's nothing truer to assign). This is
// a reduced-richness floor for the fallback case ONLY — a live re-run
// (ordinary path, tried first always) gets the full redesign: several
// moves per gap, a real opportunity-part breakdown, an entry point.
const LEGACY_MITIGATION_TO_MOVE_TYPE = { Partner: 'Team', 'Sub-consultant': 'Subcontract', 'Hire/upskill': 'Lead with strength', 'Decline scope': 'Decline' };
function migrateLegacyGap(g, i) {
  const moves = (g.mitigation || []).map((m) => ({ type: LEGACY_MITIGATION_TO_MOVE_TYPE[m.type] || 'Decline', detail: m.detail, partner: null }));
  if (!moves.length) moves.push({ type: 'Decline', detail: 'Out of scope for this engagement.', partner: null });
  return {
    gap: g.gap,
    tag: slugifyGapTag(g.gap) || `legacy-gap-${i}`,
    severity: g.severity,
    scope: WHOLE_ENGAGEMENT,
    evidence: g.evidence,
    moves,
    confidence: g.confidence,
  };
}

// Picks the right cached fit/gap entry for a fallback, given which signals
// are actually matched to the account right now (see DEMO_CACHED_FIT_GAPS
// comment above for why b7/CIP needs two variants). Returns a fresh object
// (never the cached one by reference) stamped with the real signal ids
// currently matched and a fresh checkedAt, so a fallback result looks and
// behaves exactly like a real one — same shape normalizeFitGaps produces,
// migrated through migrateLegacyGap/deriveOpportunityParts/deriveEntryPoint
// above so the new UI (moves menu, opportunity breakdown, entry point) has
// something valid to render even from this older-schema cache.
function demoCachedFitGapsFor(accountId, matchedSignalIds) {
  if (!DEMO_MODE) return null;
  let entry = DEMO_CACHED_FIT_GAPS[accountId];
  if (accountId === 'b7') {
    entry = matchedSignalIds.includes('live-dg-14-trigger') ? DEMO_CACHED_FIT_GAPS.b7_post : DEMO_CACHED_FIT_GAPS.b7_pre;
  }
  if (!entry) return null;
  const gaps = entry.gaps.map(migrateLegacyGap);
  const opportunityParts = deriveOpportunityParts(gaps, [WHOLE_ENGAGEMENT]);
  const entryPoint = deriveEntryPoint(opportunityParts, null, null);
  return { gaps, signalIds: matchedSignalIds, fit: entry.fit, opportunityParts, entryPoint, checkedAt: new Date().toISOString() };
}

// Same fallback principle as DEMO_CACHED_FIT_GAPS above, for the two
// accounts (Ørsted, CIP) a real proposal was generated and verified for.
const DEMO_CACHED_PROPOSALS = {
  b1: {
    understanding: 'Ørsted is executing a large multi-region offshore wind construction programme while simplifying its portfolio to a pure-play offshore focus and protecting its credit rating. This requires disciplined delivery across construction, grid access and divestiture execution.',
    approach: [
      'Grid connection queue strategy to protect construction schedule across markets',
      'Market and competitor intelligence to support portfolio focus decisions',
      'M&A screening support to inform divestiture and capital allocation choices',
      'Stakeholder mapping to manage execution risk across multiple geographies',
    ],
    experienceIds: ['dw1', 'dw3'],
    nextSteps: [
      { step: 'Scope grid queue review for active construction pipeline markets', timeframe: '2 weeks', owner: 'Account lead' },
      { step: 'Share credentials on M&A screening for portfolio simplification support', timeframe: '1 week', owner: 'Business development manager' },
      { step: 'Workshop to align on priority offshore markets for 2025', timeframe: '3 weeks', owner: 'Account lead' },
    ],
  },
  b7: {
    understanding: 'CIP is navigating a major restructuring of its Energy Transition Fund I as hydrogen market development slows, while simultaneously scaling its credit strategy and emerging markets platform. They need targeted support on specific project and portfolio decisions within this transition rather than the restructuring itself.',
    approach: [
      'Independent technical benchmarking to inform asset conversion or repositioning decisions',
      'Hydrogen and e-fuels project advisory via specialist partner for affected assets',
      'Market and competitor intelligence to support Growth Markets Fund II deployment',
      'M&A target screening support as portfolio composition evolves',
    ],
    experienceIds: ['dw3', 'dw4'],
    nextSteps: [
      { step: 'Introductory call to clarify scope boundaries around the fund restructuring', timeframe: '1-2 weeks', owner: 'Business Development Lead' },
      { step: 'Share Fenwick H2 Advisory capability overview for hydrogen asset-level questions', timeframe: '2 weeks', owner: 'Business Development Lead' },
      { step: 'Scope technical due diligence support for MadoquaPower2X alternative-use evaluation', timeframe: '3-4 weeks', owner: 'Account Manager' },
    ],
  },
};

function demoCachedProposalFor(accountId) {
  if (!DEMO_MODE) return null;
  const entry = DEMO_CACHED_PROPOSALS[accountId];
  return entry ? { ...entry, generatedAt: new Date().toISOString() } : null;
}

// Real research plan + web-researched report captured from a live run on
// Ørsted (2026-10-09) — the demo script's one account that walks through
// the full research workflow (step 3). Unlike the fit-gaps/proposal caches
// above, this is used as a whole-pipeline substitute (see runResearch)
// rather than a per-call fallback: the live path is a multi-call pipeline
// (plan, then one web-search call per question, then a summary call) where
// an individual call failing already degrades gracefully question-by-
// question rather than throwing, so there's no single try/catch to hook a
// fallback onto. Real, sourced findings (13 cited web sources) — not
// illustrative text.
const DEMO_CACHED_RESEARCH = {
  b1: {
    plan: {"objective":"Establish how Borkum Riffgrund 3's commissioning creates near-term O&M/optimisation needs, and how the CIP onshore divestment reshapes Ørsted's org and priorities, so outreach targets the right team with a relevant offer.","known":[{"text":"Ørsted commissioned the 913 MW Borkum Riffgrund 3 offshore wind farm in Germany, raising German offshore capacity to ~2.50 GW.","source":"live-dg-1"},{"text":"Ørsted agreed to divest its entire European onshore wind/solar business to CIP for ~EUR 1.44bn.","source":"live-dg-2"},{"text":"Ørsted's stated priorities are delivering its 8.1 GW offshore construction programme, sharpening offshore focus, and protecting investment-grade credit rating.","source":null},{"text":"No existing contacts or prior engagement history with Ørsted on file.","source":null}],"questions":[{"id":"demo-b1-q1","question":"What is the operational/organisational structure for Ørsted's German offshore O&M, and who leads it post-commissioning?","why":"Identifies the right stakeholder and entry point for any O&M-adjacent offering tied to the new asset.","priority":"Must","sources":["Company website, press releases and project pages","LinkedIn and org charts"],"minutes":25,"custom":false},{"id":"demo-b1-q2","question":"Does the CIP onshore divestment involve staff transfers, retained liabilities, or separation services Ørsted still needs support on?","why":"Determines whether there's a live advisory gap (e.g. transition, stakeholder messaging) Ørsted needs filled now.","priority":"Must","sources":["Investor materials, annual reports or SEC filings","Trade press and news archives"],"minutes":30,"custom":false},{"id":"demo-b1-q3","question":"Has Ørsted signalled any new offshore wind strategic priorities or investor messaging following the onshore sale announcement?","why":"Shapes how we frame our offer against their newly narrowed, offshore-only strategic narrative.","priority":"Must","sources":["Investor materials, annual reports or SEC filings","Trade press and news archives"],"minutes":25,"custom":false},{"id":"demo-b1-q4","question":"Are there other German or European offshore assets nearing commissioning where similar O&M/optimisation needs could compound?","why":"Clarifies if Borkum Riffgrund 3 is an isolated opportunity or part of a broader near-term wave.","priority":"Nice","sources":["Company website, press releases and project pages","ISO/RTO interconnection queue data"],"minutes":20,"custom":false},{"id":"demo-b1-q5","question":"Is the CIP transaction final/regulatory-cleared, or still pending approval that could stall internal re-prioritization?","why":"Affects timing of outreach — premature if deal isn't closed and teams haven't yet reorganised.","priority":"Nice","sources":["Trade press and news archives","Investor materials, annual reports or SEC filings"],"minutes":15,"custom":false},{"id":"demo-b1-q6","question":"Does Ørsted face grid connection or consenting bottlenecks in Germany relevant to further offshore expansion post-Borkum Riffgrund 3?","why":"Identifies whether our grid/consenting capability is a timely hook beyond the O&M angle.","priority":"Nice","sources":["ISO/RTO interconnection queue data","State utility commission (PUC) dockets"],"minutes":20,"custom":false}],"people_to_identify":["Head of German offshore operations/asset management at Ørsted","Lead on the CIP divestment transition/integration team","Senior commercial or strategy contact for Ørsted's offshore-only pivot"],"watch_outs":[{"text":"Ørsted is a Strategic-tier account with no prior relationship, so outreach needs a credible, senior-level entry point.","source":null},{"text":"Onshore divestment is a sell-side M&A process; avoid pitching M&A screening support that could seem misaligned or too late.","source":"live-dg-2"}]},
    report: {"method":"web","findings":{"demo-b1-q2":{"id":"demo-b1-q2","question":"Does the CIP onshore divestment involve staff transfers, retained liabilities, or separation services Ørsted still needs support on?","why":"Determines whether there's a live advisory gap (e.g. transition, stakeholder messaging) Ørsted needs filled now.","priority":"Must","minutes":30,"custom":false,"status":"done","answer":"The Ørsted–CIP deal is a full business transfer, not an asset carve-out requiring separation support: Ørsted's own statements say the onshore business's \"people, assets, projects\" are unchanged and move to CIP as a stand-alone entity under a new brand, and the sale has already closed. There's no indication in investor materials or trade press of retained liabilities, TSA (transition services agreement) needs, or an unresolved staff-transfer issue that would represent an open advisory gap for a third party; Ørsted frames this as the final, completed piece of its divestment programme.","confidence":"Medium","key_facts":["Ørsted stated that following the sale, the onshore business's \"people, assets, projects, or ambition\" remain unchanged under CIP ownership, operating as a stand-alone business with a new name/brand","The transaction (signed Feb 3, 2026) has already completed, with Ørsted citing it as finalizing its divestment programme and strengthening its balance sheet (~DKK46bn total proceeds)","Ørsted recorded a ~DKK1.6bn goodwill write-off tied to the divested European onshore business, but no public mention of retained liabilities or TSA/separation-service needs"],"implication":"No clear live advisory gap on transition/separation services is evident from public sources—outreach should instead focus on messaging around the offshore-only strategic pivot or new operating capacity (e.g., Borkum Riffgrund 3) rather than divestment transition support.","sources":[{"title":"Our records","url":null},{"title":"Ørsted signs agreement with CIP to divest its European onshore business, finalising divestment programme as planned","url":"https://www.investegate.co.uk/announcement/eqs/orsted-a-s--0rhe/-rsted-signs-agreement-with-cip-to-divest-its-/9403667"},{"title":"Ørsted signs agreement with Copenhagen Infrastructure Partners to divest its European onshore business, finalising divestment programme as planned","url":"https://orsted.co.uk/media/newsroom/news/2026/02/european-onshore-business-divestment"},{"title":"CIP in €1.4bn swoop for Orsted's onshore business","url":"https://www.renews.biz/uncategorized/cip-in-e1-4bn-swoop-for-orsteds-onshore-business/"},{"title":"Ørsted completes divestment of its European onshore - DredgeWire : DredgeWire","url":"https://dredgewire.com/orsted-completes-divestment-of-its-european-onshore/"}],"method":"web"},"demo-b1-q3":{"id":"demo-b1-q3","question":"Has Ørsted signalled any new offshore wind strategic priorities or investor messaging following the onshore sale announcement?","why":"Shapes how we frame our offer against their newly narrowed, offshore-only strategic narrative.","priority":"Must","minutes":25,"custom":false,"status":"done","answer":"Yes. Following the CIP divestment announcement, Ørsted's management has consistently reframed the company around four strategic priorities (balance sheet strength, delivering the 8.1 GW construction programme, disciplined capital allocation, and competitiveness), explicitly messaging that it is now 'focused' purely on offshore wind in Europe and select APAC markets. CEO Rasmus Errboe has told investors the divestment gives Ørsted 'financial flexibility to pursue attractive offshore wind opportunities in Europe and select markets in Asia Pacific,' and the company has since reinstated a dividend framework for 2026-2028, with H1 2026 messaging emphasizing pursuit of 'new, value-creating opportunities within offshore wind.' Notably, Ørsted retains its US onshore business as a profitable exception to the offshore-only narrative, and the CEO's most recent comments (Oct 2026) describe offshore wind as being on its firmest footing in 25 years.","confidence":"High","key_facts":["CFO Trond Westlie: divestment supports decision to 'concentrate our efforts on offshore wind in our core European markets'","CEO Errboe: divestment gives 'financial flexibility to pursue attractive offshore wind opportunities in Europe and select markets in Asia Pacific'","Q2 2026 report announced a 2026-2028 dividend policy framework and signaled pursuit of 'new, value-creating opportunities within offshore wind'"],"implication":"Outreach should position offers explicitly against Ørsted's offshore-only, capital-disciplined narrative (construction delivery, O&M efficiency, competitiveness/cost-reduction) rather than cross-technology or onshore-adjacent pitches, since onshore is now a closed chapter in their European story.","sources":[{"title":"Riviera - News Content Hub - Ørsted divests European onshore business, concentrates on offshore wind","url":"https://www.rivieramm.com/news-content-hub/news-content-hub/orsted-divests-european-onshore-business-to-concentrate-on-offshore-wind-87655"},{"title":"Ørsted signs agreement with CIP to divest its European onshore business, finalising divestment programme as planned","url":"https://www.investegate.co.uk/announcement/eqs/orsted-a-s--0rhe/-rsted-signs-agreement-with-cip-to-divest-its-/9403667"},{"title":"Ørsted continues its strategic progress, delivers solid operational performance, and is on track to deliver on its 2026 financial guidance","url":"https://orsted.com/en/company-announcement-list/2026/08/orsted-continues-its-strategic-progress-delivers-s-150879921"},{"title":"Ørsted delivers progress and strong operational performance","url":"https://orsted.com/en/company-announcement-list/2026/05/orsted-delivers-progress-and-strong-operational-pe-149144391"},{"title":"A stronger and more competitive Ørsted after a defining year with earnings of DKK 25.1 billion within guidance","url":"https://orsted.com/en/company-announcement-list/2026/02/a-stronger-and-morecompetitiveorstedafter-a-defini-147853101"}],"method":"web"},"demo-b1-q1":{"id":"demo-b1-q1","question":"What is the operational/organisational structure for Ørsted's German offshore O&M, and who leads it post-commissioning?","why":"Identifies the right stakeholder and entry point for any O&M-adjacent offering tied to the new asset.","priority":"Must","minutes":25,"custom":false,"status":"done","answer":"Ørsted operates its entire German offshore wind fleet — now six farms including the newly commissioned 913 MW Borkum Riffgrund 3 — through a centralized O&M model based at two sites in East Frisia: Norden-Norddeich and Emden, rather than a standalone organisation per asset. No single named 'head of German O&M' was identified in public sources; the closest confirmed stakeholder-facing role is Ørsted's Lead Category Manager of O&M Services Procurement, who negotiates O&M-adjacent service contracts (e.g., crane/lifting inspection) for the German cluster, while senior accountability for the broader construction-to-operations handover sits with Group Executive Patrick Harnett (Chief Construction Officer).","confidence":"Medium","key_facts":["Operations and maintenance for Borkum Riffgrund 3 and all of Ørsted's German offshore wind farms is carried out from Ørsted's sites in Norden-Norddeich and Emden in East Frisia, per multiple Ørsted press releases.","Ørsted's Lead Category Manager of O&M Services Procurement, named in a 2025 contract announcement with OEG Energy Group covering Ørsted's six-farm German cluster, is a concrete procurement-side contact for O&M-adjacent services.","Ørsted is Germany's largest offshore wind operator with ~2.5 GW installed (~25% market share) across six farms following Borkum Riffgrund 3's inauguration."],"implication":"Without a single named German O&M site lead confirmed publicly, the most actionable entry point is the O&M Services Procurement function (e.g., the Lead Category Manager role) that already contracts third-party service providers for the German cluster; direct outreach to the Norden-Norddeich/Emden site organisation via LinkedIn may be needed to identify the current site lead.","sources":[{"title":"Ørsted and Nuveen inaugurate Borkum Riffgrund 3, one of Germany's largest offshore wind farms","url":"https://orsted.com/en/media/news/2026/09/orsted-and-nuveen-inaugurate-borkum-riffgrund-3-on-15173591"},{"title":"Borkum Riffgrund 3 enters commercial operation, strengthening Germany's energy security with home-grown renewable power","url":"https://orsted.com/en/media/news/2026/08/borkum-riffgrund-3-enters-commercial-operation-str-1512399511"},{"title":"Borkum Riffgrund 3, Ørsted's largest offshore wind farm in Germany, produces first power","url":"https://orsted.com/en/media/news/2025/12/borkum-riffgrund-3-orsteds-largest-offshore-wind-f-14698236"},{"title":"OEG secures three-year contract for Ørsted's German wind cluster","url":"https://www.oeg.group/company/news/oeg-orsted-german-wind"},{"title":"Ørsted Awards OEG with Inspection Work on Six Offshore Wind Farms in Germany","url":"https://www.offshorewind.biz/2025/03/31/orsted-awards-oeg-with-inspection-work-on-six-offshore-wind-farms-in-germany/"}],"method":"web"}},"order":["demo-b1-q1","demo-b1-q2","demo-b1-q3"],"objective":"Establish how Borkum Riffgrund 3's commissioning creates near-term O&M/optimisation needs, and how the CIP onshore divestment reshapes Ørsted's org and priorities, so outreach targets the right team with a relevant offer.","known":[{"text":"Ørsted commissioned the 913 MW Borkum Riffgrund 3 offshore wind farm in Germany, raising German offshore capacity to ~2.50 GW.","source":"live-dg-1"},{"text":"Ørsted agreed to divest its entire European onshore wind/solar business to CIP for ~EUR 1.44bn.","source":"live-dg-2"},{"text":"Ørsted's stated priorities are delivering its 8.1 GW offshore construction programme, sharpening offshore focus, and protecting investment-grade credit rating.","source":null},{"text":"No existing contacts or prior engagement history with Ørsted on file.","source":null}],"people_to_identify":["Head of German offshore operations/asset management at Ørsted","Lead on the CIP divestment transition/integration team","Senior commercial or strategy contact for Ørsted's offshore-only pivot"],"watch_outs":[{"text":"Ørsted is a Strategic-tier account with no prior relationship, so outreach needs a credible, senior-level entry point.","source":null},{"text":"Onshore divestment is a sell-side M&A process; avoid pitching M&A screening support that could seem misaligned or too late.","source":"live-dg-2"}],"summary":{"headline":"Ørsted's shift to a pure-play, capital-disciplined offshore strategy and Borkum Riffgrund 3's commissioning point outreach toward O&M optimisation and construction-delivery offers, not divestment support.","executive_summary":"Ørsted has completed its CIP onshore divestment as a clean business transfer with no apparent residual separation or transition-services gap, and has pivoted messaging to a focused, capital-disciplined offshore wind strategy. The newly commissioned Borkum Riffgrund 3 adds to a centralized German O&M operation run from two East Frisia sites, though no single named site lead was confirmed. The most concrete entry points are the O&M Services Procurement function and senior construction-to-operations leadership. Outreach should align with Ørsted's offshore-only, competitiveness-driven narrative rather than onshore or divestment themes.","implications":["Lead with offshore O&M efficiency and competitiveness framing, not onshore or divestment-transition offers.","Target O&M Services Procurement and construction-to-operations leadership as primary entry points.","Treat the onshore divestment as closed; avoid messaging implying ongoing separation needs."],"next_steps":["Map Norden-Norddeich/Emden O&M site organisation via LinkedIn to identify current site leadership.","Prepare a capability pitch tied to post-commissioning O&M optimisation for Borkum Riffgrund 3.","Align messaging to Ørsted's four strategic priorities (balance sheet, construction delivery, capital discipline, competitiveness)."],"open_questions":["Who currently leads German offshore O&M operations day-to-day, if not procurement-level contacts?","Are there unannounced new offshore opportunities in Europe/APAC Ørsted is actively scoping?","Does the US onshore retention signal any future cross-technology appetite despite European offshore focus?"]}},
  },
};

function demoCachedResearchFor(accountId) {
  if (!DEMO_MODE) return null;
  return DEMO_CACHED_RESEARCH[accountId] || null;
}

// Same principle, for the one email draft generated and verified live
// (Ørsted) — see EmailComposer's generate() below.
const DEMO_CACHED_EMAIL = {
  b1: {
    subject: "Borkum Riffgrund 3 is live — what's next for German O&M",
    body: "Hi [contact name],\n\nCongratulations on commissioning Borkum Riffgrund 3 — taking German offshore capacity to roughly 2.5 GW is no small feat, and it lands right as Ørsted sharpens its focus into a pure-play offshore story following the CIP divestment.\n\nThat combination — fresh operating capacity in Germany plus a narrower, more capital-disciplined strategic frame — tends to raise real questions about how O&M resourcing and construction-to-operations handover scale across a growing fleet, especially with the 8.1 GW programme still running hard across three continents.\n\nWe work with offshore developers on exactly this kind of transition: stakeholder mapping across construction and operations teams, and account-based strategy to make sure delivery priorities stay aligned as the fleet grows.\n\nWould it be useful to compare notes on how peers are structuring O&M scale-up post-commissioning? Happy to find 20 minutes in the next couple of weeks.\n\n[your name]",
  },
};

// Proposal generator (added per demo-week-plan.md Day 3a). The "Delivery
// model & honest gaps" section deliberately isn't part of this normalizer
// or the model's own output at all — it's rendered straight from the
// account's already-checked fitGaps (Day 2), so the proposal can never
// restate or (worse) contradict a gap that's already been structurally
// flagged. Only understanding/approach/experience-selection/next-steps are
// freshly generated prose/selections here.
function normalizeProposal(raw, pastWorkList) {
  const validIds = new Set((pastWorkList || []).map((w) => w.id));
  // `id` stamped on each step (2026-10-13, audit follow-up) — editable list,
  // same stable-id-at-creation pattern normalizeResearchPlan uses below for
  // its own questions — so ProposalPanel's list can key by `s.id` instead of
  // array index, which otherwise causes a deleted row's DOM node (and
  // in-progress focus) to visually "stick" to the position of the item that
  // shifted into it.
  const stamp = Date.now().toString(36);
  return {
    understanding: String((raw && raw.understanding) || '').trim(),
    approach: (Array.isArray(raw && raw.approach) ? raw.approach : []).map((s) => String(s || '').trim()).filter(Boolean),
    // Never invent an experience entry: only ids that are genuinely in our
    // own PAST_WORK/DEMO_PAST_WORK list survive normalization.
    experienceIds: (Array.isArray(raw && raw.experience_ids) ? raw.experience_ids : []).filter((id) => validIds.has(id)),
    nextSteps: (Array.isArray(raw && raw.next_steps) ? raw.next_steps : [])
      .map((s) => ({ step: String((s && s.step) || '').trim(), timeframe: String((s && s.timeframe) || '').trim(), owner: String((s && s.owner) || '').trim() }))
      .filter((s) => s.step)
      .map((s, i) => ({ id: `${stamp}-${i}`, ...s })),
    generatedAt: new Date().toISOString(),
  };
}

function normalizeResearchPlan(raw) {
  // Unique ids so a regenerated plan never collides with findings already
  // sitting in the report.
  const stamp = Date.now().toString(36);
  return {
    objective: raw.objective || '',
    known: (raw.known || []).map(normalizePoint),
    questions: (raw.questions || []).map((q, i) => ({
      id: `${stamp}-${i + 1}`,
      question: (q && q.question) || String(q),
      why: (q && q.why) || '',
      priority: q && q.priority === 'Nice' ? 'Nice' : 'Must',
      sources: Array.isArray(q && q.sources) ? q.sources : [],
      minutes: Number(q && q.minutes) || 15,
      custom: false,
    })),
    people_to_identify: raw.people_to_identify || [],
    watch_outs: (raw.watch_outs || []).map(normalizePoint),
  };
}

const CONFIDENCE_LEVELS = ['High', 'Medium', 'Low', 'Not found'];

const CONFIDENCE_STYLE = {
  High: 'text-[var(--ss-positive)] border-[var(--ss-positive)]/40',
  Medium: 'text-[var(--ss-text-2)] border-[var(--ss-border)]',
  Low: 'text-[var(--ss-gap)] border-[var(--ss-gap-border)]',
  'Not found': 'text-[var(--ss-faint)] border-[var(--ss-border)]',
};

// report: {
//   createdAt, updatedAt, method: 'web' | 'model' | 'mixed',
//   findings: { [questionId]: finding }, order: [questionId],
//   summary: { headline, executive_summary, implications, next_steps, open_questions } | null,
//   objective, known, people_to_identify, watch_outs,
// }
// finding: { id, question, why, priority, minutes, status: 'done' | 'failed',
//   answer, confidence, key_facts, implication, sources: [{ title, url }], method }
function researchProgress(plan, report) {
  // A manual intake report (the user's own notes/file, no generated
  // checklist) has no `plan` — still worth a progress summary, just with
  // nothing "Must"-answer to track, so mustDone === mustTotal (0 === 0)
  // reads as ready immediately.
  if (!plan && !report) return null;
  const questions = plan ? plan.questions : [];
  const findings = (report && report.findings) || {};
  const researched = (q) => findings[q.id] && findings[q.id].status === 'done';
  const must = questions.filter((q) => q.priority === 'Must');
  const done = Object.values(findings).filter((f) => f.status === 'done');
  return {
    total: questions.length,
    researched: questions.filter(researched).length,
    mustTotal: must.length,
    mustDone: must.filter(researched).length,
    hasReport: !!report,
    withEvidence: done.filter((f) => f.confidence !== 'Not found').length,
    notFound: done.filter((f) => f.confidence === 'Not found').length,
    minutesSaved: done.reduce((sum, f) => sum + (f.minutes || 0), 0),
  };
}

// Findings in the order a reader wants them: must-answer first, then the
// rest, each group in the order they were researched.
function orderedFindings(report) {
  if (!report) return [];
  const list = report.order.map((id) => report.findings[id]).filter(Boolean);
  return [...list.filter((f) => f.priority === 'Must'), ...list.filter((f) => f.priority !== 'Must')];
}

// One numbered source list across the whole report, so findings can cite
// [1], [2] and the PDF can print the full URLs once at the end.
function reportSources(report) {
  const list = [];
  const index = new Map();
  orderedFindings(report).forEach((f) => {
    (f.sources || []).forEach((s) => {
      if (!s.url || index.has(s.url)) return;
      list.push(s);
      index.set(s.url, list.length);
    });
  });
  return { list, index };
}

// What the research established, in a form the outreach and email prompts
// can use. "Not found" is passed on as unknown so nothing assumes it.
function researchFindingsText(report) {
  if (!report) return 'No research recorded.';
  const lines = orderedFindings(report)
    .filter((f) => f.status === 'done')
    .map((f) =>
      f.confidence === 'Not found'
        ? `- Q: ${f.question}\n  Finding: Not established by research. Treat as unknown; don't assume either way.`
        : `- Q: ${f.question}\n  Finding (${f.confidence} confidence): ${f.answer}`
    );
  if (!lines.length) return 'No research recorded.';
  const head = report.summary && report.summary.headline ? `Research bottom line: ${report.summary.headline}\n` : '';
  return head + lines.join('\n');
}

const API_URL = 'https://api.anthropic.com/v1/messages';
const MODEL = 'claude-sonnet-5';

// Credential handling (added 2026-10-05) — see docs/open-items.md. This
// prototype has no backend to hold a real key server-side, so the lowest-
// effort option from that doc is used: the user pastes their own Anthropic
// API key into the app (ApiKeyControl, in the header), it's kept here in a
// plain module-level variable for the rest of the tab session, and it is
// NEVER written to localStorage or any other persistent store — see the
// PERSISTENCE section above. A refresh clears it, same as before this
// change; that's a deliberate tradeoff (lower exposure), not an oversight.
let ANTHROPIC_API_KEY = '';
function setAnthropicApiKey(key) {
  ANTHROPIC_API_KEY = (key || '').trim();
}
function hasAnthropicApiKey() {
  return !!ANTHROPIC_API_KEY;
}
const NO_API_KEY_MESSAGE = 'No Anthropic API key set — add one via "API key" in the header.';
// Anthropic's API doesn't send CORS headers for api.anthropic.com by
// default; 'anthropic-dangerous-direct-browser-access' opts into allowing a
// direct browser call with a user-supplied key, which is what this control
// is for. Still assumes whatever host serves this app over HTTPS — same
// browser-fetch-only constraint as every other Claude call in this file.
function anthropicHeaders() {
  if (!ANTHROPIC_API_KEY) throw new Error(NO_API_KEY_MESSAGE);
  return {
    'Content-Type': 'application/json',
    'x-api-key': ANTHROPIC_API_KEY,
    'anthropic-version': '2023-06-01',
    'anthropic-dangerous-direct-browser-access': 'true',
  };
}

// Every generic "couldn't do X" banner in this file goes through this so
// the one error worth calling out by name (no key configured) doesn't get
// buried under a vague "try again" — everything else stays generic on
// purpose (raw API error text isn't necessarily something to show a user).
function friendlyApiError(e, fallback) {
  return e && e.message === NO_API_KEY_MESSAGE ? e.message : fallback;
}

// Opens the header's API key control from anywhere else on the page
// (2026-10-13, /impeccable clarify per the critique's P3: "No Anthropic API
// key set — add one via 'API key' in the header" named where to go but
// didn't take the user there). `ApiKeyControl` (below) owns its own `open`
// state locally with no prop/callback to drive it externally, and giving it
// one would mean threading a new prop through every error site that needs
// this — so this just clicks the header's own toggle button by id instead,
// the same thing a user manually clicking it would do. Scrolls it into view
// first since the header can be off-screen on a long account page.
function openApiKeyControl() {
  const btn = document.getElementById('header-api-key-control');
  if (!btn) return;
  btn.scrollIntoView({ block: 'center', behavior: 'smooth' });
  btn.click();
}

// Renders an error string, turning the literal "API key" phrase into a
// clickable link to the header control when the message is specifically
// the no-key error — every other error string renders unchanged. One
// component shared by every AccountPage error site that can surface this
// exact message, so the fix is consistent rather than one-off.
function ErrorText({ message, className = 'text-[12px] text-[var(--ss-danger)]' }) {
  if (!message) return null;
  if (message !== NO_API_KEY_MESSAGE) return <div className={className}>{message}</div>;
  return (
    <div className={className}>
      No Anthropic API key set — add one via{' '}
      <button type="button" onClick={openApiKeyControl} className="underline hover:opacity-80">
        API key
      </button>{' '}
      in the header.
    </div>
  );
}

// Pull the JSON object out of a reply, even if the model wrapped it in
// fences or wrote a line of prose before it.
function extractJSON(text) {
  const cleaned = text.replace(/```json|```/g, '');
  const end = cleaned.lastIndexOf('}');
  let start = cleaned.indexOf('{');
  for (let tries = 0; start !== -1 && start < end && tries < 25; tries++) {
    try {
      return JSON.parse(cleaned.slice(start, end + 1));
    } catch (e) {
      start = cleaned.indexOf('{', start + 1);
    }
  }
  throw new Error('No JSON object in response');
}

// One place for the plain model call so the plans and the email share it.
// `thinking: disabled` (added 2026-10-09, found live): claude-sonnet-5
// enables extended thinking by default even with no `thinking` param sent
// at all, and for a short structured-JSON task the model can spend the
// *entire* max_tokens budget on an invisible "thinking" block and emit zero
// actual text — a real failure mode hit live (Octopus Energy Generation's
// fit/gap check: stop_reason "max_tokens", content was just a thinking
// block, no text block, extractJSON correctly threw "No JSON object in
// response"). These calls don't need visible reasoning anyway — explicitly
// disabling it is both the fix and the right behavior for a deterministic
// JSON-schema response.
async function callClaudeJSON(prompt, maxTokens) {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: anthropicHeaders(),
    body: JSON.stringify({ model: MODEL, max_tokens: maxTokens, thinking: { type: 'disabled' }, messages: [{ role: 'user', content: prompt }] }),
  });
  const data = await response.json();
  if (!response.ok || data.type === 'error') throw new Error((data.error && data.error.message) || `HTTP ${response.status}`);
  const text = (data.content || []).map((b) => b.text || '').join('');
  return extractJSON(text);
}

class WebSearchUnavailable extends Error {}

function normalizeUrl(u) {
  return String(u || '').trim().replace(/#.*$/, '').replace(/\/+$/, '');
}

function hostOf(u) {
  try {
    return new URL(u).hostname.replace(/^www\./, '');
  } catch (e) {
    return '';
  }
}

// A research call: Claude with the server-side web search tool. Long
// searches can pause mid-turn, so keep going until the turn finishes.
// Returns the parsed JSON plus every URL the searches actually returned,
// which is what we check the model's cited sources against.
async function callClaudeResearch(prompt, { web, maxTokens = 2500, maxSearches = 4 }) {
  // Checked up front (not left to anthropicHeaders() inside the loop's try
  // block below) so a missing key surfaces as itself, not as
  // WebSearchUnavailable — that class is reserved for an actual search
  // failure, not "never had credentials to call Claude at all."
  if (!hasAnthropicApiKey()) throw new Error('No Anthropic API key set — add one via "API key" in the header.');
  const messages = [{ role: 'user', content: prompt }];
  const blocks = [];
  for (let turn = 0; turn < 4; turn++) {
    // Same `thinking: disabled` fix as callClaudeJSON above, for the same
    // reason: this call's final turn must produce a parseable JSON text
    // block, and extended thinking enabled by default on claude-sonnet-5
    // can consume the whole max_tokens budget on reasoning instead.
    const body = { model: MODEL, max_tokens: maxTokens, thinking: { type: 'disabled' }, messages };
    if (web) body.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: maxSearches }];
    let response;
    let data;
    try {
      response = await fetch(API_URL, { method: 'POST', headers: anthropicHeaders(), body: JSON.stringify(body) });
      data = await response.json();
    } catch (e) {
      if (web && turn === 0) throw new WebSearchUnavailable(e.message);
      throw e;
    }
    if (!response.ok || data.type === 'error') {
      const msg = (data.error && data.error.message) || `HTTP ${response.status}`;
      if (web && turn === 0) throw new WebSearchUnavailable(msg);
      throw new Error(msg);
    }
    blocks.push(...(data.content || []));
    if (data.stop_reason !== 'pause_turn') break;
    messages.push({ role: 'assistant', content: data.content });
  }

  const searched = new Map(); // normalized url -> { title, url }
  const cited = [];
  blocks.forEach((b) => {
    if (b.type === 'web_search_tool_result' && Array.isArray(b.content)) {
      b.content.forEach((r) => {
        if (r.type === 'web_search_result' && r.url) searched.set(normalizeUrl(r.url), { title: r.title || hostOf(r.url), url: r.url });
      });
    }
    if (b.type === 'text' && Array.isArray(b.citations)) {
      b.citations.forEach((c) => {
        if (!c.url) return;
        const key = normalizeUrl(c.url);
        if (!searched.has(key)) searched.set(key, { title: c.title || hostOf(c.url), url: c.url });
        if (!cited.includes(key)) cited.push(key);
      });
    }
  });
  // Citations split the reply into several text blocks; joining with ''
  // reassembles the JSON exactly.
  const text = blocks.filter((b) => b.type === 'text').map((b) => b.text || '').join('');
  return { parsed: extractJSON(text), searched, cited };
}

// Keep only sources we can stand behind: URLs the searches really returned,
// plus our own records. Anything else the model offered is dropped.
function verifiedSources(parsed, searched, cited, web) {
  const out = [];
  const seen = new Set();
  (parsed.sources || []).forEach((s) => {
    const src = typeof s === 'string' ? { title: s, url: null } : s || {};
    if (src.url) {
      const key = normalizeUrl(src.url);
      if (!web || !searched.has(key) || seen.has(key)) return;
      seen.add(key);
      out.push({ title: searched.get(key).title || src.title, url: searched.get(key).url });
    } else if (/our records|internal/i.test(src.title || '') && !seen.has('internal')) {
      seen.add('internal');
      out.push({ title: 'Our records', url: null });
    }
  });
  if (web && !out.some((s) => s.url)) {
    cited.slice(0, 3).forEach((key) => {
      if (!seen.has(key)) out.push(searched.get(key));
    });
  }
  return out.slice(0, 5);
}

function normalizeFinding(parsed, q, sources, method) {
  return {
    id: q.id,
    question: q.question,
    why: q.why,
    priority: q.priority,
    minutes: q.minutes,
    custom: !!q.custom,
    status: 'done',
    answer: parsed.answer || '',
    confidence: CONFIDENCE_LEVELS.includes(parsed.confidence) ? parsed.confidence : 'Low',
    key_facts: Array.isArray(parsed.key_facts) ? parsed.key_facts.slice(0, 3) : [],
    implication: parsed.implication || '',
    sources,
    method,
  };
}

function normalizeSummary(raw) {
  const arr = (x) => (Array.isArray(x) ? x.filter(Boolean) : []);
  return {
    headline: raw.headline || '',
    executive_summary: raw.executive_summary || '',
    implications: arr(raw.implications),
    next_steps: arr(raw.next_steps),
    open_questions: arr(raw.open_questions),
  };
}

// Run async work over a list with a small concurrency limit.
async function runPool(items, limit, worker) {
  let next = 0;
  const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const item = items[next++];
      await worker(item);
    }
  });
  await Promise.all(lanes);
}

function formatDate(iso) {
  return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}

// ---------------------------------------------------------------------------
// LIVE FEED — loading real news from News Scraper/news_monitor.py's
// gridline_news.json output (see News Scraper/README.md for the script
// itself). Loaded items only reach the Signal Map one at a time, when the
// user explicitly promotes one with "Add to Signal Map" (see allNewsItems/
// allMatches and promoteLiveItemToMatch above). There's still no automatic
// account-matching pipeline (see claude.md §7) — this is a manual stand-in
// for step 2 of that pipeline, not the real thing.
// ---------------------------------------------------------------------------

// "Run news scrape" (added 2026-10-04) POSTs here and expects the response
// body to be a gridline_news.json payload, same shape as a file/URL load.
// The browser can't exec news_monitor.py itself (no filesystem access, most
// of its sources would hit CORS) — see News Scraper/README.md's "Connecting
// it to Signal Scout" — so like the Claude API calls (§2), this assumes
// whatever host serves this file also runs the scraper and exposes it at
// this same-origin path. No host wired up to it yet: the button below
// fails gracefully with instructions to run the script by hand instead.
const SCRAPE_TRIGGER_URL = '/run-scrape';

// "View all sources" (added 2026-10-07) GETs this and expects back the raw
// contents of News Scraper/sources.json — same same-origin-bridge assumption
// as SCRAPE_TRIGGER_URL above (see News Scraper/serve.py's GET /sources).
// Read-only: there's no in-app editing of sources.json, so this never POSTs.
const SOURCES_ENDPOINT = '/sources';

const LIVE_KIND_OPTIONS = ['Funding', 'Policy', 'Market'];

// Accepts whatever the script actually wrote and fills in sensible
// fallbacks for anything missing, rather than assuming a perfectly-shaped
// record. Returns null for a record with no id or headline — the caller
// skips those rather than failing the whole load over a few bad rows.
function normalizeLiveNewsItem(raw) {
  if (!raw || typeof raw !== 'object') return null;
  if (!raw.id || !raw.headline) return null;
  return {
    id: String(raw.id),
    headline: String(raw.headline),
    source: raw.source || '',
    date: raw.date || '',
    tag: raw.tag || '',
    summary: raw.summary || '',
    url: raw.url || null,
    published: raw.published || null,
    kind: LIVE_KIND_OPTIONS.includes(raw.kind) ? raw.kind : 'Market',
    topics: Array.isArray(raw.topics) ? raw.topics.filter(Boolean).map(String) : [],
    region: raw.region || '',
    deadline: raw.deadline || null,
    // No `companies` field: which accounts an item mentions is computed
    // live by matchingAccountsForLiveItem against `accounts`, not trusted
    // from a scraper-tagged field — see that function's comment (§7.1).
    // A raw record's own `companies` (older gridline_news.json exports) is
    // simply ignored.
  };
}

// Throws a plain, user-facing message for anything that isn't a loadable
// JSON array; otherwise returns the valid items plus how many were skipped.
function parseLiveNewsJSON(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (e) {
    throw new Error("That doesn't look like valid JSON.");
  }
  if (!Array.isArray(data)) {
    throw new Error('Expected a JSON array of news items (gridline_news.json).');
  }
  const items = [];
  let skipped = 0;
  data.forEach((raw) => {
    const item = normalizeLiveNewsItem(raw);
    if (item) items.push(item);
    else skipped += 1;
  });
  return { items, skipped };
}

// Whole calendar days until an ISO date — negative once it's past. Used to
// flag a funding deadline closing soon, not to hide items once they've passed.
function daysUntil(iso) {
  if (!iso) return null;
  const target = new Date(iso);
  if (Number.isNaN(target.getTime())) return null;
  const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
  return Math.round((startOfDay(target) - startOfDay(new Date())) / 86400000);
}

// Live feed "published in the past 24h" badge (added 2026-10-07) — a rolling
// 24-hour window off `published`, distinct from the session-scoped "New"
// badge above (which means "appeared in the most recent load/merge", and
// resets on refresh). This one is about the story itself, not when you
// happened to load it, so it survives a refresh and stays accurate as time
// passes rather than being fixed at load time.
function publishedWithinPastDay(iso) {
  if (!iso) return false;
  const published = new Date(iso);
  if (Number.isNaN(published.getTime())) return false;
  const age = Date.now() - published.getTime();
  // A funding item's `published` can be a forthcoming call's future start
  // date, not an actual publish timestamp (see News Scraper/news_monitor.py's
  // fetch_eu_funding) — age would be negative then, which a bare `<=` check
  // would wrongly treat as "within the window." Must be genuinely in the
  // past, not just close to now in either direction.
  return age >= 0 && age <= 24 * 60 * 60 * 1000;
}

// Whole-word match, case-sensitive for short all-caps-ish tokens (RWE, SSE,
// CIP) so "pv" inside other text or "cip" inside "principal" doesn't
// false-fire — the same care News Scraper/news_monitor.py's Matcher class
// used to take when company-tagging lived there (retired 2026-10-05, see
// claude.md §7.1). Falls back to a plain substring check if `term` isn't
// valid as a regex (e.g. unbalanced punctuation someone typed into an
// alias) rather than throwing.
function textMentionsTerm(text, term) {
  if (!term) return false;
  const isShortCode = /^[A-Z0-9&.]{2,6}$/.test(term);
  const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  try {
    return new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`, isShortCode ? '' : 'i').test(text);
  } catch {
    return text.toLowerCase().includes(term.toLowerCase());
  }
}

// The single source of truth for "which of our accounts does this item
// mention" — scans the item's own headline+summary text against each
// account's name and aliases[]. This used to trust a `companies[]` field
// the News Scraper tagged from a separately-maintained watchlist in
// sources.json; that watchlist drifted from the real account list with
// nothing keeping the two in sync, so it's retired (2026-10-05, claude.md
// §7.1) — `accounts` is now the one real list, and `aliases[]` on an
// account (e.g. Ørsted → ['Ørsted', 'Orsted']) is the only place spelling
// variants are authored, same role `sources.json`'s watchlist used to play.
function matchingAccountsForLiveItem(item, accounts) {
  const text = `${item.headline || ''} ${item.summary || ''}`;
  return accounts.filter((a) => {
    const terms = a.aliases && a.aliases.length ? a.aliases : [a.name];
    return terms.some((t) => textMentionsTerm(text, t));
  });
}

function mentionsTrackedAccount(item, accounts) {
  return matchingAccountsForLiveItem(item, accounts).length > 0;
}

// ---------------------------------------------------------------------------
// RUN SEARCH (added 2026-10-04) — the primary way to work the Live feed now:
// pick criteria once in LiveSearchModal, run it against every loaded item
// (not just a freshly-loaded batch), and get a ranked shortlist back to
// approve. Replaces the earlier "Preferences" always-on reweighting (tried
// and removed — see claude.md §5.9/§8) with one explicit, visible action.
// ---------------------------------------------------------------------------

const SEARCH_CRITERIA_EMPTY = { topics: [], kinds: [], accountIds: [] };

function hasAnySearchCriteria(criteria) {
  return criteria.topics.length > 0 || criteria.kinds.length > 0 || criteria.accountIds.length > 0;
}

// A generic, never-fabricated default next-step used only by "Accept all" —
// a workflow nudge, not a claim about the company. Anyone who wants
// something more specific can still use the per-item form instead.
const SEARCH_DEFAULT_NEXT_STEP = {
  Funding: 'Review funding eligibility and the deadline with the account team.',
  Policy: "Assess how this affects the account's plans and discuss internally.",
  Market: 'Share with the account team and discuss relevance.',
};

// Generic, never-fabricated next-step starting points offered as quick-pick
// chips in "Add to Signal Map" (LiveFeedPromote) — a draft the user can take
// as-is or edit, never submitted on its own. Same "workflow nudge, not a
// claim about the company" rule as SEARCH_DEFAULT_NEXT_STEP above, whose
// per-kind wording is reused as the item-specific suggestion.
const GENERIC_NEXT_STEP_SUGGESTIONS = [
  'Organize a meeting with the account team to discuss.',
  'Follow up on previous project work with this account.',
  'Flag to the relationship owner for their next check-in.',
];

function nextStepSuggestions(item) {
  const kindDefault = SEARCH_DEFAULT_NEXT_STEP[item.kind];
  return kindDefault ? [kindDefault, ...GENERIC_NEXT_STEP_SUGGESTIONS] : GENERIC_NEXT_STEP_SUGGESTIONS;
}

// Reorders matchingAccountsForLiveItem's result so accounts named as
// priority clients in the search criteria sort first. Never adds an account
// the item doesn't actually mention — criteria only re-rank a real match set.
function rankAccountsByPriority(matched, priorityIds) {
  if (!priorityIds.size) return matched;
  return matched.slice().sort((a, b) => Number(priorityIds.has(b.id)) - Number(priorityIds.has(a.id)));
}

// Scores one item against the criteria picked in "Run search". An empty
// criteria group never adds weight — only groups the user actually picked
// something in do, and the funding-deadline-soon bonus only fires when
// "Funding" is itself one of the selected kinds. (Unlike the earlier,
// removed Preferences highlight, there's no "mentions any tracked account"
// or "any near deadline" fallback when the user didn't ask for it — this is
// an explicit search, so an unselected group should never quietly inject
// its own scoring dimension.)
function scoreLiveItemForSearch(item, criteria, accounts) {
  let score = 0;
  if (criteria.accountIds.length) {
    const priorityAccounts = accounts.filter((a) => criteria.accountIds.includes(a.id));
    if (matchingAccountsForLiveItem(item, priorityAccounts).length) score += 50;
  }
  if (criteria.kinds.length && criteria.kinds.includes(item.kind)) score += 25;
  if (criteria.topics.length && item.topics.some((t) => criteria.topics.includes(t))) score += 20;
  if (criteria.kinds.includes('Funding') && item.kind === 'Funding' && item.deadline) {
    const d = daysUntil(item.deadline);
    if (d !== null && d >= 0 && d <= 30) score += 10;
  }
  return score;
}

// Plain-English reasons behind the score above, shown on each result card so
// "why did this show up" is never a mystery.
function searchMatchReasons(item, criteria, accounts) {
  const reasons = [];
  if (criteria.accountIds.length) {
    const priorityAccounts = accounts.filter((a) => criteria.accountIds.includes(a.id));
    const priorityMentioned = matchingAccountsForLiveItem(item, priorityAccounts);
    if (priorityMentioned.length) reasons.push(`mentions ${priorityMentioned.map((a) => a.name).join(', ')}`);
  }
  if (criteria.kinds.length && criteria.kinds.includes(item.kind)) reasons.push(`is a ${item.kind} item`);
  const topicHits = criteria.topics.length ? item.topics.filter((t) => criteria.topics.includes(t)) : [];
  if (topicHits.length) reasons.push(`covers ${topicHits.join(', ')}`);
  if (criteria.kinds.includes('Funding') && item.kind === 'Funding' && item.deadline) {
    const d = daysUntil(item.deadline);
    if (d !== null && d >= 0 && d <= 30) reasons.push('its funding window closes within 30 days');
  }
  return reasons;
}

// ---------------------------------------------------------------------------
// TODAY RADAR (added 2026-10-17) — classifies every loaded Live feed item
// into exactly one bucket: already flagged, a near miss (mentions a tracked
// account but didn't clear the bar), a lookalike (relevant, untracked
// company), or not relevant. Pure, deterministic, no model call — see
// docs/features.md for the full design note.
// ---------------------------------------------------------------------------

// 0-100 scale, same as PROMOTED_MATCHES[].confidence. Displays as "0.60".
// No prior constant existed for this — see claude.md's handoff research —
// the human-entered confidence on a promoted match had nothing to compare
// against until now.
const FLAG_THRESHOLD = 60;

const TODAY_RADAR_OLD_DAYS = 30;

// Mirrors News Scraper/sources.json's `topics` keyword lists (Python-side,
// not importable into this bundle — see claude.md §7.1/data-model.md §3.2).
// Kept in sync by hand; if sources.json's topic list changes, update this
// too. Used only for the radar's deterministic "lookalike" rule below, never
// for News Scraper's own tagging (that stays server-side).
const TODAY_TOPIC_KEYWORDS = {
  Solar: ['solar', 'photovoltaic', 'pv', 'agrivoltaic', 'agrivoltaics'],
  Wind: ['wind', 'offshore wind', 'onshore wind', 'turbine', 'turbines', 'repowering'],
  Storage: ['storage', 'bess', 'battery', 'batteries', 'long-duration storage', 'pumped hydro'],
  Hydrogen: ['hydrogen', 'electrolyser', 'electrolyzer', 'e-fuels', 'green ammonia'],
  Grid: ['grid', 'interconnection', 'transmission', 'grid connection', 'substation', 'interconnector'],
  Heat: ['heat pump', 'heat pumps', 'district heating'],
  Bioenergy: ['biomass', 'biogas', 'biomethane', 'renewable natural gas', 'anaerobic digestion', 'bioenergy'],
  Geothermal: ['geothermal'],
  Marine: ['tidal', 'wave energy', 'marine energy'],
};

// Which of TODAY_TOPIC_KEYWORDS' topics appear (by keyword) in a block of
// free text — the same whole-text, keyword-presence approach News
// Scraper/news_monitor.py uses server-side to populate item.topics[].
function todayTopicsInText(text) {
  const lower = (text || '').toLowerCase();
  return Object.keys(TODAY_TOPIC_KEYWORDS).filter((topic) =>
    TODAY_TOPIC_KEYWORDS[topic].some((kw) => lower.includes(kw))
  );
}

// An account's own topic footprint, derived from its sector + priorities
// text — there's no structured topics[] field on an account, only prose.
function todayAccountTopics(account) {
  return new Set(todayTopicsInText(`${account.sector || ''} ${(account.priorities || []).join(' ')}`));
}

function todayBookTopics(accounts) {
  const set = new Set();
  accounts.forEach((a) => todayAccountTopics(a).forEach((t) => set.add(t)));
  return set;
}

// A "lookalike" item has no tracked-account mention, but its own
// topics[] — or its kind, as a fallback when it has none — overlaps with
// the topic footprint of the accounts you track.
function todayItemTopicSignals(item) {
  return item.topics && item.topics.length ? item.topics : [item.kind];
}

function todayIsLookalike(item, accounts) {
  const book = todayBookTopics(accounts);
  return todayItemTopicSignals(item).some((t) => book.has(t));
}

function todayWords(text) {
  return (text || '').toLowerCase().match(/[a-z0-9]+/g) || [];
}

// Keyword overlap between two free-text strings — words of length > 3 only,
// so "the"/"and"/"for" never count as a match. Used to pick which of an
// account's stated priorities (or which tracked account) a radar item's
// "why it surfaced" line should cite.
function todayKeywordOverlap(a, b) {
  const setB = new Set(todayWords(b));
  let n = 0;
  todayWords(a).forEach((w) => {
    if (w.length > 3 && setB.has(w)) n += 1;
  });
  return n;
}

// The account priority with the most keyword overlap against `text`, or
// null if nothing overlaps at all (the "why" line then falls back to just
// the account/topic name — see todayNearMissReason/todayLookalikeReason).
function todayBestPriorityMatch(account, text) {
  if (!account || !account.priorities || !account.priorities.length) return null;
  let best = null;
  let bestScore = 0;
  account.priorities.forEach((p) => {
    const score = todayKeywordOverlap(text, p);
    if (score > bestScore) {
      bestScore = score;
      best = p;
    }
  });
  return best;
}

// "<Account> · priority "<priority text>"" if a priority overlaps, else
// just "<Account>".
function todayNearMissReason(entry) {
  const text = `${entry.item.headline || ''} ${entry.item.summary || ''}`;
  const priority = todayBestPriorityMatch(entry.account, text);
  return priority ? `${entry.account.name} · priority "${priority}"` : entry.account.name;
}

// "<Topic> — matches <Account>'s "<priority>"" for whichever tracked
// account's priorities overlap most, else "<Topic> — same sector as <n> of
// your accounts".
function todayLookalikeReason(entry, accounts) {
  const book = todayBookTopics(accounts);
  const signals = todayItemTopicSignals(entry.item);
  const topic = signals.find((t) => book.has(t)) || signals[0] || entry.item.kind;
  const text = `${entry.item.headline || ''} ${entry.item.summary || ''}`;
  let bestAccount = null;
  let bestPriority = null;
  let bestScore = 0;
  accounts.forEach((a) => {
    const p = todayBestPriorityMatch(a, text);
    if (p) {
      const score = todayKeywordOverlap(text, p);
      if (score > bestScore) {
        bestScore = score;
        bestPriority = p;
        bestAccount = a;
      }
    }
  });
  if (bestAccount) return `${topic} — matches ${bestAccount.name}'s "${bestPriority}"`;
  const sameSectorCount = accounts.filter((a) => todayAccountTopics(a).has(topic)).length;
  return `${topic} — same sector as ${sameSectorCount} of your accounts`;
}

// Sorts every loaded Live feed item into exactly one group:
//  - flagged: has a promoted match (to any tracked account) with
//    confidence >= flagThreshold, published within the last 30 days (or a
//    match explicitly marked `manual` — see "Flag for <Account>" below,
//    which bypasses both checks the same way a human override should).
//  - nearMiss: mentions a tracked account (by name/aliases, or already has
//    a promoted-but-under-bar match) but didn't clear the bar. `reason` is
//    'below_threshold' or 'older_than_30d'.
//  - lookalike: no tracked-account mention, but relevant to the book by
//    topic/kind (see todayIsLookalike). `reason` is always 'untracked'.
//  - notRelevant: everything else, plus anything in `dismissedIds`.
// `matches` is the live PROMOTED_MATCHES-shaped array (accountId/newsId/
// confidence/manual/archived) — pass allMatches().
function classifyForToday(items, accounts, matches, settings = {}) {
  const threshold = typeof settings.flagThreshold === 'number' ? settings.flagThreshold : FLAG_THRESHOLD;
  const dismissedIds = settings.dismissedIds || new Set();
  const groups = { flagged: [], nearMiss: [], lookalike: [], notRelevant: [] };

  items.forEach((item) => {
    if (dismissedIds.has(item.id)) {
      groups.notRelevant.push(item);
      return;
    }

    const ageDays = -daysUntil(item.published);
    const isOld = Number.isFinite(ageDays) && ageDays > TODAY_RADAR_OLD_DAYS;

    const newsId = `live-${item.id}`;
    const itemMatches = matches.filter((m) => m.newsId === newsId && !m.archived);

    if (itemMatches.length) {
      // The best (highest-confidence) match decides the item's bucket; a
      // manual flag always wins regardless of confidence/age (it's an
      // explicit human override — see promoteLiveItemToMatch's `manual`).
      const best = itemMatches.reduce((a, b) => ((b.confidence || 0) > (a.confidence || 0) ? b : a));
      const account = accounts.find((a) => a.id === best.accountId);
      if (!account) {
        groups.notRelevant.push(item);
        return;
      }
      const meetsConfidence = typeof best.confidence === 'number' && best.confidence >= threshold;
      if (best.manual || (meetsConfidence && !isOld)) {
        groups.flagged.push({ item, account, confidence: best.confidence, manual: !!best.manual });
        return;
      }
      groups.nearMiss.push({
        item,
        account,
        confidence: best.confidence,
        reason: !meetsConfidence ? 'below_threshold' : 'older_than_30d',
      });
      return;
    }

    const mentioned = matchingAccountsForLiveItem(item, accounts);
    if (mentioned.length) {
      groups.nearMiss.push({
        item,
        account: mentioned[0],
        confidence: null,
        reason: isOld ? 'older_than_30d' : 'below_threshold',
      });
      return;
    }

    if (todayIsLookalike(item, accounts)) {
      groups.lookalike.push({ item, reason: 'untracked' });
      return;
    }

    groups.notRelevant.push(item);
  });

  return groups;
}

// ---------------------------------------------------------------------------
// SEED EXAMPLES (added 2026-10-03) — five genuinely real items from an
// actual News Scraper run on 2026-10-03 (see claude.md §3.1/§5.9), each
// promoted through promoteLiveItemToMatch — the exact function "Add to
// Signal Map" calls — rather than hand-authored as MATCHES-style data. The
// Signal Map and each account's Matched signals therefore open already
// showing what that button produces, not a separate example of it.
// Headlines, sources, dates and URLs are real and unedited; confidence,
// rationale and next step are this prototype's own framing, same as any
// other promotion a user would make.
//
// Gated to a genuinely first-ever load (added 2026-10-06, alongside the
// persistence fix below): this block is plain top-level code, so without a
// guard it would call promoteLiveItemToMatch on every single page load,
// including every reload after PERSISTENCE (below) started restoring the
// very same 5 matches from localStorage — doubling, tripling, etc. these
// specific rows on every refresh. promoteLiveItemToMatch's own dedup guard
// stops that particular compounding, but re-running this unconditionally
// would still silently resurrect one of these 5 after a user deliberately
// unlinked it. loadSavedState() is a plain localStorage read (no React
// involved), safe to call here at module-evaluation time.
if (!DEMO_MODE && !loadSavedState()) {
  [
  {
    raw: { id: 'g-9506c6b45d', headline: 'Orsted confirms bid in Taiwan offshore wind auction', source: 'reNEWS', date: 'Sep 30', tag: 'Wind · Policy', summary: 'Danish developer to face one other bidder in 3.6GW Round 3.3', url: 'https://www.renews.biz/offshore-wind/two-bids-for-taiwans-latest-offshore-auction/', published: '2026-09-30T12:51:30+00:00', kind: 'Policy', topics: ['Wind'], region: 'Global', deadline: null, companies: ['Ørsted'] },
    accountId: 'a1',
    confidence: 88,
    rationale: 'Ørsted has confirmed a bid in a major offshore wind auction — directly relevant to how they are allocating capital and bidding capacity across markets right now.',
    nextStep: 'Ask whether this changes their near-term capital allocation across markets, and whether there is room to discuss delivery or financing partners.',
  },
  {
    raw: { id: 'g-f56a96c41c', headline: 'Vestas closes out Q3 with 576MW of new deals', source: 'reNEWS', date: 'Oct 1', tag: 'Energy · Market', summary: 'Australia, Romania and Austria contracts include long-term service agreements', url: 'https://www.renews.biz/onshore-wind/vestas-closes-out-q3-with-576mw-of-new-deals/', published: '2026-10-01T06:57:53+00:00', kind: 'Market', topics: [], region: 'Global', deadline: null, companies: ['Vestas'] },
    accountId: 'a2',
    confidence: 70,
    rationale: 'New order intake including long-term service agreements signals Vestas is actively growing its service book, not just turbine sales.',
    nextStep: 'Open with congratulations on the order book and ask about service & warranty priorities for the newly contracted fleet.',
  },
  {
    raw: { id: 'g-1976c3fd9e', headline: 'SSE renewables output set for 20% surge', source: 'reNEWS', date: 'Oct 1', tag: 'Energy · Market', summary: 'More favourable weather and capacity growth lift generation', url: 'https://www.renews.biz/offshore-wind/sse-renewables-output-set-for-20-surge/', published: '2026-10-01T08:57:16+00:00', kind: 'Market', topics: [], region: 'Global', deadline: null, companies: ['SSE'] },
    accountId: 'a4',
    confidence: 65,
    rationale: 'A sharp output increase suggests SSE is bringing meaningful new capacity online, which usually comes with fresh grid-connection and delivery pressure.',
    nextStep: 'Ask how the new output is being integrated and whether grid-connection timelines are keeping pace with the capacity growth.',
  },
  {
    raw: { id: 'g-67e06a3025', headline: 'Vattenfall in 285MW German battery charge', source: 'reNEWS', date: 'Sep 30', tag: 'Storage · Market', summary: 'Four projects across Germany reach financial close', url: 'https://www.renews.biz/other-news/energy-storage/vattenfall-in-285mw-german-battery-charge/', published: '2026-09-30T12:08:29+00:00', kind: 'Market', topics: ['Storage'], region: 'Global', deadline: null, companies: ['Vattenfall'] },
    accountId: 'a7',
    confidence: 80,
    rationale: 'Reaching financial close on four storage projects is a concrete sign Vattenfall is actively deploying capital into storage, not just offshore wind.',
    nextStep: 'Ask about the financing structure used here and whether it could be a model for de-risking their offshore wind economics too.',
  },
  {
    raw: { id: 'g-1ff7b22db7', headline: 'Norway’s oil fund makes €1.2bn CIP investment', source: 'reNEWS', date: 'Oct 2', tag: 'Storage · Market', summary: 'CI VI targets renewable generation and storage across OECD countries', url: 'https://www.renews.biz/other-news/finance/norways-oil-fund-makes-e1-2bn-cip-investment/', published: '2026-10-02T13:58:48+00:00', kind: 'Market', topics: ['Storage'], region: 'Global', deadline: null, companies: ['CIP'] },
    accountId: 'a8',
    confidence: 85,
    rationale: 'Fresh LP capital into their latest fund means CIP has real new capacity to deploy into generation and storage assets across OECD markets.',
    nextStep: 'Ask what the new fund is prioritizing and whether there is a fit for projects or partners we could introduce.',
  },
  // Fictional demo signal (added 2026-10-08) — unlike the 5 above, this is
  // NOT a real scraped headline; it's mock data purpose-built to demo the
  // gap-flagging fixture (CAPABILITIES deliberately excludes hydrogen, see
  // data-model.md §3.0), attached to the fictional `a9` demo account above
  // rather than a real company specifically so it can't read as a
  // fabricated claim about a real firm. `source`/`tag`/`url` all say
  // "demo"/null so it's unmistakable in the UI, not confused with a real
  // Live feed item.
  {
    raw: {
      id: 'demo-h2-1',
      headline: '[Demo] Fictional scenario: infrastructure fund weighs hydrogen storage allocation',
      source: 'Demo scenario — not a real headline',
      date: 'Oct 2026',
      tag: 'Hydrogen · Demo',
      summary: 'Illustrative, fabricated scenario (no real event) included only to demo generatePlan\'s capability gap-flagging — we don\'t offer hydrogen infrastructure services, see CAPABILITIES.',
      url: null,
      published: '2026-10-07T00:00:00+00:00',
      kind: 'Market',
      topics: ['Hydrogen'],
      region: 'Global',
      deadline: null,
      companies: ['Meridian H2 Partners'],
    },
    accountId: 'a9',
    confidence: 72,
    rationale: 'Demo scenario: a hydrogen infrastructure allocation signal, chosen to exercise the gap-flagging rule rather than to look like a real opportunity.',
    nextStep: 'Demo only — in a real account this is the kind of signal that should surface as an honest capability gap, not a forced talking point.',
  },
  ].forEach(({ raw, accountId, confidence, rationale, nextStep }) => {
    promoteLiveItemToMatch(normalizeLiveNewsItem(raw), accountId, { confidence, rationale, nextStep });
  });
}

// DEMO MODE signals (added per docs/demo-week-plan.md Day 1d) — 13 real,
// dated news items (headline/url/published date all sourced; see
// docs/demo-dataset.md for the full citation list and retrieval dates),
// matched to the 7 DEMO_ACCOUNTS_INITIAL companies above. `rationale`/
// `nextStep` on every entry are explicitly OUR interpretation of what the
// news means for us, written in that voice on purpose — per the plan's
// hard rule, they are never presented as facts about the company, only
// the headline/summary/url themselves are. confidence is an illustrative
// 0-100 score, same convention as the non-demo seed above.
if (DEMO_MODE && !loadSavedState()) {
  [
  {
    raw: { id: 'dg-1', headline: 'Ørsted opens 913 MW Borkum Riffgrund 3 offshore wind farm in Germany', source: 'ad-hoc-news.de', date: 'Oct 5', tag: 'Wind · Market', summary: 'Ørsted commissioned the 913 MW Borkum Riffgrund 3 offshore wind farm, taking its German offshore capacity to ~2.50 GW.', url: 'https://www.ad-hoc-news.de/boerse/news/corporate-news/orsted-opens-913-mw-wind-farm-and-orsted-stock-falls-4-04-percent/70232445', published: '2026-10-05T00:00:00+00:00', kind: 'Market', topics: ['Wind'], region: 'Europe', deadline: null, companies: ['Ørsted'] },
    accountId: 'b1', confidence: 76,
    rationale: 'A newly commissioned asset this size means Ørsted has fresh operating capacity and O&M needs coming online in Germany right now.',
    nextStep: 'Ask how O&M resourcing for Borkum Riffgrund 3 is being handled, and whether there is room to support the capital-structure messaging around the commissioning.',
  },
  {
    raw: { id: 'dg-2', headline: 'Ørsted signs agreement to divest European onshore business to CIP for ~EUR 1.44bn', source: 'orsted.com', date: 'Feb 2026', tag: 'Strategy · Market', summary: 'Ørsted agreed to sell its European onshore wind/solar business to Copenhagen Infrastructure Partners for roughly EUR 1.44bn.', url: 'https://orsted.com/en/company-announcement-list/2026/02/orsted-signs-agreement-with-cip-to-divest-its-euro-1477764911', published: '2026-02-01T00:00:00+00:00', kind: 'Market', topics: [], region: 'Europe', deadline: null, companies: ['Ørsted', 'CIP'] },
    accountId: 'b1', confidence: 70,
    rationale: 'A divestment at this scale usually triggers a wave of internal re-prioritization and external messaging as the company narrows its story to offshore wind alone.',
    nextStep: 'Ask how the post-divestment strategic narrative is being communicated to the market, and whether there is a role for stakeholder/communications support around the transition.',
  },
  {
    raw: { id: 'dg-3', headline: 'SSE names Montrose Port as O&M base for 1.4 GW Berwick Bank B', source: 'offshoreWIND.biz', date: 'Sep 28', tag: 'Wind · Market', summary: 'SSE selected Montrose as the operations-and-maintenance hub for Berwick Bank B, part of the wider 4.1 GW Berwick Bank project, creating ~100 long-term jobs.', url: 'https://www.offshorewind.biz/2026/09/28/sse-picks-montrose-port-as-oandm-base-for-14-gw-offshore-wind-farm', published: '2026-09-28T00:00:00+00:00', kind: 'Market', topics: ['Wind'], region: 'UK', deadline: null, companies: ['SSE Renewables'] },
    accountId: 'b2', confidence: 74,
    rationale: 'Choosing an O&M base this early is a concrete sign Berwick Bank B is moving from planning into real delivery mode, ahead of its 2027 FID.',
    nextStep: 'Ask what sits next on the Berwick Bank B critical path toward FID, and whether grid-connection sequencing is a live concern worth discussing.',
  },
  {
    raw: { id: 'dg-4', headline: 'SSE announces record £24.9m community investment across UK and Ireland', source: 'sserenewables.com', date: 'Aug 2026', tag: 'Market', summary: 'SSE reported its highest-ever annual community investment figure, supporting 1,284 projects in 2025/26.', url: 'https://www.sserenewables.com/news-and-views/2026/08/sse-announces-record-249m-community-investment-across-uk-and-ireland/', published: '2026-08-01T00:00:00+00:00', kind: 'Market', topics: [], region: 'UK', deadline: null, companies: ['SSE Renewables'] },
    accountId: 'b2', confidence: 58,
    rationale: 'A record community-investment figure suggests SSE is actively managing stakeholder relationships around its growing project footprint — the kind of work that often invites outside support, especially on contested sites.',
    nextStep: 'Ask how community engagement is resourced across the expanding pipeline, and whether support on specifically contested sites would be useful.',
  },
  {
    raw: { id: 'dg-5', headline: 'Octopus Energy Generation commits ~$1bn to Californian carbon-removal and clean-tech firms', source: 'Renewable Energy Magazine', date: 'Feb 17', tag: 'Market', summary: 'Octopus Energy Generation announced a roughly $1bn investment package into California-based clean-tech and carbon-removal companies.', url: 'https://www.renewableenergymagazine.com/storage/octopus-energy-generation-invests-1billion-in-californian-20260217', published: '2026-02-17T00:00:00+00:00', kind: 'Market', topics: [], region: 'US', deadline: null, companies: ['Octopus Energy Generation'] },
    accountId: 'b3', confidence: 65,
    rationale: 'A commitment this size into adjacent clean-tech signals Octopus is actively diversifying beyond core renewables generation.',
    nextStep: 'Ask how this new clean-tech exposure is being integrated into their existing BD/account strategy, and whether market intelligence on the adjacent space would help.',
  },
  {
    raw: { id: 'dg-6', headline: 'Octopus Energy Generation invests over $680m to acquire 321 MW European onshore wind portfolio', source: 'ESG Today', date: '2026', tag: 'Wind · Market', summary: 'Octopus Energy Generation expanded its European onshore wind holdings via a ~EUR 584m multi-country acquisition across France, Germany and Poland.', url: 'https://www.esgtoday.com/octopus-energy-generation-invests-over-680-million-to-expand-european-wind-energy-capacity/', published: '2026-05-01T00:00:00+00:00', kind: 'Market', topics: ['Wind'], region: 'Europe', deadline: null, companies: ['Octopus Energy Generation'] },
    accountId: 'b3', confidence: 60,
    rationale: 'A multi-country acquisition like this usually means integrating several new local stakeholder relationships across three markets at once.',
    nextStep: 'Ask how the newly acquired sites’ local stakeholder relationships are being managed post-acquisition.',
  },
  {
    raw: { id: 'dg-7', headline: 'Low Carbon secures ~£1.1bn committed-capital investment from CVC DIF', source: 'cvc.com', date: 'Dec 2025', tag: 'Market', summary: 'CVC DIF agreed a majority equity investment in Low Carbon to fund its next stage of growth, with follow-on investment from existing shareholder MassMutual.', url: 'https://www.cvc.com/media/news/2025/low-carbon-secures-landmark-investment-from-cvc-dif-to-drive-the-next-stage-of-growth/', published: '2025-12-01T00:00:00+00:00', kind: 'Market', topics: [], region: 'UK', deadline: null, companies: ['Low Carbon'] },
    accountId: 'b4', confidence: 68,
    rationale: 'New majority capital on this scale usually resets growth priorities and messaging — a natural moment to re-engage around the next phase.',
    nextStep: 'Ask how the new capital is being sequenced across the 16 GW pipeline, and whether market-positioning support would help heading into the next growth phase.',
  },
  {
    raw: { id: 'dg-8', headline: 'Zenobē acquires Germany’s sdp energie, entering transmission-connected BESS ownership', source: 'ESS News', date: 'Jul 27', tag: 'Storage · Market', summary: 'Zenobē bought sdp energie to expand its battery storage footprint into the German market (~700 MWh across 23 ready-to-build projects).', url: 'https://www.ess-news.com/2026/07/27/zenobe-acquires-sdp-energie-plans-to-buy-more-transmission-projects/', published: '2026-07-27T00:00:00+00:00', kind: 'Market', topics: ['Storage'], region: 'Europe', deadline: null, companies: ['Zenobē'] },
    accountId: 'b5', confidence: 71,
    rationale: 'A first move into German transmission-scale storage ownership is a genuinely new market entry, which usually means unfamiliar regulatory and stakeholder terrain to navigate.',
    nextStep: 'Ask what support is needed to navigate Germany’s new inertia-procurement mechanism, and whether local market intelligence would help the expansion.',
  },
  {
    raw: { id: 'dg-9', headline: 'Zenobē secures ~£980m financing for UK/Ireland electric bus rollout', source: 'zenobe.com', date: '2026', tag: 'Market', summary: 'Zenobē closed a large new financing facility dedicated to UK/Ireland electric bus fleet deployment, targeting up to 5,000 buses by 2028.', url: 'https://www.zenobe.com/news-and-events/zenobe-secures-980m-financing-electric-buses-uk-ireland/', published: '2026-06-01T00:00:00+00:00', kind: 'Market', topics: [], region: 'UK', deadline: null, companies: ['Zenobē'] },
    accountId: 'b5', confidence: 55,
    rationale: 'Financing at this scale signals a major scale-up in delivery, which often strains BD/account bandwidth as the pipeline grows.',
    nextStep: 'Ask how BD and account management for the UK/Ireland bus rollout is scaling alongside the new financing.',
  },
  {
    raw: { id: 'dg-10', headline: 'Island Green Power secures consent for 500 MW Green Hill solar-plus-storage project', source: 'BusinessGreen', date: 'Oct 8', tag: 'Solar · Policy', summary: 'UK planning authorities granted development consent for Island Green Power’s 500 MW Green Hill solar-plus-storage project — its fourth 2026 consent.', url: 'https://www.businessgreen.com/news/4537044/island-green-power-secures-approval-giant-500mw-green-hill-solar-storage-project', published: '2026-10-08T00:00:00+00:00', kind: 'Policy', topics: ['Solar', 'Storage'], region: 'UK', deadline: null, companies: ['Island Green Power'] },
    accountId: 'b6', confidence: 79,
    rationale: 'A fourth consent inside one year suggests Island Green Power’s consenting pipeline is moving faster than most peers right now.',
    nextStep: 'Ask what is driving the unusually fast consenting pace, and whether there is room to benchmark it against sector norms for their next submissions.',
  },
  {
    raw: { id: 'dg-11', headline: 'Island Green Power closes £315m HoldCo debt financing from Nomura and Barclays', source: 'Jones Day / Solar Power Portal', date: 'Jul 2026', tag: 'Market', summary: 'Island Green Power secured £315m in holding-company-level debt financing to refinance debt and support its transition to an independent power producer.', url: 'https://www.solarpowerportal.co.uk/solar-finance/island-green-power-closes-315m-debt-financing-as-transition-to-ipp-continues', published: '2026-07-01T00:00:00+00:00', kind: 'Market', topics: [], region: 'UK', deadline: null, companies: ['Island Green Power'] },
    accountId: 'b6', confidence: 62,
    rationale: 'Raising debt at the holding-company level, rather than per-project, usually signals a deliberate shift toward operating as an IPP rather than a pure developer.',
    nextStep: 'Ask how the shift to an IPP model is changing their BD priorities and account structure going forward.',
  },
  {
    raw: { id: 'dg-12', headline: 'CIP restructures EUR 3bn Energy Transition Fund I amid slower hydrogen market development', source: 'ION Analytics (Infralogic)', date: 'Aug 5', tag: 'Hydrogen · Market', summary: 'CIP began restructuring its flagship hydrogen-focused fund, the world’s largest dedicated green hydrogen fund, discussing returning capital to LPs and converting some projects to other uses, as hydrogen/power-to-X demand developed more slowly than expected.', url: 'https://ionanalytics.com/insights/infralogic/cip-in-major-restructuring-of-largest-ever-hydrogen-fund/', published: '2026-08-05T00:00:00+00:00', kind: 'Market', topics: ['Hydrogen'], region: 'Global', deadline: null, companies: ['CIP', 'Copenhagen Infrastructure Partners'] },
    accountId: 'b7', confidence: 74,
    rationale: 'A restructuring on their largest hydrogen fund signals real strategic uncertainty in that specific market — exactly the kind of signal that should be flagged honestly on what we can and can’t directly support, rather than forced into a talking point.',
    nextStep: 'Ask what is being considered for the restructuring (continuation vehicle, asset conversions), and flag that specialist hydrogen/e-fuels advisory input — outside what we offer directly — could help evaluate the options via a partner referral.',
  },
  {
    raw: { id: 'dg-13', headline: 'Copenhagen Infrastructure Partners’ Growth Markets Fund II closes at ~USD 3bn', source: 'GlobeNewswire', date: 'Aug 14', tag: 'Market', summary: 'CIP closed its second Growth Markets fund at roughly $3bn, tripling its first vintage, aimed at large-scale energy infrastructure in 15 selected middle-income markets.', url: 'https://www.globenewswire.com/news-release/2026/08/14/3345235/0/en/copenhagen-infrastructure-partners-growth-markets-fund-ii-closes-at-usd-3-billion.html', published: '2026-08-14T00:00:00+00:00', kind: 'Market', topics: [], region: 'Global', deadline: null, companies: ['CIP', 'Copenhagen Infrastructure Partners'] },
    accountId: 'b7', confidence: 66,
    rationale: 'Tripling a fund’s size signals CIP has a lot of fresh capital actively looking for deployment opportunities right now.',
    nextStep: 'Ask what asset types and geographies the new fund is prioritizing first, and whether there is a fit with opportunities we are already tracking.',
  },
  ].forEach(({ raw, accountId, confidence, rationale, nextStep }) => {
    promoteLiveItemToMatch(normalizeLiveNewsItem(raw), accountId, { confidence, rationale, nextStep });
  });
}

// DEMO MODE market-moves trigger (added per demo-week-plan.md Day 4) —
// held back from the Day 1 seed specifically to be injected live during a
// demo, reproducing the "market moved → Signal Scout tells you what it means
// for your ability to deliver" moment on cue rather than hoping a genuine
// live-scrape match happens to land during the pitch. Real and sourced,
// same as every other dg-* item: the same ION Analytics article dg-12
// already cites (CIP's hydrogen-fund restructuring) also reports that one
// specific project within that fund — the €1bn MadoquaPower2X plant in
// Portugal — is being weighed for conversion away from hydrogen to a
// different use (data centers). That's a believable, real trigger for the
// hydrogen capability gap (dg-12) to actually change once re-checked: the
// fund-level restructuring is still real, but this specific project is
// pivoting away from the hydrogen need that drove the original gap.
const HELD_BACK_DEMO_SIGNAL = {
  raw: {
    id: 'dg-14-trigger',
    headline: 'CIP weighs converting its MadoquaPower2X hydrogen project in Portugal to data center use',
    source: 'ION Analytics (Infralogic)',
    date: 'Aug 5',
    tag: 'Hydrogen · Market',
    summary: 'As part of its Energy Transition Fund I restructuring, CIP is reportedly considering converting the EUR 1bn MadoquaPower2X hydrogen project in Portugal to an alternative use such as data centers, rather than continuing it as a hydrogen asset.',
    url: 'https://ionanalytics.com/insights/infralogic/cip-in-major-restructuring-of-largest-ever-hydrogen-fund/',
    published: '2026-08-05T00:00:00+00:00',
    kind: 'Market',
    topics: ['Hydrogen'],
    region: 'Global',
    deadline: null,
    companies: ['CIP', 'Copenhagen Infrastructure Partners'],
  },
  accountId: 'b7',
  confidence: 69,
  rationale: 'If their flagship hydrogen project itself is pivoting away from hydrogen, the capability gap we flagged on the fund-level restructuring may no longer be the right read for this specific opportunity.',
  nextStep: 'Re-check what CIP actually needs here now that the project itself may not be a hydrogen delivery after all — this could change whether the Fenwick H2 Advisory referral still applies.',
};

const METHOD_LABEL = {
  web: 'Web-researched',
  mixed: 'Partly web-researched',
  model: 'Not web-verified',
  manual: 'From your own notes',
};

function buildQuestionPrompt({ account, q, context, web }) {
  return `You are a research analyst supporting a renewables-sector business development team. Research ONE question about a target firm before the team contacts them.

Firm: ${account.name}, a ${account.sector}.
Their stated priorities (from our CRM): ${account.priorities.join('; ')}.

Question: ${q.question}
Why it matters to us: ${q.why || 'Added by the team.'}
Suggested places to look: ${q.sources.length ? q.sources.join('; ') : 'Use your judgement.'}

Our internal records (these may answer part of the question; cite them as "Our records"):
${context}

${
  web
    ? 'Use web search to find current, specific evidence. Prefer primary sources (company site, filings, regulator dockets, interconnection queue data) and reputable trade press. Use at most 4 searches.'
    : 'You do NOT have web access in this run. Use only our internal records and well-established general knowledge. Anything firm-specific you cannot support from those must be treated as not found.'
}

Rules:
- State only what your sources support. Never invent figures, dates, people, projects or URLs.
- If you find nothing specific to this firm, set confidence to "Not found" and say so plainly. You may add one sentence of clearly labelled "Sector context:".
- confidence: "High" = confirmed by a primary source or several reputable ones; "Medium" = one reputable secondary source; "Low" = indirect or dated evidence; "Not found".
- sources: ${
    web
      ? 'only URLs your searches returned, plus {"title": "Our records", "url": null} if you used our internal records.'
      : '{"title": "Our records", "url": null} if you used our internal records, otherwise an empty list. No URLs.'
  }

When you are finished, reply with ONLY this JSON object and nothing before or after it:
{"answer": "2-4 sentences", "confidence": "High", "key_facts": ["up to 3 short facts"], "implication": "one line on what this means for our outreach", "sources": [{"title": "...", "url": "https://..."}]}`;
}

function buildSummaryPrompt({ account, objective, findings }) {
  const findingsText = findings
    .map((f) =>
      f.status === 'done'
        ? `- [${f.priority}] Q: ${f.question}\n  Confidence: ${f.confidence}\n  Answer: ${f.answer}\n  Implication: ${f.implication}`
        : `- [${f.priority}] Q: ${f.question}\n  Research failed; treat as unanswered.`
    )
    .join('\n');
  return `Write the summary sections of a pre-outreach research report for a renewables-sector business development team. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"headline": "one sentence bottom line", "executive_summary": "3-4 sentences", "implications": ["..."], "next_steps": ["..."], "open_questions": ["..."]}

Rules:
- Use ONLY the findings below. Don't add facts, names or figures that aren't in them.
- "Not found" means the research couldn't establish it. Treat it as an open question, never as evidence that the answer is no.
- Hedge anything that rests only on Low confidence findings.
- implications: up to 3, about how we should approach them. next_steps: up to 3 concrete actions. open_questions: what's still unknown that matters.
- Keep every string under 30 words.

Firm: ${account.name}, a ${account.sector} (tier: ${account.tier}).
Research objective: ${objective || 'Prepare for first outreach.'}
Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.

Findings:
${findingsText}`;
}

// Is a thought-leadership piece or past project relevant to this account?
function isRelevantTo(item, account, matchedIds) {
  return item.accountId === account.id || (item.relatedSignals || []).some((id) => matchedIds.includes(id));
}

// ---------------------------------------------------------------------------
// MANUAL RESEARCH INTAKE — a user who already knows an account can skip the
// AI research workflow entirely and hand over their own notes and/or a file
// instead. Claude only organizes what's actually in that material into the
// same report/finding shape the automated flow produces (see the `report`/
// `finding` comment above `researchProgress`), so everything downstream
// (the outreach plan generator, the email composer) works unchanged.
// ---------------------------------------------------------------------------

// File types this prototype can actually hand to Claude: plain text it can
// read itself, and PDF via the API's native document understanding. Word/
// Excel parsing would need a library this prototype deliberately has none
// of (see PDF export note) — callers should ask for text or a PDF instead.
const MANUAL_INTAKE_TEXT_EXT = /\.(txt|md|markdown|csv|eml|log)$/i;
const MANUAL_INTAKE_PDF_EXT = /\.pdf$/i;

class UnsupportedIntakeFile extends Error {}

function arrayBufferToBase64(buffer) {
  let binary = '';
  const bytes = new Uint8Array(buffer);
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
  }
  return btoa(binary);
}

async function readIntakeFile(file) {
  const isPdf = file.type === 'application/pdf' || MANUAL_INTAKE_PDF_EXT.test(file.name);
  if (isPdf) {
    const base64 = arrayBufferToBase64(await file.arrayBuffer());
    return { kind: 'pdf', name: file.name, base64 };
  }
  const looksLikeText = file.type.startsWith('text/') || MANUAL_INTAKE_TEXT_EXT.test(file.name);
  if (looksLikeText) {
    return { kind: 'text', name: file.name, text: await file.text() };
  }
  throw new UnsupportedIntakeFile(file.name);
}

function buildManualIntakePrompt({ account, matches, notes, file }) {
  const signalContext = matches.length
    ? matches.map((m) => `- [${m.newsId}] ${m.news.headline} (${m.news.date}) — ${m.rationale}`).join('\n')
    : 'None matched yet.';
  const materialText = [
    notes ? `User's typed notes:\n${notes}` : '',
    file && file.kind === 'text' ? `Attached file "${file.name}":\n${file.text}` : '',
    file && file.kind === 'pdf' ? `An attached PDF, "${file.name}", follows as a document.` : '',
  ]
    .filter(Boolean)
    .join('\n\n');

  return `You are helping a renewables-sector business development team skip straight to outreach on an account they already know well. They are handing you their own notes and/or a file instead of running research — turn ONLY what is actually in that material into a short, organized brief. Never invent facts, figures, names, dates or history that aren't present in it.

Account: ${account.name}, a ${account.sector} (tier: ${account.tier}).
Their stated priorities: ${account.priorities.join('; ')}.

News signals already matched to this account (context only — the material below is the actual source):
${signalContext}

Material supplied by the user:
${materialText || '(nothing but the file — read it directly)'}

Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"headline": "one sentence bottom line", "executive_summary": "2-3 sentences", "key_points": [{"text": "...", "confidence": "High"}], "open_questions": ["..."]}

Rules:
- key_points: up to 6 concrete, specific points pulled from the material (who, what, when, status, preferences, history with them). confidence: "High" for things stated plainly, "Medium" for things only implied or loosely summarized.
- open_questions: anything the material leaves genuinely unclear that outreach should be careful about. Empty list is fine if it's thorough.
- If the material is thin or mostly irrelevant, say so plainly in the headline rather than padding with generic filler.
- Keep every string under 30 words.`;
}

// Builds the same report/finding shape the automated research workflow
// produces, so the outreach plan generator and email composer don't need
// to know whether a report came from web research or the user's own notes.
function buildManualReport(parsed) {
  const now = new Date().toISOString();
  const findings = {};
  const order = [];
  (parsed.key_points || []).forEach((kp, i) => {
    const id = `manual-${i + 1}`;
    const point = typeof kp === 'string' ? { text: kp, confidence: 'High' } : kp || {};
    findings[id] = {
      id,
      question: 'From the notes/file you provided',
      why: '',
      priority: 'Must',
      minutes: 0,
      custom: true,
      status: 'done',
      answer: point.text || '',
      confidence: point.confidence === 'Medium' ? 'Medium' : 'High',
      key_facts: [],
      implication: '',
      sources: [{ title: 'Your notes', url: null }],
      method: 'manual',
    };
    order.push(id);
  });
  const openQuestions = Array.isArray(parsed.open_questions) ? parsed.open_questions.filter(Boolean) : [];
  return {
    createdAt: now,
    updatedAt: now,
    method: 'manual',
    findings,
    order,
    objective: 'Move straight to outreach using the notes/file the user supplied.',
    known: [],
    people_to_identify: [],
    watch_outs: openQuestions.map((text) => ({ text, source: null })),
    summary: {
      headline: parsed.headline || '',
      executive_summary: parsed.executive_summary || '',
      implications: [],
      next_steps: [],
      open_questions: openQuestions,
    },
  };
}

function buildEmailPrompt({ account, contact, leads, others, insights, work, plan, research, tone, extra, entryPoint, gapsSummary, emphasizedCapabilities = [] }) {
  const recipient = contact
    ? `${contact.name}, ${contact.role}. Relationship: ${contact.relationship}.${contact.lastTouch ? ` Last touch point: ${contact.lastTouch}.` : ''} Notes: ${contact.notes}`
    : 'No specific contact chosen. Open with "Hi [contact name]," and keep it role-neutral.';

  const leadText = leads.length
    ? leads.map((lead) => `- ${lead.news.headline} (${lead.news.date}). ${lead.news.summary} Why it matters to them: ${lead.rationale}`).join('\n')
    : 'None selected. Lead with their stated priorities instead of a news item.';

  const otherText = others.length ? others.map((m) => `- ${m.news.headline}: ${m.rationale}`).join('\n') : 'None.';

  const insightText = insights.length
    ? insights.map((i) => `- ${i.type}: "${i.title}" (${i.date}). ${i.summary}`).join('\n')
    : 'None selected. Do not mention any publications, research or thought leadership.';

  const workText = work.length
    ? work
        .map((w) =>
          w.accountId === account.id
            ? `- Work we did with ${account.name} directly: ${w.title} (${w.year}). Outcome: ${w.outcome}`
            : `- Work for ${w.clientLabel} (keep anonymous, never name the client): ${w.title} (${w.year}). Outcome: ${w.outcome}`
        )
        .join('\n')
    : 'None selected. Do not mention any past projects.';

  const planText = plan
    ? `Overview: ${plan.overview}\nTalking points: ${plan.talking_points.map((p) => p.text).join('; ')}\nCapability gaps (do not promise these): ${plan.capability_gaps.map((g) => g.text).join('; ') || 'none'}`
    : 'No outreach plan generated yet.';

  const entryPointText = entryPoint
    ? `${entryPoint.part} — ${entryPoint.rationale}`
    : 'None identified — no part of this opportunity is a clean fit yet; lead with stated priorities instead.';

  const emphasizedCapsText = emphasizedCapabilities.length
    ? `The user has specifically chosen to feature: ${emphasizedCapabilities.join('; ')} — anchor the capability mention on these (pick at most one or two of them), rather than scanning the full capabilities list below.`
    : 'None specifically chosen — pick one or two relevant capabilities from the full list below.';

  return `You are drafting an outreach email for a renewables-sector business development team. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"subject": "...", "body": "..."}

Recipient: ${recipient}
Their firm: ${account.name}, a ${account.sector}. Stated priorities: ${account.priorities.join('; ')}.

Lead signal(s) (open with these; they are the main reason for writing — if there are several, weave them together rather than listing them mechanically):
${leadText}

Other matched signals (mention at most one, briefly, and only if it strengthens the case):
${otherText}

Our thought leadership to reference (weave in naturally, one line each at most):
${insightText}

Previous work to reference as evidence we've done this before:
${workText}

Outreach plan context:
${planText}

Our team's research findings (most current information; where a finding conflicts with a signal, trust the finding):
${research || 'No research recorded.'}

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.
Capabilities to emphasize: ${emphasizedCapsText}

Recommended entry point for this account — the smallest credible piece of this opportunity we can win now (open the email emphasizing THIS, not the parts we're weaker on):
${entryPointText}

Capability gaps already identified, with whichever route the user has chosen for each, if any (do not promise coverage for a gap; if a route is chosen — e.g. a named partner, or a narrowed scope — you may reflect that framing, but never invent a partner or capability not listed above):
${gapsSummary || 'None identified.'}

Rules:
- Reference only the signals, findings, publications and projects listed above. Never invent projects, publications, figures, names or shared history.
- Write for this recipient: use what the notes say about their role and preferences. If the relationship is Warm or an existing client, you may reference the last touch point; if Cold, introduce our firm in one line.
- Lead with the recommended entry point above, not a part of the opportunity we have a gap in. Connect to one or two relevant capabilities, not the full list. Don't offer anything listed as a capability gap unless a route is chosen for it and you're reflecting that route's own framing (e.g. "working alongside our partner on X"), never a bare promise to cover it ourselves.
- End with one concrete ask and a proposed timeframe.
- Tone: ${TONES[tone] || TONES.Warm}.
- Body under 170 words, plain text, short paragraphs separated by blank lines. Sign off as "[your name]".${extra ? `\n\nAdditional instructions from the user: ${extra}` : ''}`;
}

// ---------------------------------------------------------------------------
// PDF EXPORT — a small self-contained PDF writer (no libraries), using the
// built-in Helvetica fonts, so the report downloads anywhere the prototype
// runs. Text is wrapped using Helvetica's real character widths.
// ---------------------------------------------------------------------------
// @pdf-start

// Glyph widths (per 1000 units) for character codes 32–126.
const HELV_W = [
  278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556,
  556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
  667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556,
  556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
const HELVB_W = [
  278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556,
  556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
  667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611,
  611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
];
// Characters outside Latin-1 that WinAnsi encoding can still show.
const WINANSI_EXTRA = {
  '€': 0x80, '‚': 0x82, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, '‰': 0x89,
  '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, '™': 0x99,
};
const EXTRA_W = { 0x80: 556, 0x82: 222, 0x84: 333, 0x85: 1000, 0x91: 222, 0x92: 222, 0x93: 333, 0x94: 333, 0x95: 350, 0x96: 556, 0x97: 1000, 0x99: 1000 };

function pdfCodes(str) {
  const s = String(str == null ? '' : str)
    .replace(/[→⟶]/g, '->')
    .replace(/[←]/g, '<-')
    .replace(/[−‐‑]/g, '-')
    .replace(/[   ]/g, ' ')
    .replace(/[\t\r\n]+/g, ' ');
  const codes = [];
  for (const ch of s) {
    const c = ch.codePointAt(0);
    if ((c >= 32 && c <= 126) || (c >= 160 && c <= 255)) codes.push(c);
    else if (WINANSI_EXTRA[ch]) codes.push(WINANSI_EXTRA[ch]);
    else codes.push(63); // '?'
  }
  return codes;
}

function pdfWidth(codes, font, size) {
  const table = font === 'F2' ? HELVB_W : HELV_W;
  let w = 0;
  for (const c of codes) w += c >= 32 && c <= 126 ? table[c - 32] : EXTRA_W[c] || 556;
  return (w / 1000) * size;
}

function pdfString(codes) {
  let out = '';
  for (const c of codes) out += c === 40 || c === 41 || c === 92 ? `\\${String.fromCharCode(c)}` : String.fromCharCode(c);
  return out;
}

// Greedy word wrap; words longer than the line (URLs) are broken by character.
function pdfWrap(text, font, size, maxW) {
  const words = pdfCodes(text).reduce(
    (acc, c) => {
      if (c === 32) acc.push([]);
      else acc[acc.length - 1].push(c);
      return acc;
    },
    [[]]
  ).filter((w) => w.length);
  const space = pdfWidth([32], font, size);
  const lines = [];
  let line = [];
  let lineW = 0;
  const flush = () => {
    if (line.length) lines.push(line);
    line = [];
    lineW = 0;
  };
  words.forEach((word) => {
    let w = pdfWidth(word, font, size);
    if (w > maxW) {
      flush();
      let chunk = [];
      word.forEach((c) => {
        if (pdfWidth([...chunk, c], font, size) > maxW) {
          lines.push(chunk);
          chunk = [];
        }
        chunk.push(c);
      });
      line = chunk;
      lineW = pdfWidth(chunk, font, size);
      return;
    }
    if (line.length && lineW + space + w > maxW) flush();
    if (line.length) {
      line = [...line, 32, ...word];
      lineW += space + w;
    } else {
      line = [...word];
      lineW = w;
    }
  });
  flush();
  return lines;
}

const PDF_COLOR = {
  ink: '0.09 0.11 0.15',
  body: '0.20 0.23 0.28',
  muted: '0.42 0.45 0.50',
  accent: '0.71 0.45 0.04',
  teal: '0.05 0.48 0.45',
  link: '0.06 0.36 0.62',
  rule: '0.82 0.84 0.87',
};

function createPdfLayout() {
  const W = 595.28; // A4
  const H = 841.89;
  const M = 56;
  const pages = [];
  let page;
  let y;
  const newPage = () => {
    page = { ops: [], links: [] };
    pages.push(page);
    y = H - M;
  };
  newPage();
  const ensure = (h) => {
    if (y - h < M + 14) newPage();
  };
  const draw = (codes, x, yy, font, size, color) => {
    page.ops.push(`BT /${font} ${size} Tf ${color} rg ${x.toFixed(2)} ${yy.toFixed(2)} Td (${pdfString(codes)}) Tj ET`);
  };

  return {
    W, H, M, pages,
    keep(h) {
      ensure(h);
    },
    // Forces a fresh page even if there's room left on the current one —
    // used by the meeting slide-pack builder (2026-10-11 follow-up
    // feedback) for one topic per page; the research-report PDF above
    // never needs this since it just flows continuously via keep/ensure.
    forceNewPage() {
      newPage();
    },
    space(h) {
      y -= h;
    },
    text(str, { font = 'F1', size = 10, color = PDF_COLOR.body, indent = 0, after = 4, lead = 1.42, bullet = null, url = null } = {}) {
      const maxW = W - M * 2 - indent;
      const lines = pdfWrap(str, font, size, maxW);
      const lh = size * lead;
      lines.forEach((ln, i) => {
        ensure(lh);
        y -= lh;
        if (bullet && i === 0) draw(pdfCodes(bullet), M + indent - 11, y, 'F1', size, PDF_COLOR.muted);
        draw(ln, M + indent, y, font, size, color);
        if (url) {
          page.links.push({ x1: M + indent, y1: y - 2, x2: M + indent + pdfWidth(ln, font, size), y2: y + size, url });
        }
      });
      y -= after;
    },
    rule(after = 10) {
      ensure(12);
      y -= 6;
      page.ops.push(`${PDF_COLOR.rule} RG 0.6 w ${M} ${y.toFixed(2)} m ${(W - M).toFixed(2)} ${y.toFixed(2)} l S`);
      y -= after;
    },
    footer(label) {
      pages.forEach((p, i) => {
        const right = pdfCodes(`Page ${i + 1} of ${pages.length}`);
        p.ops.push(`BT /F1 8 Tf ${PDF_COLOR.muted} rg ${M} 30 Td (${pdfString(pdfCodes(label))}) Tj ET`);
        p.ops.push(`BT /F1 8 Tf ${PDF_COLOR.muted} rg ${(W - M - pdfWidth(right, 'F1', 8)).toFixed(2)} 30 Td (${pdfString(right)}) Tj ET`);
      });
    },
  };
}

function pdfAsciiUrl(url) {
  let safe = '';
  try {
    safe = encodeURI(decodeURI(url));
  } catch (e) {
    safe = encodeURI(url);
  }
  return safe.replace(/[()\\]/g, (c) => `\\${c}`);
}

function serializePdf(layout, title) {
  const objs = [];
  const add = (body) => {
    objs.push(body);
    return objs.length;
  };
  const catalogId = add('');
  const pagesId = add('');
  const fonts = ['Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique'].map((name) =>
    add(`<< /Type /Font /Subtype /Type1 /BaseFont /${name} /Encoding /WinAnsiEncoding >>`)
  );
  const pageIds = layout.pages.map((p) => {
    const stream = p.ops.join('\n');
    const contentId = add(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
    const annots = p.links.map((l) =>
      add(
        `<< /Type /Annot /Subtype /Link /Rect [${l.x1.toFixed(2)} ${l.y1.toFixed(2)} ${l.x2.toFixed(2)} ${l.y2.toFixed(2)}] /Border [0 0 0] /A << /S /URI /URI (${pdfAsciiUrl(l.url)}) >> >>`
      )
    );
    return add(
      `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${layout.W} ${layout.H}] /Resources << /Font << /F1 ${fonts[0]} 0 R /F2 ${fonts[1]} 0 R /F3 ${fonts[2]} 0 R >> >> /Contents ${contentId} 0 R${
        annots.length ? ` /Annots [${annots.map((id) => `${id} 0 R`).join(' ')}]` : ''
      } >>`
    );
  });
  objs[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
  objs[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
  const infoId = add(`<< /Title (${pdfString(pdfCodes(title))}) /Producer (Signal Scout prototype) >>`);

  let out = '%PDF-1.4\n%âãÏÓ\n';
  const offsets = objs.map((body, i) => {
    const at = out.length;
    out += `${i + 1} 0 obj\n${body}\nendobj\n`;
    return at;
  });
  const xref = out.length;
  out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n${offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')}`;
  out += `trailer\n<< /Size ${objs.length + 1} /Root ${catalogId} 0 R /Info ${infoId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  // Every character is a single byte (text was mapped to WinAnsi codes).
  const bytes = new Uint8Array(out.length);
  for (let i = 0; i < out.length; i++) bytes[i] = out.charCodeAt(i) & 0xff;
  return bytes;
}

function buildReportPdf(report, account) {
  const L = createPdfLayout();
  const { list: sources, index: sourceIndex } = reportSources(report);
  const findings = orderedFindings(report);
  const done = findings.filter((f) => f.status === 'done');
  const heading = (t) => {
    L.keep(60);
    L.space(8);
    L.text(t, { font: 'F2', size: 12.5, color: PDF_COLOR.ink, after: 6 });
  };
  const bullets = (items) => items.forEach((t) => L.text(t, { bullet: '•', indent: 14, after: 3 }));

  L.text(
    account.opportunityNumber ? `SIGNAL SCOUT  ·  RESEARCH REPORT  ·  ${formatOpportunityNumber(account.opportunityNumber)}` : 'SIGNAL SCOUT  ·  RESEARCH REPORT',
    { font: 'F2', size: 8.5, color: PDF_COLOR.accent, after: 8 }
  );
  L.text(account.name, { font: 'F2', size: 22, color: PDF_COLOR.ink, lead: 1.2, after: 4 });
  L.text(`${account.sector}  ·  ${account.tier} account`, { size: 10, color: PDF_COLOR.muted, after: 2 });
  L.text(
    `Prepared ${formatDate(report.updatedAt)}  ·  ${METHOD_LABEL[report.method] || ''}  ·  ${done.length} question${done.length === 1 ? '' : 's'} researched  ·  ${sources.length} web source${sources.length === 1 ? '' : 's'}`,
    { size: 9, color: PDF_COLOR.muted, after: 4 }
  );
  L.rule(8);

  if (report.summary && report.summary.headline) {
    L.text(report.summary.headline, { font: 'F2', size: 12, color: PDF_COLOR.ink, lead: 1.35, after: 8 });
  }
  if (report.objective) {
    L.text(`Objective: ${report.objective}`, { font: 'F3', size: 9.5, color: PDF_COLOR.muted, after: 6 });
  }
  if (report.summary && report.summary.executive_summary) {
    heading('Executive summary');
    L.text(report.summary.executive_summary, { after: 6 });
  }

  heading('Key findings');
  findings.forEach((f, i) => {
    L.keep(70);
    if (i > 0) L.space(6);
    L.text(`${i + 1}. ${f.question}`, { font: 'F2', size: 10.5, color: PDF_COLOR.ink, after: 2 });
    if (f.status !== 'done') {
      L.text('Research failed for this question. Select it again in the checklist to retry.', { font: 'F3', size: 9.5, color: PDF_COLOR.muted, indent: 14 });
      return;
    }
    L.text(`${f.priority === 'Must' ? 'Must-answer' : 'Nice to have'}  ·  Confidence: ${f.confidence}`, {
      font: 'F3', size: 8.5, color: f.confidence === 'Not found' ? PDF_COLOR.muted : PDF_COLOR.teal, indent: 14, after: 3,
    });
    L.text(f.answer, { indent: 14, after: 3 });
    f.key_facts.forEach((k) => L.text(k, { bullet: '–', indent: 28, size: 9.5, after: 2 }));
    if (f.implication) L.text(`For outreach: ${f.implication}`, { font: 'F3', size: 9.5, color: PDF_COLOR.accent, indent: 14, after: 2 });
    const refs = f.sources.map((s) => (s.url ? `[${sourceIndex.get(s.url)}]` : 'Our records'));
    if (refs.length) L.text(`Sources: ${refs.join(', ')}`, { size: 8.5, color: PDF_COLOR.muted, indent: 14, after: 2 });
  });

  if (report.summary) {
    if (report.summary.implications.length) {
      heading('Implications for outreach');
      bullets(report.summary.implications);
    }
    if (report.summary.next_steps.length) {
      heading('Recommended next steps');
      bullets(report.summary.next_steps);
    }
    if (report.summary.open_questions.length) {
      heading('Still open');
      bullets(report.summary.open_questions);
    }
  }
  if (report.known && report.known.length) {
    heading('What we already knew');
    bullets(report.known.map((k) => k.text));
  }
  if (report.people_to_identify && report.people_to_identify.length) {
    heading('People to identify');
    bullets(report.people_to_identify);
  }
  if (report.watch_outs && report.watch_outs.length) {
    heading('Watch-outs');
    bullets(report.watch_outs.map((w) => w.text));
  }

  if (sources.length) {
    heading('Sources');
    sources.forEach((s, i) => {
      L.keep(28);
      L.text(`[${i + 1}] ${s.title || hostOf(s.url)}`, { size: 9, color: PDF_COLOR.ink, after: 0 });
      L.text(s.url, { size: 8.5, color: PDF_COLOR.link, indent: 18, after: 4, url: s.url });
    });
  }

  L.space(10);
  L.rule(4);
  L.text(
    report.method === 'model'
      ? 'Generated by Claude without web access, from our internal records and general knowledge only. Nothing here has been checked against live sources. Verify before using any of it with a client.'
      : 'Generated by Claude using web search. Only URLs returned by those searches are cited. Check key facts against the sources before using them with a client.',
    { font: 'F3', size: 8, color: PDF_COLOR.muted }
  );

  L.footer(`Signal Scout research report  ·  ${account.name}  ·  ${formatDate(report.updatedAt)}`);
  return serializePdf(L, `Research report: ${account.name}`);
}
// @pdf-end

function downloadReportPdf(report, account) {
  const bytes = buildReportPdf(report, account);
  const blob = new Blob([bytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const slug = account.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
  const a = document.createElement('a');
  a.href = url;
  a.download = `${slug}-research-report-${report.updatedAt.slice(0, 10)}.pdf`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60000);
}

// MEETING SLIDE PACK (2026-10-11 follow-up feedback: "include a capability
// to export a slide pack framework based on the suggestions") — same
// hand-rolled createPdfLayout/serializePdf primitives as the research
// report and proposal PDFs, zero new deps. This is a literal outline
// framework to build real slides from (one page per topic, title + blank
// space to work from), not a finished deck — consistent with this
// prototype never claiming to produce client-ready output on its own.
function buildMeetingSlidePackPdf(meetingPlan, account, trackLabel) {
  const L = createPdfLayout();
  L.text(
    account.opportunityNumber ? `SIGNAL SCOUT  ·  MEETING SLIDE PACK  ·  ${formatOpportunityNumber(account.opportunityNumber)}` : 'SIGNAL SCOUT  ·  MEETING SLIDE PACK',
    { font: 'F2', size: 8.5, color: PDF_COLOR.accent, after: 8 }
  );
  L.text(account.name, { font: 'F2', size: 22, color: PDF_COLOR.ink, lead: 1.2, after: 4 });
  L.text(trackLabel, { size: 11, color: PDF_COLOR.muted, after: 4 });
  L.text(`Outline framework — ${(meetingPlan.topics || []).length} topic${(meetingPlan.topics || []).length === 1 ? '' : 's'}, build the real slides from this.`, {
    size: 9, color: PDF_COLOR.muted, after: 2,
  });
  (meetingPlan.topics || []).forEach((topic, i) => {
    L.forceNewPage();
    L.text(`${i + 1}`, { font: 'F2', size: 10, color: PDF_COLOR.accent, after: 2 });
    L.text(topic, { font: 'F2', size: 18, color: PDF_COLOR.ink, lead: 1.3, after: 16 });
    L.rule(16);
    L.text('Notes:', { font: 'F3', size: 9.5, color: PDF_COLOR.muted, after: 60 });
    L.text('Supporting points:', { font: 'F3', size: 9.5, color: PDF_COLOR.muted, after: 4 });
    ['', '', ''].forEach(() => L.text('—', { bullet: '•', indent: 14, color: PDF_COLOR.muted, after: 18 }));
  });
  L.footer(`Signal Scout meeting slide pack  ·  ${account.name}  ·  ${trackLabel}`);
  return serializePdf(L, `Meeting slide pack: ${account.name} — ${trackLabel}`);
}

function downloadMeetingSlidePack(meetingPlan, account, trackLabel) {
  const bytes = buildMeetingSlidePackPdf(meetingPlan, account, trackLabel);
  const blob = new Blob([bytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const slug = account.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
  const a = document.createElement('a');
  a.href = url;
  a.download = `${slug}-meeting-slide-pack-${todayISO()}.pdf`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60000);
}

// PROPOSAL PDF (added per demo-week-plan.md Day 3a) — same createPdfLayout/
// serializePdf primitives buildReportPdf above already uses, not a second
// PDF-writing mechanism. Renders the same four sections the in-app
// ProposalPanel shows: understanding, approach, delivery model & honest
// gaps (straight from the account's fitGaps result, same as the panel),
// and relevant experience + next steps.
function buildProposalPdf(proposal, fitGap, account, selectedMoves) {
  const L = createPdfLayout();
  const heading = (t) => {
    L.keep(60);
    L.space(8);
    L.text(t, { font: 'F2', size: 12.5, color: PDF_COLOR.ink, after: 6 });
  };
  const bullets = (items) => items.forEach((t) => L.text(t, { bullet: '•', indent: 14, after: 3 }));

  L.text(
    account.opportunityNumber ? `SIGNAL SCOUT  ·  PROPOSAL  ·  ${formatOpportunityNumber(account.opportunityNumber)}` : 'SIGNAL SCOUT  ·  PROPOSAL',
    { font: 'F2', size: 8.5, color: PDF_COLOR.accent, after: 8 }
  );
  L.text(account.name, { font: 'F2', size: 22, color: PDF_COLOR.ink, lead: 1.2, after: 4 });
  L.text(`${account.sector}  ·  ${account.tier} account`, { size: 10, color: PDF_COLOR.muted, after: 2 });
  L.text(`Prepared ${formatDate(proposal.generatedAt)}`, { size: 9, color: PDF_COLOR.muted, after: 4 });
  L.rule(8);

  if (proposal.understanding) {
    heading('Understanding of the client’s need');
    L.text(proposal.understanding, { after: 6 });
  }

  if (proposal.approach.length) {
    heading('Proposed approach');
    bullets(proposal.approach);
  }

  heading('Delivery model & honest gaps');
  if (fitGap && fitGap.entryPoint) {
    L.text(`Entry point — where we win now: ${fitGap.entryPoint.part}`, { font: 'F2', size: 9.5, color: PDF_COLOR.ink, after: 1 });
    L.text(fitGap.entryPoint.rationale, { indent: 14, size: 9, color: PDF_COLOR.muted, after: 4 });
  }
  if (!fitGap || fitGap.gaps.length === 0) {
    L.text(
      fitGap ? 'Every matched signal maps cleanly to a listed capability, partnership or past project — no gaps identified.' : 'No structured fit/gap check has been run for this account yet.',
      { after: 6 }
    );
  } else {
    fitGap.gaps.forEach((g) => {
      L.keep(40);
      L.text(`${g.gap}`, { font: 'F2', size: 10, color: PDF_COLOR.ink, after: 1 });
      L.text(`${g.severity}  ·  Blocks: ${g.scope}  ·  Confidence: ${g.confidence}`, { font: 'F3', size: 8.5, color: PDF_COLOR.muted, indent: 14, after: 2 });
      const chosenIdx = selectedMoves && selectedMoves[g.tag];
      const chosen = typeof chosenIdx === 'number' ? g.moves[chosenIdx] : null;
      if (chosen) {
        L.text(`Route: ${chosen.type}${chosen.partner ? ` — ${chosen.partner}` : ''}: ${chosen.detail}`, { indent: 14, size: 9.5, after: 2 });
      } else {
        L.text('No route chosen yet for this gap.', { indent: 14, size: 9.5, color: PDF_COLOR.muted, after: 2 });
      }
    });
  }

  const work = proposal.experienceIds.map((id) => PAST_WORK.find((w) => w.id === id)).filter(Boolean);
  if (work.length) {
    heading('Relevant experience');
    work.forEach((w) => {
      L.text(`${w.clientLabel}: ${w.title} (${w.year})`, { font: 'F2', size: 10, color: PDF_COLOR.ink, after: 1 });
      L.text(`Outcome: ${w.outcome}`, { indent: 14, size: 9.5, after: 3 });
    });
  }

  if (proposal.nextSteps.length) {
    heading('Indicative next steps');
    proposal.nextSteps.forEach((s) => {
      L.text(`${s.step}`, { font: 'F2', size: 10, color: PDF_COLOR.ink, after: 1 });
      L.text(`${s.timeframe}${s.timeframe && s.owner ? '  ·  ' : ''}${s.owner}`, { indent: 14, size: 9, color: PDF_COLOR.muted, after: 4 });
    });
  }

  L.space(10);
  L.rule(4);
  L.text(
    'Generated by Claude from our own capabilities, partnerships and past work, plus a structured fit/gap check for this account. Verify before sending to a client, the same as you would any AI-drafted proposal.',
    { font: 'F3', size: 8, color: PDF_COLOR.muted }
  );

  L.footer(`Signal Scout proposal  ·  ${account.name}  ·  ${formatDate(proposal.generatedAt)}`);
  return serializePdf(L, `Proposal: ${account.name}`);
}

function downloadProposalPdf(proposal, fitGap, account, selectedMoves) {
  const bytes = buildProposalPdf(proposal, fitGap, account, selectedMoves);
  const blob = new Blob([bytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const slug = account.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
  const a = document.createElement('a');
  a.href = url;
  a.download = `${slug}-proposal-${proposal.generatedAt.slice(0, 10)}.pdf`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60000);
}

// API KEY CONTROL (added 2026-10-05) — the header widget for pasting an
// Anthropic API key in, per the credential-handling note above
// anthropicHeaders(). Self-contained: it owns the open/closed popover and
// its own draft text, and reads/writes the module-level key directly rather
// than threading it through App's state, since nothing else in the render
// tree needs to react to it — every Claude call just reads it fresh at
// call time via anthropicHeaders().
function ApiKeyControl() {
  const [open, setOpen] = useState(false);
  const [draft, setDraft] = useState('');
  const [hasKey, setHasKey] = useState(hasAnthropicApiKey());

  function save() {
    setAnthropicApiKey(draft);
    setHasKey(hasAnthropicApiKey());
    setDraft('');
    setOpen(false);
  }

  function clear() {
    setAnthropicApiKey('');
    setHasKey(false);
    setDraft('');
  }

  return (
    <div className="relative">
      <button
        type="button"
        id="header-api-key-control"
        onClick={() => setOpen((v) => !v)}
        className={`flex items-center gap-1.5 px-2 py-1 rounded-[var(--ss-radius-sm)] transition-colors ${
          hasKey ? 'text-[var(--ss-positive)] bg-[var(--ss-positive-wash)]' : 'text-[var(--ss-muted)] hover:text-[var(--ss-ink)] hover:bg-[var(--ss-rule-soft)]'
        }`}
      >
        <KeyRound size={11} /> {hasKey ? 'API key set' : 'API key'}
      </button>
      {open && (
        <div className="absolute left-0 top-full mt-2 w-72 p-3 rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] z-20 text-left normal-case tracking-normal">
          <div className="text-[11px] text-[var(--ss-ink)] font-medium mb-1 font-[var(--ss-font-sans)]">Anthropic API key</div>
          <p className="text-[10px] text-[var(--ss-muted)] mb-2 font-[var(--ss-font-sans)]">
            Needed for every live Claude call (plan generation, research, email drafts). Kept in memory for this browser tab only — never
            saved to disk, and cleared on refresh.
          </p>
          <input
            type="password"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            placeholder="sk-ant-..."
            className={`w-full px-2 py-1.5 text-xs mb-2 font-[var(--ss-font-sans)] ${ssFieldClass}`}
          />
          <div className="flex items-center gap-2">
            <Button variant="primary" size="sm" onClick={save} disabled={!draft.trim()} className="h-7 px-2.5 text-[11px] font-[var(--ss-font-sans)] normal-case">
              Save
            </Button>
            {hasKey && (
              <button type="button" onClick={clear} className="px-2.5 py-1 text-[11px] font-[var(--ss-font-sans)] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)]">
                Clear
              </button>
            )}
            <button type="button" onClick={() => setOpen(false)} className="ml-auto text-[11px] font-[var(--ss-font-sans)] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
              Close
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// CAPABILITIES PAGE (replaced the header popover 2026-10-08) — a standalone
// page, not a popover: per direct feedback, the header widget read as too
// lightweight for what this data actually is (the firm's own competency
// profile, which `generatePlan`'s gap-flagging rule is checked against for
// every signal). Same `let CAPABILITIES`/`PARTNERSHIPS` module bindings as
// before (see their own comment above `DEFAULT_CAPABILITIES`) — this page
// just gives them a page's worth of room instead of a cramped textarea, plus
// a new third input this format couldn't fit: uploaded past-project files,
// which generatePlan now reads alongside the two lists (see
// COMPETENCY_UPLOAD_TEXT_EXT/competencyUploads in App, and the updated
// generatePlan prompt below).
function listToText(list) {
  return (list || []).join('\n');
}
function textToList(text) {
  return text.split('\n').map((s) => s.trim()).filter(Boolean);
}

// Same two extension patterns MANUAL_INTAKE_TEXT_EXT/MANUAL_INTAKE_PDF_EXT
// above already define for the research-intake file upload — reused here
// rather than duplicated, same reasoning (plain text this prototype can
// read itself, PDF via the API's native document understanding, no Word/
// Excel parsing without a library this project deliberately doesn't have).
const COMPETENCY_UPLOAD_MAX_BYTES = 5 * 1024 * 1024; // 5MB/file — generous for a case study or project spec, small enough to not blow the localStorage quota in one upload.

function formatFileSize(bytes) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

// Controlled (added 2026-10-08, replacing an earlier version with its own
// internal rows/dirty/save state) — rows live in CapabilitiesPage now, so
// one page-level "Save changes & return to Overview" button can commit both
// this list and the other CompetencyListEditor on the page in a single
// action, per direct feedback. Edits flow straight into the parent's draft
// state on every keystroke; nothing is written to CAPABILITIES/PARTNERSHIPS
// until that page-level button actually saves.
// `narrow` (added 2026-10-11 direct feedback: "the geographies input... can
// be narrower") caps the row/card width for a list whose entries are short
// (a country or region name) — capabilities/partnerships entries stay full
// width, since those are genuinely sentence-length.
// This page's own section-caption style — a contrast-corrected variant of
// the shared `sectionLabel` above (2026-10-13, critique finding). `--text-6`
// reads at ~2.5:1 against this app's light panels, under the 4.5:1 floor;
// `--text-4` is the same existing "muted" token already used for this page's
// own helper paragraphs and passes at ~4.75:1. Scoped to this component
// (and CompetencyUploads/CapabilitiesPage below) rather than changing
// `sectionLabel` itself, since that shared constant renders ~180 times
// across the rest of the app and most of those uses aren't body text this
// fix is targeting. Also now an <h2> instead of a <div> — CapabilitiesPage's
// own <h1> plus one of these per section gives the page a real heading
// hierarchy for screen-reader navigation, where previously every section
// caption was a plain unlabeled node.
const companySectionLabel = 'text-[11px] font-mono uppercase tracking-wide text-[var(--ss-text-2)] mb-2';

function CompetencyListEditor({ title, description, rows, onChange, placeholder = 'e.g. Interconnection queue strategy & grid studies', narrow = false }) {
  function setRow(i, value) {
    onChange(rows.map((r, idx) => (idx === i ? value : r)));
  }
  function removeRow(i) {
    onChange(rows.filter((_, idx) => idx !== i));
  }
  function addRow() {
    onChange([...rows, '']);
  }

  return (
    <section>
      <h2 className={companySectionLabel}>{title}</h2>
      <p className="text-[12px] text-[var(--ss-muted)] mb-3 max-w-2xl">{description}</p>
      <div className={`rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3 space-y-2 ${narrow ? 'max-w-xs' : ''}`}>
        {rows.length === 0 && <p className="text-[12px] text-[var(--ss-muted)] py-1">Nothing added yet.</p>}
        {rows.map((row, i) => (
          <div key={i} className="flex items-center gap-2">
            <input
              value={row}
              onChange={(e) => setRow(i, e.target.value)}
              placeholder={placeholder}
              title={row || undefined}
              className={inputClass}
            />
            <button
              type="button"
              onClick={() => removeRow(i)}
              className="shrink-0 text-[var(--ss-muted)] hover:text-[var(--ss-danger)] p-1"
              aria-label="Remove"
            >
              <X size={14} />
            </button>
          </div>
        ))}
        <button
          type="button"
          onClick={addRow}
          className="flex items-center gap-1 text-[12px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] pt-1"
        >
          <Plus size={13} /> Add
        </button>
      </div>
    </section>
  );
}

// `title`/`description` (added 2026-10-11, see the second instance of this
// component on "Your Company" for thought leadership uploads) so the same
// upload/list/remove mechanics can back two different kinds of files
// without duplicating the component.
function CompetencyUploads({ uploads, onAdd, onRemove, title = 'Previous project work', description }) {
  const fileInputRef = useRef(null);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  async function handleFiles(e) {
    const files = Array.from(e.target.files || []);
    e.target.value = ''; // allow re-selecting the same file after removing it
    if (!files.length) return;
    setError(null);
    setPending(true);
    const accepted = [];
    const rejected = [];
    for (const file of files) {
      if (file.size > COMPETENCY_UPLOAD_MAX_BYTES) {
        rejected.push(`${file.name} (over ${formatFileSize(COMPETENCY_UPLOAD_MAX_BYTES)} limit)`);
        continue;
      }
      try {
        const info = await readIntakeFile(file);
        accepted.push({ id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: file.name, size: file.size, uploadedAt: new Date().toISOString(), ...info });
      } catch (err) {
        rejected.push(`${file.name} (${err instanceof UnsupportedIntakeFile ? 'unsupported file type' : 'could not be read'})`);
      }
    }
    if (accepted.length) onAdd(accepted);
    if (rejected.length) setError(`Skipped: ${rejected.join('; ')}.`);
    setPending(false);
  }

  return (
    <section>
      <h2 className={companySectionLabel}>{title}</h2>
      <p className="text-[12px] text-[var(--ss-muted)] mb-3 max-w-2xl">
        {description || (
          <>
            Upload case studies, proposals or project write-ups. Plain text, Markdown, CSV and PDF are sent to Claude directly (PDFs via the API's native document reading) and used
            as grounding material — alongside the capabilities and partnerships above — when generating an outreach plan's talking points and capability gaps. Word/Excel files
            aren't supported without a library this prototype deliberately doesn't carry; paste the relevant text in as a <span className="text-[var(--ss-text-2)]">.txt</span> or save
            as PDF instead. Stored in this browser's localStorage only — not uploaded anywhere else, and large files may not persist if your browser's storage quota fills up.
          </>
        )}
      </p>
      <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3 space-y-2">
        {uploads.length === 0 && <p className="text-[12px] text-[var(--ss-muted)] py-1">No files uploaded yet.</p>}
        {uploads.length > 0 && (
          <div className="divide-y divide-[var(--ss-border)]">
            {uploads.map((u) => (
              <div key={u.id} className="flex items-center justify-between gap-3 py-2 flex-wrap">
                <div className="min-w-0 flex items-center gap-2">
                  <FileText size={14} className="text-[var(--ss-muted)] shrink-0" />
                  <div className="min-w-0">
                    <div className="text-[13px] text-[var(--ss-ink)] truncate">{u.name}</div>
                    <div className="text-[11px] text-[var(--ss-muted)]">
                      {u.kind === 'pdf' ? 'PDF' : 'Text'} · {formatFileSize(u.size)} · added {formatDate(u.uploadedAt)}
                    </div>
                  </div>
                </div>
                <button
                  type="button"
                  onClick={() => onRemove(u.id)}
                  className="shrink-0 text-[var(--ss-muted)] hover:text-[var(--ss-danger)] p-1"
                  aria-label={`Remove ${u.name}`}
                >
                  <X size={14} />
                </button>
              </div>
            ))}
          </div>
        )}
        <input
          ref={fileInputRef}
          type="file"
          multiple
          accept=".txt,.md,.markdown,.csv,.eml,.log,.pdf,text/plain,text/markdown,text/csv,application/pdf"
          onChange={handleFiles}
          className="hidden"
        />
        <button
          type="button"
          onClick={() => fileInputRef.current && fileInputRef.current.click()}
          disabled={pending}
          className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-3 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
        >
          {pending ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />} {pending ? 'Reading…' : 'Upload files'}
        </button>
        {error && <p className="text-[12px] text-[var(--ss-danger)]">{error}</p>}
      </div>
    </section>
  );
}

// Grounded web-search lookup against the company website field above (added
// 2026-10-11, per direct feedback: "add a website field so the tool can get
// more background info on the user's capabilities"). Same honesty
// discipline as the rest of this file's AI features: only suggests
// competency rows the search actually turned up evidence for, never pads
// the list, and every suggestion is a one-click "add" the user reviews
// before it lands in the real draft — never auto-applied.
function CompanyWebsiteResearch({ website, companyName, onAddCapability }) {
  const [suggestions, setSuggestions] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [added, setAdded] = useState([]);

  async function research() {
    setLoading(true);
    setError(null);
    const prompt = `You are helping a renewables-sector business development team fill in their own firm's competency profile, grounded on their company website. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"capabilities": ["...", "..."]}

Rules:
- Use web search to look up the company website below (and its name, if that helps find it) and identify what services/capabilities it actually describes offering.
- Each entry: a short phrase naming one real capability or service area the website describes, under 12 words, written the same way the rest of a "areas of competency" list would read (e.g. "Offshore wind permitting advisory", not a sentence).
- Only include something the search actually found evidence for on the company's own site or clearly-attributed coverage of it — never guess or pad with generic renewables-sector capabilities that weren't actually found.
- Up to 8 entries. If the search can't find anything useful (site unreachable, too generic, no real content), return an empty array rather than guessing.

Company name: ${companyName || '(not given)'}
Company website: ${website}`;

    try {
      const { parsed } = await callClaudeResearch(prompt, { web: true, maxTokens: 1200 });
      const list = Array.isArray(parsed.capabilities)
        ? parsed.capabilities.filter((c) => typeof c === 'string' && c.trim()).map((c) => c.trim())
        : [];
      setSuggestions(list);
    } catch (e) {
      setError(friendlyApiError(e, "Couldn't research your website just now — try again."));
    } finally {
      setLoading(false);
    }
  }

  const hasKey = hasAnthropicApiKey();

  return (
    <div className="mt-2.5">
      <button
        type="button"
        onClick={research}
        disabled={loading || !website.trim() || !hasKey}
        title={!website.trim() ? 'Add a website above first' : !hasKey ? 'Add an Anthropic API key in the header first' : undefined}
        className="flex items-center gap-1.5 text-[12px] px-2.5 py-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-ink)] hover:bg-[var(--ss-ground)] disabled:opacity-40"
      >
        {loading ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
        {loading ? 'Researching your website…' : 'Research our company online'}
      </button>
      {error && <p className="text-[12px] text-[var(--ss-danger)] mt-1.5">{error}</p>}
      {suggestions.length > 0 && (
        <div className="mt-2.5 space-y-1.5 max-w-2xl">
          <div className="text-[11px] text-[var(--ss-muted)]">Found on your site — add any that belong in "Areas of competency" below:</div>
          {suggestions.map((s) => {
            const isAdded = added.includes(s);
            return (
              <div key={s} className="flex items-center justify-between gap-2 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] px-2.5 py-1.5">
                <span className="text-[12px] text-[var(--ss-ink)]">{s}</span>
                <button
                  type="button"
                  onClick={() => {
                    onAddCapability(s);
                    setAdded((prev) => [...prev, s]);
                  }}
                  disabled={isAdded}
                  className="shrink-0 flex items-center gap-1 text-[11px] px-2 py-1 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] hover:border-[var(--ss-border)] disabled:opacity-40"
                >
                  {isAdded ? <CheckCircle2 size={11} /> : <Plus size={11} />} {isAdded ? 'Added' : 'Add'}
                </button>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function CapabilitiesPage({
  onBack, uploads, onAddUploads, onRemoveUpload, onChangeCapabilities, onChangePartnerships, bidWritingCapable, onChangeBidWritingCapable,
  companyName, onChangeCompanyName, geographies, onChangeGeographies, companyWebsite, onChangeCompanyWebsite,
  thoughtLeadershipUploads, onAddThoughtLeadershipUploads, onRemoveThoughtLeadershipUpload,
}) {
  // Draft rows, lifted up from the two CompetencyListEditor instances
  // (2026-10-08, see that component's own comment) so one button below can
  // commit both in a single action. Read fresh from the module bindings on
  // mount only — same reasoning the old per-section editor used.
  const [capRows, setCapRows] = useState([...CAPABILITIES]);
  const [partRows, setPartRows] = useState([...PARTNERSHIPS]);
  // Company identity fields (added 2026-10-11, same rename that turned this
  // page from "Your Capabilities" into "Your Company") — same draft-then-
  // commit-on-save pattern as capRows/partRows above, so the name field and
  // the geographies list editor share the one "Save changes & return"
  // button rather than each needing their own.
  const [nameDraft, setNameDraft] = useState(companyName);
  const [websiteDraft, setWebsiteDraft] = useState(companyWebsite);
  const [geoRows, setGeoRows] = useState([...geographies]);
  // Brief "Saved" confirmation before navigating back (2026-10-13, critique
  // finding: the save button was a silent redirect with no confirmation,
  // undercutting the page's own framing that this data is graded for
  // honesty). Same self-dismissing-timer shape as the toasts elsewhere in
  // this file (e.g. Overview's fitGapsToast), but short and blocking rather
  // than parallel to navigation, since here the confirmation has to be seen
  // before the page changes out from under it.
  const [justSaved, setJustSaved] = useState(false);
  const saveTimerRef = useRef(null);
  useEffect(() => () => window.clearTimeout(saveTimerRef.current), []);

  function saveAndReturn() {
    onChangeCapabilities(capRows.map((r) => r.trim()).filter(Boolean));
    onChangePartnerships(partRows.map((r) => r.trim()).filter(Boolean));
    onChangeCompanyName(nameDraft.trim());
    onChangeCompanyWebsite(websiteDraft.trim());
    onChangeGeographies(geoRows.map((r) => r.trim()).filter(Boolean));
    setJustSaved(true);
    saveTimerRef.current = window.setTimeout(onBack, 700);
  }

  return (
    <div className="w-full">
      <button onClick={onBack} className="flex items-center gap-1 text-[12px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] mb-5 active:scale-95 transition-transform">
        <ChevronLeft size={13} /> Back to Overview
      </button>

      {/* Personalized once a name is entered (2026-10-11 direct feedback:
          "once the user puts the company name in, update the title on the
          page to be their company + overview") — reads off `nameDraft`
          (the live field below, not just the already-saved `companyName`)
          so the title updates as they type rather than only after Save. */}
      <h1 className="text-lg font-semibold text-[var(--ss-ink)] mb-1.5">{nameDraft.trim() ? `${nameDraft.trim()} Overview` : 'Your Company'}</h1>
      <p className="text-[12px] text-[var(--ss-muted)] mb-8">
        What Signal Scout checks every matched signal against when drafting an outreach plan — maps cleanly onto something here, and it becomes a talking point; implies a need
        that isn't here, and the plan states it plainly as a capability gap rather than softening it. Replace this prototype's placeholder entries with your own firm's real
        profile. Everything on this page is saved to this browser only (localStorage), never sent anywhere except as context on an outreach-plan generation call.
      </p>

      <div className="space-y-8">
        <div>
          <h2 id="company-name-label" className={companySectionLabel}>Company name</h2>
          <p className="text-[12px] text-[var(--ss-muted)] mb-2 max-w-2xl">Your firm's name, used as context on every outreach-plan generation call.</p>
          <input
            type="text"
            aria-labelledby="company-name-label"
            value={nameDraft}
            onChange={(e) => setNameDraft(e.target.value)}
            placeholder="e.g. Ashworth Energy Advisory"
            className="w-full max-w-md rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] text-[13px] text-[var(--ss-ink)] px-3 py-2 placeholder:text-[var(--ss-muted)] focus:outline-none focus:border-[var(--ss-frame)] focus:ring-2 focus:ring-[var(--ss-border)]"
          />
        </div>
        <div>
          <h2 id="company-website-label" className={companySectionLabel}>Company website</h2>
          <p className="text-[12px] text-[var(--ss-muted)] mb-2 max-w-2xl">
            Optional — used as context on every generation call, and lets "Research our company online" below look up real background on what your firm offers.
          </p>
          <input
            type="text"
            aria-labelledby="company-website-label"
            value={websiteDraft}
            onChange={(e) => setWebsiteDraft(e.target.value)}
            placeholder="e.g. https://ashworthenergy.example"
            className="w-full max-w-md rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] text-[13px] text-[var(--ss-ink)] px-3 py-2 placeholder:text-[var(--ss-muted)] focus:outline-none focus:border-[var(--ss-frame)] focus:ring-2 focus:ring-[var(--ss-border)]"
          />
          <CompanyWebsiteResearch
            website={websiteDraft}
            companyName={nameDraft}
            onAddCapability={(row) => setCapRows((prev) => (prev.includes(row) ? prev : [...prev, row]))}
          />
        </div>
        <CompetencyListEditor
          title="Geographies of operation"
          description="Where your firm does business — regions, countries or markets you actually operate in or serve clients from."
          rows={geoRows}
          onChange={setGeoRows}
          placeholder="e.g. United Kingdom"
          narrow
        />
        <CompetencyListEditor
          title="Areas of competency"
          description="What your firm actually does — the services, technical capabilities and domains you can credibly deliver on."
          rows={capRows}
          onChange={setCapRows}
        />
        <CompetencyListEditor
          title="Partnerships"
          description="Named partners (financing, technology, legal, etc.) that extend what you can credibly offer, even where it's not your own in-house capability."
          rows={partRows}
          onChange={setPartRows}
        />
        <CompetencyUploads uploads={uploads} onAdd={onAddUploads} onRemove={onRemoveUpload} />

        {/* Thought leadership uploads (2026-10-11 direct feedback: "there
            should be a section to upload the thought leadership that's
            linked on the email generator") — same upload mechanics as
            previous project work above, feeding the email composer's "Our
            thought leadership" picker (see FIRM_INSIGHTS merge in
            EmailComposer) instead of generatePlan's gap-flagging. */}
        <CompetencyUploads
          uploads={thoughtLeadershipUploads}
          onAdd={onAddThoughtLeadershipUploads}
          onRemove={onRemoveThoughtLeadershipUpload}
          title="Thought leadership"
          description={
            <>
              Upload white papers, briefings, webinar write-ups or market notes your firm has published. Plain text, Markdown, CSV and PDF are sent to Claude directly (PDFs via
              the API's native document reading) and offered alongside the sample thought leadership below as something to reference when drafting an outreach email. Stored in
              this browser's localStorage only — not uploaded anywhere else.
            </>
          }
        />

        {/* P1 item 8 (feedback brief): lets a funding-call signal note
            "your team has bid-writing capability" where relevant — see
            FundingBidEvaluation. Toggles immediately, same as the other
            boolean flags in the app (auto-match, manager mode), unlike the
            two list editors above which batch into the Save button. */}
        <div>
          <h2 className={companySectionLabel}>Bid-writing capability</h2>
          <p className="text-[12px] text-[var(--ss-muted)] mb-2 max-w-2xl">
            When on, a funding call or tender raised with a client notes that your team can also offer bid-writing support.
          </p>
          <TogglePill active={bidWritingCapable} onClick={() => onChangeBidWritingCapable(!bidWritingCapable)}>
            <span className="flex items-center gap-1.5 whitespace-nowrap">
              <FileText size={12} /> {bidWritingCapable ? 'Bid-writing capability: On' : 'Bid-writing capability: Off'}
            </span>
          </TogglePill>
        </div>

        <div className="flex items-center gap-3 pt-2">
          <button
            type="button"
            onClick={saveAndReturn}
            disabled={justSaved}
            className="flex items-center gap-1.5 rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-semibold px-4 py-2.5 hover:brightness-110 active:scale-95 transition-transform disabled:opacity-90"
          >
            <CheckCircle2 size={14} /> {justSaved ? 'Saved — returning to Overview…' : 'Save changes & return to Overview'}
          </button>
          <span className="text-[11px] text-[var(--ss-muted)]">File uploads above are saved as soon as you add them — this button only covers the two lists.</span>
        </div>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// DEMO GATE (added 2026-10-07) — per the Notion task "Build a demo landing
// screen: 'Start from scratch' (showcase onboarding) vs. 'Resume existing
// session' (normal app, as a real user)." Not a one-time first-visit
// screen: re-asked on every load, since this is explicitly for a
// salesperson doing back-to-back live demos who needs to choose, each time,
// whether to show a prospect the guided walkthrough from a clean slate or
// just pick up a session already in progress. See App's gateChoice/
// startFresh for the state this drives.
// ---------------------------------------------------------------------------

function DemoGate({ hasExistingSession, onStartFresh, onResume }) {
  useModalOpen(); // blocking overlay — suppress the page's own shortcuts behind it
  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--ss-ground)] p-6"
      style={{
        backgroundImage: 'linear-gradient(var(--ss-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ss-grid) 1px, transparent 1px)',
        backgroundSize: 'var(--ss-grid-size) var(--ss-grid-size)',
      }}
    >
      <div className="w-full max-w-md text-center">
        <div className="flex items-center gap-2.5 justify-center mb-10">
          <Zap size={22} className="text-[var(--ss-accent)]" strokeWidth={2.25} />
          <span className="font-[var(--ss-font-display)] text-[28px] leading-none tracking-[-0.02em] text-[var(--ss-ink)]">Signal Scout</span>
        </div>
        <h1 className="font-[var(--ss-font-display)] text-[28px] tracking-[-0.02em] text-[var(--ss-ink)] mb-2 leading-tight">Renewables news, mapped to your book of business.</h1>
        <p className="text-[13px] text-[var(--ss-muted)] mb-8">Pick a starting point for this session.</p>

        <Button variant="primary" onClick={onStartFresh} icon={PlayCircle} className="w-full h-[52px] text-sm">
          Start from scratch
        </Button>
        <p className="text-[11px] text-[var(--ss-muted)] mt-2 mb-6 leading-relaxed">
          Resets to a clean demo, then walks through each tab, adding a company to track, and running a real news scrape.
        </p>

        <Button
          variant="secondary"
          onClick={onResume}
          disabled={!hasExistingSession}
          title={hasExistingSession ? undefined : 'Nothing saved in this browser yet'}
          icon={RotateCcw}
          className="w-full"
        >
          Resume existing session
        </Button>
        {!hasExistingSession && (
          <p className="text-[11px] text-[var(--ss-faint)] mt-2">Nothing saved in this browser yet — start from scratch instead.</p>
        )}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// TAB TOUR SPOTLIGHT (added 2026-10-07, replacing an earlier in-modal tour
// card per direct feedback: the review of each tab's purpose needed to
// highlight and cycle through the real tab buttons in the app's own header,
// not a floating window's own copy of them). Dims the whole page except a
// cutout around the real `[data-tour-tab]` button (the actual Overview/
// Signal map/Live feed buttons in App's tab bar — see the `data-tour-tab`
// attribute there), with a small callout anchored to it carrying that tab's
// one-line purpose. Auto-cycles (per "cycle between each tab"); Back/Next
// let the user move at their own pace too, per onboard.md's "let users
// click through tour freely." Also actually switches the real `tab` state
// as it steps (onTabChange), so this is touring the genuine app, not a
// simulation of it — the dimmed backdrop just keeps focus on the tab bar
// itself while the real content changes underneath.
// ---------------------------------------------------------------------------

const TOUR_TABS = [
  { key: 'overview', label: 'Overview', blurb: 'Every tracked account on one pipeline timeline — see who needs research, outreach, or follow-up next.' },
  { key: 'map', label: 'Signal map', blurb: 'The connections between news stories and the accounts they affect, all on one board.' },
  { key: 'livefeed', label: 'Live feed', blurb: 'Real renewables-sector news, scraped on demand — search it, and connect stories to the accounts they matter to.' },
];

const TOUR_STEP_MS = 4000;

function TabTourSpotlight({ onTabChange, onDone, onSkip }) {
  useModalOpen(); // blocking overlay — suppress the page's own shortcuts behind it
  const [index, setIndex] = useState(0);
  const [rect, setRect] = useState(null);
  const current = TOUR_TABS[index];

  // Keep the real tab bar in sync with whichever tab is being toured.
  useEffect(() => {
    onTabChange(current.key);
  }, [current.key, onTabChange]);

  // Measure the real button's position — re-measured on every step and on
  // resize, since it's an actual DOM element elsewhere in the tree, not
  // something this component controls directly.
  useEffect(() => {
    function measure() {
      const el = document.querySelector(`[data-tour-tab="${current.key}"]`);
      if (el) setRect(el.getBoundingClientRect());
    }
    measure();
    window.addEventListener('resize', measure);
    const raf = requestAnimationFrame(measure); // catches the tab-switch re-layout a frame later
    return () => {
      window.removeEventListener('resize', measure);
      cancelAnimationFrame(raf);
    };
  }, [current.key]);

  function goNext() {
    if (index < TOUR_TABS.length - 1) setIndex((i) => i + 1);
    else onDone();
  }
  function goBack() {
    setIndex((i) => Math.max(0, i - 1));
  }

  // Auto-cycle — resets whenever the step changes, whether that happened
  // from the timer itself or a manual Back/Next click.
  useEffect(() => {
    const t = setTimeout(goNext, TOUR_STEP_MS);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [index]);

  if (!rect) return null;

  const pad = 6;
  const calloutWidth = 288;
  const spaceBelow = window.innerHeight - rect.bottom;
  const below = spaceBelow > 160; // flip above the tab bar if there's no room below
  const calloutTop = below ? rect.bottom + pad + 10 : rect.top - pad - 10;
  const calloutLeft = Math.max(16, Math.min(rect.left, window.innerWidth - calloutWidth - 16));

  return (
    // pointer-events: none on the backdrop itself so the spotlighted real
    // tab button underneath stays clickable (clicking it still just
    // navigates the real app; the tour keeps running independently). Only
    // the callout card re-enables pointer events for its own controls.
    <div className="fixed inset-0 z-50" style={{ pointerEvents: 'none' }}>
      <div
        className="ring-2 ring-[var(--ss-border)]"
        style={{
          // Only `transform` is transitioned (compositor-only, no layout
          // thrash) — width/height are set directly with no transition, so
          // the ring's size snaps instantly between differently-sized tab
          // labels while its position still slides smoothly. Animating
          // top/left/width/height directly (the first version) is exactly
          // the kind of layout-triggering transition the mechanical
          // detector flags, and for good reason on a per-frame reflow.
          position: 'fixed',
          top: 0,
          left: 0,
          width: rect.width + pad * 2,
          height: rect.height + pad * 2,
          borderRadius: 8,
          boxShadow: '0 0 0 9999px rgba(15,23,42,0.72)',
          transform: `translate(${rect.left - pad}px, ${rect.top - pad}px)`,
          transition: 'transform 0.3s ease',
        }}
      />
      <div
        className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-4"
        style={{ position: 'fixed', top: calloutTop, left: calloutLeft, width: calloutWidth, pointerEvents: 'auto', transform: below ? undefined : 'translateY(-100%)' }}
      >
        <div className="flex items-center gap-1.5 mb-2.5">
          {TOUR_TABS.map((t, i) => (
            <span key={t.key} className={`h-1.5 rounded-[var(--ss-radius-sm)] transition-all ${i === index ? 'w-6 bg-[var(--ss-accent)]' : 'w-1.5 bg-[var(--ss-border)]'}`} />
          ))}
          <button onClick={onSkip} className="ml-auto text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
            Skip
          </button>
        </div>
        <div className="text-[13px] font-semibold text-[var(--ss-ink)] mb-1">{current.label}</div>
        <p className="text-[12px] text-[var(--ss-muted)] leading-relaxed mb-3.5">{current.blurb}</p>
        <div className="flex items-center gap-2">
          {index > 0 && (
            <button onClick={goBack} className="text-[12px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] px-1 py-1.5">
              Back
            </button>
          )}
          <button
            onClick={goNext}
            className="ml-auto flex items-center gap-1 rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[12px] font-semibold px-3 py-1.5 hover:brightness-110 active:scale-95 transition-transform"
          >
            {index === TOUR_TABS.length - 1 ? 'Done' : 'Next tab'} <ChevronRight size={12} />
          </button>
        </div>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// ONBOARDING (added 2026-10-07; capabilities step added 2026-10-08) — the
// "showcase onboarding" half of the demo gate above. Explicit requirements:
// a quick review of each tab's purpose (now TabTourSpotlight, above — not
// this modal), a prompt to set up Your Capabilities (per direct feedback:
// "invite the user to start here... to tailor the experience" — first,
// since every other step's output quality depends on this being filled in
// rather than left at this prototype's placeholder profile), a prompt to
// add a few tracked companies, and a prompt to run the first scrape. Three
// short steps here (the tour runs first, separately — see App's
// onboardPhase), ending on real actions, not simulated ones — see
// onGoToCapabilities/onGoToLiveFeed/highlightScrape in App and LiveFeed,
// which point the user at the actual pages/buttons rather than duplicating
// them here. Every step is skippable (onSkip, or the capabilities step's
// own "I'll do this later"), per onboard.md's "don't block access to the
// product" — nothing here is required to use the app.
// ---------------------------------------------------------------------------

function OnboardingModal({
  onAddAccounts,
  onGoToCapabilities,
  onGoToLiveFeed,
  onSkip,
  initialStep = 'capabilities',
  onSuggestAccounts,
  suggestedAccounts,
  suggestingAccounts,
  suggestAccountsError,
  hasApiKey,
}) {
  useModalOpen(); // blocking overlay — suppress the page's own shortcuts behind it
  const [step, setStep] = useState(initialStep); // 'capabilities' | 'companies' | 'scrape'
  const [name, setName] = useState('');
  const [aliasesText, setAliasesText] = useState('');
  const [added, setAdded] = useState([]); // names added this session, for the running chip list

  function addCompany() {
    const trimmed = name.trim();
    if (!trimmed) return;
    const aliases = aliasesText.split(',').map((a) => a.trim()).filter(Boolean);
    const res = onAddAccounts([{ name: trimmed, aliases }]);
    if (res.added > 0) {
      setAdded((prev) => [...prev, trimmed]);
      setName('');
      setAliasesText('');
    }
  }

  const steps = ['capabilities', 'companies', 'scrape'];
  const stepIdx = steps.indexOf(step);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onSkip}>
      <div
        className="w-full max-w-lg rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-5"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center justify-between mb-5">
          <div className="flex items-center gap-1.5">
            {steps.map((s, i) => (
              <span key={s} className={`h-1.5 rounded-[var(--ss-radius-sm)] transition-all ${i === stepIdx ? 'w-6 bg-[var(--ss-accent)]' : 'w-1.5 bg-[var(--ss-border)]'}`} />
            ))}
          </div>
          <button onClick={onSkip} className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
            Skip
          </button>
        </div>

        {step === 'capabilities' && (
          <div>
            <div className="flex items-center gap-2 text-[var(--ss-accent)] mb-1">
              <ListChecks size={16} />
              <h2 className="text-[16px] font-medium text-[var(--ss-ink)]">Start with Your Company</h2>
            </div>
            <p className="text-[12px] text-[var(--ss-muted)] mb-5 leading-relaxed">
              Tell Signal Scout what your firm actually offers — areas of competency, partnerships, and past project work — and it'll tailor every outreach plan to your real profile:
              checking signals against what you can actually deliver, and flagging honest capability gaps instead of this prototype's placeholder ones.
            </p>
            <button
              onClick={onGoToCapabilities}
              className="w-full flex items-center justify-center gap-1.5 rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-semibold py-2.5 hover:brightness-110 active:scale-95 transition-transform mb-2"
            >
              Set up Your Company <ChevronRight size={14} />
            </button>
            <button onClick={() => setStep('companies')} className="w-full text-center text-[12px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] py-1.5">
              I'll do this later
            </button>
          </div>
        )}

        {step === 'companies' && (
          <div>
            <div className="flex items-center gap-2 text-[var(--ss-accent)] mb-1">
              <Building2 size={16} />
              <h2 className="text-[16px] font-medium text-[var(--ss-ink)]">Add a few companies to track</h2>
            </div>
            <p className="text-[12px] text-[var(--ss-muted)] mb-4 leading-relaxed">
              8 real renewables companies are already loaded to show the product working — add your own if you want to see it match against accounts you actually care about. Optional; you can always add more later from Overview.
            </p>

            {onSuggestAccounts && (
              <div className="mb-4">
                <button
                  type="button"
                  onClick={onSuggestAccounts}
                  disabled={suggestingAccounts || !hasApiKey}
                  title={hasApiKey ? undefined : 'Add an Anthropic API key in the header first'}
                  className="flex items-center gap-1.5 text-[12px] px-2.5 py-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-accent)] hover:bg-[var(--ss-accent-wash)] active:scale-95 transition-transform disabled:opacity-40 disabled:active:scale-100"
                >
                  {suggestingAccounts ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
                  {suggestingAccounts ? 'Searching for prospects…' : 'Suggest companies based on Your Company profile'}
                </button>
                {suggestAccountsError && <p className="text-[11px] text-[var(--ss-danger)] mt-1.5">{suggestAccountsError}</p>}
                {suggestedAccounts && suggestedAccounts.length > 0 && (
                  <div className="mt-2.5 space-y-1.5">
                    {suggestedAccounts.map((s) => {
                      const isAdded = added.includes(s.name);
                      return (
                        <div key={s.name} className="flex items-start justify-between gap-2 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] px-2.5 py-2">
                          <div className="min-w-0">
                            <div className="text-[12px] text-[var(--ss-ink)] font-medium">
                              {s.name}
                              {s.sector && <span className="text-[var(--ss-muted)] font-normal"> · {s.sector}</span>}
                            </div>
                            {s.rationale && <p className="text-[11px] text-[var(--ss-muted)] mt-0.5">{s.rationale}</p>}
                          </div>
                          <button
                            type="button"
                            onClick={() => {
                              const res = onAddAccounts([{ name: s.name, sector: s.sector }]);
                              if (res.added > 0) setAdded((prev) => [...prev, s.name]);
                            }}
                            disabled={isAdded}
                            className="shrink-0 flex items-center gap-1 text-[11px] px-2 py-1 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] hover:border-[var(--ss-border)] disabled:opacity-40"
                          >
                            {isAdded ? <CheckCircle2 size={11} /> : <Plus size={11} />} {isAdded ? 'Added' : 'Add'}
                          </button>
                        </div>
                      );
                    })}
                  </div>
                )}
              </div>
            )}

            <div className="grid gap-2 mb-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
              <input
                value={name}
                onChange={(e) => setName(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') addCompany();
                }}
                placeholder="Company name"
                className={inputClass}
              />
              <input
                value={aliasesText}
                onChange={(e) => setAliasesText(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') addCompany();
                }}
                placeholder="Aliases (optional)"
                className={inputClass}
              />
            </div>
            <button
              onClick={addCompany}
              disabled={!name.trim()}
              className="flex items-center gap-1.5 text-[12px] px-2.5 py-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] hover:border-[var(--ss-border)] active:scale-95 transition-transform disabled:opacity-40 disabled:active:scale-100 mb-4"
            >
              <Plus size={12} /> Add company
            </button>
            {added.length > 0 && (
              <div className="flex items-center gap-1.5 flex-wrap mb-4">
                {added.map((n) => (
                  <span key={n} className="flex items-center gap-1 text-[11px] pl-2 pr-2 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] bg-[var(--ss-positive-wash)] text-[var(--ss-positive)]">
                    <CheckCircle2 size={10} /> {n}
                  </span>
                ))}
              </div>
            )}
            <button
              onClick={() => setStep('scrape')}
              className="w-full rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-semibold py-2.5 hover:brightness-110 active:scale-95 transition-transform"
            >
              {added.length > 0 ? 'Continue' : 'Skip for now'}
            </button>
          </div>
        )}

        {step === 'scrape' && (
          <div>
            <div className="flex items-center gap-2 text-[var(--ss-accent)] mb-1">
              <Zap size={16} />
              <h2 className="text-[16px] font-medium text-[var(--ss-ink)]">Run your first scrape</h2>
            </div>
            <p className="text-[12px] text-[var(--ss-muted)] mb-5 leading-relaxed">
              One more step: head to Live feed and click "Run news scrape" to pull real renewables news and start seeing it connect to the accounts you track.
            </p>
            <button
              onClick={onGoToLiveFeed}
              className="w-full flex items-center justify-center gap-1.5 rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-semibold py-2.5 hover:brightness-110 active:scale-95 transition-transform"
            >
              Take me to Live feed <ChevronRight size={14} />
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// MAIN APP
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// KEYBOARD SHORTCUTS (added 2026-10-14) — one registry, one listener, one
// help sheet generated from it.
//
// Why a registry rather than a keydown handler per component: the help
// sheet (`?`) has to stay truthful without anyone remembering to update a
// hand-written list, and scope priority (a modal's Escape must win over the
// page's) can only be decided somewhere that can see every live binding at
// once. So every shortcut registers through `useShortcut` next to the
// handler it fires — the SAME function the button calls, never a second
// copy of the logic — and this provider owns the only document-level
// keydown listener plus the sheet.
//
// Scopes: 'global' (always), 'page' (suppressed while any modal is open),
// 'modal' (registered by useEscapeToClose, which is what tells this file a
// modal is open at all). Sequences ('g o') are supported with a ~1s window.
// Deliberately avoided: anything the browser/OS owns (mod+S/W/R/T/L etc.).
// ---------------------------------------------------------------------------

const IS_MAC =
  typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '');
const SHORTCUT_HINTS_KEY = 'gridline_shortcut_hints_v1';
const SHORTCUT_SEQUENCE_MS = 1000;
// Fixed order so the sheet reads the same way every time; anything with an
// unknown group falls to the end (see ShortcutsSheet).
const SHORTCUT_GROUP_ORDER = ['Navigation', 'Overview', 'Account', 'Signals', 'General'];

function readShortcutHintsFlag() {
  try {
    return window.localStorage.getItem(SHORTCUT_HINTS_KEY) !== '0';
  } catch (e) {
    // Private browsing / storage disabled — hints on is the better default.
    return true;
  }
}

function writeShortcutHintsFlag(on) {
  try {
    window.localStorage.setItem(SHORTCUT_HINTS_KEY, on ? '1' : '0');
  } catch (e) {
    // Can't persist the preference — it still applies for this session.
  }
}

// 'g o' -> [{key:'g'}, {key:'o'}]; 'mod+Enter' -> [{mod:true, key:'Enter'}].
// Single-character keys are lowercased so '?'/'/'/'e' compare against
// e.key directly; named keys ('Enter', 'Escape') keep their casing.
function parseShortcut(spec) {
  return String(spec)
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .map((step) => {
      const parts = step.split('+');
      const key = parts.pop();
      const mods = parts.map((p) => p.toLowerCase());
      return {
        mod: mods.includes('mod'),
        shift: mods.includes('shift'),
        alt: mods.includes('alt'),
        key: key.length === 1 ? key.toLowerCase() : key,
      };
    });
}

const KEY_GLYPH = { Enter: '↵', Escape: 'Esc', ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', ' ': 'Space' };

// One display token per step — 'mod+Enter' is one key cap ("⌘↵"), 'g o' is
// two ("G", "O").
function shortcutTokens(spec) {
  return parseShortcut(spec).map((step) => {
    let prefix = '';
    if (step.mod) prefix += IS_MAC ? '⌘' : 'Ctrl';
    if (step.alt) prefix += IS_MAC ? '⌥' : 'Alt';
    if (step.shift) prefix += IS_MAC ? '⇧' : 'Shift';
    const k = KEY_GLYPH[step.key] || (step.key.length === 1 ? step.key.toUpperCase() : step.key);
    return prefix ? `${prefix}${k}` : k;
  });
}

function shortcutLabel(spec) {
  return shortcutTokens(spec).join(' ');
}

// aria-keyshortcuts wants the DOM key names, not display glyphs.
function ariaKeyShortcuts(spec) {
  return parseShortcut(spec)
    .map((s) => {
      const parts = [];
      if (s.mod) parts.push(IS_MAC ? 'Meta' : 'Control');
      if (s.alt) parts.push('Alt');
      if (s.shift) parts.push('Shift');
      parts.push(s.key.length === 1 ? s.key.toUpperCase() : s.key);
      return parts.join('+');
    })
    .join(' ');
}

// Native hints for any control that has a shortcut — spread onto the
// button so `title` and `aria-keyshortcuts` never drift from the binding.
// Pass the action's own wording as `label` ("Draft outreach") and this
// renders "Draft outreach (E)".
function shortcutButtonProps(spec, label) {
  if (!spec) return {};
  return {
    'aria-keyshortcuts': ariaKeyShortcuts(spec),
    title: label ? `${label} (${shortcutLabel(spec)})` : shortcutLabel(spec),
  };
}

function isTypingTarget(el) {
  if (!el) return false;
  const tag = (el.tagName || '').toLowerCase();
  if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
  return !!el.isContentEditable;
}

// A focused button/link already activates on Enter/Space on its own — don't
// fire a shortcut on top of the browser's own activation.
function isActivatableTarget(el) {
  if (!el) return false;
  const tag = (el.tagName || '').toLowerCase();
  return tag === 'button' || tag === 'a' || (el.getAttribute && el.getAttribute('role') === 'button');
}

function stepMatches(step, desc) {
  if (step.key !== desc.key) return false;
  if (!!step.mod !== !!desc.mod) return false;
  if (!!step.alt !== !!desc.alt) return false;
  // Shift is ignored for single characters because the character itself
  // already encodes it ('?' only exists shifted on most layouts); for named
  // keys it has to match exactly, so mod+Enter never fires on mod+shift+Enter.
  if (step.key.length > 1 && !!step.shift !== !!desc.shift) return false;
  return true;
}

const SCOPE_RANK = { modal: 3, page: 2, global: 1 };

const ShortcutContext = createContext(null);
let shortcutIdSeq = 0;

function ShortcutProvider({ children }) {
  // The registry lives in a ref, not state: registrations happen in child
  // effects, and bumping state on each one would re-render the whole app
  // several times on every page change for no benefit. The sheet reads a
  // snapshot when it opens (by which point every binding on the page is
  // registered), which is the only moment the list is actually looked at.
  const registry = useRef(new Map());
  const pending = useRef({ steps: [], timer: null });
  // Counts overlays that are modal but have no Escape binding of their own
  // (the demo gate, the onboarding tour/modal — none of them are
  // dismissible with a key). A ref, not state: it's only ever read inside
  // the keydown handler, so changing it never needs a re-render.
  const modalDepth = useRef(0);
  const [sheetOpen, setSheetOpen] = useState(false);
  const [hintsEnabled, setHintsEnabledState] = useState(readShortcutHintsFlag);

  const setHintsEnabled = useCallback((on) => {
    setHintsEnabledState(on);
    writeShortcutHintsFlag(on);
  }, []);

  const register = useCallback((id, entry) => {
    registry.current.set(id, entry);
  }, []);
  const unregister = useCallback((id) => {
    registry.current.delete(id);
  }, []);
  // Includes currently-disabled bindings: the sheet lists them greyed out,
  // so "Enter opens the focused account" is still learnable before anything
  // is focused. The listener itself filters on enabled (below).
  const listShortcuts = useCallback(() => Array.from(registry.current.values()).filter((e) => e.label), []);

  useEffect(() => {
    function clearPending() {
      window.clearTimeout(pending.current.timer);
      pending.current = { steps: [], timer: null };
    }

    function candidatesFor(entries, prefix, desc) {
      return entries.filter((entry) => {
        if (entry.steps.length <= prefix.length) return false;
        for (let i = 0; i < prefix.length; i += 1) {
          if (!stepMatches(entry.steps[i], prefix[i])) return false;
        }
        return stepMatches(entry.steps[prefix.length], desc);
      });
    }

    function onKeyDown(e) {
      // Mid-IME-composition keystrokes belong to the input method, not us
      // (keyCode 229 is the pre-isComposing spelling of the same thing).
      if (e.isComposing || e.keyCode === 229) return;
      const desc = {
        key: e.key && e.key.length === 1 ? e.key.toLowerCase() : e.key,
        mod: IS_MAC ? e.metaKey : e.ctrlKey,
        // The modifier that ISN'T "mod" on this platform counts as an extra
        // modifier — ctrl+K on a Mac is not ⌘K and shouldn't match it.
        other: IS_MAC ? e.ctrlKey : e.metaKey,
        shift: e.shiftKey,
        alt: e.altKey,
      };
      if (!desc.key || desc.other) return;

      const allowedWhileTyping = desc.key === 'Escape' || (desc.mod && desc.key === 'Enter');
      if (isTypingTarget(e.target) && !allowedWhileTyping) {
        clearPending();
        return;
      }
      if ((desc.key === 'Enter' || desc.key === ' ') && !desc.mod && isActivatableTarget(e.target)) return;

      const live = Array.from(registry.current.values()).filter((x) => x.enabled !== false);
      const modalOpen = modalDepth.current > 0 || live.some((x) => x.scope === 'modal');
      const usable = modalOpen ? live.filter((x) => x.scope !== 'page') : live;

      let prefix = pending.current.steps;
      let candidates = candidatesFor(usable, prefix, desc);
      if (!candidates.length && prefix.length) {
        // A dead sequence shouldn't swallow the key — retry it as a fresh
        // first step ('g' then 'n' still opens Add account).
        clearPending();
        prefix = [];
        candidates = candidatesFor(usable, prefix, desc);
      }
      if (!candidates.length) {
        clearPending();
        return;
      }

      const complete = candidates.filter((c) => c.steps.length === prefix.length + 1);
      if (complete.length) {
        const winner = complete.slice().sort((a, b) => (SCOPE_RANK[b.scope] || 0) - (SCOPE_RANK[a.scope] || 0))[0];
        e.preventDefault();
        clearPending();
        winner.run(e);
        return;
      }

      // Only a prefix so far ('g') — hold it for the next keystroke.
      e.preventDefault();
      pending.current.steps = prefix.concat([desc]);
      pending.current.timer = window.setTimeout(clearPending, SHORTCUT_SEQUENCE_MS);
    }

    // Hold Ctrl to reveal the inline key caps (see .gl-kbd-hint in App's
    // <style>). Ctrl specifically because it's the one modifier that does
    // nothing on its own on either platform — and on Windows/Linux it's
    // already `mod`, so holding it is exactly the gesture that precedes the
    // shortcuts it's revealing. Synced from every key event's own ctrlKey
    // rather than only Control's keydown/keyup, so a Ctrl press that starts
    // or ends outside this window still lands; blur/visibility changes
    // clear it, since a keyup that happens in another app never arrives.
    function setKeysHeld(on) {
      const root = document.documentElement;
      if (!root) return;
      if (on) root.classList.add('gl-keys-held');
      else root.classList.remove('gl-keys-held');
    }
    function onKeyUpOrDownForHints(e) {
      setKeysHeld(!!e.ctrlKey);
    }
    function clearKeysHeld() {
      setKeysHeld(false);
    }

    document.addEventListener('keydown', onKeyDown);
    document.addEventListener('keydown', onKeyUpOrDownForHints);
    document.addEventListener('keyup', onKeyUpOrDownForHints);
    window.addEventListener('blur', clearKeysHeld);
    document.addEventListener('visibilitychange', clearKeysHeld);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      document.removeEventListener('keydown', onKeyUpOrDownForHints);
      document.removeEventListener('keyup', onKeyUpOrDownForHints);
      window.removeEventListener('blur', clearKeysHeld);
      document.removeEventListener('visibilitychange', clearKeysHeld);
      clearKeysHeld();
      window.clearTimeout(pending.current.timer);
    };
  }, []);

  const pushModal = useCallback(() => {
    modalDepth.current += 1;
  }, []);
  const popModal = useCallback(() => {
    modalDepth.current = Math.max(0, modalDepth.current - 1);
  }, []);

  const value = useMemo(
    () => ({
      register,
      unregister,
      listShortcuts,
      pushModal,
      popModal,
      hintsEnabled,
      setHintsEnabled,
      sheetOpen,
      openSheet: () => setSheetOpen(true),
      closeSheet: () => setSheetOpen(false),
      toggleSheet: () => setSheetOpen((v) => !v),
    }),
    [register, unregister, listShortcuts, pushModal, popModal, hintsEnabled, setHintsEnabled, sheetOpen]
  );

  return (
    <ShortcutContext.Provider value={value}>
      <ShortcutRootBindings />
      {children}
      {sheetOpen && <ShortcutsSheet onClose={() => setSheetOpen(false)} />}
    </ShortcutContext.Provider>
  );
}

// `?` itself goes through the same registry as everything else, so it shows
// up in the sheet it opens. Lives in its own component because a provider
// can't consume the context it provides.
function ShortcutRootBindings() {
  const ctx = useContext(ShortcutContext);
  useShortcut('?', () => ctx && ctx.toggleSheet(), { scope: 'global', label: 'Show keyboard shortcuts', group: 'General' });
  return null;
}

function useShortcuts() {
  return useContext(ShortcutContext);
}

// keys: a spec string ('e', 'g o', 'mod+Enter') or null/false to register
// nothing. enabled:false keeps the binding out of both the listener and the
// sheet — pass the button's own disabled condition so a dead button never
// has a live shortcut.
function useShortcut(keys, handler, options = {}) {
  const ctx = useContext(ShortcutContext);
  const idRef = useRef(null);
  if (idRef.current === null) {
    shortcutIdSeq += 1;
    idRef.current = `sc${shortcutIdSeq}`;
  }
  // The handler is read through a ref so a new closure each render (which is
  // every render, for inline arrows) doesn't churn the registry.
  const handlerRef = useRef(handler);
  handlerRef.current = handler;
  const { scope = 'page', label = null, group = null, enabled = true } = options;

  useEffect(() => {
    if (!ctx || !keys) return undefined;
    const id = idRef.current;
    ctx.register(id, {
      keys,
      steps: parseShortcut(keys),
      scope,
      label,
      group,
      enabled,
      run: (e) => {
        if (handlerRef.current) handlerRef.current(e);
      },
    });
    return () => ctx.unregister(id);
  }, [ctx, keys, scope, label, group, enabled]);
}

// Escape-to-close for modal dialogs (2026-10-13, audit follow-up; moved onto
// the shortcut registry 2026-10-14) — every modal in this file already closes
// on a backdrop click, but none handled the near-universal Escape convention.
// Registering it as a 'modal'-scope shortcut is also how the registry knows a
// modal is open at all: while any is mounted, 'page' shortcuts stop firing, so
// Escape closes the dialog rather than navigating the page behind it.
function useEscapeToClose(onClose) {
  useModalOpen();
  useShortcut('Escape', onClose, { scope: 'modal', label: 'Close this dialog', group: 'General' });
}

// For a blocking overlay that has no Escape of its own (the demo gate, the
// onboarding tour/modal): tells the registry a modal is up, so the page
// underneath stops responding to j/k/n/e/f while it's covered.
function useModalOpen() {
  const ctx = useContext(ShortcutContext);
  useEffect(() => {
    if (!ctx) return undefined;
    ctx.pushModal();
    return () => ctx.popModal();
  }, [ctx]);
}

// ---------------------------------------------------------------------------
// Shared house-style primitives (docs/design/tokens.css). Built once here,
// used everywhere below instead of one-off styling — see CLAUDE.md's
// restyle brief for the spec each of these implements.
// ---------------------------------------------------------------------------

// A key cap. Sized to sit inside a button without changing its height
// (10-11px text, 1px line-height, hairline padding), so it can be dropped at
// the right edge of an existing primary action. `variant="accent"`/`"ink"`
// are for key caps sitting on a solid accent/ink Button — they borrow
// translucent white for the border/background instead of the paper skin.
function Kbd({ children, variant = 'default', className = '' }) {
  const skin =
    variant === 'accent' || variant === 'ink'
      ? 'border-white/45 border-b-white/60 bg-white/15 text-current'
      : 'border-[var(--ss-border)] border-b-[var(--ss-muted)]/70 bg-[var(--ss-surface)] text-[var(--ss-muted)]';
  return (
    <kbd
      className={`inline-flex items-center justify-center shrink-0 align-middle border border-b-2 rounded-[var(--ss-radius-sm)] px-[5px] py-px text-[10px] leading-none select-none font-[var(--ss-font-mono)] ${skin} ${className}`}
    >
      {children}
    </kbd>
  );
}

// Button. Variants: primary (accent fill), ink (--ss-inverse fill),
// secondary (surface + border), ghost (text only). `kbd` renders a <Kbd> at
// the right edge (auto-themed to the variant). `as="label"` lets a file
// input trigger reuse the same skin. Height lands in the 38-44px band via
// padding, not a fixed height, so it still wraps icon-only/compact buttons.
function Button({
  variant = 'secondary',
  size = 'md',
  kbd,
  icon: Icon,
  iconPosition = 'left',
  as = 'button',
  className = '',
  children,
  ...props
}) {
  const Tag = as;
  const base =
    'inline-flex items-center justify-center gap-2 rounded-[var(--ss-radius)] font-[var(--ss-font-sans)] font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed select-none';
  const sizing = size === 'sm' ? 'h-8 px-3 text-[13px]' : size === 'icon' ? 'h-10 w-10' : 'h-10 px-4';
  const skins = {
    primary: 'bg-[var(--ss-accent)] text-[var(--ss-accent-text)] hover:brightness-110 border border-transparent',
    ink: 'bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)] hover:brightness-125 border border-transparent',
    secondary: 'bg-[var(--ss-surface)] text-[var(--ss-ink)] border border-[var(--ss-border)] hover:border-[var(--ss-frame)]',
    ghost: 'bg-transparent text-[var(--ss-ink)] border border-transparent hover:bg-[var(--ss-rule-soft)]',
    danger: 'bg-[var(--ss-surface)] text-[var(--ss-danger)] border border-[var(--ss-danger)]/40 hover:bg-[var(--ss-danger-wash)]',
  };
  const kbdVariant = variant === 'primary' ? 'accent' : variant === 'ink' ? 'ink' : 'default';
  const shortcutCtx = useContext(ShortcutContext);
  const showKbd = kbd && (!shortcutCtx || shortcutCtx.hintsEnabled);
  return (
    <Tag className={`${base} ${sizing} ${skins[variant] || skins.secondary} ${className}`} {...props}>
      {Icon && iconPosition === 'left' && <Icon size={15} strokeWidth={1.75} className="shrink-0" />}
      {children}
      {Icon && iconPosition === 'right' && <Icon size={15} strokeWidth={1.75} className="shrink-0" />}
      {showKbd && (
        <span className="gl-kbd-hint items-center ml-1" aria-hidden="true">
          <Kbd variant={kbdVariant}>{kbd}</Kbd>
        </span>
      )}
    </Tag>
  );
}

// Mono kicker + serif heading + rule, e.g. "01 — FIT & GAPS · NOT CHECKED"
// over "Fit & gaps". `right` renders trailing content (a count, an action)
// aligned with the kicker row.
function SectionHeader({ kicker, title, right, size = 'md', className = '' }) {
  const titleSize = size === 'lg' ? 'text-[32px] md:text-[36px]' : 'text-[22px] md:text-[26px]';
  return (
    <div className={className}>
      <div className="flex items-end justify-between gap-3">
        {kicker && (
          <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.08em] text-[var(--ss-muted)]">
            {kicker}
          </div>
        )}
        {right && <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">{right}</div>}
      </div>
      {title && (
        <div className={`font-[var(--ss-font-display)] tracking-[-0.02em] text-[var(--ss-ink)] leading-tight mt-0.5 ${titleSize}`}>
          {title}
        </div>
      )}
      <div className="h-px bg-[var(--ss-frame)] mt-2" />
    </div>
  );
}

// Header bar + body + optional footer bar. `focal` gives the panel the
// --ss-frame border (the one thing on a screen the eye should land on);
// otherwise it gets the quieter --ss-border. `highlight` washes the header
// in --ss-accent-wash (e.g. the account's current-stage panel).
function Panel({ label, labelRight, focal = false, highlight = false, footer, className = '', bodyClassName = '', children }) {
  return (
    <div className={`rounded-[var(--ss-radius)] bg-[var(--ss-surface)] border ${focal ? 'border-[var(--ss-frame)]' : 'border-[var(--ss-border)]'} overflow-hidden ${className}`}>
      {label && (
        <div
          className={`flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--ss-rule)] font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.07em] text-[var(--ss-muted)] ${highlight ? 'bg-[var(--ss-accent-wash)] text-[var(--ss-accent-wash-ink)]' : ''}`}
        >
          <span>{label}</span>
          {labelRight && <span>{labelRight}</span>}
        </div>
      )}
      <div className={bodyClassName || 'p-3'}>{children}</div>
      {footer && <div className="px-3 py-2 border-t border-[var(--ss-rule)] bg-[var(--ss-ground)]">{footer}</div>}
    </div>
  );
}

// A bordered grid of mono-label/mono-value cells, e.g. account-page stats.
function StatStrip({ items, className = '' }) {
  return (
    <div className={`grid border border-[var(--ss-border)] rounded-[var(--ss-radius)] overflow-hidden ${className}`} style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}>
      {items.map((it, i) => (
        <div key={i} className={`px-3 py-2 ${i > 0 ? 'border-l border-[var(--ss-rule)]' : ''}`}>
          <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.07em] text-[var(--ss-muted)]">{it.label}</div>
          <div className="font-[var(--ss-font-mono)] text-xl text-[var(--ss-ink)] mt-0.5">{it.value}</div>
        </div>
      ))}
    </div>
  );
}

// Six-segment pipeline tracker. `current` is 0-based index into `stages`.
// Segments are coloured by PHASE_META (design restyle, added 2026-10-16):
// completed = that stage's phase "soft" colour, current = phase "full" plus
// a ring against `ringBg` (the segment's actual row background, so the ring
// reads cleanly against lane-tinted rows too), upcoming = --ss-rule-soft.
// Colour is never the only cue — the stage name badge next to every track
// stays, and the aria-label below names the phase too.
function StageTrack({ stages, current, height = 'h-1.5', className = '', ringBg = 'var(--ss-surface)' }) {
  const currentPhase = current >= 0 && stages[current] ? STAGE_PHASE[stages[current]] : null;
  const label = current >= 0 && stages[current]
    ? `${stages[current]}, ${currentPhase} phase, stage ${current + 1} of ${stages.length}`
    : `Stage ${current + 1} of ${stages.length}`;
  return (
    <div className={`flex items-center gap-1 ${className}`} role="img" aria-label={label}>
      {stages.map((s, i) => {
        const done = i < current;
        const isCurrent = i === current;
        const meta = PHASE_META[STAGE_PHASE[s]] || PHASE_META[PHASES[0]];
        const background = isCurrent ? meta.full : done ? meta.soft : 'var(--ss-rule-soft)';
        return (
          <div
            key={s || i}
            className={`${height} flex-1 rounded-[1px]`}
            style={{
              background,
              boxShadow: isCurrent ? `0 0 0 2px ${ringBg}, 0 0 0 3px var(--ss-frame)` : undefined,
            }}
          />
        );
      })}
    </div>
  );
}

// 9px square (outline for Prospect). Tells tiers apart without a color fill.
function TierMarker({ tier, className = '' }) {
  const t = (tier || '').toLowerCase();
  if (t === 'strategic') return <span className={`inline-block w-[9px] h-[9px] shrink-0 bg-[var(--ss-tier-strategic)] ${className}`} />;
  if (t === 'growth') return <span className={`inline-block w-[9px] h-[9px] shrink-0 bg-[var(--ss-tier-growth)] ${className}`} />;
  return <span className={`inline-block w-[9px] h-[9px] shrink-0 border-[1.5px] border-[var(--ss-tier-prospect)] ${className}`} />;
}

// Signal-kind glyph: told apart by shape, not color. Funding/Policy/Market
// cover the kinds used across Live feed + Today; unrecognized kinds fall
// back to a dot.
function KindGlyph({ kind, className = '' }) {
  const k = (kind || '').toLowerCase();
  const glyph = k === 'funding' ? '◆' : k === 'policy' ? '■' : '●';
  return <span aria-hidden="true" className={`font-[var(--ss-font-mono)] ${className}`}>{glyph}</span>;
}

// Mono uppercase badge, 2px radius. `tone="accent"` is for TOP PICK only —
// everything else (NEW, a stage name, a status) is ink.
function Badge({ tone = 'ink', children, className = '' }) {
  const skins = {
    ink: 'bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]',
    accent: 'bg-[var(--ss-accent)] text-[var(--ss-accent-text)]',
    outline: 'bg-transparent text-[var(--ss-ink)] border border-[var(--ss-border)]',
  };
  return (
    <span className={`inline-flex items-center rounded-[var(--ss-radius-sm)] px-1.5 py-0.5 font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] ${skins[tone] || skins.ink} ${className}`}>
      {children}
    </span>
  );
}

// Joined mono buttons, one active (ink-filled). `options`: [{value,label}].
function SegmentedControl({ options, value, onChange, className = '' }) {
  return (
    <div className={`inline-flex rounded-[var(--ss-radius)] border border-[var(--ss-border)] overflow-hidden ${className}`}>
      {options.map((opt, i) => {
        const active = opt.value === value;
        return (
          <button
            key={opt.value}
            type="button"
            aria-pressed={active}
            onClick={() => onChange(opt.value)}
            className={`px-3 h-9 font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.05em] transition-colors ${i > 0 ? 'border-l border-[var(--ss-border)]' : ''} ${active ? 'bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]' : 'bg-[var(--ss-surface)] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]'}`}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}

// Thin 4px bar + mono score (0-1, two decimals) — confidence/match reads.
function ScoreBar({ value, className = '' }) {
  const pct = Math.max(0, Math.min(1, value || 0)) * 100;
  return (
    <div className={`flex items-center gap-2 ${className}`}>
      <div className="flex-1 h-1 rounded-[1px] bg-[var(--ss-rule-soft)] overflow-hidden">
        <div className="h-full bg-[var(--ss-accent)]" style={{ width: `${pct}%` }} />
      </div>
      <span className="font-[var(--ss-font-mono)] text-[11px] text-[var(--ss-ink)] tabular-nums">{(value || 0).toFixed(2)}</span>
    </div>
  );
}

// Shared form-field skin: surface, --ss-border, 3px radius, --ss-frame
// focus ring. Apply to inputs/selects/textareas via className.
const ssFieldClass =
  'bg-[var(--ss-surface)] border border-[var(--ss-border)] rounded-[var(--ss-radius)] text-[var(--ss-ink)] text-sm px-2.5 py-1.5 focus:outline-none focus:border-[var(--ss-frame)] focus:ring-1 focus:ring-[var(--ss-frame)]';

// Slim --ss-inverse footer status bar: a LIVE square, mono context items,
// a right-aligned hint. `items`: array of strings/nodes.
function StatusBar({ live = true, items = [], right, className = '' }) {
  return (
    <div className={`flex items-center gap-4 px-3 h-8 bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)] font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.04em] ${className}`}>
      {live && (
        <span className="inline-flex items-center gap-1.5">
          <span className="w-2 h-2 bg-[var(--ss-positive)]" />
          LIVE
        </span>
      )}
      {items.map((it, i) => (
        <span key={i} className="text-[var(--ss-inverse-muted)]">{it}</span>
      ))}
      {right && <span className="ml-auto text-[var(--ss-inverse-muted)]">{right}</span>}
    </div>
  );
}

// The inline hint form of <Kbd> — one cap per sequence step. Always in the
// DOM (so revealing one costs no re-render), but shown only while Ctrl is
// held, via .gl-kbd-hint/.gl-keys-held in App's own <style>; that same CSS
// keeps it hidden on touch devices and below `md`, where there's no keyboard
// to hint at and no width to spare. Returns null outright when the user has
// turned hints off — the shortcut itself keeps working either way.
function ShortcutHint({ keys, variant = 'default', className = '' }) {
  const ctx = useContext(ShortcutContext);
  if (!keys) return null;
  if (ctx && !ctx.hintsEnabled) return null;
  return (
    <span className={`gl-kbd-hint items-center gap-0.5 ${className}`} aria-hidden="true">
      {shortcutTokens(keys).map((t, i) => (
        <Kbd key={i} variant={variant}>
          {t}
        </Kbd>
      ))}
    </span>
  );
}

// "? Shortcuts" — the discoverability backstop, on the app's StatusBar
// (and the Today tab's, which shares the same primitive).
function ShortcutsFooterHint() {
  const ctx = useShortcuts();
  if (!ctx) return null;
  return (
    <button
      type="button"
      onClick={ctx.openSheet}
      {...shortcutButtonProps('?', 'Keyboard shortcuts')}
      className="flex items-center gap-1.5 text-[var(--ss-inverse-muted)] hover:text-[var(--ss-inverse-text)] transition-colors"
    >
      <Kbd variant="ink">?</Kbd> Shortcuts
    </button>
  );
}

// The help sheet. Generated entirely from what's registered right now, so
// page-specific groups only appear on the page that owns them — the heading
// note says so, rather than listing bindings that wouldn't fire.
function ShortcutsSheet({ onClose }) {
  const ctx = useContext(ShortcutContext);
  const panelRef = useRef(null);
  // Snapshotted on mount: the page behind this dialog can't change while
  // it's open, and re-reading on every render would be churn for nothing.
  const [items] = useState(() => (ctx ? ctx.listShortcuts() : []));
  useEscapeToClose(onClose);

  // Focus trap + return focus (the one dialog in this file that needs it:
  // it's opened by a key, so there's often no click target to return to).
  useEffect(() => {
    const previouslyFocused = document.activeElement;
    const node = panelRef.current;
    const firstButton = node && node.querySelector('button');
    if (firstButton) firstButton.focus();
    function onKeyDown(e) {
      if (e.key !== 'Tab' || !node) return;
      const focusables = Array.from(
        node.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
      ).filter((el) => !el.disabled);
      if (!focusables.length) return;
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
    if (node) node.addEventListener('keydown', onKeyDown);
    return () => {
      if (node) node.removeEventListener('keydown', onKeyDown);
      if (previouslyFocused && previouslyFocused.focus) previouslyFocused.focus();
    };
  }, []);

  // One row per distinct label+keys pair — several instances of the same
  // component (e.g. a per-signal tracker) register the same binding.
  const seen = new Map();
  const byGroup = new Map();
  items.forEach((it) => {
    const key = `${it.group || 'General'}|${it.label}|${it.keys}`;
    const existing = seen.get(key);
    if (existing) {
      // Several instances of the same component register the same binding —
      // one row, and it reads as live if any instance of it is.
      if (it.enabled !== false) existing.enabled = true;
      return;
    }
    const row = { ...it };
    seen.set(key, row);
    const g = it.group || 'General';
    if (!byGroup.has(g)) byGroup.set(g, []);
    byGroup.get(g).push(row);
  });
  const groups = Array.from(byGroup.keys()).sort((a, b) => {
    const ia = SHORTCUT_GROUP_ORDER.indexOf(a);
    const ib = SHORTCUT_GROUP_ORDER.indexOf(b);
    return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
  });

  return (
    // `gl-app` is what carries this file's CSS custom properties (see the
    // .gl-app rule in App's <style>), and this sheet is rendered by
    // ShortcutProvider — a sibling of the app's own .gl-app wrapper, not a
    // descendant — so without it every var(--…) below resolves to nothing.
    <div
      className="gl-app fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 text-[var(--ss-ink)]"
      onClick={onClose}
      role="presentation"
    >
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-label="Keyboard shortcuts"
        className="w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-5 space-y-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-start justify-between gap-3">
          <div>
            <div className="text-sm text-[var(--ss-ink)] font-medium">Keyboard shortcuts</div>
            <div className="text-[11px] text-[var(--ss-muted)] mt-0.5">
              Shortcuts for the page you're on — switch pages to see theirs. Nothing fires while you're typing in a field.
              Hold <Kbd>Ctrl</Kbd> anywhere in the app to show the key caps on buttons.
            </div>
          </div>
          <button
            onClick={onClose}
            aria-label="Close"
            className="text-[var(--ss-muted)] hover:text-[var(--ss-ink)] shrink-0 -m-1.5 p-1.5"
          >
            <X size={16} />
          </button>
        </div>

        {groups.length === 0 ? (
          <div className="text-[12px] text-[var(--ss-muted)]">No shortcuts are active right now.</div>
        ) : (
          groups.map((g) => (
            <div key={g}>
              <div className={sectionLabel}>{g}</div>
              <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] divide-y divide-[var(--ss-border)]">
                {byGroup.get(g).map((it) => (
                  <div
                    key={`${it.label}|${it.keys}`}
                    title={it.enabled === false ? "Not available right now — e.g. nothing is focused yet." : undefined}
                    className={`flex items-center justify-between gap-4 px-3 py-2 ${it.enabled === false ? 'opacity-50' : ''}`}
                  >
                    <span className="text-[12px] text-[var(--ss-ink)]">{it.label}</span>
                    <span className="flex items-center gap-0.5 shrink-0">
                      {shortcutTokens(it.keys).map((t, i) => (
                        <Kbd key={i}>{t}</Kbd>
                      ))}
                    </span>
                  </div>
                ))}
              </div>
            </div>
          ))
        )}

        <div className="flex items-start justify-between gap-3 border-t border-[var(--ss-border)] pt-3.5">
          <div>
            <div className="text-[12px] text-[var(--ss-ink)]">Show shortcut hints</div>
            <div className="text-[11px] text-[var(--ss-muted)] mt-0.5">
              Key caps on buttons and tabs while you hold Ctrl. Turning this off hides them even then — the shortcuts
              themselves keep working either way.
            </div>
          </div>
          {ctx && (
            <div className="shrink-0">
              <TogglePill active={ctx.hintsEnabled} onClick={() => ctx.setHintsEnabled(!ctx.hintsEnabled)}>
                {ctx.hintsEnabled ? 'On' : 'Off'}
              </TogglePill>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// App-level (global-scope) bindings. Kept in their own component so they
// register inside ShortcutProvider's context rather than above it.
//
// Escape deliberately only backs out of the two drill-in pages that have a
// plain, unconditional "back" (the account page -> Overview, Sources -> Live
// feed). Your Capabilities is left out on purpose: its own Back button can
// resume onboarding (see CapabilitiesPage/onboardReturnStep), and a bare
// setTab('overview') here would silently skip that. Modal-scope Escape
// always wins over this one, so a dialog closes before the page moves.
function AppShortcuts({ tab, onSetTab }) {
  useShortcut('g t', () => onSetTab('today'), { scope: 'global', label: 'Go to Today', group: 'Navigation' });
  useShortcut('g o', () => onSetTab('overview'), { scope: 'global', label: 'Go to Overview', group: 'Navigation' });
  useShortcut('g m', () => onSetTab('map'), { scope: 'global', label: 'Go to Signal map', group: 'Navigation' });
  useShortcut('g f', () => onSetTab('livefeed'), { scope: 'global', label: 'Go to Live feed', group: 'Navigation' });
  useShortcut(
    'Escape',
    () => {
      if (tab === 'account') onSetTab('overview');
      else if (tab === 'sources') onSetTab('livefeed');
    },
    {
      scope: 'global',
      label: tab === 'sources' ? 'Back to Live feed' : 'Back to Overview',
      group: 'Navigation',
      enabled: tab === 'account' || tab === 'sources',
    }
  );
  return null;
}

function App() {
  // Loaded once, synchronously, before the state below reads out of it —
  // see the PERSISTENCE section. hydratePromotedFromStorage also runs here
  // since PROMOTED_NEWS/PROMOTED_MATCHES need to be seeded before anything
  // reads them on this same first render.
  const [initial] = useState(() => {
    const saved = loadSavedState();
    hydratePromotedFromStorage(saved);
    hydrateCapabilitiesFromStorage(saved);
    return saved || {};
  });
  // Whether there was *any* saved session before this load — distinct from
  // `initial` above, which is always an object (falls back to `{}`) so every
  // other piece of state below can destructure it safely. Drives the demo
  // gate's "Resume existing session" option: there's nothing to resume into
  // on a genuinely first-ever load, so that choice is disabled rather than
  // silently landing on the exact same empty-seed state "Start from
  // scratch" would. See DEMO_GATE below.
  const [hadSavedSession] = useState(() => loadSavedState() !== null);

  // See SHORTCUT_NOTICE_DISMISS_KEY above — "X" on the banner below sets
  // this permanently; it never comes back on its own once dismissed.
  const [shortcutNoticeOpen, setShortcutNoticeOpen] = useState(() => !shortcutNoticeDismissed());

  // DEMO GATE (added 2026-10-07) — per the Notion task "Build a demo
  // landing screen: 'Start from scratch' (showcase onboarding) vs. 'Resume
  // existing session' (normal app, as a real user)." Re-asked on every
  // load (not a one-time "first visit" check) since this is explicitly for
  // a salesperson doing repeat live demos, not a normal product onboarding
  // gate: `gateChoice` is plain component state, never persisted.
  // 'fresh' clears all saved state and reloads so the module's own
  // first-ever-load seeding runs again for real (see the `if
  // (!loadSavedState())` seed block) rather than hand-resetting a dozen
  // pieces of state and two module-level arrays in two places that could
  // drift apart; a one-shot sessionStorage flag (cleared on read, so a
  // manual refresh afterward lands on the gate like any other load) tells
  // the next mount to skip straight past the gate into onboarding.
  const [gateChoice, setGateChoice] = useState(() => {
    try {
      if (window.sessionStorage.getItem('gridline_launch_onboarding') === '1') {
        window.sessionStorage.removeItem('gridline_launch_onboarding');
        return 'fresh';
      }
    } catch (e) {
      // Private browsing / sessionStorage disabled — just show the gate.
    }
    return null;
  });
  // 'tour' — TabTourSpotlight is live on the real tab bar; 'modal' —
  // OnboardingModal is showing (companies/scrape steps); null — onboarding
  // isn't active. Split into two phases (rather than one modal with an
  // internal 'tour' step) because the tour genuinely isn't a modal at all —
  // it highlights the real header tab buttons in place, per explicit
  // feedback that a floating window's own copy of the tabs wasn't the ask.
  const [onboardPhase, setOnboardPhase] = useState(gateChoice === 'fresh' ? 'tour' : null);
  // One-time pulse on the Live feed's "Run news scrape" banner after
  // onboarding's last step sends the user there — see LiveFeed's own
  // highlightScrape prop.
  const [highlightScrape, setHighlightScrape] = useState(false);
  // Which OnboardingModal step to land on next time it's shown — normally
  // 'capabilities' (its default), but bumped to 'companies' when the
  // capabilities step sends the user to the real Your Capabilities page and
  // they come back: onGoToCapabilities below sets cameFromOnboarding so
  // CapabilitiesPage's "Save & return"/"Back" can resume onboarding instead
  // of just dumping the user on a bare Overview with no further guidance
  // (the bug this fixes — onboarding used to end silently at that point).
  const [onboardReturnStep, setOnboardReturnStep] = useState('capabilities');
  const [cameFromOnboarding, setCameFromOnboarding] = useState(false);

  function startFresh() {
    try {
      window.localStorage.removeItem(STORAGE_KEY);
      window.sessionStorage.setItem('gridline_launch_onboarding', '1');
    } catch (e) {
      // Can't persist the "land in onboarding" flag — reload anyway; worst
      // case the gate shows again, which is a safe, visible fallback.
    }
    window.location.reload();
  }

  const [tab, setTab] = useState('today');
  // Standalone account page (added 2026-10-06) — a drill-in view, not a
  // regular tab (same pattern as About/Blog hiding the tab bar below): one
  // account's own matched-signal opportunities laid out as full-width
  // cards, each with its own pipeline progress, clearer than the Overview
  // row's compact inline expand when an account has several signals moving
  // at different paces. selectedAccountId is only meaningful while
  // tab === 'account'.
  const [selectedAccountId, setSelectedAccountId] = useState(null);
  // Which tab to return to from the account page (added 2026-10-17) — the
  // account page is reachable from both Overview and Today, and "Back"
  // should land wherever the user actually came from, not always Overview.
  const [accountReturnTab, setAccountReturnTab] = useState('overview');
  function openAccountPage(accountId, returnTab = 'overview') {
    setTab('account');
    setSelectedAccountId(accountId);
    setAccountReturnTab(returnTab);
  }
  // "See details" on the Today tab (added 2026-10-14; labeled "Draft
  // outreach" until a 2026-10-14 follow-up — renamed because it just
  // navigates, it doesn't open the composer, see TodaySignalCard's own
  // comment) — opens the standalone account page, same as above, then
  // scrolls/highlights that one matched signal's own content there via the
  // existing scrollToSignalMain helper (already used for this exact
  // cross-navigation from Overview — see its own comment). The timeout just
  // waits one tick for AccountPage to actually mount before looking up its
  // DOM ids.
  function focusSignalOnAccountPage(accountId, newsId, stage, hasGaps) {
    openAccountPage(accountId, 'today');
    window.setTimeout(() => scrollToSignalMain(newsId, stage, hasGaps), 60);
  }
  const [accounts, setAccounts] = useState(initial.accounts || ACCOUNTS_INITIAL);
  const [contacts, setContacts] = useState(initial.contacts || CONTACTS_INITIAL);
  const [activity, setActivity] = useState(initial.activity || {}); // accountId -> [{ id, from, to, action, contactId, date, note }]
  const [emailDrafts, setEmailDrafts] = useState(initial.emailDrafts || {}); // accountId -> { subject, body }
  const [selectedNewsId, setSelectedNewsId] = useState(null);
  const [expandedId, setExpandedId] = useState(null);
  const [plans, setPlans] = useState(initial.plans || {});
  const [generatingId, setGeneratingId] = useState(null);
  const [planErrors, setPlanErrors] = useState({});
  // Structured fit/gap check (added per demo-week-plan.md Day 2) — a
  // dedicated, explicit action per account (not auto-run on every match, so
  // it never fires an uncosted AI call silently), checked BEFORE "Generate
  // outreach plan" per the plan's D1 requirement that fit/gap information
  // be visible ahead of plan generation, not only inside a generated plan.
  // accountId -> { gaps: [{gap, severity, evidence, mitigation, confidence}],
  // signalIds: [ids evaluated], checkedAt }. See generateFitGaps below.
  const [fitGaps, setFitGaps] = useState(initial.fitGaps || {});
  const [fitGapGeneratingId, setFitGapGeneratingId] = useState(null);
  const [fitGapErrors, setFitGapErrors] = useState({});
  // "Full fit & gaps analysis" (Overview header) — true only while the
  // sequential per-account loop below is in flight, so the button can show
  // its own progress state even though it drives the same per-account
  // fitGapGeneratingId every row's own badge already reads.
  const [fitGapsBatchRunning, setFitGapsBatchRunning] = useState(false);
  // Day 4 (demo-week-plan.md): what changed the last time a fresh Live
  // feed match triggered an automatic re-check — accountId -> { changed,
  // newGaps, removedGaps, severityChanges, triggeringNewsId, changedAt }.
  // See recheckFitGapsOnNewMatch/diffFitGaps below. Persisted so the badge
  // survives a refresh; cleared explicitly once the user has looked (
  // dismissFitChangeNotice), not just on next render.
  const [fitChangeNotices, setFitChangeNotices] = useState(initial.fitChangeNotices || {});
  // Proposal generator (added per demo-week-plan.md Day 3a). accountId ->
  // { understanding, approach: [str], experienceIds: [PAST_WORK id],
  // nextSteps: [{step,timeframe,owner}], generatedAt }. See generateProposal
  // below; "Delivery model & honest gaps" is rendered straight from
  // fitGaps above, not stored here.
  const [proposals, setProposals] = useState(initial.proposals || {});
  const [proposalGeneratingId, setProposalGeneratingId] = useState(null);
  const [proposalErrors, setProposalErrors] = useState({});
  // Meeting stage (P1 item 10, feedback brief) — three pieces, keyed by
  // `trackKey` (accountId for the account's own baseline track, or
  // `${accountId}:${newsId}` for one matched signal's independent track —
  // same `accountId:tag` convention confirmedMitigations already uses).
  // meetingPlans: pre-meeting topic list (shown while a track is at
  // Contacted, i.e. "Hold a meeting" is still the pending action).
  // meetingNotesReports: post-meeting parsed notes (shown once a track
  // reaches Meeting, i.e. the meeting already happened).
  // meetingOutcomes: a log of any further meetings before Proposal.
  const [meetingPlans, setMeetingPlans] = useState(initial.meetingPlans || {});
  const [meetingPlanGeneratingKey, setMeetingPlanGeneratingKey] = useState(null);
  const [meetingPlanErrors, setMeetingPlanErrors] = useState({});
  const [meetingNotesReports, setMeetingNotesReports] = useState(initial.meetingNotesReports || {});
  const [meetingNotesSubmittingKey, setMeetingNotesSubmittingKey] = useState(null);
  const [meetingNotesErrors, setMeetingNotesErrors] = useState({});
  // "Generating a proposal shouldn't be an option until the user has added
  // post-meeting notes — they can skip this if they want" (2026-10-11
  // direct follow-up feedback) — a plain per-track boolean, same `trackKey`
  // convention as meetingNotesReports above; either one (a real report or
  // an explicit skip) satisfies the Meeting->Proposal gate below.
  const [meetingNotesSkipped, setMeetingNotesSkipped] = useState(initial.meetingNotesSkipped || {});
  const [meetingOutcomes, setMeetingOutcomes] = useState(initial.meetingOutcomes || {});
  // Meeting outcome pills (added 2026-10-12, direct feedback: "a few pills
  // for 'meeting outcome' above the notes attachment box — client inviting
  // proposal, awaiting further client feedback, scheduling follow-up
  // meeting, or client not moving forward") — a single current status per
  // track, not a log (meetingOutcomes above already covers "every call
  // that happened"); this is "where things stand right now," and the
  // record a future stage-dropout/speed analytics pass (see
  // docs/open-items.md) would read first. Same trackKey convention as
  // every other per-track meeting state. 'not_moving' is set only as a
  // side effect of the archive confirm actually going through (see
  // MeetingTrack) — clicking the pill alone doesn't set it, since the
  // underlying track usually disappears from view once archived anyway.
  const [meetingOutcomeFlags, setMeetingOutcomeFlagsState] = useState(initial.meetingOutcomeFlags || {});
  function setMeetingOutcomeFlag(trackKey, flag) {
    setMeetingOutcomeFlagsState((prev) => ({ ...prev, [trackKey]: { flag, setAt: new Date().toISOString() } }));
  }
  // Meeting slots (added 2026-10-12, direct follow-up: "structure the
  // meeting prep and follow up section with discrete sections that
  // collapse as the user moves through... the fields should also match
  // with the first meeting (pills, file attachment, process notes)") —
  // how many independent "meeting slots" a track has, beyond the first
  // (always implicit). Slot 1 reuses the track's own key into
  // meetingNotesReports/meetingNotesSkipped/meetingOutcomeFlags unchanged
  // (so nothing about the original single-meeting flow had to change
  // shape); slot N>1 uses a synthetic `${trackKey}__meetingN` key into
  // those exact same maps — a real trackKey never contains "__", so this
  // can't collide, and deliberately has no ":" in it either so
  // pairedTrackKey's colon-based signal-track parsing can't misread it
  // as a real opportunity key (it just finds no match and skips syncing,
  // which is the right behavior for a synthetic key). `addMeetingSlot` is
  // called once a slot's notes are processed/skipped while its own
  // outcome is "Scheduling follow-up meeting" — see the effect in
  // MeetingTrack that drives it.
  const [meetingSlotCounts, setMeetingSlotCounts] = useState(initial.meetingSlotCounts || {});
  function addMeetingSlot(trackKey) {
    setMeetingSlotCounts((prev) => ({ ...prev, [trackKey]: (prev[trackKey] || 1) + 1 }));
  }
  // "An option to delete a meeting if it's generated erroneously"
  // (2026-10-12 direct feedback) — undoes an auto-advanced follow-up slot
  // (the only kind of meeting that gets "generated": slot 1 is the real
  // meeting that moved the track into this stage in the first place).
  // Clears that slot's notes/skip/outcome-flag state so no stale data
  // lingers under its key, drops the count back down, and resets the
  // *previous* slot's outcome flag — that's the "Scheduling follow-up
  // meeting" pill that caused the auto-advance effect in MeetingTrack to
  // create this slot, so leaving it set to follow_up would just
  // regenerate the slot the user is trying to delete.
  function removeMeetingSlot(trackKey, slotIndex) {
    if (slotIndex <= 1) return;
    const slotKey = meetingSlotKey(trackKey, slotIndex);
    const prevSlotKey = meetingSlotKey(trackKey, slotIndex - 1);
    setMeetingSlotCounts((prev) => ({ ...prev, [trackKey]: slotIndex - 1 }));
    setMeetingNotesReports((prev) => {
      const next = { ...prev };
      delete next[slotKey];
      return next;
    });
    setMeetingNotesSkipped((prev) => {
      const next = { ...prev };
      delete next[slotKey];
      return next;
    });
    setMeetingOutcomeFlagsState((prev) => {
      const next = { ...prev };
      delete next[slotKey];
      delete next[prevSlotKey];
      return next;
    });
  }
  // Outreach correspondence + two-step logging (2026-10-11 direct
  // follow-up feedback: "the outreach stage should include an option to
  // upload files/email chains that the AI can evaluate in subsequent
  // stages... the first step of outreach should be logging a sent, with
  // the next step being separate for logging the response/conversation").
  // `markSent` (EmailComposer) already was step 1 — minimal, just records
  // outreach went out. `outreachCorrespondence` (account-level, same scope
  // as the rest of the outreach section) holds anything attached there, no
  // AI call at upload time — it's read as grounding context by later
  // generators (see generateMeetingPlan's prompt) rather than parsed on
  // its own. `outreachResponses` is step 2, scoped per-track like
  // meetingOutcomes above.
  const [outreachCorrespondence, setOutreachCorrespondence] = useState(initial.outreachCorrespondence || {});
  const [outreachCorrespondenceSubmittingId, setOutreachCorrespondenceSubmittingId] = useState(null);
  const [outreachCorrespondenceErrors, setOutreachCorrespondenceErrors] = useState({});
  const [outreachResponses, setOutreachResponses] = useState(initial.outreachResponses || {});
  // Outreach plan -> tracked per-signal actions (2026-10-11 direct
  // feedback: "there needs to be a clearer action for the user to take
  // once the outreach plan is generated... it should add these actions
  // [contacts to engage, talking points] to the matched signal on the
  // sidebar"). Keyed by `trackKey` (`${accountId}:${newsId}`, same
  // convention as meetingPlans/outreachResponses above) — a plain checklist
  // the user ticks off as they actually do each thing, surfaced in
  // MatchedSignalsPanel below the existing stage-status SignalActionTracker.
  const [signalActions, setSignalActions] = useState(initial.signalActions || {});
  // Close go/no-go (Day 3b) — which of a gap's chosen routes the user has
  // actually confirmed (a partner lined up, a blocker genuinely accepted)
  // before closing. Keyed by `${accountId}:${gapTag}` (tag, not array
  // index — see normalizeGap/slugifyGapTag) against that account's current
  // fitGaps result; re-checking fit & gaps produces a new result object, so
  // a confirmation for a gap that's since been resolved or re-worded is
  // naturally orphaned rather than silently carried over to a different gap
  // at the same array position.
  const [confirmedMitigations, setConfirmedMitigations] = useState(initial.confirmedMitigations || {});
  function toggleMitigationConfirmed(accountId, gapTag) {
    const key = `${accountId}:${gapTag}`;
    setConfirmedMitigations((prev) => ({ ...prev, [key]: !prev[key] }));
  }
  // Menu of moves (redesign point 3) — which move index the user has
  // picked, per gap, as "the route we're actually taking." Carries through
  // to the proposal's Delivery-model section, the outreach email's framing,
  // and the go/no-go check (point 4: "carry the chosen route through the
  // pipeline"). accountId -> { gapTag: moveIndex }.
  const [selectedMoves, setSelectedMoves] = useState(initial.selectedMoves || {});
  function selectMove(accountId, gapTag, moveIndex) {
    setSelectedMoves((prev) => {
      const forAccount = { ...(prev[accountId] || {}) };
      // Click the already-chosen route again to un-pick it, same toggle
      // affordance as the mitigation-confirmed checkbox below.
      if (forAccount[gapTag] === moveIndex) delete forAccount[gapTag];
      else forAccount[gapTag] = moveIndex;
      return { ...prev, [accountId]: forAccount };
    });
    // Picking (or un-picking) a different route invalidates any earlier
    // "Proceed"/go-no-go confirmation for this gap — see
    // proceedWithSignalRoute/GoNoGoCheck below, both of which treat
    // confirmedMitigations as "confirmed for the CURRENTLY chosen route,"
    // not a sticky flag independent of which route is selected.
    setConfirmedMitigations((prev) => {
      const key = `${accountId}:${gapTag}`;
      if (!(key in prev)) return prev;
      const next = { ...prev };
      delete next[key];
      return next;
    });
  }
  // Deal outcome (added 2026-10-10, direct feedback: "at the deal closed
  // stage, there should be a dropdown to select the outcome") — Won/Lost/
  // Paused, picked once a track reaches Closed. Keyed by opportunityId or
  // accountId, same flat-map convention as `delegations` above (a matched
  // signal's own close is independent of the account's own baseline close).
  const [dealOutcomes, setDealOutcomes] = useState(initial.dealOutcomes || {});
  function setDealOutcome(key, outcome) {
    setDealOutcomes((prev) => {
      if (!outcome) {
        const next = { ...prev };
        delete next[key];
        return next;
      }
      return { ...prev, [key]: outcome };
    });
  }
  // Research go/no-go (added 2026-10-12): once the research stage is
  // complete, the user is asked to explicitly decide go (proceed to
  // outreach, same as the normal "Research done" confirm) or no-go
  // (archive the opportunity instead of advancing it) — see the Go/No-Go
  // block in NextMilestone below. accountId -> { archivedAt, reason }.
  // Reversible (Unarchive button on Overview), consistent with every other
  // "remove from this page" control in the app — archiving is never a
  // destructive delete, just taking an account out of the active pipeline.
  const [archivedAccounts, setArchivedAccounts] = useState(initial.archivedAccounts || {});
  function archiveAccount(accountId, reason = 'no-go at the research stage') {
    setArchivedAccounts((prev) => ({ ...prev, [accountId]: { archivedAt: todayISO(), reason } }));
    const acct = accounts.find((a) => a.id === accountId);
    const stage = acct ? acct.stage : 'Identified';
    setActivity((prev) => ({
      ...prev,
      [accountId]: [...(prev[accountId] || []), { id: `act-${Date.now()}`, from: stage, to: stage, action: `Opportunity archived — ${reason}`, contactId: null, date: todayISO(), note: '' }],
    }));
  }
  function unarchiveAccount(accountId) {
    setArchivedAccounts((prev) => {
      if (!(accountId in prev)) return prev;
      const next = { ...prev };
      delete next[accountId];
      return next;
    });
  }
  // Opportunity numbers (added 2026-10-12, direct follow-up to the go/no-go
  // gate above: "if the user makes a go decision, assign the opportunity a
  // number that can be tracked elsewhere in the tool wherever relevant") —
  // assigned the moment Go is clicked, not on account creation: an
  // opportunity that's still being researched, or that gets a no-go, was
  // never "live" enough to need a reference number. Sequential starting at
  // 1001 (reads as a real reference number, not an array index) and
  // idempotent (an account that already has one is left alone, so Go can
  // never double-assign or skip the sequence on a re-render). Stored
  // directly on the account object via the existing `updateAccount` —
  // deliberately not a second parallel map — so it's already present
  // everywhere an account is displayed or exported (Overview row, the
  // account page header, PDF exports) with no extra threading.
  function assignOpportunityNumber(accountId) {
    const acct = accounts.find((a) => a.id === accountId);
    if (!acct || acct.opportunityNumber) return;
    const used = accounts.map((a) => a.opportunityNumber).filter(Boolean);
    const next = used.length ? Math.max(...used) + 1 : 1001;
    updateAccount(accountId, { opportunityNumber: next });
  }
  // External engagement assist (added 2026-10-11, direct feedback: "for
  // blockers where external engagement is required... pop up a contact
  // identifying/email generator to assist with the outreach. This should
  // then disappear and the tool can check in on the topic later.") —
  // tracked separately from confirmedMitigations/selectedMoves (which only
  // say WHICH route was picked): this is about whether the actual person
  // has been contacted yet. Three states — 'needed' (not started),
  // 'contacted' (in progress), 'done' (confirmed/lined up) — read by
  // SignalActionTracker in the sidebar. Keyed by
  // `${accountId}:${newsId}:${gapTag}` so the same gap shared across two
  // signals (see proceedWithSignalRoute's own comment on shared evidence)
  // tracks its own engagement per signal — reaching out to a subcontractor
  // is a real action tied to a specific opportunity, not an abstract
  // property of the gap itself.
  const [externalEngagements, setExternalEngagements] = useState(initial.externalEngagements || {});
  function upsertEngagement(key, patch) {
    setExternalEngagements((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), ...patch, updatedAt: new Date().toISOString() } }));
  }
  function markEngagementStatus(key, status, logEntry) {
    upsertEngagement(key, { status });
    if (logEntry) recordAction(logEntry.accountId, { action: logEntry.action, opportunityId: logEntry.newsId, opportunityLabel: logEntry.headline });
  }
  // The popup itself (ephemeral, not persisted — reopening later happens
  // through the signal's own action tracker, not a saved "still open"
  // flag) is keyed just to the signal, not a frozen snapshot of its items:
  // the modal recomputes which gaps need engagement from the live
  // fitGaps/selectedMoves each render, so it can be reopened from the
  // tracker days later and still reflect the current picture.
  const [engagementPrompt, setEngagementPrompt] = useState(null); // { accountId, newsId } | null
  const [engagementDraftingKey, setEngagementDraftingKey] = useState(null);
  const [engagementDraftErrors, setEngagementDraftErrors] = useState({});
  async function draftEngagementEmail(accountId, newsId, gapTag, item) {
    const key = engagementKey(accountId, newsId, gapTag);
    setEngagementDraftingKey(key);
    setEngagementDraftErrors((prev) => ({ ...prev, [key]: null }));
    const account = accountsRef.current.find((a) => a.id === accountId);
    const match = accountMatches(accountId).find((m) => m.newsId === newsId);
    const existing = externalEngagements[key] || {};
    const recipient = existing.contactName || item.move.partner || '';
    const prompt = `You are drafting a short outreach email from a renewables-sector business development team to a prospective delivery partner or subcontractor, asking them to help cover a capability gap on a live client opportunity. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"subject": "...", "body": "..."}

Recipient: ${recipient || 'no specific contact name given — keep it role-neutral, e.g. "Hi there,"'}${item.move.partner ? `, at ${item.move.partner}` : ''}.

Client opportunity (context only — do not name the client in the email unless appropriate for a partner conversation; keep specifics light): ${account ? account.name : ''}, a ${account ? account.sector : ''}. Signal: ${match ? match.news.headline : ''}.

The gap we need covered: ${item.gap}
Our chosen approach: ${item.move.type} — ${item.move.detail}

Rules:
- Explain briefly what we need covered and why, without disclosing confidential client specifics beyond what's given above.
- Propose a short call or intro to discuss scope and availability.
- Keep it under 150 words, professional, no invented facts, figures, names, or prior relationship.
- End with one clear ask and a proposed timeframe.`;
    try {
      const parsed = await callClaudeJSON(prompt, 500);
      upsertEngagement(key, { emailDraft: { subject: parsed.subject || '', body: parsed.body || '' } });
    } catch (e) {
      setEngagementDraftErrors((prev) => ({ ...prev, [key]: friendlyApiError(e, "Couldn't draft this email just now — try again.") }));
    } finally {
      setEngagementDraftingKey(null);
    }
  }
  function openEngagementAssist(accountId, newsId) {
    setEngagementPrompt({ accountId, newsId });
  }
  function closeEngagementAssist() {
    setEngagementPrompt(null);
  }
  function markEngagementContacted(key) {
    const eng = externalEngagements[key];
    if (!eng) return;
    markEngagementStatus(key, 'contacted', { accountId: eng.accountId, newsId: eng.newsId, action: `Reached out to ${eng.contactName || eng.partner || 'external partner'} re: ${eng.gap}` });
  }
  function markEngagementDone(key) {
    const eng = externalEngagements[key];
    if (!eng) return;
    markEngagementStatus(key, 'done', { accountId: eng.accountId, newsId: eng.newsId, action: `Confirmed with ${eng.contactName || eng.partner || 'external partner'} re: ${eng.gap}` });
  }

  // Archive (added 2026-10-10, direct feedback: "if a signal is closed, the
  // user selects the outcome and there should be a button to click to
  // archive and take it off this screen") — a matched signal, not the
  // account itself; the account always stays in Overview regardless. Kept
  // as a plain `archived` flag on the PROMOTED_MATCHES entry (same
  // mutate-in-place pattern as `reviewed`/`flaggedFor`/setMatchStage), not
  // a new state map, so it rides along with the array's existing
  // persistence for free. Deliberately reversible — see unarchiveSignal —
  // matching this file's "never silently lose data" rule elsewhere (Undo
  // last, unlink-not-delete, etc.). `autoClose`/`outcome` let
  // proceedWithSignalRoute below archive a signal directly from a declined
  // gap without requiring the user to separately close it and pick an
  // outcome first — see that function's own comment.
  function archiveSignal(newsId, accountId, { autoClose = false, outcome: forcedOutcome = null } = {}) {
    const m = PROMOTED_MATCHES.find((mm) => mm.newsId === newsId && mm.accountId === accountId);
    if (!m) return;
    const headline = (newsById(newsId) || {}).headline || newsId;
    if (autoClose) m.stage = 'Closed';
    m.archived = true;
    setPromotedVersion((v) => v + 1);
    if (forcedOutcome) setDealOutcome(newsId, forcedOutcome);
    setActivity((prev) => ({
      ...prev,
      [accountId]: [
        ...(prev[accountId] || []),
        {
          id: `${Date.now()}-${Math.random()}`,
          from: null,
          to: null,
          action: autoClose ? `Signal archived (declined): ${headline}` : `Signal archived: ${headline}`,
          contactId: null,
          date: todayISO(),
          note: '',
          opportunityId: newsId,
          opportunityLabel: headline,
          cascaded: [],
        },
      ],
    }));
  }
  function unarchiveSignal(newsId, accountId) {
    const m = PROMOTED_MATCHES.find((mm) => mm.newsId === newsId && mm.accountId === accountId);
    if (!m) return;
    const headline = (newsById(newsId) || {}).headline || newsId;
    m.archived = false;
    setPromotedVersion((v) => v + 1);
    setActivity((prev) => ({
      ...prev,
      [accountId]: [
        ...(prev[accountId] || []),
        {
          id: `${Date.now()}-${Math.random()}`,
          from: null,
          to: null,
          action: `Signal restored: ${headline}`,
          contactId: null,
          date: todayISO(),
          note: '',
          opportunityId: newsId,
          opportunityLabel: headline,
          cascaded: [],
        },
      ],
    }));
  }
  // Fit & gaps "Proceed" (added 2026-10-10, direct feedback: "the user
  // should select from the suggested blocker solutions (or an override)
  // and click to proceed"). Adds a user-typed resolution directly onto the
  // gap's own `moves` array in `fitGaps` state — not through validateMove,
  // since that guards against the MODEL inventing an unverifiable partner,
  // not against the user's own words — so every downstream reader that
  // already does `g.moves[idx]` (GoNoGoCheck, ProposalPanel, the email
  // composer, buildProposalPdf) picks it up automatically with no changes
  // of its own. Auto-selects the new move since the user just typed it
  // specifically to use it.
  function addOverrideMove(accountId, gapTag, detail) {
    const text = String(detail || '').trim();
    if (!text) return;
    const fg = fitGaps[accountId];
    if (!fg) return;
    const gaps = fg.gaps.map((g) => (g.tag === gapTag ? { ...g, moves: [...g.moves, { type: 'Custom', detail: text, partner: null }] } : g));
    const updatedGap = gaps.find((g) => g.tag === gapTag);
    if (!updatedGap) return;
    setFitGaps((prev) => ({ ...prev, [accountId]: { ...fg, gaps } }));
    selectMove(accountId, gapTag, updatedGap.moves.length - 1);
  }
  // "...click to proceed. If the suggestion they proceed with is to
  // decline, it should archive that signal. The other options should
  // proceed with whatever considerations are relevant given the blocker
  // and the user's choice." Grouped by matched signal (2026-10-10
  // follow-up: "any matched signal with multiple blockers or coverables
  // should interact and have a single move forward button that
  // incorporates the user's chosen approaches") rather than one button per
  // gap — proceedWithSignalRoute confirms every gap whose evidence cites
  // this ONE signal at once. A gap can still appear under more than one
  // signal if its evidence cites several (shared evidence is how the asked-
  // for "interact" actually happens: picking a route for a shared gap under
  // signal A is the same selectedMoves entry signal B's card reads too).
  // Confirming is the same confirmedMitigations flag Go/no-go already reads.
  // Decline specifically archives THIS signal (not every signal the gap's
  // evidence cites — that would silently archive a signal the user wasn't
  // even looking at) with a "Lost" outcome, same end state the manual
  // archive button requires; every other move type just confirms the
  // route, since its downstream effects (proposal, email, go/no-go) are
  // already driven by selectedMoves/confirmedMitigations.
  function proceedWithSignalRoute(accountId, newsId) {
    const fg = fitGaps[accountId];
    if (!fg) return;
    const gaps = fg.gaps.filter((g) => g.evidence.includes(newsId));
    if (!gaps.length) return;
    const movesForAccount = selectedMoves[accountId] || {};
    const chosenFor = (g) => {
      const idx = movesForAccount[g.tag];
      return typeof idx === 'number' ? g.moves[idx] : null;
    };
    if (!gaps.every((g) => chosenFor(g))) return; // every gap on this signal needs a route picked first
    setConfirmedMitigations((prev) => {
      const next = { ...prev };
      gaps.forEach((g) => {
        next[`${accountId}:${g.tag}`] = true;
      });
      return next;
    });
    if (gaps.some((g) => chosenFor(g).type === 'Decline')) {
      archiveSignal(newsId, accountId, { autoClose: true, outcome: 'Lost' });
      return; // a signal being archived as Lost isn't one to go find a subcontractor for
    }
    // External engagement assist (2026-10-11 follow-up: "for blockers where
    // external engagement is required... pop up a contact
    // identifying/email generator to assist with the outreach"). Seeds a
    // tracked 'needed' entry for every gap whose just-confirmed route names
    // a real external party, then surfaces the one-time assist popup —
    // see engagementPrompt/EngagementAssistModal below. Doesn't overwrite
    // an engagement already in progress (e.g. re-confirming after an Undo).
    const engageGaps = gaps.filter((g) => needsExternalEngagement(chosenFor(g)));
    if (engageGaps.length) {
      engageGaps.forEach((g) => {
        const key = engagementKey(accountId, newsId, g.tag);
        const move = chosenFor(g);
        setExternalEngagements((prev) =>
          prev[key]
            ? prev
            : {
                ...prev,
                [key]: { status: 'needed', accountId, newsId, partner: move.partner || null, moveType: move.type, moveDetail: move.detail, gap: g.gap, updatedAt: new Date().toISOString() },
              }
        );
      });
      setEngagementPrompt({ accountId, newsId });
    }
  }
  // Undo for the above (direct feedback 2026-10-10: "there should be an undo
  // button when choosing a route given the blocker"). Mirrors
  // proceedWithSignalRoute in reverse, scoped to this one signal: clears the
  // confirmation flag on every gap shown under it (a gap shared with another
  // signal stays confirmed there independently — this only un-confirms the
  // copy this signal's card is responsible for... but since confirmation is
  // keyed by gapTag alone, not gapTag+signal, undoing here does un-confirm a
  // shared gap everywhere it appears; acceptable for a prototype since the
  // route itself, selectedMoves, is already shared state across signals by
  // design), and if THIS signal was archived by its own Decline route,
  // unarchives it and clears the "Lost" outcome it set.
  function undoSignalRoute(accountId, newsId) {
    const fg = fitGaps[accountId];
    if (!fg) return;
    const gaps = fg.gaps.filter((g) => g.evidence.includes(newsId));
    if (!gaps.length) return;
    setConfirmedMitigations((prev) => {
      const next = { ...prev };
      gaps.forEach((g) => {
        delete next[`${accountId}:${g.tag}`];
      });
      return next;
    });
    const m = PROMOTED_MATCHES.find((mm) => mm.newsId === newsId && mm.accountId === accountId);
    if (m && m.archived) {
      unarchiveSignal(newsId, accountId);
      setDealOutcome(newsId, null);
    }
  }
  const [researchPlans, setResearchPlans] = useState(initial.researchPlans || {}); // accountId -> normalized research plan
  const [researchSelections, setResearchSelections] = useState(initial.researchSelections || {}); // accountId -> [questionId] ticked in the checklist
  // Not persisted — an in-flight run can't resume after a reload (the async
  // work itself is gone), so a saved "running" status would just be a
  // permanently stuck spinner. Always starts empty; see PERSISTENCE above.
  const [researchRuns, setResearchRuns] = useState({}); // accountId -> { phase, statuses: { questionId: status } }
  const [reports, setReports] = useState(initial.reports || {}); // accountId -> research report (see researchProgress)
  const [researchingId, setResearchingId] = useState(null);
  const [researchErrors, setResearchErrors] = useState({});
  // accountId -> 'full' | 'skip' | undefined (undefined = not asked yet).
  // 'skip' means the user already knows this account and is handing over
  // their own notes/file instead of running the AI research workflow.
  const [intakeChoices, setIntakeChoices] = useState(initial.intakeChoices || {});
  const [manualSubmittingId, setManualSubmittingId] = useState(null);
  const [manualErrors, setManualErrors] = useState({});
  // Overview tab grouping: 'none' | 'tier' | 'sector' | 'stage' | 'custom'.
  const [groupBy, setGroupBy] = useState(initial.groupBy || 'tier');
  // Overview tab's "Sort by gap severity" toggle — orthogonal to groupBy
  // above (it reorders accounts within whatever grouping is active, rather
  // than changing the grouping itself). See groupAccounts/compareGapSeverity.
  const [sortBySeverity, setSortBySeverity] = useState(initial.sortBySeverity || false);
  const [customGroups, setCustomGroups] = useState(initial.customGroups || []); // user-created group names, in creation order
  const [accountGroups, setAccountGroups] = useState(initial.accountGroups || {}); // accountId -> custom group name
  // Live feed: loaded from gridline_news.json (see News Scraper/). Stays
  // separate from the Signal Map unless the user explicitly promotes one
  // item via "Add to Signal Map" (see addLiveItemToSignalMap below).
  const [liveNews, setLiveNews] = useState(initial.liveNews || []);
  const [liveNewsError, setLiveNewsError] = useState(null);
  const [newLiveIds, setNewLiveIds] = useState(new Set()); // ids added by the most recent load
  // "Mark as read" (added 2026-10-07) — read items sink to the bottom of the
  // Live feed list instead of disappearing, so nothing already acted on is
  // lost, just deprioritized. A deliberate user action (like a stage
  // confirmation), not transient load-state like newLiveIds above, so this
  // one does persist — see the PERSISTENCE useEffect below.
  const [readLiveIds, setReadLiveIds] = useState(new Set(initial.readLiveIds || []));
  function toggleLiveItemRead(id) {
    setReadLiveIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }
  const [promotedVersion, setPromotedVersion] = useState(0); // bumped on every "Add to Signal Map" — see allNewsItems/allMatches
  // "Auto-match on scrape" (added 2026-10-07) — off by default, same
  // never-change-state-without-a-human default the rest of the app uses
  // (stage confirmation, PDF/email send, etc.). When on, a freshly loaded
  // batch of Live feed items is matched against tracked accounts the same
  // way "Add to Signal Map"/"Accept all" already do, with no extra click —
  // see autoMatchNewLiveItems below, called from loadLiveNewsFromText.
  const [autoMatchEnabled, setAutoMatchEnabled] = useState(initial.autoMatchEnabled || false);
  function reviewSignal(newsId, accountId) {
    reviewMatch(newsId, accountId);
    setPromotedVersion((v) => v + 1);
  }
  // Flags a matched signal for a colleague's review (added 2026-10-08, see
  // MANAGER_COLLEAGUES above) — mutates the same PROMOTED_MATCHES entry
  // reviewMatch/unlinkMatch already mutate in place, so `flaggedFor` is just
  // another field on it, persisted for free via the existing
  // `promotedMatches: PROMOTED_MATCHES` line below. Demo-only: nothing is
  // actually sent to anyone.
  function flagSignalForReview(newsId, accountId, colleague) {
    const m = PROMOTED_MATCHES.find((m) => m.newsId === newsId && m.accountId === accountId);
    if (m) m.flaggedFor = colleague;
    setPromotedVersion((v) => v + 1);
  }
  function clearSignalFlag(newsId, accountId) {
    const m = PROMOTED_MATCHES.find((m) => m.newsId === newsId && m.accountId === accountId);
    if (m) m.flaggedFor = null;
    setPromotedVersion((v) => v + 1);
  }
  // Manager mode (added 2026-10-08) — off by default, same never-change-
  // state-without-explicit-action default the rest of the app uses. On,
  // it exposes a "Delegate to..." control on every stage-action confirm
  // (NextMilestone, below) and the flag-for-review control above. Purely a
  // local toggle — there's no real login, so this doesn't mean "you are a
  // manager," just "show the delegate/flag UI."
  const [managerMode, setManagerMode] = useState(initial.managerMode || false);
  // Who a given stage action is delegated to, keyed by opportunityId (a
  // matched signal's newsId) or accountId (the account's own baseline
  // action) — same key NextMilestone's onRecord already uses to tell those
  // two apart. Demo-only, per MANAGER_COLLEAGUES above: delegating doesn't
  // stop the manager from completing the action themselves.
  const [delegations, setDelegations] = useState(initial.delegations || {});
  function delegateAction(key, colleague) {
    setDelegations((prev) => ({ ...prev, [key]: colleague }));
  }
  function clearDelegation(key) {
    setDelegations((prev) => {
      const next = { ...prev };
      delete next[key];
      return next;
    });
  }
  // CAPABILITIES/PARTNERSHIPS editable settings (added 2026-10-08, moved to
  // a standalone CapabilitiesPage same day) — the module-level mutable
  // arrays (see their own comment above) are reassigned directly rather
  // than kept in React state, since plain prompt-building functions read
  // them by name; this counter just forces a re-render and re-save when
  // CapabilitiesPage changes them. See the persistence useEffect below for
  // what actually gets written to localStorage.
  const [capabilitiesVersion, setCapabilitiesVersion] = useState(0);
  function updateCapabilities(list) {
    CAPABILITIES = list;
    setCapabilitiesVersion((v) => v + 1);
  }
  function updatePartnerships(list) {
    PARTNERSHIPS = list;
    setCapabilitiesVersion((v) => v + 1);
  }
  function setBidWritingCapable(value) {
    BID_WRITING_CAPABLE = value;
    setCapabilitiesVersion((v) => v + 1);
  }
  function updateCompanyName(value) {
    COMPANY_NAME = value;
    setCapabilitiesVersion((v) => v + 1);
  }
  function updateCompanyWebsite(value) {
    COMPANY_WEBSITE = value;
    setCapabilitiesVersion((v) => v + 1);
  }
  function updateGeographies(list) {
    GEOGRAPHIES = list;
    setCapabilitiesVersion((v) => v + 1);
  }
  // Uploaded past-project files (added 2026-10-08) — genuine React state,
  // unlike CAPABILITIES/PARTNERSHIPS above: nothing outside App reads this,
  // so there's no need for the module-level-binding workaround those two
  // need for the plain prompt-building functions. Each entry is whatever
  // readIntakeFile (defined above, already used by the research-intake file
  // upload) returned — { kind: 'text', text } or { kind: 'pdf', base64 } —
  // plus { id, name, size, uploadedAt }. Read by generatePlan below.
  const [competencyUploads, setCompetencyUploads] = useState(initial.competencyUploads || []);
  function addCompetencyUploads(entries) {
    setCompetencyUploads((prev) => [...prev, ...entries]);
  }
  function removeCompetencyUpload(id) {
    setCompetencyUploads((prev) => prev.filter((u) => u.id !== id));
  }
  // Thought leadership uploads (added 2026-10-11, direct feedback: "there
  // should be a section to upload the thought leadership that's linked on
  // the email generator") — same shape/mechanics as competencyUploads
  // above, just read by EmailComposer's "Our thought leadership" picker
  // (merged with FIRM_INSIGHTS) instead of generatePlan.
  const [thoughtLeadershipUploads, setThoughtLeadershipUploads] = useState(initial.thoughtLeadershipUploads || []);
  function addThoughtLeadershipUploads(entries) {
    setThoughtLeadershipUploads((prev) => [...prev, ...entries]);
  }
  function removeThoughtLeadershipUpload(id) {
    setThoughtLeadershipUploads((prev) => prev.filter((u) => u.id !== id));
  }

  // Persist on every change to anything saved above — see loadSavedState/
  // saveState in the PERSISTENCE section. promotedVersion is in the
  // dependency list (not PROMOTED_NEWS/PROMOTED_MATCHES themselves, which
  // React can't see mutations on) purely to re-run this when a promotion or
  // unlink happens.
  useEffect(() => {
    saveState({
      accounts,
      contacts,
      activity,
      emailDrafts,
      plans,
      fitGaps,
      fitChangeNotices,
      proposals,
      confirmedMitigations,
      selectedMoves,
      dealOutcomes,
      externalEngagements,
      researchPlans,
      researchSelections,
      reports,
      intakeChoices,
      groupBy,
      sortBySeverity,
      customGroups,
      accountGroups,
      liveNews,
      readLiveIds: [...readLiveIds],
      autoMatchEnabled,
      promotedNews: PROMOTED_NEWS,
      promotedMatches: PROMOTED_MATCHES,
      capabilities: CAPABILITIES,
      partnerships: PARTNERSHIPS,
      bidWritingCapable: BID_WRITING_CAPABLE,
      companyName: COMPANY_NAME,
      companyWebsite: COMPANY_WEBSITE,
      geographies: GEOGRAPHIES,
      competencyUploads,
      thoughtLeadershipUploads,
      managerMode,
      delegations,
      meetingPlans,
      meetingNotesReports,
      meetingNotesSkipped,
      meetingOutcomes,
      meetingOutcomeFlags,
      meetingSlotCounts,
      outreachCorrespondence,
      outreachResponses,
      signalActions,
      archivedAccounts,
    });
  }, [
    accounts, contacts, activity, emailDrafts, plans, fitGaps, fitChangeNotices, proposals, confirmedMitigations, selectedMoves, dealOutcomes, externalEngagements, researchPlans, researchSelections,
    archivedAccounts,
    reports, intakeChoices, groupBy, sortBySeverity, customGroups, accountGroups, liveNews, readLiveIds, autoMatchEnabled, promotedVersion,
    capabilitiesVersion, competencyUploads, thoughtLeadershipUploads, managerMode, delegations, meetingPlans, meetingNotesReports, meetingNotesSkipped, meetingOutcomes, meetingOutcomeFlags, meetingSlotCounts, outreachCorrespondence, outreachResponses, signalActions,
  ]);

  // Async handlers (plan generation, research runs) finish after re-renders,
  // so read the latest state from refs rather than stale closures.
  const accountsRef = useRef(accounts);
  accountsRef.current = accounts;
  const reportsRef = useRef(reports);
  reportsRef.current = reports;
  const researchPlansRef = useRef(researchPlans);
  researchPlansRef.current = researchPlans;
  const liveNewsRef = useRef(liveNews);
  liveNewsRef.current = liveNews;
  const fitGapsRef = useRef(fitGaps);
  fitGapsRef.current = fitGaps;
  // null = not tried yet, true = web search works here, false = it doesn't,
  // so later questions skip straight to the no-web fallback.
  const webSearchRef = useRef(null);

  // Log something the user did. If `to` is a later stage, the account moves
  // there; if it's absent or not ahead of the current stage, it's log-only.
  // An `opportunityId` (a matched signal's newsId, added 2026-10-06) scopes
  // the stage move to that one matched signal instead of the account as a
  // whole — see setMatchStage/accountRollupStage — so several signals on
  // the same account can sit at different stages at once. opportunityId is
  // stored on the activity entry either way, purely so the shared activity
  // log can say which signal (if any) an entry was about.
  // Research is a company-wide fact, not a per-signal one — confirming it
  // once (via the account's own "Next milestone" or one matched signal's
  // own compact confirm) covers every signal tagged to this company, not
  // just whichever one was just confirmed. Called only when something just
  // moved to 'Researched' (see recordAction below): bumps the account's own
  // stage to 'Researched' if it's still behind, and every *other* matched
  // signal still stuck at 'Identified' straight to 'Researched' too — so
  // nobody is ever asked to "research the account" again per signal. Never
  // touches a later stage (Contacted, Meeting, Proposal...): those stay
  // genuinely per-signal, since whether you've met/proposed is a fact about
  // that specific opportunity, not the company. Returns what it actually
  // changed so undoLastAction can revert the same things.
  function cascadeResearchCompletion(accountId, justConfirmedOpportunityId) {
    const changes = [];
    const acct = accountsRef.current.find((a) => a.id === accountId);
    if (acct && STAGES.indexOf(acct.stage) < STAGES.indexOf('Researched')) {
      changes.push({ type: 'account', previousStage: acct.stage });
      setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: 'Researched' } : a)));
    }
    allMatches().forEach((m) => {
      if (m.accountId === accountId && m.newsId !== justConfirmedOpportunityId && (m.stage || 'Identified') === 'Identified') {
        changes.push({ type: 'match', newsId: m.newsId, previousStage: m.stage || 'Identified' });
        setMatchStage(m.newsId, accountId, 'Researched');
      }
    });
    if (changes.some((c) => c.type === 'match')) setPromotedVersion((v) => v + 1);
    return changes;
  }

  function recordAction(accountId, { action, to = null, contactId = null, date = todayISO(), note = '', opportunityId = null, opportunityLabel = null }) {
    let from, moves;
    if (opportunityId) {
      const match = allMatches().find((m) => m.newsId === opportunityId && m.accountId === accountId);
      if (!match) return;
      from = match.stage || 'Identified';
      moves = to && STAGES.indexOf(to) > STAGES.indexOf(from);
      if (moves) {
        setMatchStage(opportunityId, accountId, to);
        setPromotedVersion((v) => v + 1);
      }
    } else {
      const acct = accountsRef.current.find((a) => a.id === accountId);
      if (!acct) return;
      from = acct.stage;
      moves = to && STAGES.indexOf(to) > STAGES.indexOf(from);
      if (moves) {
        setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: to } : a)));
      }
    }
    // "If there's only one matched signal, the overall company should move
    // at the same pace" (2026-10-11 direct follow-up feedback) — with
    // exactly one non-archived matched signal there's no ambiguity about
    // which opportunity the account's own baseline stage is tracking, so
    // whichever of the two (account baseline or the lone signal) this call
    // just advanced, bring the other one up to the same stage too. Never
    // regresses the other track, and does nothing with 0 or 2+ matches —
    // with multiple signals each one genuinely is its own independent
    // opportunity (see setMatchStage's own comment), so they stay
    // decoupled from the account baseline as before.
    if (moves) {
      const soleMatches = allMatches().filter((m) => m.accountId === accountId && !m.archived);
      if (soleMatches.length === 1) {
        if (opportunityId) {
          const acct = accountsRef.current.find((a) => a.id === accountId);
          if (acct && STAGES.indexOf(to) > STAGES.indexOf(acct.stage)) {
            setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: to } : a)));
          }
        } else {
          const lone = soleMatches[0];
          if (STAGES.indexOf(to) > STAGES.indexOf(lone.stage || 'Identified')) {
            setMatchStage(lone.newsId, accountId, to);
            setPromotedVersion((v) => v + 1);
          }
        }
      }
    }
    const cascaded = moves && to === 'Researched' ? cascadeResearchCompletion(accountId, opportunityId) : [];
    const entry = { id: `${Date.now()}-${Math.random()}`, from, to: moves ? to : null, action, contactId, date, note, opportunityId, opportunityLabel, cascaded };
    setActivity((prev) => ({ ...prev, [accountId]: [...(prev[accountId] || []), entry] }));
  }

  // `opportunityId` added 2026-10-10 (direct feedback: "update all the
  // activity panels on the company pages to ensure they're matching with
  // the status shown on the gantt") — every NextMilestone now shows only
  // its OWN track's activity (see activityForTrack below), so "Undo last"
  // needs to undo that track's own last entry, not whatever's globally
  // last across the whole account (which could be a different matched
  // signal's more recent action) — otherwise a signal's Activity panel
  // could show "Undo last" doing nothing, or worse, silently undoing a
  // different signal's progress while that panel's own Gantt bar doesn't
  // move. `null` (the default) means the account's own baseline track,
  // same convention `recordAction`'s own `opportunityId` already uses.
  function undoLastAction(accountId, opportunityId = null) {
    const log = activity[accountId] || [];
    let idx = -1;
    for (let i = log.length - 1; i >= 0; i--) {
      if ((log[i].opportunityId || null) === opportunityId) {
        idx = i;
        break;
      }
    }
    if (idx === -1) return;
    const last = log[idx];
    if (last.to) {
      if (last.opportunityId) {
        setMatchStage(last.opportunityId, accountId, last.from);
        setPromotedVersion((v) => v + 1);
      } else {
        setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: last.from } : a)));
      }
      // Undo the other half of the single-matched-signal sync above, but
      // only if the other track is still exactly where this action left it
      // — if it moved again independently since, leave it alone rather than
      // clobbering a later, unrelated change.
      const soleMatches = allMatches().filter((m) => m.accountId === accountId && !m.archived);
      if (soleMatches.length === 1) {
        if (last.opportunityId) {
          const acct = accountsRef.current.find((a) => a.id === accountId);
          if (acct && acct.stage === last.to) {
            setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: last.from } : a)));
          }
        } else {
          const lone = soleMatches[0];
          if ((lone.stage || 'Identified') === last.to) {
            setMatchStage(lone.newsId, accountId, last.from);
            setPromotedVersion((v) => v + 1);
          }
        }
      }
    }
    // Revert any research-cascade side effects from the same confirm — see
    // cascadeResearchCompletion/recordAction above — so "Undo last" fully
    // undoes a company-wide research confirm, not just the one signal/
    // account stage the user directly clicked.
    if (last.cascaded && last.cascaded.length) {
      last.cascaded.forEach((c) => {
        if (c.type === 'account') {
          setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, stage: c.previousStage } : a)));
        } else {
          setMatchStage(c.newsId, accountId, c.previousStage);
        }
      });
      setPromotedVersion((v) => v + 1);
    }
    setActivity((prev) => ({ ...prev, [accountId]: log.filter((_, i) => i !== idx) }));
  }

  function addContact(contact) {
    setContacts((prev) => [...prev, { ...contact, id: `c-${Date.now()}` }]);
  }

  // Adds one or more accounts (from the "Add one" form or a parsed CSV —
  // see AddAccountModal). Every new account starts at stage 'Identified'
  // with no activity/contacts/matches, same rule as every seeded account
  // (§3.0) — never invent pipeline history for a company someone just
  // typed in. Dedupes by name (case-insensitive) against what's already
  // there; skipped accounts are reported back so the UI can say so. Reads
  // accountsRef (not the `accounts` closure) so a bulk CSV import sees
  // accounts added earlier in the same batch, and returns synchronously —
  // setAccounts is async, so the counts can't come from its updater.
  function addAccountsBulk(entries) {
    const current = accountsRef.current;
    const existingNames = new Set(current.map((a) => a.name.trim().toLowerCase()));
    const existingIds = new Set(current.map((a) => a.id));
    let idCounter = current.length;
    let duplicates = 0;
    const toAdd = [];
    entries.forEach((entry) => {
      const name = (entry.name || '').trim();
      const key = name.toLowerCase();
      if (!name || existingNames.has(key)) {
        if (name) duplicates += 1;
        return;
      }
      existingNames.add(key);
      idCounter += 1;
      let id = `a${idCounter}`;
      while (existingIds.has(id)) {
        idCounter += 1;
        id = `a${idCounter}`;
      }
      existingIds.add(id);
      toAdd.push({
        id,
        name,
        aliases: entry.aliases && entry.aliases.length ? entry.aliases : [name],
        sector: (entry.sector || '').trim(),
        tier: TIER_ORDER.includes(entry.tier) ? entry.tier : 'Prospect',
        priorities: entry.priorities || [],
        accountManager: (entry.accountManager || '').trim(),
        stage: 'Identified',
      });
    });
    if (toAdd.length) setAccounts((prev) => [...prev, ...toAdd]);
    return { added: toAdd.length, duplicates };
  }

  // Suggested companies (added 2026-10-11, per direct feedback: "option to
  // auto populate the overview page with suggested companies based on the
  // user's company's capabilities on first startup"). A real, grounded web
  // search call — not a canned list — so it only ever names companies that
  // actually turned up, same discipline as the research workflow's own
  // `verifiedSources`. Deliberately a separate on-demand action (button in
  // OnboardingModal's companies step), not an automatic call on first load:
  // this zero-backend prototype should never fire an API call before the
  // user has pasted a key, and a suggestion made before Your Company is
  // filled in would have nothing but the placeholder profile to go on.
  const [suggestedAccounts, setSuggestedAccounts] = useState([]);
  const [suggestingAccounts, setSuggestingAccounts] = useState(false);
  const [suggestAccountsError, setSuggestAccountsError] = useState(null);

  async function suggestAccounts() {
    setSuggestingAccounts(true);
    setSuggestAccountsError(null);
    const already = accountsRef.current.map((a) => a.name).join('; ') || 'None yet.';
    const prompt = `You are helping a renewables-sector business development team find new companies worth tracking as sales targets, based on their own firm's profile below. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"suggestions": [{"name": "...", "sector": "...", "rationale": "..."}]}

Rules:
- Suggest up to 6 real, currently-operating companies in the renewables/clean-energy sector that would be plausible prospects for this firm, found via web search. Never invent a company — only name ones your search actually turned up.
- Favor companies whose public activity (projects, funding, expansion) plausibly intersects with our firm's stated capabilities and geographies below.
- Don't suggest any company already in our tracked list below.
- "sector": a short description (e.g. "Offshore wind developer").
- "rationale": one sentence, under 25 words, on why this company fits our profile specifically — not a generic description.

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ') || '(not set — suggest broadly across the sector)'}.
Our partnerships: ${PARTNERSHIPS.join('; ') || 'None listed.'}.

Already tracked (don't repeat these): ${already}`;

    try {
      const { parsed } = await callClaudeResearch(prompt, { web: true, maxTokens: 1500 });
      const suggestions = Array.isArray(parsed.suggestions)
        ? parsed.suggestions
            .filter((s) => s && typeof s.name === 'string' && s.name.trim())
            .map((s) => ({ name: s.name.trim(), sector: (s.sector || '').trim(), rationale: (s.rationale || '').trim() }))
        : [];
      setSuggestedAccounts(suggestions);
    } catch (e) {
      setSuggestAccountsError(friendlyApiError(e, "Couldn't get suggestions just now — try again."));
    } finally {
      setSuggestingAccounts(false);
    }
  }

  // Applied by the "Manage accounts" window — a straight field-level patch
  // (name/sector/tier/priorities/aliases) with no stage, activity, or signal
  // side effects; those stay owned by their own dedicated flows.
  function updateAccount(accountId, updates) {
    setAccounts((prev) => prev.map((a) => (a.id === accountId ? { ...a, ...updates } : a)));
  }

  // Lets the user change their mind later too — e.g. start on "skip to
  // outreach" and then decide they want the full research workflow after all.
  function setIntakeChoice(accountId, choice) {
    setIntakeChoices((prev) => ({ ...prev, [accountId]: choice }));
  }

  function addCustomGroup(name) {
    setCustomGroups((prev) => (prev.includes(name) ? prev : [...prev, name]));
  }

  // Removing a group ungroups any account that was in it, rather than
  // leaving a dangling reference to a group that no longer exists.
  function removeCustomGroup(name) {
    setCustomGroups((prev) => prev.filter((g) => g !== name));
    setAccountGroups((prev) => {
      const next = { ...prev };
      Object.keys(next).forEach((id) => {
        if (next[id] === name) delete next[id];
      });
      return next;
    });
  }

  function setAccountGroup(accountId, name) {
    setAccountGroups((prev) => ({ ...prev, [accountId]: name || null }));
  }

  // Parses and merges a gridline_news.json payload (from the file picker or
  // the "Load from URL" field — either way it ends up here as raw text).
  // Loading a second batch merges by id rather than replacing the list, so
  // re-running the script and reloading doesn't lose anything already seen.
  function loadLiveNewsFromText(text) {
    setLiveNewsError(null);
    let parsed;
    try {
      parsed = parseLiveNewsJSON(text);
    } catch (e) {
      setLiveNewsError(e.message || "Couldn't read that file.");
      return;
    }
    if (!parsed.items.length) {
      setLiveNewsError(
        parsed.skipped > 0
          ? `No usable items found — ${parsed.skipped} record${parsed.skipped === 1 ? '' : 's'} missing id or headline.`
          : 'No items found in that file.'
      );
      return;
    }
    const existingIds = new Set(liveNewsRef.current.map((it) => it.id));
    const addedItems = parsed.items.filter((it) => !existingIds.has(it.id));
    const addedIds = addedItems.map((it) => it.id);
    setLiveNews((prev) => {
      const byId = new Map(prev.map((it) => [it.id, it]));
      parsed.items.forEach((it) => byId.set(it.id, it));
      return [...byId.values()];
    });
    setNewLiveIds(new Set(addedIds));
    if (autoMatchEnabled) autoMatchNewLiveItems(addedItems);
  }

  // "Auto-match on scrape" (see autoMatchEnabled above) — the automatic
  // counterpart to "Add to Signal Map"/"Accept all": for every freshly
  // loaded item that mentions a tracked account (matchingAccountsForLiveItem
  // — the same check that drives the company chips and "Watchlist mentions
  // only" filter), promotes it onto that account's Signal Map with `auto:
  // true`, so it starts life flagged "Auto-matched" (unreviewed) on the
  // Overview row and the account page until the user actually looks at it
  // (reviewSignal/MatchedSignalsPanel). Same generic, never-fabricated
  // default next-step text "Accept all" already uses — this is a workflow
  // nudge, not a claim about the account. Only ever adds a signal for an
  // account it genuinely mentions; never guesses or pads the match.
  function autoMatchNewLiveItems(newItems) {
    newItems.forEach((item) => {
      matchingAccountsForLiveItem(item, accountsRef.current).forEach((account) => {
        promoteLiveItemToMatch(item, account.id, {
          confidence: 75,
          rationale: item.summary || item.headline,
          nextStep: SEARCH_DEFAULT_NEXT_STEP[item.kind] || 'Review with the account team and decide the next action.',
          auto: true,
          stage: initialMatchStage(account),
        });
        recordAction(account.id, { action: `Signal auto-matched from Live feed: ${item.headline}` });
        // Day 4 (demo-week-plan.md) — a freshly-matched signal on an
        // account that's already been fit/gap-checked gets that check
        // re-run and diffed, same as the manual promote path below.
        recheckFitGapsOnNewMatch(account.id, item.id);
      });
    });
    setPromotedVersion((v) => v + 1);
  }

  // "Add to Signal Map" — the real promotion. Writes through
  // promoteLiveItemToMatch (module-level, read by
  // accountMatches/newsById/SignalMap everywhere), then bumps a counter to
  // force the re-render that picks the change up — see the long comment
  // above allNewsItems/allMatches for why this isn't React state directly.
  function addLiveItemToSignalMap(item, accountId, details) {
    const account = accountsRef.current.find((a) => a.id === accountId);
    promoteLiveItemToMatch(item, accountId, { ...details, stage: account ? initialMatchStage(account) : 'Identified' });
    recordAction(accountId, { action: `Signal added from Live feed: ${item.headline}` });
    setPromotedVersion((v) => v + 1);
    // Day 4 — see the matching comment in autoMatchNewLiveItems above.
    recheckFitGapsOnNewMatch(accountId, item.id);
  }

  // "Flag for <Account>" on the Today radar (added 2026-10-17) — a human
  // override, same spirit as "Add to Signal Map" above but for an item the
  // radar surfaced as a near miss: if it was never promoted, promote it now
  // and keep whatever confidence classifyForToday computed (never
  // overwritten); if it was already promoted but sat below the flag
  // threshold or past the 30-day window, just mark that existing match
  // `manual: true` so classifyForToday treats it as flagged regardless —
  // the explicit human call always wins over the computed one.
  function flagSignalManually(item, accountId, fallbackConfidence) {
    const account = accountsRef.current.find((a) => a.id === accountId);
    const newsId = `live-${item.id}`;
    const existing = PROMOTED_MATCHES.find((m) => m.newsId === newsId && m.accountId === accountId);
    if (existing) {
      existing.manual = true;
    } else {
      promoteLiveItemToMatch(item, accountId, {
        confidence: typeof fallbackConfidence === 'number' ? fallbackConfidence : 50,
        rationale: '',
        nextStep: nextStepSuggestions(item)[0],
        manual: true,
        stage: account ? initialMatchStage(account) : 'Identified',
      });
    }
    recordAction(accountId, { action: `Signal manually flagged from Today radar: ${item.headline}` });
    setPromotedVersion((v) => v + 1);
    recheckFitGapsOnNewMatch(accountId, item.id);
  }

  // Day 4 demo trigger (demo-week-plan.md) — injects HELD_BACK_DEMO_SIGNAL
  // as if it had just arrived from a fresh scrape: adds it to the Live feed
  // list (so it's visibly "new feed item" there too, not just silently
  // promoted) and then promotes it exactly the way "Add to Signal Map"
  // does, which in turn triggers the same recheckFitGapsOnNewMatch path
  // every other promotion does — this is a real exercise of the Day 4
  // mechanism, not a separate simulated one. Demo-mode only; no-ops
  // otherwise since the button that calls this is itself demo-mode-gated.
  function injectMarketMovesTrigger() {
    const normalized = normalizeLiveNewsItem(HELD_BACK_DEMO_SIGNAL.raw);
    setLiveNews((prev) => (prev.some((it) => it.id === normalized.id) ? prev : [...prev, normalized]));
    setNewLiveIds(new Set([normalized.id]));
    addLiveItemToSignalMap(normalized, HELD_BACK_DEMO_SIGNAL.accountId, {
      confidence: HELD_BACK_DEMO_SIGNAL.confidence,
      rationale: HELD_BACK_DEMO_SIGNAL.rationale,
      nextStep: HELD_BACK_DEMO_SIGNAL.nextStep,
    });
  }

  // Inverse of the above — called from the Signal Map's "Why it matters"
  // panel and the Overview's "Matched signals" list when a signal turns out
  // not to be relevant to an account after all. Logged the same way a
  // promotion is (log-only, no stage change), and clears the Signal Map
  // selection if this was the signal's last remaining connection, since
  // unlinkMatch will have dropped the news card entirely in that case.
  function unlinkSignal(newsId, accountId) {
    const news = newsById(newsId);
    unlinkMatch(newsId, accountId);
    if (news) recordAction(accountId, { action: `Signal unlinked: ${news.headline}` });
    if (!allMatches().some((m) => m.newsId === newsId)) setSelectedNewsId((id) => (id === newsId ? null : id));
    setPromotedVersion((v) => v + 1);
  }

  function toggleResearchQuestion(accountId, questionId) {
    setResearchSelections((prev) => {
      const current = prev[accountId] || [];
      return {
        ...prev,
        [accountId]: current.includes(questionId) ? current.filter((id) => id !== questionId) : [...current, questionId],
      };
    });
  }

  function setResearchSelection(accountId, ids) {
    setResearchSelections((prev) => ({ ...prev, [accountId]: ids }));
  }

  function addResearchQuestion(accountId, text) {
    const question = {
      id: `custom-${Date.now().toString(36)}`,
      question: text,
      why: 'Added by you.',
      priority: 'Nice',
      sources: [],
      minutes: 15,
      custom: true,
    };
    setResearchPlans((prev) => {
      const plan = prev[accountId];
      return plan ? { ...prev, [accountId]: { ...plan, questions: [...plan.questions, question] } } : prev;
    });
    setResearchSelections((prev) => ({ ...prev, [accountId]: [...(prev[accountId] || []), question.id] }));
  }

  // The "skip to outreach" path: turn the user's own notes and/or an
  // uploaded file into the same report shape the AI research workflow
  // produces (see buildManualReport), so nothing downstream needs to know
  // the difference. Logged like any other action, but never advances the
  // stage on its own — the user still confirms "Research done" themselves.
  async function submitManualResearch(account, { notes, file }) {
    setManualSubmittingId(account.id);
    setManualErrors((prev) => ({ ...prev, [account.id]: null }));
    try {
      let fileInfo = null;
      if (file) {
        try {
          fileInfo = await readIntakeFile(file);
        } catch (e) {
          if (e instanceof UnsupportedIntakeFile) {
            throw new Error(
              `This prototype can only read plain text, CSV, Markdown or PDF files directly — "${file.name}" isn't one of those. Paste the relevant text in instead, or save it as a PDF.`
            );
          }
          throw e;
        }
      }
      const matches = accountMatches(account.id);
      const prompt = buildManualIntakePrompt({ account, matches, notes, file: fileInfo });
      const content =
        fileInfo && fileInfo.kind === 'pdf'
          ? [
              { type: 'text', text: prompt },
              { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: fileInfo.base64 } },
            ]
          : prompt;
      const parsed = await callClaudeJSON(content, 1200);
      const report = buildManualReport(parsed);
      setReports((prev) => ({ ...prev, [account.id]: report }));
      recordAction(account.id, { action: 'Added own research notes in place of the research workflow' });
    } catch (e) {
      setManualErrors((prev) => ({ ...prev, [account.id]: e.message || "Couldn't process that — try again." }));
    } finally {
      setManualSubmittingId(null);
    }
  }

  // What we already hold internally about the firm, given to every research
  // call so the agent doesn't go looking for things we know.
  function internalContext(account) {
    const known = contacts.filter((c) => c.accountId === account.id);
    const work = PAST_WORK.filter((w) => w.accountId === account.id);
    const signals = accountMatches(account.id);
    return [
      known.length
        ? `Address book:\n${known.map((c) => `- ${c.name}, ${c.role} (${c.relationship}${c.lastTouch ? `; last touch: ${c.lastTouch}` : ''}). ${c.notes}`).join('\n')}`
        : 'Address book: no contacts on file.',
      work.length ? `Our past work with them:\n${work.map((w) => `- ${w.title} (${w.year}). ${w.outcome}`).join('\n')}` : 'Our past work with them: none.',
      signals.length ? `News signals that flagged them:\n${signals.map((m) => `- ${m.news.headline} (${m.news.date}). ${m.rationale}`).join('\n')}` : '',
    ]
      .filter(Boolean)
      .join('\n\n');
  }

  async function researchOneQuestion(account, q, context) {
    if (webSearchRef.current !== false) {
      try {
        const { parsed, searched, cited } = await callClaudeResearch(buildQuestionPrompt({ account, q, context, web: true }), { web: true });
        webSearchRef.current = true;
        return normalizeFinding(parsed, q, verifiedSources(parsed, searched, cited, true), 'web');
      } catch (e) {
        if (!(e instanceof WebSearchUnavailable)) throw e;
        webSearchRef.current = false;
      }
    }
    const { parsed, searched, cited } = await callClaudeResearch(buildQuestionPrompt({ account, q, context, web: false }), { web: false, maxTokens: 1200 });
    return normalizeFinding(parsed, q, verifiedSources(parsed, searched, cited, false), 'model');
  }

  async function summarizeReport(account, base) {
    try {
      const raw = await callClaudeJSON(
        buildSummaryPrompt({ account, objective: base.objective, findings: orderedFindings(base) }),
        1200
      );
      return normalizeSummary(raw);
    } catch (e) {
      return null;
    }
  }

  // Research every ticked question (three at a time), then write the summary
  // over everything in the report, including findings from earlier runs.
  async function runResearch(account) {
    const plan = researchPlansRef.current[account.id];
    const ids = researchSelections[account.id] || [];
    const questions = plan ? plan.questions.filter((q) => ids.includes(q.id)) : [];
    if (!questions.length || researchRuns[account.id]) return;

    // Day 5b reliability fallback — see DEMO_CACHED_RESEARCH above. Unlike
    // fetchFitGaps/generateProposal, this pipeline degrades question-by-
    // question on a real failure (see the per-question catch below) rather
    // than throwing, so there's no single call site to hook a reactive
    // fallback onto. Instead: with no API key at all in demo mode, skip the
    // live pipeline entirely and adopt the cached report wholesale — the
    // one scenario this is actually for (no network/key available), not a
    // substitute for a real run when a key IS present.
    if (DEMO_MODE && !hasAnthropicApiKey()) {
      const cached = demoCachedResearchFor(account.id);
      if (cached) {
        setResearchPlans((prev) => ({ ...prev, [account.id]: cached.plan }));
        setReports((prev) => ({ ...prev, [account.id]: { ...cached.report, updatedAt: new Date().toISOString() } }));
        setResearchSelections((prev) => ({ ...prev, [account.id]: [] }));
        recordAction(account.id, { action: `Research report generated: ${Object.keys(cached.report.findings).length} of ${Object.keys(cached.report.findings).length} questions researched` });
        return;
      }
    }

    const setStatus = (qid, status) =>
      setResearchRuns((prev) => ({
        ...prev,
        [account.id]: { ...prev[account.id], statuses: { ...prev[account.id].statuses, [qid]: status } },
      }));
    setResearchRuns((prev) => ({
      ...prev,
      [account.id]: { phase: 'questions', statuses: Object.fromEntries(questions.map((q) => [q.id, 'queued'])) },
    }));

    const context = internalContext(account);
    const results = {};
    await runPool(questions, 3, async (q) => {
      setStatus(q.id, 'running');
      try {
        results[q.id] = await researchOneQuestion(account, q, context);
        setStatus(q.id, 'done');
      } catch (e) {
        results[q.id] = { id: q.id, question: q.question, why: q.why, priority: q.priority, minutes: q.minutes, status: 'failed', sources: [], key_facts: [] };
        setStatus(q.id, 'failed');
      }
    });

    setResearchRuns((prev) => ({ ...prev, [account.id]: { ...prev[account.id], phase: 'summary' } }));
    const previous = reportsRef.current[account.id];
    const now = new Date().toISOString();
    const order = [...((previous && previous.order) || [])];
    questions.forEach((q) => {
      if (!order.includes(q.id)) order.push(q.id);
    });
    const findings = { ...((previous && previous.findings) || {}), ...results };
    const methods = new Set(Object.values(findings).filter((f) => f.status === 'done').map((f) => f.method));
    const base = {
      createdAt: (previous && previous.createdAt) || now,
      updatedAt: now,
      method: methods.size > 1 ? 'mixed' : methods.has('web') ? 'web' : 'model',
      findings,
      order,
      objective: plan.objective,
      known: plan.known,
      people_to_identify: plan.people_to_identify,
      watch_outs: plan.watch_outs,
      summary: null,
    };
    base.summary = await summarizeReport(account, base);

    setReports((prev) => ({ ...prev, [account.id]: base }));
    setResearchRuns((prev) => {
      const next = { ...prev };
      delete next[account.id];
      return next;
    });
    // Untick what's now in the report; leave failures ticked for a retry.
    setResearchSelections((prev) => ({
      ...prev,
      [account.id]: (prev[account.id] || []).filter((qid) => !(results[qid] && results[qid].status === 'done')),
    }));
    const ok = Object.values(results).filter((r) => r.status === 'done').length;
    recordAction(account.id, {
      action: `Research report ${previous ? 'updated' : 'generated'}: ${ok} of ${questions.length} question${questions.length === 1 ? '' : 's'} researched`,
    });
  }

  async function regenerateSummary(account) {
    const report = reportsRef.current[account.id];
    if (!report || researchRuns[account.id]) return;
    setResearchRuns((prev) => ({ ...prev, [account.id]: { phase: 'summary', statuses: {} } }));
    const summary = await summarizeReport(account, report);
    setReports((prev) => ({ ...prev, [account.id]: { ...prev[account.id], summary } }));
    setResearchRuns((prev) => {
      const next = { ...prev };
      delete next[account.id];
      return next;
    });
  }

  async function generateResearchPlan(account) {
    setResearchingId(account.id);
    setResearchErrors((prev) => ({ ...prev, [account.id]: null }));

    const matched = accountMatches(account.id);
    const newsContext = matched.length
      ? matched.map((m) => `- [${m.newsId}] (${m.confidence}% match) ${m.news.headline} — ${m.news.summary} | Why flagged: ${m.rationale}`).join('\n')
      : 'None.';
    const depth = RESEARCH_DEPTH[account.tier] || RESEARCH_DEPTH.Prospect;
    const known = contacts.filter((c) => c.accountId === account.id);
    const knownText = known.length
      ? known.map((c) => `- ${c.name}, ${c.role} (${c.relationship}). ${c.notes}`).join('\n')
      : 'None on file.';
    const work = PAST_WORK.filter((w) => w.accountId === account.id);
    const workText = work.length ? work.map((w) => `- ${w.title} (${w.year}). Outcome: ${w.outcome}`).join('\n') : 'None.';

    const prompt = `You are helping a renewables-sector business development team get ready to act on a specific piece of sector news for one account, BEFORE any outreach. Focus the research on what the news signal(s) below actually mean for this account — NOT a general company dossier. Only pull in the firm's own background where it's directly needed to work out the signal's impact on them (e.g. do they have a relevant pipeline, position, or exposure). The goal is the shortest path to knowing enough to act on the signal well, not an exhaustive profile of the firm. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"objective": "1-2 sentences naming the signal(s) and what this research must establish about their impact on this account before outreach", "known": [{"text": "...", "source": "n1"}], "questions": [{"question": "...", "why": "...", "priority": "Must", "sources": ["..."], "minutes": 15}], "people_to_identify": ["Role: why they matter"], "watch_outs": [{"text": "...", "source": "n1"}]}

Rules:
- known: what we can already take as given from the signals, address book and past work below. Don't turn known facts into questions. Set "source" to the bracketed signal id it comes from, or null if it comes from our own records.
- questions: the open questions whose answers most change whether and how THE SIGNAL(S) BELOW should be raised with this account — not generic firm background that isn't needed to interpret the signal. Ordered by importance, up to ${depth.questions}. Each must be answerable by desk research on public sources (an AI research agent will run them with web search). Phrase each as one specific, searchable question tied to the signal's effect on this account (e.g. "Does [firm] have projects in the queue segment this reform targets?" not "What is [firm]'s history?").
- priority: "Must" if outreach shouldn't happen without the answer, otherwise "Nice". At most ${Math.ceil(depth.questions / 2)} Must questions.
- why: one line on how the answer changes how we raise the signal with them.
- sources: 1 to 3 entries copied verbatim from the source list below. Never invent URLs or name specific documents.
- minutes: a realistic estimate of how long a person would take to answer that one question by hand. Keep the Must questions within a combined ${depth.minutes} minutes.
- people_to_identify: up to 3 roles we should find names for, not already in the address book.
- watch_outs: things that could make raising this signal with them a poor fit or need care. Don't list anything our capabilities or partnerships already cover. Fewer is fine.
- Keep every string under 25 words.

News signal(s) this research is in service of (the actual subject of the research):
${newsContext}

Account this signal was matched to: ${account.name}, a ${account.sector} (relationship tier: ${account.tier}).
Their stated priorities: ${account.priorities.join('; ')}.

Known contacts at this firm (our address book):
${knownText}

Our previous work with this firm:
${workText}

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.
Our partnerships: ${PARTNERSHIPS.join('; ')}.

Source list:
${RESEARCH_SOURCES.map((s) => `- ${s}`).join('\n')}`;

    try {
      const parsed = normalizeResearchPlan(await callClaudeJSON(prompt, 1500));
      setResearchPlans((prev) => ({ ...prev, [account.id]: parsed }));
      // Pre-tick the must-answer questions; the report (if any) is kept.
      setResearchSelection(account.id, parsed.questions.filter((q) => q.priority === 'Must').map((q) => q.id));
    } catch (e) {
      // Day 5b reliability fallback — see DEMO_CACHED_RESEARCH above.
      const cached = demoCachedResearchFor(account.id);
      if (cached) {
        setResearchPlans((prev) => ({ ...prev, [account.id]: cached.plan }));
        setResearchSelection(account.id, cached.plan.questions.filter((q) => q.priority === 'Must').map((q) => q.id));
      } else {
        setResearchErrors((prev) => ({ ...prev, [account.id]: friendlyApiError(e, "Couldn't generate a research plan just now — try again.") }));
      }
    } finally {
      setResearchingId(null);
    }
  }

  async function generatePlan(account) {
    setGeneratingId(account.id);
    setPlanErrors((prev) => ({ ...prev, [account.id]: null }));

    const matched = accountMatches(account.id);
    const newsContext = matched
      .map((m) => `- [${m.newsId}] (${m.confidence}% match) ${m.news.headline} — ${m.news.summary} | Why relevant: ${m.rationale}`)
      .join('\n');
    const depth = PLAN_DEPTH[account.tier] || PLAN_DEPTH.Prospect;
    const known = contacts.filter((c) => c.accountId === account.id);
    const knownText = known.length
      ? known.map((c) => `- ${c.name}, ${c.role} (${c.relationship}). ${c.notes}`).join('\n')
      : 'None on file.';

    // Past-project uploads (added 2026-10-08, see CapabilitiesPage) — text-
    // kind files are inlined into the prompt (capped per file so several
    // uploads can't blow the token budget); PDFs ride along as additional
    // `document` content blocks, same mechanism submitManualResearch above
    // already uses for the research-intake PDF upload.
    const textUploads = competencyUploads.filter((u) => u.kind === 'text');
    const pdfUploads = competencyUploads.filter((u) => u.kind === 'pdf');
    const UPLOAD_EXCERPT_CHARS = 6000;
    const uploadsText = textUploads.length
      ? textUploads
          .map((u) => `--- ${u.name} ---\n${u.text.slice(0, UPLOAD_EXCERPT_CHARS)}${u.text.length > UPLOAD_EXCERPT_CHARS ? '\n[truncated]' : ''}`)
          .join('\n\n')
      : '';
    const uploadsSummary = [
      uploadsText,
      pdfUploads.length ? `${pdfUploads.length} PDF document${pdfUploads.length === 1 ? '' : 's'} attached below: ${pdfUploads.map((u) => u.name).join(', ')}.` : '',
    ]
      .filter(Boolean)
      .join('\n\n') || 'None uploaded.';

    const prompt = `You are helping a renewables-sector business development team prepare for client outreach. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"overview": "2-3 sentence overview of the opportunity", "contacts_to_engage": ["Name, Role or role/title", "..."], "talking_points": [{"text": "...", "source": "n1"}], "capability_gaps": [{"text": "...", "source": "n1"}], "timeline": [{"step": "...", "timeframe": "...", "owner": "..."}]}

Grounding rule: every entry in talking_points and capability_gaps must set "source" to the exact bracketed id (e.g. "n1") of the ONE signal below it is grounded in. Use null only for a point that is genuinely general context with no single signal behind it — prefer a grounded point over a general one, and never invent a point just to hit a count.

Gap-checking rule: go through the signals below one at a time. For each, check it against our capabilities, partnerships, AND previous project work (below/attached). If it maps cleanly onto something we offer or have demonstrably done before, that's a talking point. If it implies a need we do NOT have listed or evidenced in our past work, state that plainly as a capability gap — don't soften it into a talking point and don't claim partial coverage we don't have. Don't report a gap for something a listed capability, partnership, or a past project already covers.

Contacts rule: suggest up to 3 people to engage. Where someone in our address book (below) fits, list them as "Name, Role". Otherwise suggest a role/title only. Never invent names.

Depth: this is a ${account.tier} account. Provide up to ${depth.points} talking points and up to ${depth.points} capability gaps (fewer is fine if the signals don't support more — don't pad to hit the count), and exactly ${depth.timelineSteps} timeline steps.

Keep every string concise, under 20 words.

Account: ${account.name}, a ${account.sector} (relationship tier: ${account.tier}).
Their stated priorities: ${account.priorities.join('; ')}.

Known contacts at this firm (our address book):
${knownText}

Our team's research findings (most current information; where a finding conflicts with a signal, trust the finding, and let findings shape the talking points and timeline):
${researchFindingsText(reports[account.id])}

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.
Our partnerships: ${PARTNERSHIPS.join('; ')}.

Our previous project work (uploaded by the user — real evidence of what we've actually delivered, weigh it alongside capabilities/partnerships above):
${uploadsSummary}

Recent relevant news signals:
${newsContext}

Draft a concise, client-ready outreach plan connecting the news signals to our capabilities and their priorities.`;

    const content = pdfUploads.length
      ? [
          { type: 'text', text: prompt },
          ...pdfUploads.map((u) => ({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: u.base64 } })),
        ]
      : prompt;

    try {
      const parsed = normalizePlan(await callClaudeJSON(content, 1200));
      setPlans((prev) => ({ ...prev, [account.id]: parsed }));
    } catch (e) {
      setPlanErrors((prev) => ({ ...prev, [account.id]: friendlyApiError(e, "Couldn't generate a plan just now — try again.") }));
    } finally {
      setGeneratingId(null);
    }
  }

  // Structured fit/gap check (added per demo-week-plan.md Day 2a) — a
  // dedicated call, separate from generatePlan above, so fit/gap
  // information can be checked and shown BEFORE a user ever clicks
  // "Generate outreach plan" (D1: "gap/fit information must be visible
  // before anyone clicks Generate, not only inside a generated plan").
  // Explicit action, not auto-run on every match — same reasoning as every
  // other AI call in this app (stage confirmation, PDF export): nothing
  // costs an API call without a human choosing it.
  // Core fetch, shared by the explicit "Check fit & gaps" button
  // (generateFitGaps below) and the Day 4 auto-recheck-on-new-match path
  // (recheckFitGapsOnNewMatch) — pulled out so the latter can diff the
  // freshly-fetched result against the previous one without racing React's
  // async state updates. Throws on failure; callers decide how to surface
  // that (a visible error banner for the explicit button, a quieter
  // no-op for the background recheck).
  async function fetchFitGaps(account) {
    const matched = accountMatches(account.id);
    if (!matched.length) return null;

    const signalIds = matched.map((m) => m.newsId);
    const newsContext = matched
      .map((m) => `- [${m.newsId}] (${m.confidence}% match) ${m.news.headline} — ${m.news.summary} | Why flagged: ${m.rationale}`)
      .join('\n');

    const textUploads = competencyUploads.filter((u) => u.kind === 'text');
    const pdfUploads = competencyUploads.filter((u) => u.kind === 'pdf');
    const UPLOAD_EXCERPT_CHARS = 6000;
    const uploadsText = textUploads.length
      ? textUploads
          .map((u) => `--- ${u.name} ---\n${u.text.slice(0, UPLOAD_EXCERPT_CHARS)}${u.text.length > UPLOAD_EXCERPT_CHARS ? '\n[truncated]' : ''}`)
          .join('\n\n')
      : '';
    const uploadsSummary = [
      uploadsText,
      pdfUploads.length ? `${pdfUploads.length} PDF document${pdfUploads.length === 1 ? '' : 's'} attached below: ${pdfUploads.map((u) => u.name).join(', ')}.` : '',
    ]
      .filter(Boolean)
      .join('\n\n') || 'None uploaded.';

    const prompt = `You are checking, for one account, which of its recent news signals we can genuinely deliver on and which expose a real capability gap — BEFORE any outreach plan is drafted. This check should answer "how do we win this, given what we can't do?", not just "can we win this?" — so a gap on one piece of the opportunity should never read as a blanket rejection of the whole account. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"opportunity_parts": ["short name of one piece of the opportunity", "..."], "gaps": [{"gap": "1 sentence: what's needed that we don't cover", "tag": "short-kebab-case-category-slug", "severity": "Blocker" | "Coverable" | "Minor", "scope": "must exactly match one name from opportunity_parts, or 'Whole engagement'", "evidence": ["n1"], "moves": [{"type": "Team" | "Subcontract" | "Narrow scope" | "Timing" | "Lead with strength" | "Decline", "detail": "...", "partner": "exact partnership name, or null"}], "confidence": "High" | "Medium" | "Low"}], "entry_point": {"part": "must exactly match one name from opportunity_parts", "rationale": "..."}}

Step 1 — opportunity_parts: break the work implied by the matched signals below into 2-5 distinct, concrete pieces (e.g. "Grid connection strategy", "Consenting support", "Electrolyser EPC advisory") — whatever a client would actually be buying separately. If the signals genuinely only imply one undifferentiated piece of work, return exactly one part naming the whole engagement.

Step 2 — gaps: go through the signals one at a time:
- If a signal maps cleanly onto something in our capabilities, partnerships, or previous project work (below), it is a FIT. Do not add an entry for it — only report gaps, never fits.
- If a signal implies a need we do NOT have listed or evidenced, that is a GAP. State it plainly — never soften it into something we can partially do, and never report a gap for something a listed capability, partnership, or past project already covers.
- scope: name exactly which opportunity_parts entry this gap blocks — a gap almost never blocks everything, so only use "Whole engagement" if it genuinely does.
- tag: a short (2-4 word) kebab-case category for this gap (e.g. "hydrogen-delivery", "german-market-entry") — used to spot the same kind of gap recurring across different accounts, so keep it general enough to match a similar gap elsewhere, not a restatement of this specific sentence.
- severity "Blocker": nothing in our capabilities or partnerships could plausibly cover this, even with help.
- severity "Coverable": one of our listed partnerships below could cover it.
- severity "Minor": a small or edge-case gap that doesn't materially affect whether we can pursue this account.
- evidence: the exact bracketed signal id(s) (e.g. "n1") the gap is based on, copied from the list below. Never invent an id.
- Only evaluate the signals listed below. Fewer gaps (even zero) is correct if the signals are all genuine fits — don't invent a gap to pad the list.

Step 3 — moves: for each gap, give 2-4 DIFFERENT real options for how we could still win this, best option first:
- "Team": partner with one of our listed partnerships to cover it — set "partner" to that exact partnership name. If no listed partnership fits, set "partner" to null and have "detail" name the TYPE of firm to find (e.g. "a boutique hydrogen EPC advisory") — never invent a specific company name either way.
- "Subcontract": hand off a specifically-defined piece of the work, same partner-naming rule as "Team."
- "Narrow scope": pitch only the part of this opportunity_parts breakdown we can actually do, explicitly naming which part.
- "Timing": enter at a different project phase, or come back once the project reaches a stage we do cover.
- "Lead with strength": open with a different, related capability we do have, to get in the door despite this gap.
- "Decline": only include this if none of the above are genuinely realistic — and the detail must say why.
- Never give a "Minor" or "Coverable" gap only a "Decline" move with nothing else — there should always be a real option short of declining unless the gap is a hard "Blocker."

Step 4 — entry_point: of the opportunity_parts, recommend the smallest credible engagement — the part with the fewest/no gaps blocking it, the one we could win now and grow into the rest from. "part" must be one of the opportunity_parts names exactly; "rationale" in under 20 words.

Keep "gap", each move's "detail", and the entry_point "rationale" under 20 words each.

Account: ${account.name}, a ${account.sector} (relationship tier: ${account.tier}).
Their stated priorities: ${account.priorities.join('; ')}.

Our team's research findings (if any — where a finding conflicts with a signal, trust the finding):
${researchFindingsText(reports[account.id])}

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.
Our partnerships: ${PARTNERSHIPS.join('; ')}.

Our previous project work (uploaded by the user — real evidence of what we've actually delivered):
${uploadsSummary}

Signals to evaluate (evaluate every one):
${newsContext}`;

    const content = pdfUploads.length
      ? [
          { type: 'text', text: prompt },
          ...pdfUploads.map((u) => ({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: u.base64 } })),
        ]
      : prompt;

    try {
      return normalizeFitGaps(await callClaudeJSON(content, 1800), signalIds);
    } catch (e) {
      // Day 5b reliability fallback — see DEMO_CACHED_FIT_GAPS above. Only
      // kicks in once the real live call has already failed, and only in
      // demo mode; a non-demo account always just surfaces the real error.
      const cached = demoCachedFitGapsFor(account.id, signalIds);
      if (cached) return cached;
      throw e;
    }
  }

  // Returns whether the check actually succeeded — used by
  // runFullFitGapsAnalysis below to summarize the batch run (and by the
  // row-level "Check fit & gaps" button, which just ignores it).
  async function generateFitGaps(account) {
    if (!accountMatches(account.id).length) return false;
    setFitGapGeneratingId(account.id);
    setFitGapErrors((prev) => ({ ...prev, [account.id]: null }));
    try {
      const parsed = await fetchFitGaps(account);
      setFitGaps((prev) => ({ ...prev, [account.id]: parsed }));
      return true;
    } catch (e) {
      setFitGapErrors((prev) => ({ ...prev, [account.id]: friendlyApiError(e, "Couldn't check fit & gaps just now — try again.") }));
      return false;
    } finally {
      setFitGapGeneratingId(null);
    }
  }

  // "Full fit & gaps analysis" (Overview header button) — runs the same
  // per-account check as the row-level "Check fit & gaps" button, across
  // every account that has at least one matched signal (generateFitGaps
  // itself no-ops on an account with none). Sequential, not Promise.all:
  // keeps exactly one real Claude call in flight at a time, same as every
  // other AI action in this app, and keeps fitGapGeneratingId's single-id
  // shape meaningful so each row's own spinner still lights up in turn.
  // Returns a { checked, failed, total } summary so the Overview button can
  // show a one-shot "done" toast (see Overview's runFullAnalysisWithToast).
  async function runFullFitGapsAnalysis() {
    if (fitGapsBatchRunning) return { checked: 0, failed: 0, total: 0 };
    setFitGapsBatchRunning(true);
    let checked = 0;
    let failed = 0;
    try {
      const targets = accountsRef.current.filter((a) => accountMatches(a.id).length > 0);
      for (const account of targets) {
        const ok = await generateFitGaps(account);
        if (ok) checked += 1;
        else failed += 1;
      }
    } finally {
      setFitGapsBatchRunning(false);
    }
    return { checked, failed, total: checked + failed };
  }

  // Day 4 (demo-week-plan.md): "when a new feed item matches an account,
  // re-run that account's fit check and diff against the previous result."
  // Called after a signal is freshly promoted to an account (addLiveItem-
  // ToSignalMap / autoMatchNewLiveItems below) — downstream of an explicit
  // human action (promoting the signal), not a silent background timer, so
  // it stays consistent with this app's "no AI call without a human
  // choosing it" rule even though nobody clicked "Check fit & gaps" this
  // time. If the account has never been checked before there's nothing to
  // diff against — still runs the check (so the indicator populates either
  // way, which matters for demo robustness/not depending on click order
  // per D1), just without a "changed" notice.
  async function recheckFitGapsOnNewMatch(accountId, triggeringNewsId) {
    const account = accountsRef.current.find((a) => a.id === accountId);
    // In demo mode, still proceed with no key: fetchFitGaps's own Day 5b
    // cache fallback (see DEMO_CACHED_FIT_GAPS) covers the call, so the
    // trigger's "what changed" moment still works with no live API access.
    // Outside demo mode there's no cache to fall back to, so skip exactly
    // as before — no point attempting a call guaranteed to fail.
    if (!account || (!hasAnthropicApiKey() && !DEMO_MODE)) return;
    const previous = fitGapsRef.current[accountId];
    setFitGapGeneratingId((prev) => prev || accountId);
    try {
      const next = await fetchFitGaps(account);
      if (!next) return;
      setFitGaps((prev) => ({ ...prev, [accountId]: next }));
      if (previous) {
        const diff = diffFitGaps(previous, next);
        if (diff.changed) {
          setFitChangeNotices((prev) => ({
            ...prev,
            [accountId]: { ...diff, triggeringNewsId, changedAt: new Date().toISOString() },
          }));
          recordAction(accountId, { action: `Fit & gaps changed after a new signal: ${diff.newGaps.length} new, ${diff.removedGaps.length} resolved, ${diff.severityChanges.length} re-rated` });
        }
      }
    } catch (e) {
      // Quiet failure, by design — see the function comment above: this is
      // a background follow-on to an explicit action, not itself a
      // button the user clicked, so a loud new error banner for it would
      // be surprising. The explicit "Check fit & gaps" button still
      // surfaces real errors normally.
    } finally {
      setFitGapGeneratingId((prev) => (prev === accountId ? null : prev));
    }
  }

  function dismissFitChangeNotice(accountId) {
    setFitChangeNotices((prev) => {
      const next = { ...prev };
      delete next[accountId];
      return next;
    });
  }

  // Proposal generator (added per demo-week-plan.md Day 3a). Requires a
  // fit/gap check to already exist for this account — the "Delivery model &
  // honest gaps" section is rendered straight from that result (see
  // ProposalPanel), never re-derived here, so the proposal can't drift from
  // or contradict what Day 2's check already found. The UI (ProposalPanel)
  // is responsible for only offering this action once fitGaps[account.id]
  // exists; this function itself still no-ops defensively if called without it.
  async function generateProposal(account) {
    const fg = fitGaps[account.id];
    if (!fg) return;
    setProposalGeneratingId(account.id);
    setProposalErrors((prev) => ({ ...prev, [account.id]: null }));

    const matched = accountMatches(account.id);
    const newsContext = matched.length
      ? matched.map((m) => `- [${m.newsId}] ${m.news.headline} — ${m.news.summary}`).join('\n')
      : 'None.';
    const workList = PAST_WORK;
    const workText = workList.length
      ? workList.map((w) => `- [${w.id}] ${w.clientLabel}: ${w.title} (${w.year}). Outcome: ${w.outcome}`).join('\n')
      : 'None on file.';
    // Point 4 of the gap-flagging redesign ("carry the chosen route through
    // the pipeline"): tell the model which route the user already picked
    // for each gap (see selectedMoves in App), and the recommended entry
    // point, as grounding context — NOT so it writes new "gap" prose (the
    // Delivery model section below is still rendered straight from fg/
    // selectedMoves, never model-authored, same non-fabrication guarantee
    // as before), but so "understanding"/"approach" reads consistently
    // with the teaming arrangement already chosen rather than contradicting
    // or ignoring it.
    const chosenRouteFor = (g) => {
      const idx = selectedMoves[account.id] && selectedMoves[account.id][g.tag];
      return typeof idx === 'number' ? g.moves[idx] : null;
    };
    const gapsContext = fg.gaps.length
      ? fg.gaps
          .map((g) => {
            const chosen = chosenRouteFor(g);
            return `- ${g.gap} (${g.severity}, blocks: ${g.scope}). ${chosen ? `Route already chosen: ${chosen.type}${chosen.partner ? ` (${chosen.partner})` : ''} — ${chosen.detail}` : 'No route chosen yet.'}`;
          })
          .join('\n')
      : 'No gaps — every matched signal maps cleanly to our capabilities, partnerships or past work.';
    const entryPointContext = fg.entryPoint
      ? `${fg.entryPoint.part} — ${fg.entryPoint.rationale}`
      : 'None identified — every opportunity part has at least one unresolved blocker.';

    const prompt = `You are drafting a client-facing proposal for a renewables-sector business development team, for one account. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"understanding": "2-3 sentences on the client's need", "approach": ["short phrase describing one piece of our proposed approach", "..."], "experience_ids": ["w1", "w3"], "next_steps": [{"step": "...", "timeframe": "...", "owner": "..."}]}

Rules:
- understanding: synthesize from the account's stated priorities and matched signals below. Don't invent facts about them.
- approach: up to 4 short phrases (under 15 words each) describing how we'd deliver, grounded ONLY in our capabilities/partnerships below. Lead with the recommended entry point below where possible — that's the credible way in — and if a gap already has a chosen route (context below), reflect that teaming/narrowed-scope arrangement rather than ignoring or contradicting it. Never claim something not listed.
- experience_ids: up to 3 ids copied EXACTLY from "Our previous work" below that are genuinely relevant. Never invent an id or describe work not in that list. An empty array is correct if nothing fits — don't force a weak match.
- next_steps: up to 4 indicative next steps with a rough timeframe and an owner (a role, or a named contact only if they're in our address book — never invent a name).
- Do NOT write anything about capability gaps or what we can't do — a structured gap check already exists for this account (context below) and is rendered separately; don't restate or contradict it.
- Keep every string under 20 words.

Account: ${account.name}, a ${account.sector} (relationship tier: ${account.tier}).
Their stated priorities: ${account.priorities.join('; ')}.

Matched signals:
${newsContext}

Our team's research findings (if any):
${researchFindingsText(reports[account.id])}

Our firm: ${COMPANY_NAME || '(name not set)'}${COMPANY_WEBSITE ? ` (${COMPANY_WEBSITE})` : ''}, operating in: ${GEOGRAPHIES.join('; ') || '(not set)'}.
Our capabilities: ${CAPABILITIES.join('; ')}.
Our partnerships: ${PARTNERSHIPS.join('; ')}.

Our previous work (select from this list only, by id — never invent a new one):
${workText}

Recommended entry point (the smallest credible engagement — lead the approach with this):
${entryPointContext}

Capability gaps already identified for this account (context only — do not restate or contradict; reflect any already-chosen route in the prose):
${gapsContext}`;

    try {
      const parsed = normalizeProposal(await callClaudeJSON(prompt, 900), workList);
      setProposals((prev) => ({ ...prev, [account.id]: parsed }));
    } catch (e) {
      // Day 5b reliability fallback — see DEMO_CACHED_PROPOSALS above.
      const cached = demoCachedProposalFor(account.id);
      if (cached) {
        setProposals((prev) => ({ ...prev, [account.id]: cached }));
      } else {
        setProposalErrors((prev) => ({ ...prev, [account.id]: friendlyApiError(e, "Couldn't generate a proposal just now — try again.") }));
      }
    } finally {
      setProposalGeneratingId(null);
    }
  }

  function updateProposal(accountId, patch) {
    setProposals((prev) => ({ ...prev, [accountId]: { ...prev[accountId], ...patch } }));
  }

  // P1 item 7 (feedback brief): lets a research report's "recommended next
  // step" be added straight into the generated outreach plan's timeline,
  // same update-by-patch shape as updateProposal above.
  function updatePlan(accountId, patch) {
    setPlans((prev) => ({ ...prev, [accountId]: { ...prev[accountId], ...patch } }));
  }

  // P1 item 10a (feedback brief): meeting plan generator — drafted from the
  // same grounding context the research/outreach prompts already use (fit
  // & gaps, the research report, the outreach plan), scoped to one track
  // (the account's own baseline, or one matched signal). 2026-10-11 direct
  // follow-up feedback ("the meeting generator needs to integrate any
  // input from the client engagements already had... feels shoehorned
  // in") added `correspondence`/`responses`/`priorMeetings` — the uploaded
  // outreach correspondence, logged response/conversation notes, and any
  // earlier logged meetings for this same track — so a plan reflects what
  // actually happened with this client, not just static fit-gap/research
  // context.
  function buildMeetingPlanPrompt(account, trackLabel, fitGap, report, plan, correspondence, responses, priorMeetings) {
    const gapsContext = fitGap && fitGap.gaps.length ? fitGap.gaps.map((g) => `- ${g.gap} (${g.severity})`).join('\n') : 'No gaps on file.';
    const entryPointContext = fitGap && fitGap.entryPoint ? `${fitGap.entryPoint.part} — ${fitGap.entryPoint.rationale}` : 'None identified.';
    const correspondenceContext =
      correspondence && correspondence.length
        ? correspondence.map((c) => `- ${c.name ? `File "${c.name}"` : 'Pasted note'}${c.text ? `: ${c.text}` : ' (PDF, not shown here)'}`).join('\n')
        : 'None attached.';
    const responsesContext =
      responses && responses.length
        ? responses
            .map((r) => `- ${formatDate(r.date)}: ${r.notes}${r.attachmentName ? ` (attached "${r.attachmentName}"${r.attachmentText ? `: ${r.attachmentText}` : ''})` : ''}`)
            .join('\n')
        : 'No response logged yet.';
    const priorMeetingsContext =
      priorMeetings && priorMeetings.length ? priorMeetings.map((m) => `- ${formatDate(m.date)}: ${m.summary}${m.actions ? ` (actions: ${m.actions})` : ''}`).join('\n') : 'None yet.';
    return `You are helping a renewables-sector business development team prepare for an upcoming meeting with a client. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"topics": ["short topic or talking point", "..."]}

Rules:
- topics: up to 6 short, specific topics or talking points (under 20 words each) to structure the meeting around and help build slides — grounded only in the context below, never invented facts.
- Order roughly in the sequence they'd come up in the meeting.
- If correspondence, a logged response, or an earlier meeting raised something specific (a question, a concern, a follow-up they asked for), reflect it as its own topic rather than only general talking points.

Account: ${account.name}, a ${account.sector} (tier: ${account.tier}).
What this meeting is about: ${trackLabel}.
Their stated priorities: ${account.priorities.join('; ')}.

Our team's research findings (if any):
${researchFindingsText(report)}

Outreach plan overview (if any): ${plan && plan.overview ? plan.overview : 'None generated yet.'}

Correspondence attached during outreach:
${correspondenceContext}

Logged response/conversation since outreach:
${responsesContext}

Earlier meetings logged for this same track:
${priorMeetingsContext}

Recommended entry point: ${entryPointContext}

Known capability gaps (context — don't promise these): ${gapsContext}`;
  }

  async function generateMeetingPlan(account, trackKey, trackLabel) {
    setMeetingPlanGeneratingKey(trackKey);
    setMeetingPlanErrors((prev) => ({ ...prev, [trackKey]: null }));
    try {
      const prompt = buildMeetingPlanPrompt(
        account,
        trackLabel,
        fitGaps[account.id],
        reports[account.id],
        plans[account.id],
        outreachCorrespondence[account.id],
        outreachResponses[trackKey],
        meetingOutcomes[trackKey]
      );
      const parsed = await callClaudeJSON(prompt, 500);
      setMeetingPlans((prev) => ({ ...prev, [trackKey]: { topics: parsed.topics || [], generatedAt: new Date().toISOString() } }));
    } catch (e) {
      setMeetingPlanErrors((prev) => ({ ...prev, [trackKey]: friendlyApiError(e, "Couldn't build a meeting plan just now — try again.") }));
    } finally {
      setMeetingPlanGeneratingKey(null);
    }
  }

  // P1 follow-up (2026-10-11): "the user should also be able to make
  // additions to the plan" — same patch-merge shape as updatePlan/
  // updateProposal, used by MeetingTrack's "Add topic"/remove controls.
  function updateMeetingPlan(trackKey, patch) {
    setMeetingPlans((prev) => ({ ...prev, [trackKey]: { ...prev[trackKey], ...patch } }));
  }

  // P1 item 10b: meeting-notes parsing — same file/paste-intake shape as
  // submitManualResearch above (readIntakeFile, text-vs-pdf content block).
  function buildMeetingNotesPrompt(account, trackLabel, notes, file) {
    const materialText = [
      notes ? `Typed notes:\n${notes}` : '',
      file && file.kind === 'text' ? `Attached file "${file.name}":\n${file.text}` : '',
      file && file.kind === 'pdf' ? `An attached PDF, "${file.name}", follows as a document.` : '',
    ]
      .filter(Boolean)
      .join('\n\n');
    return `You are helping a renewables-sector business development team process their notes from a meeting that just happened. Turn ONLY what is actually in the material below into three structured lists — never invent anything not present in it. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"next_steps": ["..."], "actions": ["..."], "key_intelligence": ["..."]}

Rules:
- next_steps: what should happen next in the sales process (up to 5).
- actions: concrete follow-up tasks/owners mentioned (up to 5).
- key_intelligence: any facts, preferences, concerns or org details worth remembering about this account (up to 6).
- Keep every string under 25 words. Empty lists are fine if the material doesn't support an entry.

Account: ${account.name}. Meeting was about: ${trackLabel}.

Meeting notes:
${materialText || '(nothing but the file — read it directly)'}`;
  }

  async function submitMeetingNotes(account, trackKey, trackLabel, { notes, file }) {
    setMeetingNotesSubmittingKey(trackKey);
    setMeetingNotesErrors((prev) => ({ ...prev, [trackKey]: null }));
    try {
      let fileInfo = null;
      if (file) {
        try {
          fileInfo = await readIntakeFile(file);
        } catch (e) {
          if (e instanceof UnsupportedIntakeFile) {
            throw new Error(
              `This prototype can only read plain text, CSV, Markdown or PDF files directly — "${file.name}" isn't one of those. Paste the relevant text in instead, or save it as a PDF.`
            );
          }
          throw e;
        }
      }
      const prompt = buildMeetingNotesPrompt(account, trackLabel, notes, fileInfo);
      const content =
        fileInfo && fileInfo.kind === 'pdf'
          ? [
              { type: 'text', text: prompt },
              { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: fileInfo.base64 } },
            ]
          : prompt;
      const parsed = await callClaudeJSON(content, 800);
      const report = { nextSteps: parsed.next_steps || [], actions: parsed.actions || [], keyIntelligence: parsed.key_intelligence || [], updatedAt: new Date().toISOString() };
      setMeetingNotesReports((prev) => {
        // Single-matched-signal sync (see pairedTrackKey) — otherwise the
        // account-baseline NextMilestone's notesReady gate never sees notes
        // processed against the one matched signal's own card (the only
        // one shown once MeetingPanel's dedup kicks in), and vice versa.
        const next = { ...prev, [trackKey]: report };
        const paired = pairedTrackKey(trackKey);
        if (paired) next[paired] = report;
        return next;
      });
    } catch (e) {
      setMeetingNotesErrors((prev) => ({ ...prev, [trackKey]: friendlyApiError(e, "Couldn't process those notes just now — try again.") }));
    } finally {
      setMeetingNotesSubmittingKey(null);
    }
  }

  // "They can skip this if they want" (2026-10-11 direct follow-up
  // feedback, on gating the proposal on post-meeting notes) — a plain flag,
  // no AI call, satisfies the same gate a real notesReport would. Mirrored
  // to the paired track for the same single-matched-signal-sync reason as
  // submitMeetingNotes above.
  function skipMeetingNotes(trackKey) {
    setMeetingNotesSkipped((prev) => {
      const next = { ...prev, [trackKey]: true };
      const paired = pairedTrackKey(trackKey);
      if (paired) next[paired] = true;
      return next;
    });
  }

  // P1 item 10c: additional-meetings outcomes tracker — just an append, no
  // AI call; date/summary/actions are typed directly by the user.
  function logMeetingOutcome(trackKey, entry) {
    setMeetingOutcomes((prev) => ({ ...prev, [trackKey]: [...(prev[trackKey] || []), entry] }));
  }

  // Outreach correspondence upload (2026-10-11 follow-up feedback) — same
  // file/paste-intake shape as submitManualResearch/submitMeetingNotes
  // (readIntakeFile, text-vs-pdf). Deliberately no AI call here: this just
  // stores what was attached so later generators (the meeting plan) can
  // read it as grounding context — "the AI can evaluate it in subsequent
  // stages," not now.
  async function addOutreachCorrespondence(accountId, { notes, file }) {
    setOutreachCorrespondenceSubmittingId(accountId);
    setOutreachCorrespondenceErrors((prev) => ({ ...prev, [accountId]: null }));
    try {
      let fileInfo = null;
      if (file) {
        try {
          fileInfo = await readIntakeFile(file);
        } catch (e) {
          if (e instanceof UnsupportedIntakeFile) {
            throw new Error(
              `This prototype can only read plain text, CSV, Markdown or PDF files directly — "${file.name}" isn't one of those. Paste the relevant text in instead, or save it as a PDF.`
            );
          }
          throw e;
        }
      }
      if (!notes.trim() && !fileInfo) return;
      const entry = {
        id: `oc-${Date.now()}`,
        kind: fileInfo ? fileInfo.kind : 'text',
        name: fileInfo ? fileInfo.name : null,
        text: fileInfo && fileInfo.kind === 'text' ? fileInfo.text : notes.trim() || null,
        base64: fileInfo && fileInfo.kind === 'pdf' ? fileInfo.base64 : null,
        uploadedAt: new Date().toISOString(),
      };
      setOutreachCorrespondence((prev) => ({ ...prev, [accountId]: [...(prev[accountId] || []), entry] }));
    } catch (e) {
      setOutreachCorrespondenceErrors((prev) => ({ ...prev, [accountId]: e.message || "Couldn't attach that — try again." }));
    } finally {
      setOutreachCorrespondenceSubmittingId(null);
    }
  }

  // Step 2 of outreach (2026-10-11 follow-up feedback): logging a response
  // or conversation that came back, separate from markSent (step 1) since
  // "it's not realistic to already have a response when logging the
  // initial outreach." Append-only, no AI call, scoped per-track — same
  // shape as logMeetingOutcome above except it can also carry an attached
  // file (2026-10-11 direct follow-up: "an email/chain should be
  // attached"), read via the same readIntakeFile helper
  // OutreachCorrespondenceIntake already uses, so a reply with a pasted
  // chain or an attached .eml/PDF is available as grounding context to
  // later generators (buildMeetingPlanPrompt already reads
  // outreachResponses) the same way outreachCorrespondence already is.
  async function logOutreachResponse(trackKey, { date, notes, file }) {
    let fileInfo = null;
    if (file) {
      try {
        fileInfo = await readIntakeFile(file);
      } catch (e) {
        if (e instanceof UnsupportedIntakeFile) {
          throw new Error(
            `This prototype can only read plain text, CSV, Markdown or PDF files directly — "${file.name}" isn't one of those. Paste the relevant text in instead, or save it as a PDF.`
          );
        }
        throw e;
      }
    }
    const entry = {
      date,
      notes,
      attachmentName: fileInfo ? fileInfo.name : null,
      attachmentText: fileInfo && fileInfo.kind === 'text' ? fileInfo.text : null,
      attachmentBase64: fileInfo && fileInfo.kind === 'pdf' ? fileInfo.base64 : null,
    };
    setOutreachResponses((prev) => {
      const next = { ...prev, [trackKey]: [...(prev[trackKey] || []), entry] };
      // "If there's only one matched signal, the overall company should
      // move at the same pace" (2026-10-11 direct follow-up feedback) —
      // NextMilestone's "log a response before confirming a meeting" gate
      // (PlanPanel) only reads the account-baseline track's own
      // outreachResponses, but with the account/signal dedup in
      // MeetingPanel (same feedback) there's often no separate
      // account-level response form to log into once there's a lone
      // matched signal — its MeetingTrack card is the only one shown.
      // Mirror the entry into the paired track (see pairedTrackKey above)
      // so logging it against either one satisfies both, the same
      // symmetric sync recordAction already does for stage.
      const paired = pairedTrackKey(trackKey);
      if (paired) next[paired] = [...(prev[paired] || []), entry];
      return next;
    });
  }

  // "A clearer action... once the outreach plan is generated: based on the
  // contacts to engage and talking points, add these actions to the matched
  // signal on the sidebar" (2026-10-11 direct feedback). Turns a generated
  // plan's contacts_to_engage/talking_points into a plain per-track
  // checklist (signalActions above) the user ticks off as they go. Skips
  // text already present for this track so clicking the button again after
  // a regenerate doesn't duplicate every item. Returns the count actually
  // added, so the caller can show "N actions added" rather than a generic
  // confirmation even when some were already there.
  function addSignalActionsFromPlan(trackKey, plan) {
    const existingText = new Set((signalActions[trackKey] || []).map((a) => a.text));
    const candidates = [
      ...((plan.contacts_to_engage || []).map((text) => ({ kind: 'contact', text }))),
      ...((plan.talking_points || []).map((p) => ({ kind: 'talking_point', text: p.text }))),
    ].filter((c) => c.text && !existingText.has(c.text));
    if (!candidates.length) return 0;
    const items = candidates.map((c, i) => ({ id: `sa-${Date.now()}-${i}`, kind: c.kind, text: c.text, done: false }));
    setSignalActions((prev) => ({ ...prev, [trackKey]: [...(prev[trackKey] || []), ...items] }));
    return items.length;
  }

  function toggleSignalAction(trackKey, actionId) {
    setSignalActions((prev) => ({
      ...prev,
      [trackKey]: (prev[trackKey] || []).map((a) => (a.id === actionId ? { ...a, done: !a.done } : a)),
    }));
  }

  // Used by the signal map: clicking an account there should land you on its
  // row in the Overview, expanded, rather than opening a separate drawer.
  function openInOverview(accountId) {
    setTab('overview');
    setExpandedId(accountId);
  }

  return (
    // Keyboard shortcuts (2026-10-14) — the provider owns the single
    // document keydown listener, the `?` help sheet, and the "show hints"
    // preference; every binding below registers through useShortcut next to
    // the handler its own button already calls. AppShortcuts (just inside)
    // carries the global ones, since a component can't consume the context
    // it provides.
    <ShortcutProvider>
    <div
      className="gl-app min-h-screen bg-[var(--ss-ground)] text-[var(--ss-ink)] font-[var(--ss-font-sans)]"
      style={{
        backgroundImage:
          'linear-gradient(var(--ss-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ss-grid) 1px, transparent 1px)',
        backgroundSize: 'var(--ss-grid-size) var(--ss-grid-size)',
      }}
    >
      <AppShortcuts tab={tab} onSetTab={setTab} />
      <style>{`
        @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
        /* Keeps the page's available width constant across tabs (fixes a
           reported "background grid shifts a bit when switching tabs" —
           the grid background below is painted on .gl-app, the single
           unmoving root div every tab shares, so it can't itself be the
           moving part; the real cause is the Live feed tab's "Run news
           scrape" full-bleed banner using the classic w-screen/-50vw
           breakout trick, where 100vw measures the viewport *including* the
           scrollbar strip while the document's actual layout width excludes
           it — so that banner silently overflows the page by exactly the
           scrollbar's width whenever a vertical scrollbar is present, which
           only some tabs need (Live feed's long list almost always does;
           About/Sources often don't). That overflow comes and goes by tab,
           nudging the whole page's horizontal scroll range — and the grid
           background's vertical lines — by a few pixels each time. Reserving
           the scrollbar gutter unconditionally makes the page's layout width
           identical whether or not a given tab's content is tall enough to
           need scrolling, so that overflow (and the shift) stops varying by
           tab. */
        html { scrollbar-gutter: stable; }
        .gl-app {
          /* Signal Scout house style (docs/design/tokens.css is the source
             of truth — this block mirrors its light/":root" theme). Light
             is the only theme shipped; dark values live in tokens.css for
             later but aren't wired to anything here (CLAUDE.md §5.8: a
             light/dark toggle was built once, never visually verified, and
             deliberately removed — re-adding it is a decision to make
             again, not a default). */
          --ss-ground: #F4F0E6;
          --ss-grid: #EAE4D4;
          --ss-surface: #FBF9F3;
          --ss-surface-raised: #FFFFFF;
          --ss-inverse: #1F1D19;
          --ss-inverse-text: #F4F0E6;
          --ss-inverse-muted: #CFC7B4;
          --ss-inverse-rule: #3A3730;

          --ss-ink: #1F1D19;
          --ss-text-2: #4A463D;
          --ss-muted: #6B6559;
          --ss-faint: #8C8576;

          --ss-frame: #1F1D19;
          --ss-border: #C9BFA8;
          --ss-rule: #D8D0BE;
          --ss-rule-soft: #E4DDCB;

          --ss-accent: #B8441C;
          --ss-accent-text: #FFFFFF;
          --ss-accent-soft: #E6B39E;
          --ss-accent-wash: #F5E1D6;
          --ss-accent-wash-ink: #6E2810;

          --ss-positive: #2F6B4A;
          --ss-positive-wash: #E1ECE3;
          --ss-gap: #8A4B0F;
          --ss-gap-wash: #F5EAD6;
          --ss-gap-border: #E2C9A0;
          --ss-danger: #A32D2D;
          --ss-danger-wash: #F6E0DC;

          --ss-tier-strategic: #1F1D19;
          --ss-tier-growth: #8C8576;
          --ss-tier-prospect: #8C8576;

          /* Phases + tier lanes (design restyle, added 2026-10-16 — see
             docs/design/tokens.css, which is the source of truth; mirrored
             here the same way every other --ss-* token above already is). */
          --ss-phase-research: #87570A;
          --ss-phase-research-soft: #DABD8A;
          --ss-phase-engage: #17655F;
          --ss-phase-engage-soft: #9CC6C0;
          --ss-phase-commercial: #6B2D5C;
          --ss-phase-commercial-soft: #CFA8C4;

          /* Today radar (added 2026-10-17) — see docs/design/tokens.css. */
          --ss-radar: #6B2D5C;
          --ss-reason-below: #87570A;

          --ss-lane-1-bg: var(--ss-inverse);
          --ss-lane-1-ink: var(--ss-inverse-text);
          --ss-lane-2-bg: var(--ss-accent-wash);
          --ss-lane-2-ink: var(--ss-accent-wash-ink);
          --ss-lane-3-bg: #EBE5D6;
          --ss-lane-3-ink: #4A463D;
          --ss-row-1-bg: var(--ss-surface-raised);
          --ss-row-2-bg: var(--ss-surface);
          --ss-row-3-bg: #F7F4EC;

          --ss-font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
          --ss-font-sans: "Geist", "Helvetica Neue", Helvetica, sans-serif;
          --ss-font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
          --ss-radius: 3px;
          --ss-radius-sm: 2px;
          --ss-grid-size: 32px;
        }
        .gl-app :focus-visible { outline: 2px solid var(--ss-frame); outline-offset: 2px; }
        /* The restyle's TEMP bridge aliases (old token names pointed at
           their nearest --ss-* equivalent, used while components were
           migrated one by one) are gone — every component now references
           --ss-* tokens directly. --bg-panel-60 remains: a translucent
           surface variant with no --ss-* equivalent of its own, not a
           legacy alias. */
        .gl-app {
          --bg-panel-60: rgba(251,249,243,0.72);
        }
        /* Inline shortcut key caps (2026-10-14). Hold-to-reveal (direct
           feedback the same day: "i don't want them up all the time") —
           hidden until Ctrl is held down, at which point ShortcutProvider
           stamps .gl-keys-held on <html>. Deliberately a CSS class toggle
           rather than React state: the reveal would otherwise re-render
           every component holding a hint on each Ctrl press, for a purely
           visual change. Caps inside the Shortcuts sheet are plain <Kbd>s,
           not .gl-kbd-hint, so they're always visible — that sheet IS the
           always-available reference.
           The viewport rules stay CSS for the same reason as before (they
           track a resize live): no keyboard to hint at on touch, no width
           to spare below Tailwind's md. The "Show shortcut hints" toggle in
           the sheet is the third gate — that one stops rendering them at
           all, at every size, held or not. */
        .gl-kbd-hint { display: none; }
        .gl-keys-held .gl-kbd-hint { display: inline-flex; }
        @media (hover: none) { .gl-keys-held .gl-kbd-hint { display: none !important; } }
        @media (max-width: 767px) { .gl-keys-held .gl-kbd-hint { display: none !important; } }
        @keyframes flow { to { stroke-dashoffset: -20; } }
        .flow-line { animation: flow 1.1s linear infinite; }
        @media (prefers-reduced-motion: reduce) { .flow-line { animation: none; } }
        /* Tactile add/remove for Live feed → Signal Map (2026-10-07, toned
           down the same day — the rotate/wobble and the wider scale swing
           read as cartoonish). A signal "clips" onto the board on add and
           "unclips" on remove: small, quiet, no rotation, scale barely
           leaving 1 — the kind of motion a native toggle control already
           has, not a character animation. Stays local to the badge itself
           (no cross-screen travel, unlike the flight animation tried and
           reverted the same week) so it holds up at the hundredth click,
           not just the first. */
        @keyframes gl-signal-snap-in {
          0% { transform: scale(0.92); opacity: 0; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes gl-signal-unclip {
          0% { transform: scale(1); opacity: 1; }
          100% { transform: scale(0.92); opacity: 0; }
        }
        .gl-signal-snap-in { animation: gl-signal-snap-in 160ms ease-out both; }
        .gl-signal-unclip { animation: gl-signal-unclip 140ms ease-in forwards; }
        @media (prefers-reduced-motion: reduce) {
          /* Keep the fade (it's the part that carries meaning — appeared/
             disappeared) and drop only the scale/rotate travel. */
          .gl-signal-snap-in { animation-name: none; opacity: 1; }
          .gl-signal-unclip { animation: none; opacity: 0; transition: opacity 150ms ease-out; }
        }
        /* Onboarding's one-time nudge toward the real "Run news scrape"
           button (see highlightScrape in LiveFeed) — a soft expanding ring,
           not a color change, so it reads as "look here" without fighting
           the button's own amber identity. */
        @keyframes gl-onboard-pulse {
          0%, 100% { box-shadow: 0 0 0 0 rgba(217,119,6,0.45); }
          50% { box-shadow: 0 0 0 8px rgba(217,119,6,0); }
        }
        .gl-onboard-pulse { animation: gl-onboard-pulse 1.4s ease-in-out 3; }
        @media (prefers-reduced-motion: reduce) {
          .gl-onboard-pulse { animation: none; box-shadow: 0 0 0 3px rgba(217,119,6,0.45); }
        }
        /* NextMilestone confirm delight (added 2026-10-10) — a quick,
           satisfied-feeling pulse + checkmark pop right after confirming a
           stage move, on both the account's own tracker and a matched
           signal's. A small overshoot easing reads as "good, that landed"
           without becoming a distraction on the hundredth click. */
        @keyframes gl-milestone-advance {
          0% { transform: scale(1); }
          40% { transform: scale(1.016); }
          100% { transform: scale(1); }
        }
        .gl-milestone-advance { animation: gl-milestone-advance 340ms cubic-bezier(0.34,1.56,0.64,1); }
        @keyframes gl-check-pop {
          0% { transform: scale(0.4) rotate(-10deg); opacity: 0; }
          60% { transform: scale(1.2) rotate(4deg); opacity: 1; }
          100% { transform: scale(1) rotate(0deg); opacity: 1; }
        }
        .gl-check-pop { animation: gl-check-pop 420ms cubic-bezier(0.34,1.56,0.64,1) both; }
        @media (prefers-reduced-motion: reduce) {
          .gl-milestone-advance { animation: none; }
          .gl-check-pop { animation: none; }
        }
        /* Print / Save as PDF: show only the open research report, on white. */
        @media print {
          @page { margin: 16mm; }
          body * { visibility: hidden !important; }
          .gl-print, .gl-print * { visibility: visible !important; }
          .gl-print { position: absolute; left: 0; top: 0; width: 100%; background: #FFFFFF !important; border: none !important; padding: 0 !important; }
          .gl-print * { color: var(--ss-ink) !important; background: transparent !important; border-color: var(--ss-rule) !important; box-shadow: none !important; font-family: var(--ss-font-sans) !important; }
          .gl-print a { color: var(--ss-accent) !important; }
          .gl-noprint { display: none !important; }
        }
      `}</style>

      {/* Demo gate / onboarding render as full-viewport overlays rather than
          replacing the app's own JSX tree below — the app still mounts
          underneath (harmless; it's covered) rather than risking a large
          conditional wrap around the entire rest of this return. */}
      {gateChoice === null && (
        <DemoGate hasExistingSession={hadSavedSession} onStartFresh={startFresh} onResume={() => setGateChoice('resume')} />
      )}
      {onboardPhase === 'tour' && (
        <TabTourSpotlight onTabChange={setTab} onDone={() => setOnboardPhase('modal')} onSkip={() => setOnboardPhase(null)} />
      )}
      {onboardPhase === 'modal' && (
        <OnboardingModal
          onAddAccounts={addAccountsBulk}
          onSuggestAccounts={suggestAccounts}
          suggestedAccounts={suggestedAccounts}
          suggestingAccounts={suggestingAccounts}
          suggestAccountsError={suggestAccountsError}
          hasApiKey={hasAnthropicApiKey()}
          initialStep={onboardReturnStep}
          onGoToCapabilities={() => {
            setOnboardPhase(null);
            setTab('capabilities');
            setCameFromOnboarding(true);
          }}
          onGoToLiveFeed={() => {
            setOnboardPhase(null);
            setTab('livefeed');
            setHighlightScrape(true);
          }}
          onSkip={() => {
            setOnboardPhase(null);
            setCameFromOnboarding(false);
          }}
        />
      )}

      <div className="px-6 py-8">
        {/* Every page spans the full window width (2026-10-11 direct
            feedback: "make sure all the pages are browser width") — this
            used to cap at max-w-6xl and center; now it's just the px-6
            padding above keeping content off the window edges. */}
        <div className="w-full">
          <div className="flex items-start justify-between flex-wrap gap-4 mb-2 pb-4 border-b border-[var(--ss-frame)]">
            <div>
              <button type="button" onClick={() => setTab('overview')} className="flex items-center gap-2.5">
                <Zap size={20} className="text-[var(--ss-accent)]" strokeWidth={2.25} />
                <span className="font-[var(--ss-font-display)] text-[26px] leading-none tracking-[-0.02em] text-[var(--ss-ink)]">Signal Scout</span>
              </button>
              <p className="text-xs text-[var(--ss-muted)] mt-1 ml-[30px]">
                Renewables news, mapped to your book of business.
              </p>
            </div>
            {/* Two groups, not one flat row (2026-10-08): workspace controls
                (API key, Your Capabilities, Manager mode — things that
                configure how this session behaves) read left, separated by
                a hairline from content pages (About, Blog). The two
                stateful toggles (API key, Manager mode) share one "on" pill
                treatment so their active state reads the same way at a
                glance; Your Capabilities gets the same hover/padding shape
                without a colored state, since it's a destination, not a
                toggle. Demo mode keeps its own accent-wash "on" skin,
                distinct from Manager mode/API key's positive-wash, so it
                still reads as the bigger, more deliberate switch. */}
            <nav className="flex items-center text-[12px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] ml-auto">
              <div className="flex items-center gap-1">
                {/* Demo mode toggle (added per docs/demo-week-plan.md Day 1a)
                    — confirms before flipping, since it reloads and (per
                    DEMO_MODE above) swaps the entire seeded dataset. */}
                <button
                  type="button"
                  onClick={() => {
                    const turningOn = !DEMO_MODE;
                    const msg = turningOn
                      ? `Switch to demo mode? This loads the ${DEMO_FIRM_NAME} demo dataset and reloads the app. Your current session is untouched — switching back restores it exactly as it is now.`
                      : 'Turn off demo mode? This reloads the app and restores your normal session exactly as it was.';
                    if (window.confirm(msg)) setDemoMode(turningOn);
                  }}
                  className={`flex items-center gap-1.5 px-2 py-1 rounded-[var(--ss-radius-sm)] transition-colors ${
                    DEMO_MODE ? 'text-[var(--ss-accent-wash-ink)] bg-[var(--ss-accent-wash)]' : 'text-[var(--ss-muted)] hover:text-[var(--ss-ink)] hover:bg-[var(--ss-rule-soft)]'
                  }`}
                >
                  <PlayCircle size={11} /> {DEMO_MODE ? 'Demo mode: On' : 'Demo mode'}
                </button>
                <ApiKeyControl />
                <button
                  type="button"
                  onClick={() => setTab('capabilities')}
                  className="flex items-center gap-1.5 px-2 py-1 rounded-[var(--ss-radius-sm)] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] hover:bg-[var(--ss-rule-soft)] transition-colors"
                >
                  <ListChecks size={11} /> Your Company
                </button>
                {/* Manager mode is parked for the demo sprint (D4, §2 of
                    demo-week-plan.md) — hidden rather than removed whenever
                    demo mode is on, so it's never visible mid-demo; the
                    code and its data (delegations, flaggedFor) are
                    untouched and the control reappears unchanged with demo
                    mode off. */}
                {!DEMO_MODE && (
                  <button
                    type="button"
                    onClick={() => setManagerMode((v) => !v)}
                    className={`flex items-center gap-1.5 px-2 py-1 rounded-[var(--ss-radius-sm)] transition-colors ${
                      managerMode ? 'text-[var(--ss-positive)] bg-[var(--ss-positive-wash)]' : 'text-[var(--ss-muted)] hover:text-[var(--ss-ink)] hover:bg-[var(--ss-rule-soft)]'
                    }`}
                  >
                    <UserCheck size={11} /> {managerMode ? 'Manager mode: On' : 'Manager mode'}
                  </button>
                )}
              </div>
              <div className="w-px h-4 bg-[var(--ss-rule)] mx-3" aria-hidden="true" />
              <div className="flex items-center gap-4 text-[var(--ss-muted)]">
                <button type="button" onClick={() => setTab('about')} className="hover:text-[var(--ss-ink)] transition-colors">
                  About
                </button>
                <button type="button" onClick={() => setTab('blog')} className="hover:text-[var(--ss-ink)] transition-colors">
                  Blog
                </button>
              </div>
            </nav>
          </div>

          {shortcutNoticeOpen && (
            <div
              className="flex items-center gap-2.5 text-[12px] rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] bg-[var(--ss-surface)] text-[var(--ss-muted)] px-3 py-2 mb-4"
              role="status"
            >
              <Keyboard size={13} className="shrink-0" aria-hidden="true" />
              <span>
                Hold <Kbd>Ctrl</Kbd> anywhere in the app to reveal keyboard shortcuts on buttons — press <Kbd variant="ink">?</Kbd> for the full list.
              </span>
              <button
                type="button"
                onClick={() => {
                  setShortcutNoticeOpen(false);
                  dismissShortcutNoticeForever();
                }}
                aria-label="Dismiss this tip"
                className="ml-auto shrink-0 text-[var(--ss-muted)] hover:text-[var(--ss-ink)] transition-colors"
              >
                <X size={13} />
              </button>
            </div>
          )}

          {tab === 'about' && <AboutPage />}
          {tab === 'blog' && <BlogPage />}
          {tab === 'capabilities' && (
            <CapabilitiesPage
              onBack={() => {
                setTab('overview');
                if (cameFromOnboarding) {
                  setCameFromOnboarding(false);
                  setOnboardReturnStep('companies');
                  setOnboardPhase('modal');
                }
              }}
              uploads={competencyUploads}
              onAddUploads={addCompetencyUploads}
              onRemoveUpload={removeCompetencyUpload}
              onChangeCapabilities={updateCapabilities}
              onChangePartnerships={updatePartnerships}
              bidWritingCapable={BID_WRITING_CAPABLE}
              onChangeBidWritingCapable={setBidWritingCapable}
              companyName={COMPANY_NAME}
              onChangeCompanyName={updateCompanyName}
              companyWebsite={COMPANY_WEBSITE}
              onChangeCompanyWebsite={updateCompanyWebsite}
              geographies={GEOGRAPHIES}
              onChangeGeographies={updateGeographies}
              thoughtLeadershipUploads={thoughtLeadershipUploads}
              onAddThoughtLeadershipUploads={addThoughtLeadershipUploads}
              onRemoveThoughtLeadershipUpload={removeThoughtLeadershipUpload}
            />
          )}

          {tab !== 'about' && tab !== 'blog' && tab !== 'account' && tab !== 'sources' && tab !== 'capabilities' && (
            <>
              <p className="text-[11px] text-[var(--ss-muted)] mb-6 border-b border-[var(--ss-rule)] pb-6">
                Prototype note — {DEMO_MODE ? `demo mode: the 7 accounts are real companies, researched for a fictional firm (${DEMO_FIRM_NAME})` : 'the 8 accounts are real companies'}; their stated priorities{DEMO_MODE ? '' : ', contacts,'} past work and the match rationale/confidence scores below are illustrative mock data layered on top of them. <span className="text-[var(--ss-text-2)]">Generate research plan</span>, <span className="text-[var(--ss-text-2)]">Research selected questions</span>, <span className="text-[var(--ss-text-2)]">Generate outreach plan</span> and <span className="text-[var(--ss-text-2)]">Draft email</span> are live: they call Claude in real time. Research uses web search where this environment allows it — since the accounts are real, expect some firm-specific answers to come back genuinely grounded; verify anything before using it with a client, the same as you would any AI-drafted research.
              </p>

              {/* Tabs */}
              <div className="flex gap-1 mb-8 border-b border-[var(--ss-rule)]">
                {[
                  ['today', 'Today', 'g t'],
                  ['overview', 'Overview', 'g o'],
                  ['map', 'Signal map', 'g m'],
                  ['livefeed', 'Live feed', 'g f'],
                ].map(([key, label, keys]) => (
                  <button
                    key={key}
                    data-tour-tab={key}
                    onClick={() => setTab(key)}
                    {...shortcutButtonProps(keys, label)}
                    className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors active:scale-95 transition-transform border-b-2 -mb-px ${
                      tab === key ? 'border-[var(--ss-accent)] text-[var(--ss-ink)]' : 'border-transparent text-[var(--ss-text-2)] hover:text-[var(--ss-ink)]'
                    }`}
                  >
                    {label}
                    <ShortcutHint keys={keys} />
                  </button>
                ))}
              </div>
            </>
          )}

          {tab === 'today' && (
            <TodayPage
              accounts={accounts}
              activity={activity}
              emailDrafts={emailDrafts}
              contacts={contacts}
              fitGaps={fitGaps}
              liveNews={liveNews}
              promotedVersion={promotedVersion}
              onOpenAccount={(accountId) => openAccountPage(accountId, 'today')}
              onFocusSignal={focusSignalOnAccountPage}
              onGoToOverview={() => setTab('overview')}
              onFlagForAccount={flagSignalManually}
              onAddAccounts={addAccountsBulk}
            />
          )}

          {tab === 'overview' && (
            <Overview
              accounts={accounts}
              archivedAccounts={archivedAccounts}
              contacts={contacts}
              activity={activity}
              plans={plans}
              emailDrafts={emailDrafts}
              generatingId={generatingId}
              planErrors={planErrors}
              onGenerate={generatePlan}
              research={{
                plans: researchPlans,
                selections: researchSelections,
                runs: researchRuns,
                reports,
                generatingId: researchingId,
                errors: researchErrors,
                onGeneratePlan: generateResearchPlan,
                onToggle: toggleResearchQuestion,
                onSelect: setResearchSelection,
                onAddQuestion: addResearchQuestion,
                onRun: runResearch,
                onRegenerateSummary: regenerateSummary,
                intakeChoices,
                onSetIntakeChoice: setIntakeChoice,
                manualSubmittingId,
                manualErrors,
                onSubmitManual: submitManualResearch,
              }}
              onRecord={recordAction}
              onUndo={undoLastAction}
              onAddContact={addContact}
              onDraftChange={(accountId, draft) => setEmailDrafts((prev) => ({ ...prev, [accountId]: draft }))}
              onUnlinkSignal={unlinkSignal}
              expandedId={expandedId}
              onToggleExpand={(id) => setExpandedId((prev) => (prev === id ? null : id))}
              groupBy={groupBy}
              onGroupByChange={setGroupBy}
              sortBySeverity={sortBySeverity}
              onSortBySeverityChange={setSortBySeverity}
              onRunFullFitGaps={runFullFitGapsAnalysis}
              fitGapsBatchRunning={fitGapsBatchRunning}
              customGroups={customGroups}
              onAddCustomGroup={addCustomGroup}
              onRemoveCustomGroup={removeCustomGroup}
              accountGroups={accountGroups}
              onSetAccountGroup={setAccountGroup}
              onAddAccounts={addAccountsBulk}
              onUpdateAccount={updateAccount}
              onOpenAccount={openAccountPage}
              fitGaps={fitGaps}
              fitGapGeneratingId={fitGapGeneratingId}
              onCheckFitGaps={generateFitGaps}
              fitChangeNotices={fitChangeNotices}
            />
          )}

          {tab === 'livefeed' && (
            <LiveFeed
              accounts={accounts}
              items={liveNews}
              newIds={newLiveIds}
              onLoadText={loadLiveNewsFromText}
              error={liveNewsError}
              onAddToSignalMap={addLiveItemToSignalMap}
              onUnlinkSignal={unlinkSignal}
              onOpenSources={() => setTab('sources')}
              readIds={readLiveIds}
              onToggleRead={toggleLiveItemRead}
              onAddAccounts={addAccountsBulk}
              autoMatchEnabled={autoMatchEnabled}
              onToggleAutoMatch={() => setAutoMatchEnabled((v) => !v)}
              highlightScrape={highlightScrape}
              onClearHighlightScrape={() => setHighlightScrape(false)}
              onInjectMarketMovesTrigger={injectMarketMovesTrigger}
            />
          )}

          {tab === 'sources' && <SourcesPage onBack={() => setTab('livefeed')} />}
        </div>

        {/* Account page (added 2026-10-06, widened to full-bleed 2026-10-07,
            full browser width 2026-10-11 direct feedback: "make sure all the
            pages are browser width") — rendered as its own sibling rather
            than nested in the header/tab-bar wrapper above, same as before;
            every page (including that wrapper) now spans the full window
            width, so there's no longer a narrower "centered column" this
            one is breaking out of. */}
        {tab === 'account' && (
          <AccountPage
            account={accounts.find((a) => a.id === selectedAccountId) || null}
            accounts={accounts}
            onUpdateAccount={updateAccount}
            contacts={contacts.filter((c) => c.accountId === selectedAccountId)}
            activity={activity[selectedAccountId] || []}
            plan={plans[selectedAccountId]}
            draft={emailDrafts[selectedAccountId]}
            generating={generatingId === selectedAccountId}
            error={planErrors[selectedAccountId]}
            onGenerate={() => generatePlan(accounts.find((a) => a.id === selectedAccountId))}
            research={buildResearchProps(accounts.find((a) => a.id === selectedAccountId) || { id: selectedAccountId }, {
              plans: researchPlans, selections: researchSelections, runs: researchRuns, reports,
              generatingId: researchingId, errors: researchErrors, onGeneratePlan: generateResearchPlan,
              onToggle: toggleResearchQuestion, onSelect: setResearchSelection, onAddQuestion: addResearchQuestion,
              onRun: runResearch, onRegenerateSummary: regenerateSummary, intakeChoices, onSetIntakeChoice: setIntakeChoice,
              manualSubmittingId, manualErrors, onSubmitManual: submitManualResearch,
            })}
            onRecord={(entry) => recordAction(selectedAccountId, entry)}
            onUndo={(opportunityId) => undoLastAction(selectedAccountId, opportunityId)}
            onAddContact={(c) => addContact({ ...c, accountId: selectedAccountId })}
            onDraftChange={(d) => setEmailDrafts((prev) => ({ ...prev, [selectedAccountId]: d }))}
            thoughtLeadershipUploads={thoughtLeadershipUploads}
            onUnlinkSignal={(newsId) => unlinkSignal(newsId, selectedAccountId)}
            onReviewSignal={reviewSignal}
            onArchiveSignal={(newsId, opts) => archiveSignal(newsId, selectedAccountId, opts)}
            onUnarchiveSignal={(newsId) => unarchiveSignal(newsId, selectedAccountId)}
            onAddOverrideMove={(gapTag, detail) => addOverrideMove(selectedAccountId, gapTag, detail)}
            onProceedSignalRoute={(newsId) => proceedWithSignalRoute(selectedAccountId, newsId)}
            onUndoSignalRoute={(newsId) => undoSignalRoute(selectedAccountId, newsId)}
            onBack={() => setTab(accountReturnTab)}
            returnTab={accountReturnTab}
            managerMode={managerMode}
            delegations={delegations}
            onDelegate={delegateAction}
            onClearDelegate={clearDelegation}
            onFlagSignal={flagSignalForReview}
            onClearSignalFlag={clearSignalFlag}
            fitGap={fitGaps[selectedAccountId]}
            fitGapGenerating={fitGapGeneratingId === selectedAccountId}
            fitGapError={fitGapErrors[selectedAccountId]}
            onCheckFitGaps={generateFitGaps}
            fitChangeNotice={fitChangeNotices[selectedAccountId]}
            onDismissFitChangeNotice={dismissFitChangeNotice}
            proposal={proposals[selectedAccountId]}
            proposalGenerating={proposalGeneratingId === selectedAccountId}
            proposalError={proposalErrors[selectedAccountId]}
            onGenerateProposal={generateProposal}
            onUpdateProposal={updateProposal}
            onUpdatePlan={updatePlan}
            confirmedMitigations={confirmedMitigations}
            onToggleMitigation={toggleMitigationConfirmed}
            selectedMoves={selectedMoves[selectedAccountId] || {}}
            onSelectMove={selectMove}
            dealOutcomes={dealOutcomes}
            onSetOutcome={setDealOutcome}
            archived={!!archivedAccounts[selectedAccountId]}
            archivedReason={archivedAccounts[selectedAccountId] && archivedAccounts[selectedAccountId].reason}
            onArchiveOpportunity={(reason) => archiveAccount(selectedAccountId, reason)}
            onUnarchiveOpportunity={() => unarchiveAccount(selectedAccountId)}
            onAssignOpportunityNumber={() => assignOpportunityNumber(selectedAccountId)}
            externalEngagements={externalEngagements}
            engagementPrompt={engagementPrompt}
            onOpenEngagementAssist={openEngagementAssist}
            onCloseEngagementAssist={closeEngagementAssist}
            engagementDraftingKey={engagementDraftingKey}
            engagementDraftErrors={engagementDraftErrors}
            onUpdateEngagementContact={upsertEngagement}
            onDraftEngagementEmail={draftEngagementEmail}
            onMarkEngagementContacted={markEngagementContacted}
            onMarkEngagementDone={markEngagementDone}
            meetingPlans={meetingPlans}
            onGenerateMeetingPlan={generateMeetingPlan}
            meetingPlanGeneratingKey={meetingPlanGeneratingKey}
            meetingPlanErrors={meetingPlanErrors}
            onUpdateMeetingPlan={updateMeetingPlan}
            meetingNotesReports={meetingNotesReports}
            onSubmitMeetingNotes={submitMeetingNotes}
            meetingNotesSubmittingKey={meetingNotesSubmittingKey}
            meetingNotesErrors={meetingNotesErrors}
            meetingNotesSkipped={meetingNotesSkipped}
            onSkipMeetingNotes={skipMeetingNotes}
            meetingOutcomes={meetingOutcomes}
            onLogMeetingOutcome={logMeetingOutcome}
            meetingOutcomeFlags={meetingOutcomeFlags}
            onSetMeetingOutcomeFlag={setMeetingOutcomeFlag}
            meetingSlotCounts={meetingSlotCounts}
            onAddMeetingSlot={addMeetingSlot}
            onRemoveMeetingSlot={removeMeetingSlot}
            outreachCorrespondence={outreachCorrespondence}
            onAddOutreachCorrespondence={addOutreachCorrespondence}
            outreachCorrespondenceSubmittingId={outreachCorrespondenceSubmittingId}
            outreachCorrespondenceErrors={outreachCorrespondenceErrors}
            outreachResponses={outreachResponses}
            onLogOutreachResponse={logOutreachResponse}
            signalActions={signalActions}
            onAddSignalActionsFromPlan={addSignalActionsFromPlan}
            onToggleSignalAction={toggleSignalAction}
          />
        )}

        {tab === 'map' && (
          <SignalMap
            accounts={accounts}
            selectedNewsId={selectedNewsId}
            setSelectedNewsId={setSelectedNewsId}
            onOpenAccount={openInOverview}
            onUnlinkSignal={unlinkSignal}
            fitGaps={fitGaps}
          />
        )}
      </div>

      {/* Slim footer (added 2026-10-14) — the app had none; it exists so
          "? Shortcuts" has a permanent, unobtrusive home, which is the
          discoverability backstop for every binding that isn't hinted
          inline on a button. Shares the StatusBar primitive with the
          Today tab's own dark status bar (its first, scoped usage). */}
      <StatusBar
        live={false}
        className="mt-10"
        items={['SIGNAL SCOUT — PROTOTYPE', 'HOLD CTRL TO SHOW KEY HINTS']}
        right={<ShortcutsFooterHint />}
      />
    </div>
    </ShortcutProvider>
  );
}

// ---------------------------------------------------------------------------
// ABOUT / BLOG (added 2026-10-05) — basic site-level pages, linked top-right
// next to the app's own tabs (Overview/Signal map/Live feed), which they
// deliberately sit outside of: these are "about the product/company" pages,
// not another view onto the CRM data, so the prototype note and app tab bar
// are hidden while on either (see the tab !== 'about' && tab !== 'blog'
// guard in App). The logo doubles as a "back to the app" link.
// ---------------------------------------------------------------------------

function AboutPage() {
  return (
    <div className="w-full mb-10">
      <h1 className="text-lg text-[var(--ss-ink)] font-medium mb-3">About Signal Scout</h1>
      <p className="text-sm text-[var(--ss-text-2)] leading-relaxed mb-4">
        Signal Scout aggregates renewables-sector news, maps it against your own accounts and their stated priorities, and generates
        AI-assisted account outreach plans — talking points, capability gaps, contacts, and a timeline — grounded in the signals
        that actually matter for each account.
      </p>
      <p className="text-sm text-[var(--ss-text-2)] leading-relaxed mb-8">
        This is a working prototype: the plan generator, research workflow and email composer call Claude live; the Live feed tab
        reads real scraped news. Explore the <span className="text-[var(--ss-ink)]">Overview</span>,{' '}
        <span className="text-[var(--ss-ink)]">Signal map</span> and <span className="text-[var(--ss-ink)]">Live feed</span> tabs to
        see it end to end.
      </p>

      <div className={sectionLabel}>About the author</div>
      <div className="rounded-[var(--ss-radius)] border border-dashed border-[var(--ss-border)] bg-[var(--ss-surface)] p-4">
        <p className="text-[13px] text-[var(--ss-muted)] italic">
          Placeholder — a short bio goes here (who you are, your background, why you built Signal Scout).
        </p>
      </div>
    </div>
  );
}

function BlogPage() {
  return (
    <div className="w-full mb-10">
      <h1 className="text-lg text-[var(--ss-ink)] font-medium mb-3">Blog</h1>
      <p className="text-sm text-[var(--ss-muted)]">Nothing published yet — check back soon.</p>
    </div>
  );
}

// ---------------------------------------------------------------------------
// ACCOUNT PAGE (added 2026-10-06) — a standalone drill-in view for one
// account, reached via the "Open" link on its Overview row (same pattern as
// About/Blog hiding the tab bar, see the tab !== 'account' guard in App).
// Exists specifically for accounts with several matched signals: the
// Overview row's inline expand is still the quick-glance path and shows the
// same per-signal opportunity cards, but on a wide row they compete for
// space with everything else in PlanPanel. This page gives the account its
// own full-width real estate so each matched signal's independent pipeline
// progress (see setMatchStage/accountRollupStage) reads clearly side by
// side. Deliberately reuses PlanPanel as-is rather than re-implementing its
// content — research/outreach-plan/email composer/address book stay exactly
// as they behave inline; only the account header and layout differ here.
// ---------------------------------------------------------------------------

// Gantt scope selector (2026-10-11 direct feedback: "too confusing for the
// gantt to be related to engagement with the general company — the user
// should be able to select the matched signals, which in turn changes the
// gantt") — same themed-dropdown pattern FilterDropdown already uses (see
// §5.16's rule against a native <select> for anything dropdown-shaped), just
// keyed by {id, label} pairs instead of plain strings since two matched
// signals can share a headline-shaped label but never an id.
function SignalScopeDropdown({ value, options, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  const current = options.find((o) => o.id === value) || options[0];

  return (
    <div className="relative shrink-0 max-w-sm h-full" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        className="w-full h-full flex items-center justify-between gap-1.5 text-[13px] text-[var(--ss-ink)] px-4 py-2.5 hover:bg-[var(--ss-rule-soft)] transition-colors"
      >
        <span className="truncate">{current ? current.label : 'General company engagement'}</span>
        <ChevronDown size={12} className="text-[var(--ss-muted)] shrink-0" />
      </button>
      {open && (
        <div className="absolute left-0 mt-1.5 z-10 min-w-full w-max max-w-[320px] max-h-64 overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] py-1">
          {options.map((o) => (
            <button
              key={o.id ?? 'general'}
              type="button"
              onClick={() => {
                onChange(o.id);
                setOpen(false);
              }}
              className={`w-full text-left text-[12px] px-3 py-1.5 hover:bg-[var(--ss-rule-soft)] transition-colors ${
                value === o.id ? 'text-[var(--ss-accent)]' : 'text-[var(--ss-text-2)]'
              }`}
            >
              {o.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// Popup shown the moment the matched signal the Gantt was tracking reaches
// Closed (2026-10-11 direct feedback: "once the matched signal is closed
// after a proposal outcome is recorded, it should revert back to general
// engagement with the company or prompt the user to pick up another one of
// the matched signals in a pop up dialog box") — the Gantt has already
// reverted to general engagement by the time this renders (see
// selectSignal(null) in AccountPage); this just offers the one-click
// alternative of picking up straight back with whichever other matched
// signal is still open, instead of leaving that only reachable by scrolling
// down to Matched signals.
function SignalClosedPromptModal({ headline, options, onPick, onDismiss }) {
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onDismiss}>
      <div
        className="w-full max-w-md rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-start justify-between gap-3 mb-1">
          <div className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--ss-ink)]">
            <CheckCircle2 size={14} className="text-[var(--ss-positive)]" /> Signal closed
          </div>
          <button onClick={onDismiss} className="text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
            <X size={14} />
          </button>
        </div>
        <p className="text-[12px] text-[var(--ss-muted)] mb-3">
          "{headline}" is closed. The timeline above is now showing general engagement with this account — pick up another matched signal instead?
        </p>
        <div className="space-y-1.5 mb-3">
          {options.map((m) => (
            <button
              key={m.newsId}
              onClick={() => onPick(m.newsId)}
              className="w-full text-left text-[12px] text-[var(--ss-ink)] rounded-[var(--ss-radius)] border border-[var(--ss-border)] hover:border-[var(--ss-border)] px-2.5 py-1.5 truncate"
            >
              {m.news.headline}
            </button>
          ))}
        </div>
        <div className="flex items-center justify-end">
          <button onClick={onDismiss} className="text-[12px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] px-2 py-1">
            Stay on general engagement
          </button>
        </div>
      </div>
    </div>
  );
}

function AccountPage({
  account, accounts, onUpdateAccount, contacts, activity, plan, draft, generating, error, onGenerate, onRecord, onUndo, onAddContact, onDraftChange,
  thoughtLeadershipUploads,
  onUnlinkSignal, onReviewSignal, research, onBack, returnTab,
  managerMode, delegations, onDelegate, onClearDelegate, onFlagSignal, onClearSignalFlag,
  fitGap, fitGapGenerating, fitGapError, onCheckFitGaps,
  fitChangeNotice, onDismissFitChangeNotice,
  proposal, proposalGenerating, proposalError, onGenerateProposal, onUpdateProposal, onUpdatePlan,
  confirmedMitigations, onToggleMitigation, selectedMoves, onSelectMove,
  dealOutcomes, onSetOutcome, onArchiveSignal, onUnarchiveSignal, onAddOverrideMove, onProceedSignalRoute, onUndoSignalRoute,
  archived, archivedReason, onArchiveOpportunity, onUnarchiveOpportunity, onAssignOpportunityNumber,
  externalEngagements, engagementPrompt, onOpenEngagementAssist, onCloseEngagementAssist,
  engagementDraftingKey, engagementDraftErrors, onUpdateEngagementContact, onDraftEngagementEmail, onMarkEngagementContacted, onMarkEngagementDone,
  meetingPlans, onGenerateMeetingPlan, meetingPlanGeneratingKey, meetingPlanErrors, onUpdateMeetingPlan,
  meetingNotesReports, onSubmitMeetingNotes, meetingNotesSubmittingKey, meetingNotesErrors,
  meetingNotesSkipped, onSkipMeetingNotes,
  meetingOutcomes, onLogMeetingOutcome,
  meetingOutcomeFlags, onSetMeetingOutcomeFlag,
  meetingSlotCounts, onAddMeetingSlot, onRemoveMeetingSlot,
  outreachCorrespondence, onAddOutreachCorrespondence, outreachCorrespondenceSubmittingId, outreachCorrespondenceErrors,
  outreachResponses, onLogOutreachResponse,
  signalActions, onAddSignalActionsFromPlan, onToggleSignalAction,
}) {
  const returnLabel = returnTab === 'today' ? 'Today' : 'Overview';
  if (!account) {
    return (
      <div className="w-full mb-10">
        <button onClick={onBack} className="flex items-center gap-1 text-[12px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] mb-4">
          <ChevronLeft size={13} /> Back to {returnLabel}
        </button>
        <div className="text-sm text-[var(--ss-muted)]">Account not found.</div>
      </div>
    );
  }

  // Edit account details (moved here from the Overview row's own "Edit"
  // button, 2026-10-11 direct feedback: "the edit account button on the
  // overview tab should be deleted and can just be on the individual
  // company page") — reuses the same ManageAccountsModal Overview's
  // "Manage accounts" already opens, just scoped to this one account via
  // focusId, so there's exactly one edit form in the app, not two.
  const [editOpen, setEditOpen] = useState(false);
  // Fit & gaps dock (2026-10-11 direct follow-up feedback: "once the user
  // decides on fit and gap check actions, this can be minimized on the
  // side so the next immediate actions are clearer") — lifted up from
  // FitGapPanel so it can switch which column renders it: PlanPanel skips
  // it in the main column while minimized, and the sidebar below renders
  // FitGapDockedSummary instead.
  const [fitGapMinimized, setFitGapMinimized] = useState(false);
  // Outreach tool pills + outreach-section visibility (2026-10-11 direct
  // feedback: "the outreach plan... covers up the address book and email
  // generator pills — move these pills to the sidebar") — lifted up from
  // PlanPanel for the same reason fitGapMinimized was: the sidebar now owns
  // the pill row itself (always visible, not pushed off-screen by a long
  // generated plan above it in the main column), while PlanPanel still
  // decides what to show in the main column based on which one is open.
  const [openChip, setOpenChip] = useState(null); // 'email' | 'contacts' | 'framework'
  const [outreachMinimized, setOutreachMinimized] = useState(() => STAGES.indexOf(account.stage) > STAGES.indexOf('Researched'));
  const prevStageRef = useRef(account.stage);
  useEffect(() => {
    if (prevStageRef.current !== account.stage) {
      const wasAtOrBeforeOutreach = STAGES.indexOf(prevStageRef.current) <= STAGES.indexOf('Researched');
      const nowPastOutreach = STAGES.indexOf(account.stage) > STAGES.indexOf('Researched');
      if (wasAtOrBeforeOutreach && nowPastOutreach) setOutreachMinimized(true);
      prevStageRef.current = account.stage;
    }
  }, [account.stage]);
  const isResearchStage = account.stage === 'Identified';
  const showOutreach = !isResearchStage || (research.intakeChoice === 'skip' && !!research.report);
  const matches = accountMatches(account.id);
  // `f` lives here rather than on FitGapPanel's own check button: once the
  // panel is minimized (fitGapMinimized) that button isn't rendered at all,
  // and the shortcut should still re-check from the docked state. Calls the
  // same onCheckFitGaps the panel's button does.
  useShortcut('f', () => onCheckFitGaps(account), {
    label: fitGap ? 'Re-check fit & gaps' : 'Check fit & gaps',
    group: 'Account',
    enabled: matches.length > 0 && !fitGapGenerating,
  });
  const rollupStage = accountRollupStage(account);
  const accountIdx = STAGES.indexOf(account.stage);
  // Ahead of the account's own stage specifically — not just "there are
  // matches" — so this only appears when it says something the bar below
  // doesn't already show on its own.
  const signalAhead = matches.length > 0 && STAGES.indexOf(rollupStage) > accountIdx;

  // Which track the top Gantt is showing (2026-10-11 direct feedback: "too
  // confusing for the gantt to be related to engagement with the general
  // company — the user should be able to select the matched signals, which
  // in turn changes the gantt"). `null` means the account's own general-
  // engagement baseline (the previous, only behavior); a newsId means one
  // specific matched signal's own independent stage (see
  // setMatchStage/accountRollupStage). Defaults to the first open matched
  // signal when there is one, since that's almost always the thing actually
  // being worked — falls back to general engagement when there are none.
  const openMatches = matches.filter((m) => !m.archived && m.stage !== 'Closed');
  const [selectedSignalId, setSelectedSignalId] = useState(() => (openMatches[0] ? openMatches[0].newsId : null));
  // Headline + remaining options for the "pick another matched signal"
  // popup, set the moment the currently-selected signal reaches Closed —
  // see the effect below. Null means no popup is showing.
  const [closedSignalPrompt, setClosedSignalPrompt] = useState(null);
  // Tracks the *previously seen* stage of whichever signal is selected, so
  // the effect below can tell "just reached Closed" apart from "was already
  // Closed when the user reopened it to check the outcome" — same
  // previous-stage-ref pattern MatchedSignalsPanel's own auto-collapse uses.
  const prevSelectedStageRef = useRef(selectedSignalId ? (matches.find((m) => m.newsId === selectedSignalId) || {}).stage : null);

  function selectSignal(newsId) {
    setSelectedSignalId(newsId);
    const m = newsId ? matches.find((x) => x.newsId === newsId) : null;
    prevSelectedStageRef.current = m ? m.stage : null;
  }

  useEffect(() => {
    if (!selectedSignalId) return;
    const m = matches.find((x) => x.newsId === selectedSignalId);
    if (!m || m.archived) {
      // The selected signal was unlinked or archived out from under the
      // selection — fall back quietly (no popup; that's a deliberate user
      // action elsewhere, not a "this just closed" moment).
      const fallback = matches.find((x) => !x.archived && x.stage !== 'Closed');
      selectSignal(fallback ? fallback.newsId : null);
      return;
    }
    const prevStage = prevSelectedStageRef.current;
    if (prevStage !== 'Closed' && m.stage === 'Closed') {
      const remaining = matches.filter((x) => x.newsId !== selectedSignalId && !x.archived && x.stage !== 'Closed');
      selectSignal(null); // revert to general company engagement
      if (remaining.length > 0) setClosedSignalPrompt({ headline: m.news.headline, options: remaining });
    } else {
      prevSelectedStageRef.current = m.stage;
    }
  }, [selectedSignalId, matches]);

  const selectedMatch = selectedSignalId ? matches.find((m) => m.newsId === selectedSignalId) : null;
  const ganttIdx = selectedMatch ? STAGES.indexOf(selectedMatch.stage) : accountIdx;
  const activeMatches = matches.filter((m) => !m.archived);
  const ganttScopeOptions = [
    { id: null, label: 'General company engagement' },
    ...activeMatches.filter((m) => m.stage !== 'Closed').map((m) => ({ id: m.newsId, label: m.news.headline })),
  ];
  // Header stat strip — same "FIT" reading as the Today queue's own 0-1
  // score (a signal's match confidence; there's no separate account-level
  // fit number), scoped to whichever signal the timeline above is showing.
  const headerFitMatch = selectedMatch || activeMatches[0] || null;
  const headerFit = headerFitMatch && typeof headerFitMatch.confidence === 'number' ? headerFitMatch.confidence / 100 : null;
  const lastTouchEntry = activity.length ? activity.reduce((a, b) => (new Date(b.date) > new Date(a.date) ? b : a)) : null;

  // External engagement assist modal (2026-10-11) — recomputed from the
  // live fitGap/selectedMoves every render (see EngagementAssistModal's own
  // comment) rather than from a frozen item list, so it works both as the
  // one-time popup right after Proceed and as the tracker's "check in on
  // this" reopen later, which might be days after the route was chosen.
  const engagementMatch = engagementPrompt && engagementPrompt.accountId === account.id ? matches.find((m) => m.newsId === engagementPrompt.newsId) : null;
  const engagementItems =
    engagementMatch && fitGap
      ? fitGap.gaps
          .filter((g) => g.evidence.includes(engagementMatch.newsId))
          .map((g) => {
            const idx = selectedMoves ? selectedMoves[g.tag] : undefined;
            const move = typeof idx === 'number' ? g.moves[idx] : null;
            return needsExternalEngagement(move) ? { gapTag: g.tag, gap: g.gap, move } : null;
          })
          .filter(Boolean)
      : [];

  return (
    <div className="w-full mb-10">
      {/* Breadcrumb (docs/design/mockups/account.html's header pattern) —
          replaces the plain "Back to Overview" link with the same
          tier/account trail the mockup uses. */}
      <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.04em] text-[var(--ss-muted)] mb-4">
        <button onClick={onBack} className="inline-flex items-center gap-1 text-[var(--ss-text-2)] hover:text-[var(--ss-ink)]">
          <ChevronLeft size={12} /> {returnLabel}
        </button>
        <span aria-hidden="true">/</span>
        <span>{account.tier}</span>
        <span aria-hidden="true">/</span>
        <span className="text-[var(--ss-ink)] normal-case">{account.name}</span>
      </nav>

      <div className="flex flex-wrap items-end justify-between gap-7 mb-8">
        <div className="flex-[999_1_460px] min-w-0 flex flex-col gap-2">
          <div className="flex flex-wrap items-center gap-2.5">
            <TierBadge tier={account.tier} />
            {account.sector && (
              <span className="font-[var(--ss-font-mono)] text-[11px] tracking-[0.06em] uppercase text-[var(--ss-muted)]">{account.sector}</span>
            )}
          </div>
          <h1 className="font-[var(--ss-font-display)] text-[56px] sm:text-[72px] lg:text-[88px] leading-[0.95] tracking-[-0.025em] text-[var(--ss-ink)]">
            {account.name}
          </h1>
          {account.accountManager && (
            <div className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)]">
              <User size={11} /> {account.accountManager}
            </div>
          )}
        </div>
        <div className="flex-[1_1_420px] min-w-0 flex flex-col items-stretch gap-2.5">
          <div className="flex justify-end items-center gap-2">
            <OpportunityNumberBadge number={account.opportunityNumber} />
            <Button variant="secondary" size="sm" icon={Pencil} onClick={() => setEditOpen(true)} title="Edit account details">
              Edit
            </Button>
          </div>
          <StatStrip
            items={[
              { label: 'Stage', value: <>{String(accountIdx + 1).padStart(2, '0')}<span className="text-[var(--ss-faint)]">/06</span></> },
              { label: 'Signals', value: activeMatches.length > 0 ? activeMatches.length : <span className="text-[var(--ss-faint)]">—</span> },
              { label: 'Fit', value: headerFit != null ? headerFit.toFixed(2) : <span className="text-[var(--ss-faint)]">—</span> },
              { label: 'Contacts', value: contacts.length > 0 ? contacts.length : <span className="text-[var(--ss-faint)]">—</span> },
              { label: 'Last touch', value: lastTouchEntry ? formatDate(lastTouchEntry.date) : <span className="text-[var(--ss-faint)]">—</span> },
            ]}
          />
        </div>
      </div>

      {/* This account's own pipeline position — the same bar/columns as the
          Overview row, labeled here since this page has no shared list
          header to borrow one from. Deliberately the account's own stage,
          not the rollup shown on the Overview row: this is also exactly
          what the "Next milestone" card right below acts on, so the two
          never contradict each other. */}
      <Panel focal className="mb-6" bodyClassName="p-0">
        <div className="flex items-stretch border-b border-[var(--ss-rule)] font-[var(--ss-font-mono)] text-[11px] tracking-[0.05em] uppercase">
          <div className="flex items-center px-4 py-2.5 border-r border-[var(--ss-rule)] text-[var(--ss-muted)] shrink-0">Timeline</div>
          <div className="flex-1 overflow-x-auto">
            <SignalScopeDropdown value={selectedSignalId} options={ganttScopeOptions} onChange={selectSignal} />
          </div>
        </div>
        <div className="p-3">
          <div className="mb-2">
            <GanttHeader />
          </div>
          <GanttBar idx={ganttIdx} />
          {!selectedMatch && signalAhead && (
            <div className="flex items-center gap-1.5 text-[12px] text-[var(--ss-gap)] mt-2">
              <Flag size={12} />
              One or more matched signals have moved further — to {rollupStage} — than the account's own research stage below. See "Matched signals."
            </div>
          )}
        </div>
      </Panel>

      {/* Two columns (2026-10-07) so the page actually uses the width it
          was given instead of one text column stranded on the left: the
          sidebar (quick-reference, mostly short cards) runs down the
          right, the deep/actionable content (research, outreach plan,
          email composer) keeps a sane reading measure in the main column
          on the left. Stacks to one column below `lg`. */}
      <div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-8 items-start">
        <PlanPanel
          account={account}
          contacts={contacts}
          activity={activity}
          plan={plan}
          draft={draft}
          generating={generating}
          error={error}
          onGenerate={onGenerate}
          onRecord={onRecord}
          onUndo={onUndo}
          onAddContact={onAddContact}
          onDraftChange={onDraftChange}
          thoughtLeadershipUploads={thoughtLeadershipUploads}
          research={research}
          managerMode={managerMode}
          delegatedTo={delegations[account.id]}
          onDelegate={(colleague) => onDelegate(account.id, colleague)}
          onClearDelegate={() => onClearDelegate(account.id)}
          fitGap={fitGap}
          fitGapGenerating={fitGapGenerating}
          fitGapError={fitGapError}
          onCheckFitGaps={() => onCheckFitGaps(account)}
          fitChangeNotice={fitChangeNotice}
          onDismissFitChangeNotice={() => onDismissFitChangeNotice(account.id)}
          proposal={proposal}
          proposalGenerating={proposalGenerating}
          proposalError={proposalError}
          onGenerateProposal={onGenerateProposal}
          onUpdateProposal={onUpdateProposal}
          onUpdatePlan={onUpdatePlan}
          confirmedMitigations={confirmedMitigations}
          onToggleMitigation={onToggleMitigation}
          selectedMoves={selectedMoves}
          onSelectMove={onSelectMove}
          dealOutcomes={dealOutcomes}
          onSetOutcome={onSetOutcome}
          archived={archived}
          archivedReason={archivedReason}
          onArchiveOpportunity={onArchiveOpportunity}
          onUnarchiveOpportunity={onUnarchiveOpportunity}
          onAssignOpportunityNumber={onAssignOpportunityNumber}
          onArchiveSignal={onArchiveSignal}
          onAddOverrideMove={onAddOverrideMove}
          onProceedSignalRoute={onProceedSignalRoute}
          onUndoSignalRoute={onUndoSignalRoute}
          meetingPlans={meetingPlans}
          onGenerateMeetingPlan={onGenerateMeetingPlan}
          meetingPlanGeneratingKey={meetingPlanGeneratingKey}
          meetingPlanErrors={meetingPlanErrors}
          onUpdateMeetingPlan={onUpdateMeetingPlan}
          meetingNotesReports={meetingNotesReports}
          onSubmitMeetingNotes={onSubmitMeetingNotes}
          meetingNotesSubmittingKey={meetingNotesSubmittingKey}
          meetingNotesErrors={meetingNotesErrors}
          meetingNotesSkipped={meetingNotesSkipped}
          onSkipMeetingNotes={onSkipMeetingNotes}
          meetingOutcomes={meetingOutcomes}
          onLogMeetingOutcome={onLogMeetingOutcome}
          meetingOutcomeFlags={meetingOutcomeFlags}
          onSetMeetingOutcomeFlag={onSetMeetingOutcomeFlag}
          meetingSlotCounts={meetingSlotCounts}
          onAddMeetingSlot={onAddMeetingSlot}
          onRemoveMeetingSlot={onRemoveMeetingSlot}
          fitGapMinimized={fitGapMinimized}
          onSetFitGapMinimized={setFitGapMinimized}
          outreachCorrespondence={outreachCorrespondence[account.id] || []}
          onAddOutreachCorrespondence={onAddOutreachCorrespondence}
          outreachCorrespondenceSubmitting={outreachCorrespondenceSubmittingId === account.id}
          outreachCorrespondenceError={outreachCorrespondenceErrors[account.id]}
          outreachResponses={outreachResponses}
          onLogOutreachResponse={onLogOutreachResponse}
          openChip={openChip}
          onSetOpenChip={setOpenChip}
          outreachMinimized={outreachMinimized}
          onSetOutreachMinimized={setOutreachMinimized}
          showOutreach={showOutreach}
          signalActions={signalActions}
          onAddSignalActionsFromPlan={onAddSignalActionsFromPlan}
          onToggleSignalAction={onToggleSignalAction}
        />
        <div className="space-y-6">
          <StatedPriorities account={account} />
          {/* "Move these pills to the sidebar" (2026-10-11 direct feedback:
              the generated outreach plan was long enough in the main
              column that it covered Address book/Email composer, pushing
              them out of view). The pills themselves — and Address
              book/Outreach framework's own content, since both are compact
              lists that fit a 360px column fine — now live here, at the
              top of the sidebar below Stated priorities, so a long
              generated plan in the main column never pushes them out of
              reach; the Email composer's own (much larger) form still
              renders centrally in PlanPanel, this pill only sets which
              chip is open. Neither column is `position: sticky` (removed
              2026-10-11 direct follow-up feedback: "the email composer,
              address book, and outreach framework all move separately
              from the contents of the page so there's overlap if the
              user scrolls" — the sidebar's own sticky, plus a stray
              `lg:sticky lg:top-6 lg:z-10` on the outreach section in the
              main column, were fighting each other and the page's own
              scroll; everything now scrolls in normal document flow on
              one layer instead). */}
          <OutreachToolsSidebar
            contacts={contacts}
            showOutreach={showOutreach}
            outreachMinimized={outreachMinimized}
            openChip={openChip}
            onSetOpenChip={setOpenChip}
            onAddContact={onAddContact}
          />
          {fitGapMinimized && fitGap && (
            <FitGapDockedSummary fitGap={fitGap} onExpand={() => setFitGapMinimized(false)} />
          )}
          <MatchedSignalsPanel
            account={account}
            contacts={contacts}
            activity={activity}
            onRecord={onRecord}
            onUndo={onUndo}
            onUnlinkSignal={onUnlinkSignal}
            onReviewSignal={onReviewSignal}
            managerMode={managerMode}
            delegations={delegations}
            onDelegate={onDelegate}
            onClearDelegate={onClearDelegate}
            onFlagSignal={onFlagSignal}
            onClearSignalFlag={onClearSignalFlag}
            fitGap={fitGap}
            selectedMoves={selectedMoves}
            externalEngagements={externalEngagements}
            onOpenEngagementAssist={onOpenEngagementAssist ? (newsId) => onOpenEngagementAssist(account.id, newsId) : null}
            dealOutcomes={dealOutcomes}
            onSetOutcome={onSetOutcome}
            onArchiveSignal={onArchiveSignal}
            onUnarchiveSignal={onUnarchiveSignal}
            outreachResponses={outreachResponses}
            meetingNotesReports={meetingNotesReports}
            meetingNotesSkipped={meetingNotesSkipped}
            meetingOutcomeFlags={meetingOutcomeFlags}
            meetingSlotCounts={meetingSlotCounts}
            signalActions={signalActions}
            onToggleSignalAction={onToggleSignalAction}
          />
        </div>
      </div>

      {closedSignalPrompt && (
        <SignalClosedPromptModal
          headline={closedSignalPrompt.headline}
          options={closedSignalPrompt.options}
          onPick={(newsId) => {
            selectSignal(newsId);
            setClosedSignalPrompt(null);
          }}
          onDismiss={() => setClosedSignalPrompt(null)}
        />
      )}

      {engagementItems.length > 0 && (
        <EngagementAssistModal
          accountId={account.id}
          newsId={engagementMatch.newsId}
          account={account}
          match={engagementMatch}
          items={engagementItems}
          engagements={externalEngagements || {}}
          draftingKey={engagementDraftingKey}
          draftErrors={engagementDraftErrors || {}}
          onUpdateContact={onUpdateEngagementContact}
          onDraftEmail={(item) => onDraftEngagementEmail(account.id, engagementMatch.newsId, item.gapTag, item)}
          onMarkContacted={onMarkEngagementContacted}
          onMarkDone={onMarkEngagementDone}
          onClose={onCloseEngagementAssist}
        />
      )}

      {editOpen && (
        <ManageAccountsModal accounts={accounts} focusId={account.id} onUpdate={onUpdateAccount} onClose={() => setEditOpen(false)} />
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// OVERVIEW — every opportunity on one Gantt-style timeline, colored by tier.
// Click a row to expand it in place: next milestone, signals, plan, email.
// ---------------------------------------------------------------------------

// Builds the per-account `research` prop object OpportunityRow/PlanPanel and
// the standalone AccountPage both need, from the bulk research maps App
// passes down (plans/selections/runs/reports keyed by accountId, plus the
// handler functions). Shared so the two render paths can't drift apart.
function buildResearchProps(a, research) {
  return {
    plan: research.plans[a.id],
    selection: research.selections[a.id] || [],
    run: research.runs[a.id],
    report: research.reports[a.id],
    generating: research.generatingId === a.id,
    error: research.errors[a.id],
    onGeneratePlan: () => research.onGeneratePlan(a),
    onToggle: (qid) => research.onToggle(a.id, qid),
    onSelect: (ids) => research.onSelect(a.id, ids),
    onAddQuestion: (text) => research.onAddQuestion(a.id, text),
    onRun: () => research.onRun(a),
    onRegenerateSummary: () => research.onRegenerateSummary(a),
    intakeChoice: research.intakeChoices[a.id],
    onSetIntakeChoice: (choice) => research.onSetIntakeChoice(a.id, choice),
    manualSubmitting: research.manualSubmittingId === a.id,
    manualError: research.manualErrors[a.id],
    onSubmitManual: (payload) => research.onSubmitManual(a, payload),
  };
}

// Redesign point 5: "show recurring gaps across accounts." Scans every
// account's own fitGaps result (already computed, nothing new fetched) and
// groups material (non-Minor) gaps by their `tag` — the same short category
// slug diffFitGaps uses — so a hydrogen-delivery gap on three different
// accounts reads as one strategic signal instead of three separate per-lead
// warnings. Deliberately portfolio-level, not per-account: this is the one
// screen meant to make the case for closing a gap at the firm level (e.g.
// "a hydrogen partnership would open up 3 accounts"), not just flag it.
function tagLabel(tag) {
  return String(tag || '').replace(/-/g, ' ');
}
function computeRecurringGaps(fitGaps, accounts) {
  const byTag = new Map();
  Object.entries(fitGaps).forEach(([accountId, fg]) => {
    if (!fg) return;
    const account = accounts.find((a) => a.id === accountId);
    if (!account) return;
    fg.gaps.forEach((g) => {
      if (g.severity === 'Minor') return;
      if (!byTag.has(g.tag)) byTag.set(g.tag, { tag: g.tag, gapText: g.gap, severity: g.severity, moves: g.moves, accountIds: new Set() });
      const entry = byTag.get(g.tag);
      entry.accountIds.add(accountId);
      if (g.severity === 'Blocker') entry.severity = 'Blocker'; // the worse rating across occurrences wins
    });
  });
  return [...byTag.values()]
    .filter((e) => e.accountIds.size >= 2)
    .map((e) => ({ ...e, accountNames: [...e.accountIds].map((id) => accounts.find((a) => a.id === id)?.name).filter(Boolean) }))
    .sort((a, b) => b.accountIds.size - a.accountIds.size);
}

function CrossAccountGapsPanel({ fitGaps, accounts }) {
  const recurring = computeRecurringGaps(fitGaps, accounts);
  if (!recurring.length) return null;
  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-accent-wash)] p-4 mb-5">
      <div className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--ss-accent)] mb-0.5">
        <Users size={13} /> Recurring capability gaps across your pipeline
      </div>
      <p className="text-[11px] text-[var(--ss-muted)] mb-2.5">
        The same kind of gap, checked on 2+ accounts — a candidate for the firm to actually close, not just work around one lead at a time.
      </p>
      <div className="space-y-2">
        {recurring.map((r) => {
          const topMove = r.moves.find((m) => m.type === 'Team' || m.type === 'Subcontract') || r.moves[0];
          const actionPhrase = topMove && (topMove.type === 'Team' || topMove.type === 'Subcontract') ? `A ${tagLabel(r.tag)} partnership` : `Closing the ${tagLabel(r.tag)} gap`;
          return (
            <div key={r.tag} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] px-3 py-2">
              <div className="flex items-start justify-between gap-2">
                <span className="text-[12px] text-[var(--ss-ink)]">{r.gapText}</span>
                <span className={`text-[10px] font-mono uppercase shrink-0 ${r.severity === 'Blocker' ? 'text-[var(--ss-danger)]' : 'text-[var(--ss-accent)]'}`}>{r.severity}</span>
              </div>
              <div className="text-[11px] text-[var(--ss-muted)] mt-1">Affects {r.accountIds.size} accounts: {r.accountNames.join(', ')}</div>
              <div className="text-[11px] text-[var(--ss-positive)] mt-1 font-medium">
                {actionPhrase} would open up {r.accountIds.size} accounts.
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ============================================================================
// TODAY TAB (added 2026-10-14) — the app's new landing page: a morning brief
// over real matched-signal/account/activity state, styled with its own
// "paper" theme (data-theme="paper", scoped to this page only via the
// .ss-today wrapper below — see docs/design/mockups/today.html, the visual
// reference this was built from; no runtime/markup from that export was
// reused, just layout/spacing/type sizes/colour). Every number and row here
// reads off the same module-level PROMOTED_MATCHES/PROMOTED_NEWS, fitGaps,
// activity and emailDrafts state every other tab already uses — nothing new
// is invented or persisted. A few things the mockup assumed exist and this
// app doesn't track yet are hidden or clearly marked a placeholder rather
// than faked — see SPARKLINE_PLACEHOLDER/TodaySparklinePlaceholder (no daily
// flagged-count history is stored) and TodayDraftCard (no timestamp/
// recipient/linked-signal/tone saved alongside an email draft, so those read
// "—" or are left out).
// ----------------------------------------------------------------------------

const TODAY_FONT_SERIF = "'Instrument Serif', Georgia, 'Times New Roman', serif";
const TODAY_FONT_SANS = "'Geist', 'Helvetica Neue', Helvetica, sans-serif";
const TODAY_FONT_MONO = "'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace";

const TODAY_KIND_GLYPH = { Funding: '◆', Policy: '■', Market: '●' };
const TODAY_KIND_SHORT = { Funding: 'FUND', Policy: 'POL', Market: 'MKT' };
const TODAY_FOLLOWUP_THRESHOLD_DAYS = 30;

const TODAY_REASON_LABEL = {
  below_threshold: 'Below threshold',
  older_than_30d: 'Older than 30d',
  untracked: 'Not on your list',
};

// Session state for the radar view (added 2026-10-17) — view/threshold/
// toggle choices and the "not relevant"/"saved to radar" sets, so they
// survive a tab switch within the same load. sessionStorage, same tier as
// the one other session-only flag in this file (gridline_launch_onboarding)
// — NOT localStorage: this is UI preference/session scratch, not the app's
// real work product (see the PERSISTENCE comment above loadSavedState).
const TODAY_RADAR_SESSION_KEY = 'gridline_today_radar_session_v1';

function loadTodayRadarSession() {
  try {
    const raw = window.sessionStorage.getItem(TODAY_RADAR_SESSION_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch (e) {
    return null;
  }
}

function saveTodayRadarSession(state) {
  try {
    window.sessionStorage.setItem(TODAY_RADAR_SESSION_KEY, JSON.stringify(state));
  } catch (e) {
    // Private browsing / quota exceeded — same non-fatal fallback as
    // saveState above.
  }
}

// PLACEHOLDER — no daily history of flagged-signal counts is stored
// anywhere in this app (see docs/features.md §5.75's note on exactly this
// gap). These six illustrative heights (Sunday through Friday) are NOT
// real data; only today's bar (passed separately, see TodaySparkline) is.
// Replacing this requires a real per-day count persisted somewhere (e.g. a
// `{date: count}` map written to localStorage once a day), which doesn't
// exist yet.
// TODO: replace with a real daily flagged-count history once one is stored.
const SPARKLINE_PLACEHOLDER = [3, 5, 2, 6, 4, 7];

// The one real link between a Live feed item's `tag` ("Wind · Market") and
// the Funding/Policy/Market taxonomy LIVE_KIND_OPTIONS already uses — the
// promoted PROMOTED_NEWS record only ever keeps `tag`, not the original
// `kind` field (see promoteLiveItemToMatch), so this re-derives it from the
// tag's own last segment rather than guessing at a new field.
function todayKindFromTag(tag) {
  if (!tag) return 'Market';
  const parts = tag.split('·').map((s) => s.trim());
  const last = parts[parts.length - 1];
  return LIVE_KIND_OPTIONS.includes(last) ? last : 'Market';
}

// Matched signals have no stable display id of their own (see
// docs/data-model.md) — derives a short, stable one from the real newsId (a
// deterministic hash, not a random/incrementing counter, so the same signal
// always gets the same SIG-xxxx label across reloads and across every
// account it's matched to).
function todaySignalDisplayId(newsId) {
  let h = 0;
  for (let i = 0; i < newsId.length; i += 1) h = (h * 31 + newsId.charCodeAt(i)) >>> 0;
  return `SIG-${(h % 10000).toString().padStart(4, '0')}`;
}

function todayDomainFromUrl(url) {
  if (!url) return null;
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch (e) {
    return null;
  }
}

function todayGreeting() {
  const h = new Date().getHours();
  if (h < 12) return 'Good morning';
  if (h < 18) return 'Good afternoon';
  return 'Good evening';
}

// A small mono "label" chip used all over this page for kind/fit/gap
// headers — kept as one helper so the uppercase/letter-spacing/size stays
// identical everywhere instead of drifting copy to copy.
function TodayMonoLabel({ children, className = '' }) {
  return (
    <span
      className={`text-[10px] uppercase tracking-[0.08em] text-[var(--ss-muted)] ${className}`}
      style={{ fontFamily: TODAY_FONT_MONO }}
    >
      {children}
    </span>
  );
}

// ---------------------------------------------------------------------------
// Data assembly — pure functions over already-loaded app state, no network
// calls, so there's no async "loading" state for this page itself (the data
// is whatever Overview/Live feed/the fit & gaps check have already produced).
// ---------------------------------------------------------------------------

// One row per non-archived matched signal, across every account — the
// "queue" the signal cards/table below are built from. Confidence/rationale
// come straight off the PROMOTED_MATCHES entry (same one Overview/AccountPage
// read); gaps come from that account's fit & gaps check if one has run
// (`null` when it hasn't, so the UI can show "—" rather than "0").
function buildTodayQueue(accounts, fitGaps) {
  return allMatches()
    .filter((m) => !m.archived)
    .map((m) => {
      const account = accounts.find((a) => a.id === m.accountId);
      const news = newsById(m.newsId);
      if (!account || !news) return null;
      const fg = fitGaps[m.accountId];
      const gaps = fg ? fg.gaps.filter((g) => g.evidence.includes(m.newsId)) : null;
      // 1-based position within this account's own gap list — the closest
      // thing to a stable "GAP-xx" id without inventing one; two different
      // accounts' first gap both read "GAP-01", same as how each account's
      // fit/gap check is entirely its own.
      const gapNumber = gaps && gaps.length ? fg.gaps.indexOf(gaps[0]) + 1 : null;
      return {
        newsId: m.newsId,
        accountId: m.accountId,
        account,
        news,
        kind: todayKindFromTag(news.tag),
        confidence: typeof m.confidence === 'number' ? m.confidence : null,
        rationale: m.rationale,
        manual: !!m.manual,
        stage: m.stage || 'Identified',
        gaps,
        gapNumber,
        evidenceUrl: news.sources && news.sources[0] ? news.sources[0].url : null,
        evidenceTitle: news.sources && news.sources[0] ? news.sources[0].title : null,
      };
    })
    .filter(Boolean)
    .sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
}

// The account whose last activity-log entry is oldest, past the 30-day
// threshold — only ever considers accounts that actually have activity
// (an account that's never been touched isn't "overdue for a follow-up" in
// any way this page can tell; it's simply unstarted, which the Accounts
// panel already shows via its stage).
function findTodayFollowUp(accounts, activity) {
  let best = null;
  accounts.forEach((a) => {
    const log = activity[a.id] || [];
    if (!log.length) return;
    const lastDate = log.reduce((max, e) => (e.date > max ? e.date : max), log[0].date);
    const daysSince = -daysUntil(lastDate);
    if (daysSince >= TODAY_FOLLOWUP_THRESHOLD_DAYS && (!best || daysSince > best.daysSince)) {
      best = { account: a, daysSince, lastDate };
    }
  });
  return best;
}

// The most recently *touched* email draft this page can actually tell apart
// from the others — emailDrafts is keyed by accountId with no saved-at
// timestamp (see the module comment above), so "most recent" isn't
// knowable; this instead picks the first non-empty draft in account order,
// same as every other "pick one" fallback in this file when there's no real
// ordering to go on. See TodayDraftCard for what's shown as "—" as a result.
function findTodayDraft(accounts, emailDrafts) {
  for (const a of accounts) {
    const d = emailDrafts[a.id];
    if (d && (d.subject || d.body)) return { account: a, draft: d };
  }
  return null;
}

// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------

// Segmented FLAGGED/RADAR/BOTH control (added 2026-10-17). aria-pressed per
// account, not color alone, per CLAUDE.md §7's accessibility rule.
function TodayViewToggle({ view, onChange, flaggedCount, radarCount }) {
  const options = [
    { key: 'flagged', label: `Flagged ${flaggedCount}` },
    { key: 'radar', label: `Radar ${radarCount}` },
    { key: 'both', label: 'Both' },
  ];
  return (
    <div
      className="flex rounded-[3px] overflow-hidden shrink-0"
      style={{ border: '1px solid var(--ss-ink)' }}
      role="group"
      aria-label="View"
    >
      {options.map((o, i) => {
        const active = view === o.key;
        return (
          <button
            key={o.key}
            type="button"
            aria-pressed={active}
            onClick={() => onChange(o.key)}
            {...shortcutButtonProps(i === 0 ? 'v' : null, 'Switch radar view')}
            className="h-9 px-3 text-[11px] uppercase"
            style={{
              fontFamily: TODAY_FONT_MONO,
              background: active ? 'var(--ss-ink)' : 'var(--ss-surface)',
              color: active ? 'var(--ss-ground)' : 'var(--ss-text-2)',
              borderLeft: i === 0 ? 'none' : '1px solid var(--ss-border)',
            }}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

// Reworked 2026-10-17 for the radar layout (CLAUDE.md/today-radar.png) — the
// old "Start today's brief"/"Open signal queue" buttons and "moved
// overnight" toast are gone from here; the view toggle + funnel strip below
// now carry that job, and the compact flagged list's "OPEN SIGNAL QUEUE →"
// link (see TodayCompactFlaggedList) replaces the old queue shortcut.
function TodayHero({ flaggedCount, radarCount, view, onViewChange }) {
  const headline =
    flaggedCount === 0 && radarCount === 0
      ? 'Quiet day. Nothing new hit your accounts.'
      : radarCount === 0
        ? `${flaggedCount} signal${flaggedCount === 1 ? '' : 's'} hit your accounts.`
        : `${flaggedCount} signal${flaggedCount === 1 ? '' : 's'} hit your accounts. ${radarCount} more nearly did.`;
  return (
    <section className="flex flex-wrap items-end justify-between gap-x-8 gap-y-5 pt-11">
      <div className="flex flex-col gap-2.5 max-w-[760px]">
        <TodayMonoLabel>Today · Radar view</TodayMonoLabel>
        <h1
          className="m-0 text-[32px] sm:text-[44px] md:text-[52px] leading-[1.04] tracking-[-0.02em]"
          style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}
        >
          {headline}
        </h1>
        <p className="m-0 text-[15px] sm:text-[16px] leading-[1.55]" style={{ color: 'var(--ss-text-2)' }}>
          The flagged queue is the obvious work. The radar is everything Scout read that was relevant but didn't
          clear the bar — below threshold, about a company you don't track yet, or too old to rank.
        </p>
      </div>
      <TodayViewToggle view={view} onChange={onViewChange} flaggedCount={flaggedCount} radarCount={radarCount} />
    </section>
  );
}

// The funnel strip (reworked 2026-10-17 for the radar layout — replaces the
// old items-swept/kept/matched/gaps strip). Four real counts, no invented
// ones: read by Scout (every loaded Live feed item), relevant (flagged +
// radar), flagged, and on radar. Bar widths are relative to "Read by
// Scout"; if nothing's been scraped this session (readByScout === 0) every
// bar shows "—" instead of a 0-width bar reading as "nothing happened".
function TodayFunnelStrip({ readByScout, relevant, flagged, radar }) {
  const hasData = readByScout > 0;
  const pct = (n) => (hasData ? Math.min(100, Math.round((n / readByScout) * 100)) : 0);
  const cells = [
    { label: 'Read by Scout', value: readByScout, sub: 'items in the current feed', color: 'var(--ss-ink)', pct: hasData ? 100 : 0 },
    { label: 'Relevant', value: relevant, sub: 'about your sector or accounts', color: 'var(--ss-ink)', pct: pct(relevant) },
    { label: 'Flagged', value: flagged, sub: 'matched an account above the threshold', color: 'var(--ss-accent)', pct: pct(flagged) },
    { label: 'On radar', value: radar, sub: 'relevant, not flagged — listed below', color: 'var(--ss-radar)', pct: pct(radar) },
  ];
  return (
    <div
      className="mt-6 grid grid-cols-2 sm:grid-cols-4 rounded-[3px]"
      style={{ border: '1px solid var(--ss-ink)', background: 'var(--ss-surface)' }}
    >
      {cells.map((c, i) => (
        <div
          key={c.label}
          className="flex flex-col gap-1.5 px-[18px] py-3.5"
          style={{ borderRight: i < cells.length - 1 ? '1px solid var(--ss-rule)' : 'none', borderBottom: '1px solid var(--ss-rule)' }}
        >
          <TodayMonoLabel>{c.label}</TodayMonoLabel>
          <span className="text-[26px] leading-[1.1]" style={{ fontFamily: TODAY_FONT_MONO, color: c.color }}>
            {hasData ? c.value : '—'}
          </span>
          <span className="inline-block w-full h-1" style={{ background: 'var(--ss-rule-soft)' }}>
            <span className="block h-1" style={{ width: `${c.pct}%`, background: c.color }} />
          </span>
          <TodayMonoLabel>{c.sub}</TodayMonoLabel>
        </div>
      ))}
    </div>
  );
}

function TodayFitBar({ value }) {
  if (value == null) return <span style={{ fontFamily: TODAY_FONT_MONO }}>—</span>;
  const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
  return (
    <div className="flex items-center gap-2" style={{ fontFamily: TODAY_FONT_MONO }}>
      <span className="inline-block w-11 h-1" style={{ background: 'var(--ss-rule-soft)' }}>
        <span className="block h-1" style={{ width: `${pct}%`, background: 'var(--ss-accent)' }} />
      </span>
      {value.toFixed(2)}
    </div>
  );
}

function TodayGapCell({ gaps }) {
  if (gaps == null) return <span style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)' }}>—</span>;
  if (gaps.length === 0) return <span style={{ fontFamily: TODAY_FONT_MONO }}>0</span>;
  return (
    <span style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-gap)' }}>
      {gaps.length} ▲
    </span>
  );
}

function TodaySignalCard({ signal, isTopPick, state, onDraft, onToggleSave, onDismiss, focused, onFocus }) {
  const id = todaySignalDisplayId(signal.newsId);
  const domain = todayDomainFromUrl(signal.evidenceUrl);
  return (
    <article
      onMouseDown={onFocus}
      className="rounded-[3px] overflow-hidden"
      style={{
        background: 'var(--ss-surface)',
        border: `1px solid ${isTopPick || focused ? 'var(--ss-ink)' : 'var(--ss-border)'}`,
      }}
    >
      <div
        className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-[18px] py-2 text-[11px]"
        style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)', borderBottom: '1px solid var(--ss-rule)' }}
      >
        <span className="font-medium" style={{ color: 'var(--ss-ink)' }}>
          {TODAY_KIND_GLYPH[signal.kind]} {signal.kind.toUpperCase()}
        </span>
        <span>{id}</span>
        {domain && <span>src: {domain}</span>}
        {signal.news.date && <span>{signal.news.date}</span>}
        {signal.manual && (
          <span className="px-1.5 py-0.5 rounded-[2px] tracking-[0.06em]" style={{ background: 'var(--ss-rule-soft)', color: 'var(--ss-text-2)' }}>
            MANUAL
          </span>
        )}
        {isTopPick && (
          <span
            className="ml-auto px-1.5 py-0.5 rounded-[2px] tracking-[0.06em]"
            style={{ background: 'var(--ss-accent)', color: 'var(--ss-accent-text)' }}
          >
            TOP PICK
          </span>
        )}
      </div>
      <div className="px-[18px] pt-5 pb-[18px] flex flex-col gap-3.5">
        <h3
          className="m-0 text-[22px] sm:text-[26px] md:text-[30px] leading-[1.12] tracking-[-0.01em]"
          style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}
        >
          {signal.news.headline}
        </h3>
        {signal.rationale && (
          <p className="m-0 max-w-[660px] text-[15px]" style={{ color: 'var(--ss-text-2)' }}>
            <span className="mr-1" style={{ fontFamily: TODAY_FONT_MONO, fontSize: 11, color: 'var(--ss-ink)', letterSpacing: '0.06em' }}>
              WHY →
            </span>
            {signal.rationale}
          </p>
        )}
        <div
          className="grid grid-cols-2 sm:grid-cols-4 text-[13px] rounded-[2px]"
          style={{ border: '1px solid var(--ss-rule)' }}
        >
          <div className="px-3 py-2.5" style={{ borderRight: '1px solid var(--ss-rule)' }}>
            <TodayMonoLabel>Account</TodayMonoLabel>
            <div className="font-medium" style={{ color: 'var(--ss-ink)' }}>{signal.account.name}</div>
          </div>
          <div className="px-3 py-2.5 sm:border-r" style={{ borderRight: '1px solid var(--ss-rule)' }}>
            <TodayMonoLabel>Fit</TodayMonoLabel>
            <TodayFitBar value={signal.confidence != null ? signal.confidence / 100 : null} />
          </div>
          <div className="px-3 py-2.5" style={{ borderRight: '1px solid var(--ss-rule)' }}>
            <TodayMonoLabel>Gaps</TodayMonoLabel>
            <TodayGapCell gaps={signal.gaps} />
          </div>
          <div className="px-3 py-2.5">
            <TodayMonoLabel>Evidence</TodayMonoLabel>
            {signal.evidenceUrl ? (
              <a
                href={signal.evidenceUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="font-medium underline"
                style={{ textUnderlineOffset: 3, color: 'var(--ss-ink)' }}
              >
                {signal.evidenceTitle || 'Source'} ↗
              </a>
            ) : (
              <span style={{ color: 'var(--ss-muted)' }}>—</span>
            )}
          </div>
        </div>
        {signal.gaps && signal.gaps.length > 0 && (
          <div
            className="flex gap-3 items-baseline rounded-[2px] px-3 py-2.5 text-[14px]"
            style={{ border: '1px solid var(--ss-gap-border)', background: 'var(--ss-gap-wash)', color: 'var(--ss-gap)' }}
          >
            <span className="shrink-0 font-medium" style={{ fontFamily: TODAY_FONT_MONO, fontSize: 10, letterSpacing: '0.08em' }}>
              GAP-{String(signal.gapNumber).padStart(2, '0')} ▲
            </span>
            <span>{signal.gaps[0].gap}</span>
          </div>
        )}
      </div>
      <div
        className="flex flex-wrap gap-2 px-[18px] py-2.5"
        style={{ borderTop: '1px solid var(--ss-rule)', background: 'var(--ss-ground)' }}
      >
        <button
          type="button"
          onClick={onDraft}
          {...shortcutButtonProps(focused ? 'e' : null, 'See details')}
          className="h-[38px] pl-[14px] pr-[10px] rounded-[3px] font-medium text-[14px] inline-flex items-center gap-2.5"
          style={{ background: 'var(--ss-ink)', border: '1px solid var(--ss-ink)', color: 'var(--ss-ground)' }}
        >
          See details <ShortcutHint keys={focused ? 'e' : null} variant="accent" />
        </button>
        <button
          type="button"
          onClick={onToggleSave}
          {...shortcutButtonProps(focused ? 's' : null, state.saved ? 'Unsave' : 'Save')}
          className="h-[38px] pl-[14px] pr-[10px] rounded-[3px] text-[14px] inline-flex items-center gap-2.5"
          style={{
            background: state.saved ? 'var(--ss-accent-wash)' : 'var(--ss-surface)',
            border: `1px solid ${state.saved ? 'var(--ss-accent)' : 'var(--ss-border)'}`,
            color: state.saved ? 'var(--ss-accent-wash-ink)' : 'var(--ss-ink)',
          }}
        >
          <Bookmark size={13} fill={state.saved ? 'currentColor' : 'none'} /> {state.saved ? 'Saved' : 'Save'} <ShortcutHint keys={focused ? 's' : null} />
        </button>
        <button
          type="button"
          onClick={onDismiss}
          {...shortcutButtonProps(focused ? 'x' : null, 'Dismiss')}
          className="h-[38px] px-2.5 ml-auto text-[13px] inline-flex items-center gap-2"
          style={{ color: 'var(--ss-muted)' }}
        >
          Dismiss <ShortcutHint keys={focused ? 'x' : null} />
        </button>
      </div>
    </article>
  );
}

function TodayLowerPriorityTable({ signals, focusedId, onFocus, onOpen }) {
  if (signals.length === 0) return null;
  return (
    <div className="rounded-[3px] overflow-x-auto" style={{ border: '1px solid var(--ss-border)', background: 'var(--ss-surface)' }}>
      <div className="min-w-[560px]">
        <div
          className="grid gap-3 px-[18px] py-2 text-[10px] uppercase tracking-[0.08em]"
          style={{ gridTemplateColumns: '76px 64px minmax(0,1fr) 100px 48px', fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)', borderBottom: '1px solid var(--ss-rule)' }}
        >
          <span>ID</span>
          <span>Kind</span>
          <span>Lower priority · {signals.length}</span>
          <span>Account</span>
          <span className="text-right">Fit</span>
        </div>
        {signals.map((s) => (
          <button
            key={s.newsId}
            type="button"
            onMouseDown={() => onFocus(s.newsId)}
            onClick={() => onOpen(s)}
            className="w-full grid gap-3 items-center text-left min-h-[44px] px-[18px]"
            style={{
              gridTemplateColumns: '76px 64px minmax(0,1fr) 100px 48px',
              borderBottom: '1px solid var(--ss-rule-soft)',
              background: focusedId === s.newsId ? 'var(--ss-accent-wash)' : 'transparent',
              color: 'var(--ss-ink)',
            }}
          >
            <span className="text-[12px]" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)' }}>
              {todaySignalDisplayId(s.newsId)}
            </span>
            <span className="text-[11px]" style={{ fontFamily: TODAY_FONT_MONO }}>
              {TODAY_KIND_GLYPH[s.kind]} {TODAY_KIND_SHORT[s.kind]}
            </span>
            <span className="text-[14px] font-medium overflow-hidden text-ellipsis whitespace-nowrap">
              {s.news.headline}
              {s.manual && (
                <span className="ml-1.5 text-[9px] align-middle px-1 py-0.5 rounded-[2px]" style={{ fontFamily: TODAY_FONT_MONO, background: 'var(--ss-rule-soft)', color: 'var(--ss-text-2)' }}>
                  MANUAL
                </span>
              )}
            </span>
            <span className="text-[12px] overflow-hidden text-ellipsis whitespace-nowrap" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-text-2)' }}>
              {s.account.name}
            </span>
            <span className="text-[12px] text-right" style={{ fontFamily: TODAY_FONT_MONO }}>
              {s.confidence != null ? (s.confidence / 100).toFixed(2) : '—'}
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}

// Only rendered when something is actually overdue (reworked 2026-10-17 for
// the radar sidebar — the "nothing's overdue" message used to render as its
// own panel; now it simply doesn't take sidebar space).
function TodayFollowUpPanel({ followUp, onDraftCheckIn }) {
  if (!followUp) return null;
  return (
    <section className="rounded-[3px] overflow-hidden" style={{ border: '1px solid var(--ss-ink)', background: 'var(--ss-surface)' }}>
      <div
        className="flex items-center justify-between px-4 py-2"
        style={{ background: 'var(--ss-accent-wash)', color: 'var(--ss-accent-wash-ink)', borderBottom: '1px solid var(--ss-ink)' }}
      >
        <h2 className="m-0 text-[11px] font-medium uppercase tracking-[0.08em]" style={{ fontFamily: TODAY_FONT_MONO }}>
          Follow-up due
        </h2>
        <span className="text-[11px]" style={{ fontFamily: TODAY_FONT_MONO }}>
          T−{followUp.daysSince}d
        </span>
      </div>
      <div className="p-4 flex flex-col gap-2">
        <div className="text-[22px] leading-[1.15]" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
          {followUp.account.name} — {followUp.daysSince} days since last contact.
        </div>
        <p className="m-0 text-[14px]" style={{ color: 'var(--ss-text-2)' }}>
          {followUp.account.priorities && followUp.account.priorities[0] ? followUp.account.priorities[0] : followUp.account.sector}
        </p>
        <button
          type="button"
          onClick={() => onDraftCheckIn(followUp.account.id)}
          className="font-medium text-[14px] inline-flex items-center min-h-8 text-left"
          style={{ color: 'var(--ss-accent)', textUnderlineOffset: 3, textDecoration: 'underline' }}
        >
          Draft a check-in →
        </button>
      </div>
    </section>
  );
}

// `radarCountsByAccount`: Map<accountId, number> of near-miss items for
// that account (added 2026-10-17 for the radar sidebar's "+N RADAR" tag).
// `onFilterByAccount(accountId)` is optional — when given, the tag becomes
// a button that filters the radar list to that account.
function TodayAccountsPanel({ accounts, fitGaps, onOpenAccount, onViewAll, radarCountsByAccount, onFilterByAccount }) {
  const top5 = useMemo(() => accounts.slice().sort(compareAccounts).slice(0, 5), [accounts]);
  return (
    <section className="flex flex-col">
      <div className="flex items-end justify-between pb-2.5" style={{ borderBottom: '1px solid var(--ss-ink)' }}>
        <div className="flex flex-col gap-0.5">
          <TodayMonoLabel>02 — Accounts · {top5.length} of {accounts.length}</TodayMonoLabel>
          <h2 className="m-0 text-[26px] sm:text-[30px] leading-none" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
            Your accounts
          </h2>
        </div>
        <button type="button" onClick={onViewAll} className="text-[11px]" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-text-2)' }}>
          ALL →
        </button>
      </div>
      <div
        className="grid gap-3 py-2 text-[10px] uppercase tracking-[0.08em]"
        style={{ gridTemplateColumns: 'minmax(0,1fr) 84px 52px', fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)', borderBottom: '1px solid var(--ss-rule)' }}
      >
        <span>Account</span>
        <span>Stage</span>
        <span className="text-right">Fit</span>
      </div>
      {top5.map((a) => {
        const stage = accountRollupStage(a);
        const idx = STAGES.indexOf(stage);
        const matches = accountMatches(a.id).filter((m) => !m.archived);
        const bestConfidence = matches.reduce((max, m) => (typeof m.confidence === 'number' && m.confidence > max ? m.confidence : max), -1);
        const fg = fitGaps[a.id];
        const hasGaps = !!(fg && fg.gaps.length > 0);
        const radarCount = (radarCountsByAccount && radarCountsByAccount.get(a.id)) || 0;
        return (
          <div
            key={a.id}
            className="w-full grid gap-3 items-center py-2.5"
            style={{ gridTemplateColumns: 'minmax(0,1fr) 84px 52px', borderBottom: '1px solid var(--ss-rule-soft)' }}
          >
            <button type="button" onClick={() => onOpenAccount(a.id)} className="min-w-0 text-left">
              <div className="flex items-center gap-1.5 min-w-0">
                <span className="font-medium text-[15px] truncate" style={{ color: 'var(--ss-ink)' }}>{a.name}</span>
                {radarCount > 0 && (
                  <span
                    role={onFilterByAccount ? 'button' : undefined}
                    tabIndex={onFilterByAccount ? 0 : undefined}
                    onClick={onFilterByAccount ? (e) => { e.stopPropagation(); onFilterByAccount(a.id); } : undefined}
                    className="shrink-0 px-1.5 py-0.5 rounded-[2px] text-[9px] font-medium tracking-[0.04em]"
                    style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-radar)', border: '1px solid var(--ss-radar)' }}
                  >
                    +{radarCount} RADAR
                  </span>
                )}
              </div>
              <div className="text-[11px] uppercase truncate" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)' }}>{a.sector}</div>
            </button>
            <button type="button" onClick={() => onOpenAccount(a.id)} className="flex flex-col gap-1 text-left">
              <StageTrack stages={STAGES} current={idx} height="h-[5px]" ringBg="var(--ss-surface)" />
              <span className="text-[10px] uppercase" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-text-2)' }}>{stage}</span>
            </button>
            <button
              type="button"
              onClick={() => onOpenAccount(a.id)}
              className="text-right text-[12px]"
              style={{ fontFamily: TODAY_FONT_MONO, color: hasGaps ? 'var(--ss-gap)' : 'var(--ss-ink)' }}
            >
              {bestConfidence >= 0 ? (bestConfidence / 100).toFixed(2) : '—'}
              {hasGaps ? ' ▲' : ''}
            </button>
          </div>
        );
      })}
    </section>
  );
}

function todayLast7DayLabels() {
  const labels = [];
  const now = new Date();
  for (let i = 6; i >= 0; i -= 1) {
    const d = new Date(now);
    d.setDate(d.getDate() - i);
    labels.push('SMTWTFS'[d.getDay()]);
  }
  return labels;
}

// PLACEHOLDER sidebar sparkline (added 2026-10-17) — see SPARKLINE_PLACEHOLDER
// above for why six of these seven bars are illustrative, not real. Only the
// last bar (today, in solid accent) is a real count. role="img" + the
// aria-label say so explicitly, not just the visible PLACEHOLDER tag — color
// alone (hatched vs. solid) is never the only cue.
function TodaySparklinePlaceholder({ todayCount }) {
  const labels = useMemo(() => todayLast7DayLabels(), []);
  const bars = useMemo(() => [...SPARKLINE_PLACEHOLDER, todayCount], [todayCount]);
  const max = Math.max(...bars, 1);
  return (
    <section
      className="rounded-[3px] overflow-hidden"
      style={{ border: '1px solid var(--ss-border)', background: 'var(--ss-surface)' }}
      role="img"
      aria-label={`Placeholder chart. Illustrative flagged-signal counts for the past six days, plus today's real count of ${todayCount}. Needs a daily history of flagged signals to be real.`}
    >
      <div className="flex items-center justify-between px-4 py-2" style={{ borderBottom: '1px solid var(--ss-rule)' }}>
        <TodayMonoLabel>Flagged · last 7 days</TodayMonoLabel>
        <span
          className="px-1.5 py-0.5 rounded-[2px] text-[9px] font-medium tracking-[0.06em]"
          style={{ fontFamily: TODAY_FONT_MONO, border: '1px solid var(--ss-border)', color: 'var(--ss-muted)' }}
        >
          PLACEHOLDER
        </span>
      </div>
      <div className="px-4 pt-4 pb-1 flex items-end gap-1.5 h-20" aria-hidden="true">
        {bars.map((v, i) => {
          const isToday = i === bars.length - 1;
          const h = Math.max(6, Math.round((v / max) * 64));
          return (
            <div key={i} className="flex-1 flex flex-col items-center gap-1 justify-end h-full">
              <div
                className="w-full rounded-[1px]"
                style={{
                  height: h,
                  background: isToday ? 'var(--ss-accent)' : 'repeating-linear-gradient(135deg, var(--ss-rule-soft), var(--ss-rule-soft) 2px, transparent 2px, transparent 5px)',
                  border: isToday ? 'none' : '1px dashed var(--ss-border)',
                }}
              />
              <span className="text-[9px]" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)' }}>{labels[i]}</span>
            </div>
          );
        })}
      </div>
      <p className="px-4 pb-3 pt-1 text-[11px]" style={{ color: 'var(--ss-muted)' }}>
        Needs a daily count of flagged signals. Bars are illustrative until that history is stored.
      </p>
    </section>
  );
}

// "FLAGGED · N · ALREADY IN YOUR QUEUE" — the existing flagged queue,
// compacted for the radar sidebar. Reuses buildTodayQueue's own output
// (`queue`), so nothing here is a second source of truth.
function TodayCompactFlaggedList({ queue, onOpen, onViewQueue }) {
  const top = queue.slice(0, 5);
  return (
    <section className="rounded-[3px] overflow-hidden" style={{ border: '1px solid var(--ss-ink)' }}>
      <div className="px-4 py-2" style={{ background: 'var(--ss-ink)', color: 'var(--ss-ground)' }}>
        <span className="text-[11px] font-medium uppercase tracking-[0.08em]" style={{ fontFamily: TODAY_FONT_MONO }}>
          Flagged · {queue.length} · already in your queue
        </span>
      </div>
      <div style={{ background: 'var(--ss-surface)' }}>
        {top.length === 0 ? (
          <p className="m-0 px-4 py-3 text-[13px]" style={{ color: 'var(--ss-muted)' }}>
            Nothing flagged yet.
          </p>
        ) : (
          top.map((s) => (
            <button
              key={s.newsId}
              type="button"
              onClick={() => onOpen(s)}
              className="w-full flex items-center justify-between gap-3 px-4 py-2.5 text-left"
              style={{ borderTop: '1px solid var(--ss-rule-soft)' }}
            >
              <span className="min-w-0">
                <span className="flex items-center gap-1.5">
                  <span className="font-medium text-[13px] truncate" style={{ color: 'var(--ss-ink)' }}>{s.account.name}</span>
                  {s.manual && (
                    <span
                      className="shrink-0 text-[9px] px-1 py-0.5 rounded-[2px]"
                      style={{ fontFamily: TODAY_FONT_MONO, background: 'var(--ss-rule-soft)', color: 'var(--ss-text-2)' }}
                    >
                      MANUAL
                    </span>
                  )}
                </span>
                <span className="block text-[12px] truncate" style={{ color: 'var(--ss-text-2)' }}>{s.news.headline}</span>
              </span>
              <span className="shrink-0 text-[12px]" style={{ fontFamily: TODAY_FONT_MONO }}>
                {s.confidence != null ? (s.confidence / 100).toFixed(2) : '—'}
              </span>
            </button>
          ))
        )}
        <button
          type="button"
          onClick={onViewQueue}
          className="w-full text-left px-4 py-2.5 text-[12px] font-medium"
          style={{ borderTop: '1px solid var(--ss-rule)', color: 'var(--ss-accent)' }}
        >
          OPEN SIGNAL QUEUE →
        </button>
      </div>
    </section>
  );
}

// One near-miss or lookalike card. `dashed` (lookalike) vs. solid (near
// miss) border per the mockup — color is never the only cue, the reason tag
// is also always text (BELOW THRESHOLD / OLDER THAN 30D / NOT ON YOUR LIST).
function TodayRadarCard({
  entry, reasonText, whyText, dashed, focused, onFocus,
  primaryLabel, onPrimary, secondaryLabel, onSecondary, onDismiss,
}) {
  const item = entry.item;
  const reasonColor = entry.reason === 'untracked' ? 'var(--ss-radar)' : 'var(--ss-reason-below)';
  return (
    <article
      id={`today-radar-${item.id}`}
      onMouseDown={onFocus}
      className="rounded-[3px] overflow-hidden"
      style={{
        background: 'var(--ss-surface)',
        border: `1px ${dashed ? 'dashed' : 'solid'} ${focused ? 'var(--ss-ink)' : 'var(--ss-border)'}`,
      }}
    >
      <div className="flex flex-col sm:flex-row gap-3 sm:gap-4 p-4">
        <div className="sm:w-[150px] shrink-0 flex flex-row sm:flex-col gap-2 sm:gap-1.5">
          <span
            className="inline-flex self-start px-1.5 py-0.5 rounded-[2px] text-[10px] font-medium tracking-[0.04em] uppercase"
            style={{ fontFamily: TODAY_FONT_MONO, color: reasonColor, border: `1px solid ${reasonColor}` }}
          >
            {reasonText}
          </span>
          <div className="text-[11px] leading-[1.5]" style={{ fontFamily: TODAY_FONT_MONO, color: 'var(--ss-muted)' }}>
            {item.source || '—'}<br />{item.date || ''}
          </div>
        </div>
        <div className="flex-1 min-w-0 flex flex-col gap-1.5">
          <h3 className="m-0 text-[18px] sm:text-[20px] leading-[1.2]" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
            {item.headline}
          </h3>
          {item.summary && <p className="m-0 text-[13px]" style={{ color: 'var(--ss-text-2)' }}>{item.summary}</p>}
          {whyText && (
            <p className="m-0 text-[12px]" style={{ color: 'var(--ss-text-2)' }}>
              <span className="mr-1" style={{ fontFamily: TODAY_FONT_MONO, fontSize: 10, color: 'var(--ss-ink)', letterSpacing: '0.06em' }}>
                WHY IT SURFACED →
              </span>
              {whyText}
            </p>
          )}
        </div>
        <div className="sm:w-[170px] shrink-0 flex flex-row sm:flex-col gap-2">
          <button
            type="button"
            onClick={onPrimary}
            className="h-9 px-3 rounded-[3px] font-medium text-[13px] flex-1 sm:flex-none"
            style={{ background: 'var(--ss-ink)', color: 'var(--ss-ground)' }}
          >
            {primaryLabel}
          </button>
          {onSecondary && (
            <button
              type="button"
              onClick={onSecondary}
              className="h-9 px-3 rounded-[3px] text-[13px] flex-1 sm:flex-none"
              style={{ background: 'var(--ss-surface)', border: '1px solid var(--ss-border)', color: 'var(--ss-ink)' }}
            >
              {secondaryLabel}
            </button>
          )}
          <button
            type="button"
            onClick={onDismiss}
            className="h-9 px-3 text-[12px] flex-1 sm:flex-none"
            style={{ color: 'var(--ss-muted)' }}
          >
            NOT RELEVANT
          </button>
        </div>
      </div>
    </article>
  );
}

function TodayRadarSection({ kicker, title, description, count, children }) {
  return (
    <section className="flex flex-col gap-3">
      <div className="flex flex-wrap items-end justify-between gap-3 pb-2" style={{ borderBottom: '1px solid var(--ss-rule)' }}>
        <div className="flex flex-col gap-0.5">
          <TodayMonoLabel>{kicker}</TodayMonoLabel>
          <h3 className="m-0 text-[22px] sm:text-[26px]" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
            {title} · {count}
          </h3>
        </div>
        <p className="m-0 max-w-[320px] text-[12px] text-right" style={{ color: 'var(--ss-muted)' }}>{description}</p>
      </div>
      <div className="flex flex-col gap-3">{children}</div>
    </section>
  );
}

function TodayRadarSettings({ flagThreshold, onFlagThreshold, showUntracked, onShowUntracked, includeOld, onIncludeOld }) {
  return (
    <section className="rounded-[3px] p-4 flex flex-col gap-4" style={{ border: '1px solid var(--ss-border)', background: 'var(--ss-surface)' }}>
      <TodayMonoLabel>Radar settings</TodayMonoLabel>
      <div className="flex flex-col gap-1.5">
        <div className="flex items-center justify-between text-[13px]" style={{ color: 'var(--ss-ink)' }}>
          <label htmlFor="today-radar-threshold">Flag threshold</label>
          <span style={{ fontFamily: TODAY_FONT_MONO }}>{(flagThreshold / 100).toFixed(2)}</span>
        </div>
        <input
          id="today-radar-threshold"
          type="range"
          min={30}
          max={90}
          step={5}
          value={flagThreshold}
          onChange={(e) => onFlagThreshold(Number(e.target.value))}
          className="w-full"
          style={{ accentColor: 'var(--ss-accent)' }}
        />
      </div>
      <label className="flex items-center gap-2 text-[13px]" style={{ color: 'var(--ss-ink)' }}>
        <input type="checkbox" checked={showUntracked} onChange={(e) => onShowUntracked(e.target.checked)} />
        Show untracked companies
      </label>
      <label className="flex items-center gap-2 text-[13px]" style={{ color: 'var(--ss-ink)' }}>
        <input type="checkbox" checked={includeOld} onChange={(e) => onIncludeOld(e.target.checked)} />
        Include items older than 30 days
      </label>
      <p className="m-0 text-[11px]" style={{ color: 'var(--ss-muted)' }}>
        Not relevant hides an item from Today.
      </p>
    </section>
  );
}

// No savedAt/recipient/linked-signal/tone is persisted alongside an email
// draft today (emailDrafts[accountId] is just { subject, body } — see
// EmailComposer) — those four fields read "—" (or, for tone, are omitted
// entirely) rather than inventing a value. Lighting them up for real would
// mean EmailComposer writing a richer draft object (subject, body, tone,
// contactId, savedAt, and the newsId it was drafted from) instead of just
// { subject, body }.
function TodayDraftCard({ entry, contacts, onOpenDraft }) {
  if (!entry) return null;
  const { account, draft } = entry;
  const recipient = contacts.find((c) => c.accountId === account.id);
  const preview = (draft.body || '').slice(0, 160);
  return (
    <section className="rounded-[3px] overflow-hidden" style={{ background: 'var(--ss-ink)', color: 'var(--ss-ground)' }}>
      <div
        className="flex items-center justify-between px-4 py-2 text-[11px]"
        style={{ fontFamily: TODAY_FONT_MONO, color: 'rgba(244,240,230,0.7)', borderBottom: '1px solid rgba(255,255,255,0.15)' }}
      >
        <span className="font-medium uppercase tracking-[0.08em]">Draft</span>
        <span>saved —</span>
      </div>
      <div className="p-4 flex flex-col gap-3">
        <div className="text-[11px]" style={{ fontFamily: TODAY_FONT_MONO, color: 'rgba(244,240,230,0.7)' }}>
          TO {recipient ? recipient.name : '—'} · {account.name}
        </div>
        {draft.subject && (
          <div className="text-[20px] sm:text-[23px] leading-[1.15]" style={{ fontFamily: TODAY_FONT_SERIF }}>
            {draft.subject}
          </div>
        )}
        {preview && <p className="m-0 text-[14px] leading-[1.55]" style={{ color: 'rgba(244,240,230,0.85)' }}>{preview}{(draft.body || '').length > 160 ? '…' : ''}</p>}
        <div className="text-[11px]" style={{ fontFamily: TODAY_FONT_MONO, color: 'rgba(244,240,230,0.7)' }}>
          GROUNDED IN: —
        </div>
        <button
          type="button"
          onClick={() => onOpenDraft(account.id)}
          className="h-[34px] px-3 rounded-[3px] font-medium text-[13px] self-start"
          style={{ background: 'var(--ss-ground)', color: 'var(--ss-ink)' }}
        >
          Open draft
        </button>
      </div>
    </section>
  );
}

// TodayStatusBar now just feeds the shared StatusBar primitive (see
// docs/design brief) — Today was this bar's original, scoped home.
function TodayStatusBar({ sourceCount }) {
  const items = sourceCount != null ? [`${sourceCount} SOURCES`, 'EVERY CLAIM LINKS TO ITS SOURCE'] : ['EVERY CLAIM LINKS TO ITS SOURCE'];
  return <StatusBar items={items} right={<ShortcutsFooterHint />} className="px-6 sm:px-8 h-auto py-2" />;
}

// ---------------------------------------------------------------------------
// TodayPage — the landing page itself.
// ---------------------------------------------------------------------------
function TodayPage({
  accounts, activity, emailDrafts, contacts, fitGaps, liveNews, promotedVersion,
  onOpenAccount, onFocusSignal, onGoToOverview, onFlagForAccount, onAddAccounts,
}) {
  const [kindFilter, setKindFilter] = useState('ALL');
  const [query, setQuery] = useState('');
  const [savedIds, setSavedIds] = useState(() => new Set());
  const [dismissedIds, setDismissedIds] = useState(() => new Set());
  const [focusedId, setFocusedId] = useState(null);
  const searchRef = useRef(null);

  // Radar view state (added 2026-10-17) — view/threshold/toggle choices and
  // the not-relevant/saved-to-radar sets are remembered in session state
  // (TODAY_RADAR_SESSION_KEY) so they survive a tab switch within the same
  // load; see the comment above loadTodayRadarSession.
  const initialRadarSession = useMemo(() => loadTodayRadarSession() || {}, []);
  const [view, setView] = useState(initialRadarSession.view || 'radar');
  const [flagThreshold, setFlagThreshold] = useState(
    typeof initialRadarSession.flagThreshold === 'number' ? initialRadarSession.flagThreshold : FLAG_THRESHOLD
  );
  const [showUntracked, setShowUntracked] = useState(initialRadarSession.showUntracked !== false);
  const [includeOld, setIncludeOld] = useState(initialRadarSession.includeOld !== false);
  const [radarDismissedIds, setRadarDismissedIds] = useState(() => new Set(initialRadarSession.radarDismissedIds || []));
  const [savedToRadarIds, setSavedToRadarIds] = useState(() => new Set(initialRadarSession.savedToRadarIds || []));
  const [radarFocusedId, setRadarFocusedId] = useState(null);
  const [accountFilterId, setAccountFilterId] = useState(null);
  const [trackCompanyPrefill, setTrackCompanyPrefill] = useState(null); // item | null — AddAccountModal open when set

  useEffect(() => {
    saveTodayRadarSession({
      view,
      flagThreshold,
      showUntracked,
      includeOld,
      radarDismissedIds: Array.from(radarDismissedIds),
      savedToRadarIds: Array.from(savedToRadarIds),
    });
  }, [view, flagThreshold, showUntracked, includeOld, radarDismissedIds, savedToRadarIds]);

  // promotedVersion is in the dep list (unused by buildTodayQueue's own
  // params) purely to force a recompute on every promotion/manual flag —
  // see the .slice() comment above the radar's own `matches` useMemo for
  // why PROMOTED_MATCHES' stable object identity otherwise defeats this.
  const queueAll = useMemo(() => buildTodayQueue(accounts, fitGaps), [accounts, fitGaps, promotedVersion]);
  const queue = useMemo(() => {
    const q = query.trim().toLowerCase();
    return queueAll
      .filter((s) => !dismissedIds.has(s.newsId))
      .filter((s) => kindFilter === 'ALL' || s.kind === kindFilter)
      .filter((s) => {
        if (!q) return true;
        return (
          s.news.headline.toLowerCase().includes(q) ||
          s.account.name.toLowerCase().includes(q) ||
          todaySignalDisplayId(s.newsId).toLowerCase().includes(q)
        );
      });
  }, [queueAll, dismissedIds, kindFilter, query]);

  const kindCounts = useMemo(() => {
    const counts = { Funding: 0, Policy: 0, Market: 0 };
    queueAll.filter((s) => !dismissedIds.has(s.newsId)).forEach((s) => { counts[s.kind] += 1; });
    return counts;
  }, [queueAll, dismissedIds]);

  const cardSignals = queue.slice(0, Math.min(3, queue.length));
  const tableSignals = queue.slice(cardSignals.length);
  const focusOrder = useMemo(() => queue.map((s) => s.newsId), [queue]);

  function focus(newsId) {
    setFocusedId(newsId);
    const el = document.getElementById(`today-signal-${newsId}`);
    if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }
  function moveFocus(delta) {
    if (!focusOrder.length) return;
    const idx = focusedId ? focusOrder.indexOf(focusedId) : -1;
    const next = ((idx === -1 ? 0 : idx + delta) + focusOrder.length) % focusOrder.length;
    focus(focusOrder[next]);
  }
  const focusedSignal = queue.find((s) => s.newsId === focusedId) || null;

  function draftOutreach(signal) {
    onFocusSignal(signal.accountId, signal.newsId, signal.stage, !!(signal.gaps && signal.gaps.length > 0));
  }
  function toggleSave(newsId) {
    setSavedIds((prev) => {
      const next = new Set(prev);
      if (next.has(newsId)) next.delete(newsId);
      else next.add(newsId);
      return next;
    });
  }
  function dismiss(newsId) {
    setDismissedIds((prev) => new Set(prev).add(newsId));
    if (focusedId === newsId) setFocusedId(null);
  }

  function focusSearch() {
    if (searchRef.current) searchRef.current.focus();
  }

  useShortcut('j', () => moveFocus(1), { label: 'Focus next signal', group: 'Today', enabled: view !== 'radar' && focusOrder.length > 0 });
  useShortcut('k', () => moveFocus(-1), { label: 'Focus previous signal', group: 'Today', enabled: view !== 'radar' && focusOrder.length > 0 });
  useShortcut('e', () => focusedSignal && draftOutreach(focusedSignal), { label: 'See details for the focused signal', group: 'Today', enabled: view !== 'radar' && !!focusedSignal });
  useShortcut('s', () => focusedSignal && toggleSave(focusedSignal.newsId), { label: 'Save/unsave the focused signal', group: 'Today', enabled: view !== 'radar' && !!focusedSignal });
  useShortcut('mod+k', focusSearch, { label: 'Focus the search field', group: 'Today' });
  useShortcut('/', focusSearch, { label: 'Focus the search field', group: 'Today' });

  const followUp = useMemo(() => findTodayFollowUp(accounts, activity), [accounts, activity]);
  const draftEntry = useMemo(() => findTodayDraft(accounts, emailDrafts), [accounts, emailDrafts]);

  // ---------------------------------------------------------------------
  // Radar classification (added 2026-10-17) — see classifyForToday above.
  // `matches` re-derives on every promotion (promotedVersion bumps on every
  // "Add to Signal Map"/auto-match/manual flag) since PROMOTED_MATCHES is a
  // module-level array, not React state — see the long comment above
  // allNewsItems/allMatches. `.slice()` is load-bearing here: allMatches()
  // returns PROMOTED_MATCHES itself (mutated in place via .push, same
  // object identity every call), so without the copy this useMemo would
  // keep handing classified's own dependency array that exact same
  // reference on every render — React's Object.is dependency check would
  // then see "unchanged" and skip reclassifying even though promotedVersion
  // (and the array's contents) really did change.
  const matches = useMemo(() => allMatches().slice(), [promotedVersion]);
  const classified = useMemo(
    () => classifyForToday(liveNews, accounts, matches, { flagThreshold, dismissedIds: radarDismissedIds }),
    [liveNews, accounts, matches, flagThreshold, radarDismissedIds]
  );

  function sortRadarEntries(entries) {
    return entries.slice().sort((a, b) => {
      const confDiff = (b.confidence ?? -1) - (a.confidence ?? -1);
      if (confDiff !== 0) return confDiff;
      return (b.item.published || '').localeCompare(a.item.published || '');
    });
  }

  const nearMissAll = useMemo(() => sortRadarEntries(classified.nearMiss), [classified]);
  const nearMissDisplayed = useMemo(() => {
    return nearMissAll
      .filter((e) => includeOld || e.reason !== 'older_than_30d')
      .filter((e) => !accountFilterId || e.account.id === accountFilterId);
  }, [nearMissAll, includeOld, accountFilterId]);

  const lookalikeAll = useMemo(() => sortRadarEntries(classified.lookalike), [classified]);
  const lookalikeDisplayed = showUntracked ? lookalikeAll : [];

  const radarCountsByAccount = useMemo(() => {
    const map = new Map();
    nearMissAll.forEach((e) => map.set(e.account.id, (map.get(e.account.id) || 0) + 1));
    return map;
  }, [nearMissAll]);

  const radarFocusOrder = useMemo(
    () => [
      ...nearMissDisplayed.map((e) => ({ groupKind: 'nearMiss', entry: e })),
      ...lookalikeDisplayed.map((e) => ({ groupKind: 'lookalike', entry: e })),
    ],
    [nearMissDisplayed, lookalikeDisplayed]
  );
  const radarFocusedRow = radarFocusOrder.find((r) => r.entry.item.id === radarFocusedId) || null;

  function radarFocus(itemId) {
    setRadarFocusedId(itemId);
    const el = document.getElementById(`today-radar-${itemId}`);
    if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }
  function moveRadarFocus(delta) {
    if (!radarFocusOrder.length) return;
    const idx = radarFocusedId ? radarFocusOrder.findIndex((r) => r.entry.item.id === radarFocusedId) : -1;
    const next = ((idx === -1 ? 0 : idx + delta) + radarFocusOrder.length) % radarFocusOrder.length;
    radarFocus(radarFocusOrder[next].entry.item.id);
  }

  function flagNearMiss(entry) {
    onFlagForAccount(entry.item, entry.account.id, entry.confidence);
  }
  function trackCompany(entry) {
    setTrackCompanyPrefill(entry.item);
  }
  function saveToRadar(itemId) {
    setSavedToRadarIds((prev) => new Set(prev).add(itemId));
  }
  function notRelevantRadar(itemId) {
    setRadarDismissedIds((prev) => new Set(prev).add(itemId));
    if (radarFocusedId === itemId) setRadarFocusedId(null);
  }

  useShortcut('v', () => setView((v) => (v === 'flagged' ? 'radar' : v === 'radar' ? 'both' : 'flagged')), {
    label: 'Switch between Flagged / Radar / Both',
    group: 'Today',
  });
  useShortcut('j', () => moveRadarFocus(1), { label: 'Focus next radar card', group: 'Today', enabled: view === 'radar' && radarFocusOrder.length > 0 });
  useShortcut('k', () => moveRadarFocus(-1), { label: 'Focus previous radar card', group: 'Today', enabled: view === 'radar' && radarFocusOrder.length > 0 });
  useShortcut('f', () => radarFocusedRow && radarFocusedRow.groupKind === 'nearMiss' && flagNearMiss(radarFocusedRow.entry), {
    label: 'Flag the focused near miss for its account',
    group: 'Today',
    enabled: view === 'radar' && !!radarFocusedRow && radarFocusedRow.groupKind === 'nearMiss',
  });
  useShortcut('t', () => radarFocusedRow && radarFocusedRow.groupKind === 'lookalike' && trackCompany(radarFocusedRow.entry), {
    label: 'Track the focused lookalike company',
    group: 'Today',
    enabled: view === 'radar' && !!radarFocusedRow && radarFocusedRow.groupKind === 'lookalike',
  });
  useShortcut('x', () => {
    if (view === 'radar') {
      if (radarFocusedRow) notRelevantRadar(radarFocusedRow.entry.item.id);
    } else if (focusedSignal) {
      dismiss(focusedSignal.newsId);
    }
  }, {
    label: 'Mark the focused item not relevant',
    group: 'Today',
    enabled: view === 'radar' ? !!radarFocusedRow : !!focusedSignal,
  });

  const itemsSwept = liveNews.length;
  const flaggedCount = classified.flagged.length;
  const radarCount = nearMissAll.length + lookalikeAll.length;
  const relevantCount = flaggedCount + radarCount;

  return (
    <div
      className="ss-today -mx-6 rounded-[var(--ss-radius)]"
      data-theme="paper"
      style={{ fontFamily: TODAY_FONT_SANS, color: 'var(--ss-ink)' }}
    >
      <style>{`
        /* Tokens come from .gl-app (docs/design/tokens.css) now that the
           whole app shares this palette — Today was the original proof of
           it, so only its own background/focus rules stay local. */
        .ss-today {
          background-color: var(--ss-ground);
          background-image: linear-gradient(var(--ss-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ss-grid) 1px, transparent 1px);
          background-size: 32px 32px;
        }
        .ss-today :focus-visible { outline: 2px solid var(--ss-accent); outline-offset: 2px; }
      `}</style>
      <div className="max-w-[1280px] mx-auto px-4 sm:px-8 pb-2">
        <TodayHero flaggedCount={flaggedCount} radarCount={radarCount} view={view} onViewChange={setView} />
        <TodayFunnelStrip readByScout={itemsSwept} relevant={relevantCount} flagged={flaggedCount} radar={radarCount} />

        <div className="flex flex-wrap gap-10 py-8 sm:py-10">
          <div className="flex-[999_1_620px] min-w-0 flex flex-col gap-10">
            {(view === 'flagged' || view === 'both') && (
              <section id="today-queue" aria-labelledby="today-worth" className="flex flex-col gap-3.5">
                <div className="flex flex-wrap items-end justify-between gap-3 pb-2.5" style={{ borderBottom: '1px solid var(--ss-ink)' }}>
                  <div className="flex flex-col gap-0.5">
                    <TodayMonoLabel>01 — Signal queue · {queue.length} item{queue.length === 1 ? '' : 's'}</TodayMonoLabel>
                    <h2 id="today-worth" className="m-0 text-[28px] sm:text-[36px] leading-[1.05]" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
                      Worth your time
                    </h2>
                  </div>
                  <div className="flex flex-wrap items-center gap-2">
                    <label
                      className="flex items-center gap-2 h-[34px] px-2.5 rounded-[3px]"
                      style={{ border: '1px solid var(--ss-border)', background: 'var(--ss-surface)' }}
                    >
                      <Search size={13} style={{ color: 'var(--ss-muted)' }} aria-hidden="true" />
                      <span className="sr-only">Search signals and accounts</span>
                      <input
                        ref={searchRef}
                        type="search"
                        value={query}
                        onChange={(e) => setQuery(e.target.value)}
                        placeholder="Search signals, accounts, IDs"
                        aria-keyshortcuts={ariaKeyShortcuts('mod+k')}
                        title={`Search signals, accounts, IDs (${shortcutLabel('mod+k')} or ${shortcutLabel('/')})`}
                        className="bg-transparent outline-none text-[13px] w-[150px] sm:w-[190px]"
                        style={{ color: 'var(--ss-ink)' }}
                      />
                      <ShortcutHint keys="mod+k" />
                    </label>
                    <div className="flex rounded-[3px] overflow-hidden flex-wrap" style={{ border: '1px solid var(--ss-border)' }} role="group" aria-label="Filter by kind">
                    {['ALL', 'Funding', 'Policy', 'Market'].map((k) => {
                      const active = kindFilter === k;
                      const count = k === 'ALL' ? queueAll.length - dismissedIds.size : kindCounts[k];
                      return (
                        <button
                          key={k}
                          type="button"
                          aria-pressed={active}
                          onClick={() => setKindFilter(k)}
                          className="h-[34px] px-3 text-[11px]"
                          style={{
                            fontFamily: TODAY_FONT_MONO,
                            background: active ? 'var(--ss-ink)' : 'var(--ss-surface)',
                            color: active ? 'var(--ss-ground)' : 'var(--ss-text-2)',
                            borderLeft: k === 'ALL' ? 'none' : '1px solid var(--ss-border)',
                          }}
                        >
                          {k === 'ALL' ? 'ALL' : `${TODAY_KIND_GLYPH[k]} ${TODAY_KIND_SHORT[k]}`} {count}
                        </button>
                      );
                    })}
                    </div>
                  </div>
                </div>

                {queue.length === 0 ? (
                  <div className="py-10 flex flex-col gap-1.5">
                    <div className="text-[22px]" style={{ fontFamily: TODAY_FONT_SERIF, color: 'var(--ss-ink)' }}>
                      Nothing worth your time yet.
                    </div>
                    <p className="m-0 text-[13px]" style={{ color: 'var(--ss-muted)' }}>
                      {queueAll.length === 0
                        ? 'No signals have been matched to an account yet — add some from the Live feed.'
                        : 'Every matched signal is filtered out, dismissed, or already handled.'}
                    </p>
                  </div>
                ) : (
                  <>
                    {cardSignals.map((s, i) => (
                      <div key={s.newsId} id={`today-signal-${s.newsId}`}>
                        <TodaySignalCard
                          signal={s}
                          isTopPick={i === 0 && kindFilter === 'ALL' && !query}
                          state={{ saved: savedIds.has(s.newsId) }}
                          onDraft={() => draftOutreach(s)}
                          onToggleSave={() => toggleSave(s.newsId)}
                          onDismiss={() => dismiss(s.newsId)}
                          focused={view !== 'radar' && focusedId === s.newsId}
                          onFocus={() => setFocusedId(s.newsId)}
                        />
                      </div>
                    ))}
                    <TodayLowerPriorityTable
                      signals={tableSignals}
                      focusedId={view !== 'radar' ? focusedId : null}
                      onFocus={setFocusedId}
                      onOpen={(s) => onFocusSignal(s.accountId, s.newsId, s.stage, !!(s.gaps && s.gaps.length > 0))}
                    />
                  </>
                )}
              </section>
            )}

            {(view === 'radar' || view === 'both') && (
              <div className="flex flex-col gap-10">
                {accountFilterId && (
                  <div className="flex items-center gap-2 text-[12px]" style={{ color: 'var(--ss-text-2)' }}>
                    Filtered to {accounts.find((a) => a.id === accountFilterId)?.name || 'one account'}
                    <button type="button" onClick={() => setAccountFilterId(null)} className="underline" style={{ color: 'var(--ss-accent)' }}>
                      Clear
                    </button>
                  </div>
                )}
                <TodayRadarSection
                  kicker="A — About your accounts, not flagged"
                  title="Near misses"
                  description="Mentions an account you track, but scored under the flag threshold or is too old to rank."
                  count={nearMissDisplayed.length}
                >
                  {nearMissDisplayed.length === 0 ? (
                    <p className="m-0 py-3 text-[13px]" style={{ color: 'var(--ss-muted)' }}>
                      {nearMissAll.length === 0 ? 'Nothing nearby right now.' : 'Nothing matches the current radar settings.'}
                    </p>
                  ) : (
                    nearMissDisplayed.map((entry) => (
                      <TodayRadarCard
                        key={entry.item.id}
                        entry={entry}
                        reasonText={TODAY_REASON_LABEL[entry.reason]}
                        whyText={todayNearMissReason(entry)}
                        dashed={false}
                        focused={radarFocusedId === entry.item.id}
                        onFocus={() => setRadarFocusedId(entry.item.id)}
                        primaryLabel={`Flag for ${entry.account.name}`}
                        onPrimary={() => flagNearMiss(entry)}
                        secondaryLabel={null}
                        onSecondary={null}
                        onDismiss={() => notRelevantRadar(entry.item.id)}
                      />
                    ))
                  )}
                </TodayRadarSection>

                {showUntracked && (
                  <TodayRadarSection
                    kicker="B — Companies you don't track"
                    title="Lookalikes"
                    description={`Behaving like your accounts — same sector, same kind of move. ${savedToRadarIds.size} saved to radar.`}
                    count={lookalikeDisplayed.length}
                  >
                    {lookalikeDisplayed.length === 0 ? (
                      <p className="m-0 py-3 text-[13px]" style={{ color: 'var(--ss-muted)' }}>
                        No untracked companies look relevant right now.
                      </p>
                    ) : (
                      lookalikeDisplayed.map((entry) => (
                        <TodayRadarCard
                          key={entry.item.id}
                          entry={entry}
                          reasonText={TODAY_REASON_LABEL[entry.reason]}
                          whyText={todayLookalikeReason(entry, accounts)}
                          dashed
                          focused={radarFocusedId === entry.item.id}
                          onFocus={() => setRadarFocusedId(entry.item.id)}
                          primaryLabel="+ Track company"
                          onPrimary={() => trackCompany(entry)}
                          secondaryLabel={savedToRadarIds.has(entry.item.id) ? 'Saved' : 'Save to radar'}
                          onSecondary={() => saveToRadar(entry.item.id)}
                          onDismiss={() => notRelevantRadar(entry.item.id)}
                        />
                      ))
                    )}
                  </TodayRadarSection>
                )}
              </div>
            )}
          </div>

          <aside className="flex-[1_1_360px] min-w-0 flex flex-col gap-7">
            <TodaySparklinePlaceholder todayCount={flaggedCount} />
            <TodayCompactFlaggedList
              queue={queue}
              onOpen={(s) => onFocusSignal(s.accountId, s.newsId, s.stage, !!(s.gaps && s.gaps.length > 0))}
              onViewQueue={() => setView('flagged')}
            />
            <TodayAccountsPanel
              accounts={accounts}
              fitGaps={fitGaps}
              onOpenAccount={onOpenAccount}
              onViewAll={onGoToOverview}
              radarCountsByAccount={radarCountsByAccount}
              onFilterByAccount={(id) => { setView('radar'); setAccountFilterId(id); }}
            />
            <TodayFollowUpPanel followUp={followUp} onDraftCheckIn={onOpenAccount} />
            <TodayRadarSettings
              flagThreshold={flagThreshold}
              onFlagThreshold={setFlagThreshold}
              showUntracked={showUntracked}
              onShowUntracked={setShowUntracked}
              includeOld={includeOld}
              onIncludeOld={setIncludeOld}
            />
            <TodayDraftCard entry={draftEntry} contacts={contacts} onOpenDraft={onOpenAccount} />
          </aside>
        </div>
      </div>
      <TodayStatusBar sourceCount={null} />
      {trackCompanyPrefill && (
        <AddAccountModal
          accounts={accounts}
          onAddAccounts={onAddAccounts}
          onClose={() => setTrackCompanyPrefill(null)}
          initialValues={{
            prioritiesText: `Flagged from Live feed: "${trackCompanyPrefill.headline}" (source: ${trackCompanyPrefill.source || 'unknown'})`,
          }}
        />
      )}
    </div>
  );
}

function Overview({
  accounts, contacts, activity, plans, emailDrafts, generatingId, planErrors,
  research, onGenerate, onRecord, onUndo, onAddContact, onDraftChange, onUnlinkSignal, expandedId, onToggleExpand,
  groupBy, onGroupByChange, sortBySeverity, onSortBySeverityChange, onRunFullFitGaps, fitGapsBatchRunning,
  customGroups, onAddCustomGroup, onRemoveCustomGroup, accountGroups, onSetAccountGroup,
  onAddAccounts, onUpdateAccount, onOpenAccount, fitGaps, fitGapGeneratingId, onCheckFitGaps, fitChangeNotices,
  archivedAccounts = {},
}) {
  const groups = groupAccounts(accounts, groupBy, accountGroups, customGroups, fitGaps, sortBySeverity);
  const hasAnyMatches = accounts.some((a) => accountMatches(a.id).length > 0);
  const [addAccountOpen, setAddAccountOpen] = useState(false);
  const [manageOpen, setManageOpen] = useState(false);
  const [manageFocusId, setManageFocusId] = useState(null);

  // Keyboard row focus (2026-10-14) — j/k walk the rows, Enter opens one, f
  // checks its fit & gaps. The order has to be the *visual* one, which isn't
  // just `accounts`: the render below re-splits each group by tier in the
  // ungrouped/by-tier cases (see renderRow's two call sites), so this
  // mirrors exactly that traversal rather than assuming a flat list.
  const flatOrder = [];
  groups.forEach((g) => {
    if (groupBy === 'none' || groupBy === 'tier') {
      TIER_ORDER.filter((tier) => g.accounts.some((a) => a.tier === tier)).forEach((tier) => {
        g.accounts.filter((a) => a.tier === tier).forEach((a) => flatOrder.push(a.id));
      });
    } else {
      g.accounts.forEach((a) => flatOrder.push(a.id));
    }
  });
  // Mouse clicks and Tab focus write to the same state (see OpportunityRow's
  // onFocusRow), so there's only ever one notion of "the focused row".
  const [focusedRowId, setFocusedRowId] = useState(null);
  const focusedExists = focusedRowId !== null && flatOrder.indexOf(focusedRowId) !== -1;
  useEffect(() => {
    // The focused account was archived/filtered out from under the cursor.
    if (focusedRowId !== null && flatOrder.indexOf(focusedRowId) === -1) setFocusedRowId(null);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [flatOrder.join(',')]);

  // Functional update so a fast double-press (two keydowns before React has
  // re-rendered) moves two rows, not one.
  function moveRowFocus(delta) {
    if (!flatOrder.length) return;
    setFocusedRowId((prev) => {
      const i = flatOrder.indexOf(prev);
      if (i === -1) return delta > 0 ? flatOrder[0] : flatOrder[flatOrder.length - 1];
      return flatOrder[Math.min(flatOrder.length - 1, Math.max(0, i + delta))];
    });
  }

  const focusedAccount = focusedExists ? accounts.find((a) => a.id === focusedRowId) : null;
  const focusedHasMatches = !!focusedAccount && accountMatches(focusedAccount.id).length > 0;

  useShortcut('j', () => moveRowFocus(1), { label: 'Focus next account', group: 'Overview', enabled: flatOrder.length > 0 });
  useShortcut('k', () => moveRowFocus(-1), { label: 'Focus previous account', group: 'Overview', enabled: flatOrder.length > 0 });
  useShortcut('Enter', () => focusedAccount && onOpenAccount(focusedAccount.id), {
    label: 'Open the focused account',
    group: 'Overview',
    enabled: !!focusedAccount,
  });
  useShortcut('n', () => setAddAccountOpen(true), { label: 'Add account', group: 'Overview' });
  useShortcut('f', () => focusedAccount && onCheckFitGaps(focusedAccount), {
    label: 'Check fit & gaps for the focused account',
    group: 'Overview',
    enabled: focusedHasMatches && fitGapGeneratingId !== focusedRowId,
  });

  function openManage(focusId) {
    setManageFocusId(focusId || null);
    setManageOpen(true);
  }

  // "Full fit & gaps analysis" done toast — same self-dismissing pattern as
  // AccountPage's actionsToast (a 4s timer, cleared on unmount, replaced
  // rather than queued on a re-click). onRunFullFitGaps (App's
  // runFullFitGapsAnalysis) resolves with { checked, failed, total } once
  // every account in the batch has been checked; this just surfaces that
  // summary right on the button so there's confirmation the run actually
  // finished, not just that it started.
  const [fitGapsToast, setFitGapsToast] = useState(null);
  const fitGapsToastTimer = useRef(null);
  useEffect(() => () => window.clearTimeout(fitGapsToastTimer.current), []);

  async function runFullAnalysisWithToast() {
    const result = await onRunFullFitGaps();
    if (!result || result.total === 0) return; // nothing ran (already in flight, or no matched accounts) — no toast
    window.clearTimeout(fitGapsToastTimer.current);
    setFitGapsToast(result);
    fitGapsToastTimer.current = window.setTimeout(() => setFitGapsToast(null), 4000);
  }

  return (
    <div>
      <p className="text-xs text-[var(--ss-muted)] mb-4">
        Every account on one timeline, marked by tier. Each row shows the next action; confirm it's done to move the account forward.
      </p>

      <CrossAccountGapsPanel fitGaps={fitGaps} accounts={accounts} />

      <div className="flex items-center justify-between gap-4 mb-5 flex-wrap">
        <div className="flex items-center gap-4 flex-wrap">
          <div className="flex items-center gap-1.5">
            {TIER_ORDER.map((tier) => (
              <TierMarker key={tier} tier={tier} />
            ))}
          </div>
          <PhaseLegend />
        </div>
        <div className="flex items-center gap-2">
          <Button
            variant="secondary"
            size="sm"
            icon={Plus}
            onClick={() => setAddAccountOpen(true)}
            {...shortcutButtonProps('n', 'Add account')}
          >
            Add account
            <ShortcutHint keys="n" />
          </Button>
          <Button variant="secondary" size="sm" icon={Pencil} onClick={() => openManage(null)}>
            Manage accounts
          </Button>
          <GroupByMenu value={groupBy} onChange={onGroupByChange} />
          <Button
            variant="secondary"
            size="sm"
            icon={AlertTriangle}
            onClick={() => onSortBySeverityChange(!sortBySeverity)}
            aria-pressed={sortBySeverity}
            title="Reorder accounts within each group by their worst checked gap — Blocker first, then Coverable, then clean; never-checked accounts sink to the bottom."
            className={sortBySeverity ? 'border-[var(--ss-danger)] text-[var(--ss-danger)] bg-[var(--ss-danger-wash)]' : ''}
          >
            Sort by gap severity
          </Button>
          <div className="relative">
            <Button
              variant="secondary"
              size="sm"
              icon={fitGapsBatchRunning ? undefined : Crosshair}
              onClick={runFullAnalysisWithToast}
              disabled={fitGapsBatchRunning || !hasAnyMatches}
              title={hasAnyMatches ? 'Run "Check fit & gaps" across every account with a matched signal, one at a time.' : 'No account has a matched signal yet — nothing to check.'}
            >
              {fitGapsBatchRunning && <Loader2 size={13} className="animate-spin" />}
              {fitGapsBatchRunning ? 'Checking all accounts…' : 'Full fit & gaps analysis'}
            </Button>
            {fitGapsToast && (
              <div
                className={`absolute right-0 mt-1.5 z-10 whitespace-nowrap rounded-[var(--ss-radius)] border text-[12px] px-3 py-2 flex items-center gap-1.5 ${
                  fitGapsToast.failed > 0
                    ? 'border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)] text-[var(--ss-gap)]'
                    : 'border-[var(--ss-positive)]/40 bg-[var(--ss-positive-wash)] text-[var(--ss-positive)]'
                }`}
              >
                <CheckCircle2 size={13} className="shrink-0" />
                {fitGapsToast.failed > 0
                  ? `Checked ${fitGapsToast.checked} of ${fitGapsToast.total} — ${fitGapsToast.failed} failed.`
                  : `Done — checked fit & gaps for ${fitGapsToast.checked} account${fitGapsToast.checked === 1 ? '' : 's'}.`}
              </div>
            )}
          </div>
        </div>
      </div>

      {manageOpen && (
        <ManageAccountsModal
          accounts={accounts}
          focusId={manageFocusId}
          onUpdate={onUpdateAccount}
          onClose={() => setManageOpen(false)}
        />
      )}

      {addAccountOpen && (
        <AddAccountModal accounts={accounts} onAddAccounts={onAddAccounts} onClose={() => setAddAccountOpen(false)} />
      )}

      {groupBy === 'custom' && (
        <CustomGroupManager groups={customGroups} onAdd={onAddCustomGroup} onRemove={onRemoveCustomGroup} />
      )}

      <div>
        {/* Used to be a hard `minWidth: 760` + overflow-x-auto — below that
            width the row just triggered a horizontal scrollbar rather than
            actually reflowing, and name/Gantt/next-step/headline text could
            still crowd each other inside that fixed-width box well before
            760px of *window* width was available (padding, scrollbars,
            etc. eat into it). Replaced with a real responsive grid (see
            OpportunityRow): one column — name block, then the Gantt
            tracker below it — under `sm`, the original 200px/1fr side-by-
            side layout at `sm` and up. This header row mirrors that (hidden
            below `sm`, where each row already shows its own stage label
            inline, so a separate unaligned header would be redundant). */}
          <div className="hidden sm:grid mb-2 gap-4" style={{ gridTemplateColumns: '200px 1fr' }}>
            <div />
            <div className="pb-2 border-b border-[var(--ss-border)]">
              <GanttHeader />
            </div>
          </div>

          {(() => {
            // Shared across both branches below — `tierIndex` (1/2/3) is only
            // ever passed from TierLanes, for a row rendered inside a lane;
            // every other grouping leaves it undefined, so OpportunityRow
            // falls back to its normal (non-lane) look, tier marker included.
            const renderRow = (a, tierIndex) => (
              <OpportunityRow
                key={a.id}
                account={a}
                tierIndex={tierIndex}
                contacts={contacts.filter((c) => c.accountId === a.id)}
                activity={activity[a.id] || []}
                plan={plans[a.id]}
                draft={emailDrafts[a.id]}
                generating={generatingId === a.id}
                error={planErrors[a.id]}
                onGenerate={() => onGenerate(a)}
                research={buildResearchProps(a, research)}
                onRecord={(entry) => onRecord(a.id, entry)}
                onUndo={() => onUndo(a.id)}
                onAddContact={(c) => onAddContact({ ...c, accountId: a.id })}
                onDraftChange={(d) => onDraftChange(a.id, d)}
                onUnlinkSignal={(newsId) => onUnlinkSignal(newsId, a.id)}
                onOpenAccount={() => onOpenAccount(a.id)}
                expanded={expandedId === a.id}
                onToggle={() => onToggleExpand(a.id)}
                groupBy={groupBy}
                customGroups={customGroups}
                accountGroup={accountGroups[a.id] || ''}
                onSetAccountGroup={(name) => onSetAccountGroup(a.id, name)}
                fitGap={fitGaps[a.id]}
                fitGapGenerating={fitGapGeneratingId === a.id}
                onCheckFitGaps={() => onCheckFitGaps(a)}
                fitChanged={!!fitChangeNotices[a.id]}
                archived={!!archivedAccounts[a.id]}
                focused={focusedRowId === a.id}
                onFocusRow={() => setFocusedRowId(a.id)}
              />
            );

            // "Group by: Tier" is the one case that gets the framed tier-
            // lane layout (design restyle, added 2026-10-16) — every other
            // grouping (none, stage, sector, custom) stays the plain flat
            // list it always was, with each row's own TierMarker carrying
            // tier identity instead of a lane.
            if (groupBy === 'tier') {
              const lanes = TIER_ORDER.map((tier) => {
                const g = groups.find((gr) => gr.key === tier);
                return { tier, accounts: g ? g.accounts : [] };
              });
              return <TierLanes lanes={lanes} renderRow={renderRow} />;
            }

            return groups.map((g) => (
              <div key={g.key ?? '__all'} className="mb-5 last:mb-0">
                {g.label && (
                  <div className="flex items-center gap-2 mb-2">
                    <span className="text-[11px] font-mono uppercase tracking-wide text-[var(--ss-text-2)]">{g.label}</span>
                    <span className="text-[10px] text-[var(--ss-muted)]">{g.accounts.length}</span>
                    <div className="flex-1 border-t border-[var(--ss-border)]" />
                  </div>
                )}
                {g.accounts.length === 0 ? (
                  <div className="text-[12px] text-[var(--ss-muted)] italic pb-1">No accounts in this group yet — assign one below.</div>
                ) : (
                  <div className="space-y-2">{g.accounts.map((a) => renderRow(a))}</div>
                )}
              </div>
            ));
          })()}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// TIER LANES (design restyle, added 2026-10-16) — the Overview's "Group by:
// Tier" layout: one framed box, one lane per tier (ink/rust-wash/beige,
// lightest to least-priority), each lane showing its own meaning (kicker,
// name, one-line description, research depth) next to its accounts, whose
// rows sit on that tier's own paper tint. Replaces the old sub-banded
// sections (a muted heading + a tinted block per tier) that both the
// ungrouped and "Group by: Tier" views used to share — ungrouped no longer
// bands by tier at all (see Overview's renderRow branch above), so this is
// now the only place tier shows up as a structural lane rather than a marker.
// ---------------------------------------------------------------------------

function TierLanes({ lanes, renderRow }) {
  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-frame)] overflow-hidden">
      {lanes.map((lane, i) => (
        <TierLane key={lane.tier} lane={lane} index={i + 1} isLast={i === lanes.length - 1} renderRow={renderRow} />
      ))}
    </div>
  );
}

function TierLane({ lane, index, isLast, renderRow }) {
  const { tier, accounts } = lane;
  const meta = TIER_META[tier];
  const depth = RESEARCH_DEPTH[tier];
  return (
    <div className={`grid grid-cols-1 min-[900px]:grid-cols-[200px_1fr] ${isLast ? '' : 'border-b border-[var(--ss-frame)]'}`}>
      <div
        className="flex flex-col gap-1.5 p-[18px] border-b min-[900px]:border-b-0 min-[900px]:border-r border-[var(--ss-frame)]"
        style={{ background: `var(--ss-lane-${index}-bg)`, color: `var(--ss-lane-${index}-ink)` }}
      >
        <div className="flex items-baseline gap-2 flex-wrap min-[900px]:block min-[900px]:gap-0">
          <div className="font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.08em] opacity-80">
            TIER {index} · [{accounts.length}]
          </div>
          <div className="font-[var(--ss-font-display)] text-[28px] min-[900px]:text-[36px] leading-none min-[900px]:mt-1">
            {tier}
          </div>
        </div>
        <p className="text-[13px] leading-[1.4] min-[900px]:mt-1">{meta.description}</p>
        {depth && (
          <div className="hidden min-[900px]:block mt-auto font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.05em] pt-3 opacity-90">
            {meta.depthLabel} · {depth.questions} Q
          </div>
        )}
      </div>
      <div className="min-w-0" style={{ background: `var(--ss-row-${index}-bg)` }}>
        {accounts.length === 0 ? (
          <div className="px-4 py-3 text-[12px] text-[var(--ss-muted)] italic">No {tier} accounts yet.</div>
        ) : (
          <div className="divide-y divide-[var(--ss-rule-soft)]">
            {accounts.map((a) => (
              <div key={a.id} className="px-4">
                {renderRow(a, index)}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// GROUP BY — a small dropdown for the Overview tab's grouping control, plus
// the "custom groups" manager shown only in that mode.
// ---------------------------------------------------------------------------

function GroupByMenu({ value, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  const current = GROUP_OPTIONS.find((o) => o.key === value) || GROUP_OPTIONS[0];

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[12px] text-[var(--ss-text-2)] px-2.5 py-1.5 hover:border-[var(--ss-border)] transition-colors"
      >
        <Layers size={13} className="text-[var(--ss-muted)]" />
        Group by: <span className="text-[var(--ss-ink)]">{current.label}</span>
        <ChevronDown size={12} className="text-[var(--ss-muted)]" />
      </button>
      {open && (
        <div className="absolute right-0 mt-1.5 z-10 w-52 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] py-1">
          {GROUP_OPTIONS.map((o) => (
            <button
              key={o.key}
              type="button"
              onClick={() => {
                onChange(o.key);
                setOpen(false);
              }}
              className={`w-full text-left text-[12px] px-3 py-1.5 hover:bg-[var(--ss-rule-soft)] transition-colors ${
                o.key === value ? 'text-[var(--ss-accent)]' : 'text-[var(--ss-text-2)]'
              }`}
            >
              {o.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function CustomGroupManager({ groups, onAdd, onRemove }) {
  const [draft, setDraft] = useState('');

  function add() {
    const name = draft.trim();
    if (!name || groups.includes(name)) return;
    onAdd(name);
    setDraft('');
  }

  return (
    <div className="flex items-center gap-2 flex-wrap mb-4 p-3 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)]">
      <span className="text-[11px] font-mono uppercase tracking-wide text-[var(--ss-muted)] shrink-0">Your groups</span>
      {groups.length === 0 && <span className="text-[12px] text-[var(--ss-muted)]">None yet — add one, then assign accounts to it below.</span>}
      {groups.map((g) => (
        <span key={g} className="flex items-center gap-1 text-[11px] pl-2.5 pr-1.5 py-1 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-text-2)]">
          {g}
          <button type="button" onClick={() => onRemove(g)} className="text-[var(--ss-muted)] hover:text-[var(--ss-danger)] p-0.5" aria-label={`Remove group ${g}`}>
            <X size={11} />
          </button>
        </span>
      ))}
      <input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') add();
        }}
        placeholder="New group name"
        className="text-[12px] bg-[var(--ss-ground)] border border-[var(--ss-border)] rounded-[var(--ss-radius)] px-2 py-1 text-[var(--ss-ink)] placeholder:text-[var(--ss-muted)] focus:outline-none focus:border-[var(--ss-frame)] focus:ring-2 focus:ring-[var(--ss-border)]"
        style={{ width: 150 }}
      />
      <button type="button" onClick={add} disabled={!draft.trim()} className="flex items-center gap-1 text-[11px] text-[var(--ss-positive)] hover:text-[var(--ss-positive)] disabled:opacity-40">
        <Plus size={12} /> Add
      </button>
    </div>
  );
}

// ---------------------------------------------------------------------------
// ADD ACCOUNT (added 2026-10-04) — the only way into `accounts` from the UI
// so far (see claude.md §7.1): one at a time via a short form, or in bulk
// from a CSV. Every new account starts at stage 'Identified' with no
// activity/contacts/matches, same rule as every seeded account (§3.0) —
// never fabricate pipeline history for a company someone just typed in.
// ---------------------------------------------------------------------------

// Minimal RFC4180-ish CSV tokenizer — quoted fields (embedded commas,
// escaped "" quotes) and bare fields, \n or \r\n line endings. This is an
// import for small account lists, not a general CSV library, so it's
// hand-rolled rather than a new dependency — same zero-install-step rule
// the PDF writer and manual-intake file reading already follow (§5.4/§5.5).
function parseCSVRows(text) {
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;
  const pushField = () => { row.push(field); field = ''; };
  const pushRow = () => { pushField(); rows.push(row); row = []; };
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"') {
        if (text[i + 1] === '"') { field += '"'; i += 1; } else inQuotes = false;
      } else field += c;
    } else if (c === '"') inQuotes = true;
    else if (c === ',') pushField();
    else if (c === '\n') pushRow();
    else if (c === '\r') { /* ignore — \n handles the line break */ }
    else field += c;
  }
  if (field !== '' || row.length) pushRow();
  return rows.filter((r) => !(r.length === 1 && r[0].trim() === ''));
}

// Expected columns: name (required), sector, tier (Strategic/Growth/
// Prospect — anything else falls back to Prospect), priorities and aliases
// (both semicolon-separated, since commas are already the field delimiter).
// `aliases` is the spelling variants Live feed matching should also treat
// as this account — e.g. "Orsted" without the Ø, or "CIP" for an account
// named "CIP (Copenhagen Infrastructure Partners)" — see accounts' comment
// and matchingAccountsForLiveItem (§7.1). Column order doesn't matter and
// unknown columns are ignored. A row with no name is skipped, not fatal to
// the rest of the import.
function parseAccountsCSV(text) {
  const rows = parseCSVRows(text);
  if (rows.length === 0) throw new Error('That file is empty.');
  const header = rows[0].map((h) => h.trim().toLowerCase());
  const nameIdx = header.indexOf('name');
  if (nameIdx === -1) throw new Error('The CSV needs a "name" column.');
  const sectorIdx = header.indexOf('sector');
  const tierIdx = header.indexOf('tier');
  const prioritiesIdx = header.indexOf('priorities');
  const aliasesIdx = header.indexOf('aliases');
  const accountManagerIdx = header.indexOf('accountmanager') !== -1 ? header.indexOf('accountmanager') : header.indexOf('account manager');

  const accounts = [];
  let skipped = 0;
  rows.slice(1).forEach((row) => {
    const name = (row[nameIdx] || '').trim();
    if (!name) {
      skipped += 1;
      return;
    }
    const tierRaw = tierIdx !== -1 ? (row[tierIdx] || '').trim() : '';
    accounts.push({
      name,
      sector: sectorIdx !== -1 ? (row[sectorIdx] || '').trim() : '',
      tier: TIER_ORDER.includes(tierRaw) ? tierRaw : 'Prospect',
      priorities: prioritiesIdx !== -1
        ? (row[prioritiesIdx] || '').split(';').map((p) => p.trim()).filter(Boolean)
        : [],
      aliases: aliasesIdx !== -1
        ? (row[aliasesIdx] || '').split(';').map((a) => a.trim()).filter(Boolean)
        : [],
      accountManager: accountManagerIdx !== -1 ? (row[accountManagerIdx] || '').trim() : '',
    });
  });
  return { accounts, skipped };
}

function AddAccountModal({ accounts, onAddAccounts, onClose, initialValues = null }) {
  useEscapeToClose(onClose);
  const [mode, setMode] = useState('one'); // 'one' | 'upload'
  const fileInputRef = useRef(null);

  const [name, setName] = useState((initialValues && initialValues.name) || '');
  const [sector, setSector] = useState((initialValues && initialValues.sector) || '');
  const [tier, setTier] = useState('Prospect');
  const [prioritiesText, setPrioritiesText] = useState((initialValues && initialValues.prioritiesText) || '');
  const [aliasesText, setAliasesText] = useState('');
  const [accountManager, setAccountManager] = useState('');
  const [oneResult, setOneResult] = useState(null); // {added, duplicates} once submitted

  const [parsed, setParsed] = useState(null); // {accounts, skipped} | null
  const [parseError, setParseError] = useState(null);
  const [uploadResult, setUploadResult] = useState(null);

  function submitOne() {
    const trimmed = name.trim();
    if (!trimmed) return;
    const priorities = prioritiesText.split('\n').map((p) => p.trim()).filter(Boolean);
    const aliases = aliasesText.split(',').map((a) => a.trim()).filter(Boolean);
    const result = onAddAccounts([{ name: trimmed, sector: sector.trim(), tier, priorities, aliases, accountManager: accountManager.trim() }]);
    if (result.added > 0) {
      onClose();
    } else {
      setOneResult(result);
    }
  }

  async function handleFile(e) {
    const file = e.target.files && e.target.files[0];
    e.target.value = ''; // lets the same file be re-selected after a fix
    if (!file) return;
    setParsed(null);
    setParseError(null);
    setUploadResult(null);
    if (!/\.csv$/i.test(file.name) && file.type !== 'text/csv') {
      setParseError(`This prototype only reads CSV files directly — "${file.name}" isn't one. Export your spreadsheet to CSV first.`);
      return;
    }
    try {
      setParsed(parseAccountsCSV(await file.text()));
    } catch (err) {
      setParseError(err.message || "Couldn't read that file.");
    }
  }

  function confirmImport() {
    if (!parsed || parsed.accounts.length === 0) return;
    setUploadResult(onAddAccounts(parsed.accounts));
    setParsed(null);
  }

  const existingNames = new Set(accounts.map((a) => a.name.trim().toLowerCase()));

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
      <div
        className="w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-5 space-y-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-start justify-between gap-3">
          <div className="text-sm text-[var(--ss-ink)] font-medium">Add account</div>
          <button onClick={onClose} aria-label="Close" className="text-[var(--ss-muted)] hover:text-[var(--ss-ink)] shrink-0 -m-1.5 p-1.5">
            <X size={16} />
          </button>
        </div>

        <div className="flex items-center gap-1.5">
          <TogglePill active={mode === 'one'} onClick={() => setMode('one')}>Add one</TogglePill>
          <TogglePill active={mode === 'upload'} onClick={() => setMode('upload')}>Upload a list</TogglePill>
        </div>

        {mode === 'one' ? (
          <div className="space-y-3">
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Company name</span>
              <input
                value={name}
                onChange={(e) => { setName(e.target.value); setOneResult(null); }}
                className={inputClass}
                placeholder="e.g. Equinor"
              />
            </label>
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Company type / sector</span>
              <input value={sector} onChange={(e) => setSector(e.target.value)} className={inputClass} placeholder="e.g. Offshore wind developer" />
            </label>
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Tier</span>
              <select value={tier} onChange={(e) => setTier(e.target.value)} className={inputClass}>
                {TIER_ORDER.map((t) => (
                  <option key={t} value={t}>{t}</option>
                ))}
              </select>
            </label>
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Account manager (optional)</span>
              <input
                value={accountManager}
                onChange={(e) => setAccountManager(e.target.value)}
                className={inputClass}
                placeholder="e.g. Priya Shah"
              />
            </label>
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Stated priorities (one per line, optional)</span>
              <textarea value={prioritiesText} onChange={(e) => setPrioritiesText(e.target.value)} rows={3} className={inputClass} />
            </label>
            <label className="block">
              <span className="text-[10px] text-[var(--ss-muted)]">Alternate spellings Live feed should also match (comma-separated, optional)</span>
              <input
                value={aliasesText}
                onChange={(e) => setAliasesText(e.target.value)}
                className={inputClass}
                placeholder="e.g. Equinor ASA, Equinor Renewables"
              />
            </label>
            {oneResult && oneResult.duplicates > 0 && (
              <p className="text-[12px] text-[var(--ss-danger)]">Already have an account named "{name.trim()}" — no changes made.</p>
            )}
            <div className="flex items-center gap-2 pt-1">
              <button
                onClick={submitOne}
                disabled={!name.trim()}
                className="rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-medium px-3 py-1.5 hover:brightness-110 disabled:opacity-40"
              >
                Add account
              </button>
              <button onClick={onClose} className="text-[12px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] px-2 py-1.5">
                Cancel
              </button>
            </div>
          </div>
        ) : (
          <div className="space-y-3">
            <p className="text-[11px] text-[var(--ss-muted)]">
              Columns: <span className="text-[var(--ss-text-2)]">name</span> (required), <span className="text-[var(--ss-text-2)]">sector</span>,{' '}
              <span className="text-[var(--ss-text-2)]">tier</span> (Strategic/Growth/Prospect), <span className="text-[var(--ss-text-2)]">accountManager</span>,{' '}
              <span className="text-[var(--ss-text-2)]">priorities</span> and <span className="text-[var(--ss-text-2)]">aliases</span> (both separate multiple with{' '}
              <span className="text-[var(--ss-text-2)]">;</span> — aliases are alternate spellings Live feed matching should also recognize). Extra columns are
              ignored. Excel files aren't parsed directly — export to CSV first.
            </p>
            <input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={handleFile} className="hidden" />
            <button
              onClick={() => fileInputRef.current && fileInputRef.current.click()}
              className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-3 py-1.5 hover:border-[var(--ss-frame)]"
            >
              <Upload size={13} /> Choose CSV file
            </button>

            {parseError && <p className="text-[12px] text-[var(--ss-danger)]">{parseError}</p>}

            {parsed && (
              <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] p-3 space-y-2">
                <p className="text-[12px] text-[var(--ss-text-2)]">
                  {parsed.accounts.length} account{parsed.accounts.length === 1 ? '' : 's'} found
                  {parsed.skipped > 0 && `, ${parsed.skipped} row${parsed.skipped === 1 ? '' : 's'} skipped (no name)`}.
                </p>
                <div className="flex flex-wrap gap-1.5">
                  {parsed.accounts.map((a, i) => (
                    <span
                      key={`${a.name}-${i}`}
                      className={`text-[11px] px-2 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] ${
                        existingNames.has(a.name.toLowerCase()) ? 'text-[var(--ss-danger)]' : 'text-[var(--ss-text-2)]'
                      }`}
                    >
                      {a.name}
                      {existingNames.has(a.name.toLowerCase()) ? ' (already exists)' : ''}
                    </span>
                  ))}
                </div>
                <button
                  onClick={confirmImport}
                  className="rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-medium px-3 py-1.5 hover:brightness-110"
                >
                  Import {parsed.accounts.length} account{parsed.accounts.length === 1 ? '' : 's'}
                </button>
              </div>
            )}

            {uploadResult && (
              <p className="text-[12px] text-[var(--ss-positive)]">
                Added {uploadResult.added} account{uploadResult.added === 1 ? '' : 's'}
                {uploadResult.duplicates > 0 && `, skipped ${uploadResult.duplicates} that already existed`}.
              </p>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// MANAGE ACCOUNTS — a popup (not a route) for editing accounts that already
// exist: rename, reclassify tier/company type, and update stated priorities
// or the alternate spellings Live feed matching uses. Deliberately separate
// from "Add account" (adding is its own flow above); this one only patches
// accounts already in the list. Opened from the Overview header ("Manage
// accounts", no particular account expanded) or from a row's own "Edit"
// link (opens with that account already expanded) — both funnel through
// Overview's openManage(focusId).
// ---------------------------------------------------------------------------

function ManageAccountsModal({ accounts, focusId, onUpdate, onClose }) {
  useEscapeToClose(onClose);
  const [expandedId, setExpandedId] = useState(focusId || null);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
      <div
        className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-5 space-y-3"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-start justify-between gap-3">
          <div>
            <div className="text-sm text-[var(--ss-ink)] font-medium">Manage accounts</div>
            <div className="text-[11px] text-[var(--ss-muted)]">Click a company to edit its name, type, tier, stated priorities, or alternate spellings.</div>
          </div>
          <button onClick={onClose} aria-label="Close" className="text-[var(--ss-muted)] hover:text-[var(--ss-ink)] shrink-0 -m-1.5 p-1.5">
            <X size={16} />
          </button>
        </div>

        <div className="space-y-2">
          {accounts.map((a) => (
            <AccountManageCard
              key={a.id}
              account={a}
              accounts={accounts}
              expanded={expandedId === a.id}
              onToggle={() => setExpandedId((prev) => (prev === a.id ? null : a.id))}
              onUpdate={(updates) => onUpdate(a.id, updates)}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

// One account's card: collapsed shows name/sector/tier; expanded turns into
// an edit form. Edits are a local draft (reset fresh on every expand) and
// only reach `onUpdate` — real App state — on "Save changes", same "nothing
// changes without an explicit action" rule as the rest of the app.
function AccountManageCard({ account, accounts, expanded, onToggle, onUpdate }) {
  const [draft, setDraft] = useState(null);
  const [error, setError] = useState(null);
  const [saved, setSaved] = useState(false);

  useEffect(() => {
    if (expanded) {
      setDraft({
        name: account.name,
        sector: account.sector,
        tier: account.tier,
        accountManager: account.accountManager || '',
        prioritiesText: account.priorities.join('\n'),
        aliasesText: account.aliases.join(', '),
      });
      setError(null);
      setSaved(false);
    } else {
      setDraft(null);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [expanded]);

  function save() {
    const trimmedName = draft.name.trim();
    if (!trimmedName) {
      setError('Company name is required.');
      return;
    }
    const clash = accounts.some((a) => a.id !== account.id && a.name.trim().toLowerCase() === trimmedName.toLowerCase());
    if (clash) {
      setError(`Another account is already named "${trimmedName}".`);
      return;
    }
    const priorities = draft.prioritiesText.split('\n').map((p) => p.trim()).filter(Boolean);
    const aliases = draft.aliasesText.split(',').map((al) => al.trim()).filter(Boolean);
    onUpdate({
      name: trimmedName,
      sector: draft.sector.trim(),
      tier: draft.tier,
      accountManager: draft.accountManager.trim(),
      priorities,
      aliases: aliases.length ? aliases : [trimmedName],
    });
    setError(null);
    setSaved(true);
  }

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)]">
      <button
        onClick={onToggle}
        className="w-full flex items-center justify-between gap-3 px-3.5 py-2.5 text-left hover:bg-[var(--ss-surface)] rounded-[var(--ss-radius)] transition-colors"
      >
        <div className="flex items-center gap-2 min-w-0">
          {expanded ? (
            <ChevronDown size={13} className="text-[var(--ss-muted)] shrink-0" />
          ) : (
            <ChevronRight size={13} className="text-[var(--ss-muted)] shrink-0" />
          )}
          <div className="min-w-0">
            <div className="text-[13px] text-[var(--ss-ink)] font-medium truncate">{account.name}</div>
            <div className="text-[11px] text-[var(--ss-muted)] truncate">{account.sector || 'No company type set'}</div>
            {account.accountManager && (
              <div className="flex items-center gap-1 text-[10px] text-[var(--ss-muted)] truncate mt-0.5">
                <User size={10} /> {account.accountManager}
              </div>
            )}
          </div>
        </div>
        <TierBadge tier={account.tier} />
      </button>

      {expanded && draft && (
        <div className="border-t border-[var(--ss-border)] px-3.5 py-3 space-y-3">
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Company name</span>
            <input value={draft.name} onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))} className={inputClass} />
          </label>
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Company type / sector</span>
            <input value={draft.sector} onChange={(e) => setDraft((d) => ({ ...d, sector: e.target.value }))} className={inputClass} />
          </label>
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Tier</span>
            <select value={draft.tier} onChange={(e) => setDraft((d) => ({ ...d, tier: e.target.value }))} className={inputClass}>
              {TIER_ORDER.map((t) => (
                <option key={t} value={t}>{t}</option>
              ))}
            </select>
          </label>
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Account manager</span>
            <input
              value={draft.accountManager}
              onChange={(e) => setDraft((d) => ({ ...d, accountManager: e.target.value }))}
              className={inputClass}
              placeholder="e.g. Priya Shah"
            />
            <span className="text-[10px] text-[var(--ss-muted)]">Planned outreach for this account routes through them for verification before it's marked sent.</span>
          </label>
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Stated priorities (one per line)</span>
            <textarea
              value={draft.prioritiesText}
              onChange={(e) => setDraft((d) => ({ ...d, prioritiesText: e.target.value }))}
              rows={3}
              className={inputClass}
            />
          </label>
          <label className="block">
            <span className="text-[10px] text-[var(--ss-muted)]">Alternate spellings Live feed should also match (comma-separated)</span>
            <input
              value={draft.aliasesText}
              onChange={(e) => setDraft((d) => ({ ...d, aliasesText: e.target.value }))}
              className={inputClass}
            />
          </label>

          {error && <p className="text-[12px] text-[var(--ss-danger)]">{error}</p>}
          {saved && !error && <p className="text-[12px] text-[var(--ss-positive)]">Saved.</p>}

          <div className="flex items-center gap-2">
            <button
              onClick={save}
              className="rounded-[var(--ss-radius)] bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[12px] font-medium px-3 py-1.5 hover:brightness-110"
            >
              Save changes
            </button>
            <button onClick={onToggle} className="text-[12px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] px-2 py-1.5">
              Close
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// Shared segmented pipeline bar (2026-10-06, extracted so the Overview row,
// each matched signal's own progress, and the standalone account page's
// header all draw "where is this in the pipeline" the same way — a signal's
// own track should read as obviously the same kind of thing as an
// account's, not a lesser decoration).
// Thin wrapper over the shared StageTrack primitive (tier no longer drives
// this bar's color — TierMarker carries tier identity separately now; every
// stage track in the app, Gantt or otherwise, is the one accent/accent-soft/
// rule-soft pattern).
function GanttBar({ idx, height = 'h-2.5', ringBg }) {
  return <StageTrack stages={STAGES} current={idx} height={height} ringBg={ringBg} />;
}

// The column header row (stage name + week range) that sits above the
// Overview's whole account list — reused on the standalone account page so
// its own Gantt bars have the same labeled columns instead of an
// unlabeled stripe (that page has no shared list header to borrow one
// from, since it only ever shows one account).
function GanttHeader() {
  return (
    <div className="flex gap-1">
      {GANTT_STAGE_META.map((m) => {
        const meta = PHASE_META[STAGE_PHASE[m.label]];
        return (
          <div
            key={m.label}
            className="flex-1 text-center font-[var(--ss-font-mono)] pb-1"
            style={{ borderBottom: `2px solid ${meta.soft}` }}
          >
            <div className="text-[10px] uppercase tracking-[0.04em] text-[var(--ss-text-2)]">{m.label}</div>
            <div className="text-[9px] text-[var(--ss-faint)]">{m.sub}</div>
          </div>
        );
      })}
    </div>
  );
}

// Mono swatch legend for the phase colouring above — Overview toolbar.
function PhaseLegend() {
  return (
    <div className="flex items-center gap-3 font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">
      {PHASES.map((phase) => (
        <span key={phase} className="flex items-center gap-1.5">
          <span className="inline-block w-[16px] h-[7px] rounded-[1px] shrink-0" style={{ background: PHASE_META[phase].full }} />
          {phase} {PHASE_META[phase].range}
        </span>
      ))}
    </div>
  );
}

// ---------------------------------------------------------------------------
// FIT & GAPS (added per demo-week-plan.md Day 2b) — surfaces generateFitGaps
// /normalizeFitGaps' output in two places: a compact badge on the Overview
// row (seen before anyone clicks Generate) and a fuller panel on the
// account page (near the top, above the research/outreach workflow). Both
// read the same { gaps, fit: {fits,coverable,blockers} } shape.
// ---------------------------------------------------------------------------

const GAP_SEVERITY_STYLE = {
  Blocker: 'text-[var(--ss-danger)] border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]',
  Coverable: 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]',
  Minor: 'text-[var(--ss-muted)] border-[var(--ss-border)] bg-[var(--ss-rule-soft)]',
};

// Text-only color, same severity vocabulary as GAP_SEVERITY_STYLE above —
// for a group heading or icon that sits beside colored gap cards rather
// than carrying its own colored background/border (see SignalGapGroup).
const GAP_SEVERITY_TEXT = {
  Blocker: 'text-[var(--ss-danger)]',
  Coverable: 'text-[var(--ss-gap)]',
  Minor: 'text-[var(--ss-muted)]',
};

// Deliberately display-only (span, not button): the only place this
// renders is inside OpportunityRow's own row-toggle <button>, and a
// <button> can't contain another <button> (same constraint already noted
// throughout this file for Edit/Open details and the Signal Map's news
// cards). The actual "Check fit & gaps" action sits as a sibling button
// next to Edit/Open details instead — see OpportunityRow below.
function FitGapBadge({ fitGap, generating, hasMatches }) {
  if (!hasMatches) return null;
  if (generating) {
    return (
      <span className="flex items-center gap-1 text-[10px] text-[var(--ss-muted)]">
        <Loader2 size={10} className="animate-spin" /> Checking fit &amp; gaps…
      </span>
    );
  }
  if (!fitGap) {
    return (
      <span className="flex items-center gap-1 text-[10px] text-[var(--ss-faint)]">
        <Crosshair size={10} /> Fit &amp; gaps not checked yet
      </span>
    );
  }
  const { fits, coverable, blockers } = fitGap.fit;
  return (
    <span className="flex items-center gap-2 text-[10px]">
      {fits > 0 && (
        <span className="flex items-center gap-0.5 text-[var(--ss-positive)]">
          <CheckCircle2 size={10} /> {fits} fit
        </span>
      )}
      {coverable > 0 && (
        <span className="flex items-center gap-0.5 text-[var(--ss-gap)]">
          <Users size={10} /> {coverable} coverable
        </span>
      )}
      {blockers > 0 && (
        <span className="flex items-center gap-0.5 text-[var(--ss-danger)]">
          <AlertTriangle size={10} /> {blockers} blocker{blockers === 1 ? '' : 's'}
        </span>
      )}
      {fits === 0 && coverable === 0 && blockers === 0 && <span className="text-[var(--ss-faint)]">No gaps found</span>}
    </span>
  );
}

// Per-signal fit/gap badge (added 2026-10-10, direct feedback: "anywhere
// there is a blocker/fit, it needs to be clearer which signal it relates
// to"). FitGapBadge above is the account-wide rollup (counts only); this is
// the inverse view — given one matched signal, which gap (if any) its own
// evidence points to, so a signal card reads as fit/blocker/coverable on
// its own rather than only the aggregate. Same lookup the Signal Map's
// worstGapForSignal already does, just for one account instead of ranking
// across several. Renders nothing if this account's fit/gaps haven't been
// checked yet — same "no misleading default" rule FitGapBadge follows.
// Every gap whose evidence cites this signal — a signal can carry more than
// one (e.g. a Blocker and a separate Coverable gap at once), which the old
// single-gap lookup below silently hid (`.find` only ever surfaced the
// FIRST matching gap in the array, so a second, possibly more severe gap
// added by a later re-check could sit there invisibly). Returns `undefined`
// when fit/gaps hasn't been checked yet (distinct from `[]`, "checked, no
// gaps here").
function gapsForSignal(fitGap, newsId) {
  if (!fitGap) return undefined;
  return fitGap.gaps.filter((g) => g.evidence.includes(newsId));
}

function worstGapOf(gaps) {
  if (!gaps || !gaps.length) return null;
  return gaps.reduce((worst, g) => (!worst || GAP_SEVERITY_RANK[g.severity] > GAP_SEVERITY_RANK[worst.severity] ? g : worst), null);
}

// Single-gap view of the above, for callers that just need "the one gap
// that matters most here" (the severity badge/tint, the blocker pop-up) —
// now the WORST of every gap citing this signal, not just the first one in
// the array, so a second gap from a later re-check can never be masked.
function gapForSignal(fitGap, newsId) {
  const gaps = gapsForSignal(fitGap, newsId);
  if (gaps === undefined) return undefined; // not checked — distinct from null ("checked, and it's a fit")
  return worstGapOf(gaps);
}

// Narrowed to Blocker severity only — feeds NextMilestone's "pop up before
// you select a next step on a blocked signal" warning (added 2026-10-10).
// A Coverable/Minor gap doesn't warrant interrupting the confirm flow.
function blockerGapForSignal(fitGap, newsId) {
  const gaps = gapsForSignal(fitGap, newsId);
  return (gaps && gaps.find((g) => g.severity === 'Blocker')) || null;
}

// A per-track slice of the account's shared activity log (added 2026-10-10,
// direct feedback: "update all the activity panels on the company pages to
// ensure they're matching with the status shown on the gantt"). Every
// activity entry carries an `opportunityId` (null for the account's own
// baseline track, a newsId for one matched signal's independent track —
// see recordAction) but every NextMilestone used to be handed the WHOLE
// account-wide log regardless of which Gantt bar it was attached to, so a
// signal's own panel could read as contradicting (or, worse, "Undo last"
// could silently undo a *different* track's more recent action — see
// undoLastAction's own opportunityId param). This filters to exactly the
// track this one NextMilestone instance is showing.
function activityForTrack(activity, opportunityId) {
  const key = opportunityId || null;
  const direct = (activity || []).filter((e) => (e.opportunityId || null) === key);
  // A matched signal can also reach Researched purely as a side effect of
  // the ACCOUNT's own "Research done" confirm (cascadeResearchCompletion)
  // rather than its own direct confirm — so it can have zero direct
  // entries even though its Gantt bar has clearly moved. Synthesize a
  // read-only marker from whichever entry's `cascaded` metadata mentions
  // it, so this panel never reads as "no activity" next to real progress.
  if (key && !direct.some((e) => e.to)) {
    for (const e of activity || []) {
      const c = (e.cascaded || []).find((c) => c.type === 'match' && c.newsId === key);
      if (c) {
        return [
          ...direct,
          {
            id: `${e.id}-cascade-${key}`,
            from: c.previousStage,
            to: 'Researched',
            action: 'Research completed (moved with the account)',
            contactId: null,
            date: e.date,
            note: '',
            opportunityId: key,
            opportunityLabel: null,
            cascaded: [],
            synthetic: true,
          },
        ];
      }
    }
  }
  return direct;
}

function SignalGapBadge({ fitGap, newsId }) {
  const gaps = gapsForSignal(fitGap, newsId);
  if (gaps === undefined) return null;
  const gap = worstGapOf(gaps);
  if (gap) {
    return (
      <span
        title={gaps.map((g) => `${g.severity}: ${g.gap} (blocks: ${g.scope})`).join('\n')}
        className={`flex items-center gap-1 text-[9px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border shrink-0 ${GAP_SEVERITY_STYLE[gap.severity]}`}
      >
        <AlertTriangle size={9} /> {gap.severity}
        {gaps.length > 1 ? ` +${gaps.length - 1} more` : ''}
      </span>
    );
  }
  return (
    <span className="flex items-center gap-1 text-[9px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border shrink-0 text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]">
      <CheckCircle2 size={9} /> Fit
    </span>
  );
}

// Card-level tint (not just the badge) so a blocker/fit reads at a glance
// from the signal card's own border/background, not only a small label —
// the direct feedback that prompted this was "highlight" matched signals
// with a blocker/fit, not just label them. Teal/amber/red bg-soft + border
// pairs, same vocabulary GAP_SEVERITY_STYLE already uses; a signal with no
// check yet (gap === undefined) or a Minor gap keeps the plain default
// panel styling rather than a tint — Minor gaps are flagged via the badge
// text, not a card-wide color, so they don't visually compete with real
// blockers.
function signalCardTint(fitGap, newsId) {
  const gap = gapForSignal(fitGap, newsId);
  if (gap === undefined) return 'border-[var(--ss-border)] bg-[var(--ss-surface)]';
  if (gap && gap.severity === 'Blocker') return 'border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]';
  if (gap && gap.severity === 'Coverable') return 'border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]';
  if (gap) return 'border-[var(--ss-border)] bg-[var(--ss-surface)]'; // Minor — badge only, no card tint
  return 'border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]'; // checked, no gap — a confirmed fit
}

// Day 4 (demo-week-plan.md) — "what changed" after a fresh Live feed match
// triggered an automatic re-check (recheckFitGapsOnNewMatch/diffFitGaps in
// App). Shown on the account page, above FitGapPanel, so it's the first
// thing visible when a market move has actually altered this account's
// delivery picture; dismissed explicitly once reviewed, not just on the
// next render, so it survives a tab switch.
function FitChangeNotice({ notice, onDismiss }) {
  if (!notice) return null;
  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5 space-y-1.5">
      <div className="flex items-center justify-between gap-2">
        <div className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--ss-ink)]">
          <Sparkles size={12} /> Fit changed — {formatDate(notice.changedAt)}
        </div>
        <button onClick={onDismiss} aria-label="Dismiss" className="text-[var(--ss-faint)] hover:text-[var(--ss-ink)] -m-1.5 p-1.5">
          <X size={13} />
        </button>
      </div>
      <div className="space-y-1">
        {notice.newGaps.map((g, i) => (
          <div key={`new-${i}`} className="text-[11px] text-[var(--ss-text-2)]">
            + New {g.severity.toLowerCase()} gap: {g.gap}
          </div>
        ))}
        {notice.removedGaps.map((g, i) => (
          <div key={`rm-${i}`} className="text-[11px] text-[var(--ss-text-2)]">
            − Resolved: {g.gap}
          </div>
        ))}
        {notice.severityChanges.map((c, i) => (
          <div key={`sv-${i}`} className="text-[11px] text-[var(--ss-text-2)]">
            ↕ {c.gap}: {c.from} → {c.to}
          </div>
        ))}
      </div>
      {notice.triggeringNewsId && <Citation sourceId={notice.triggeringNewsId} />}
    </div>
  );
}

// Opportunity-part status styling (redesign point 1) — same severity
// vocabulary GAP_SEVERITY_STYLE already uses, reused here since "Blocked"/
// "Coverable"/"Fit" is the same traffic-light logic one level up (a part,
// not a single gap).
const PART_STATUS_STYLE = {
  Blocked: 'text-[var(--ss-danger)] border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]',
  Coverable: 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]',
  Fit: 'text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]',
};
const PART_STATUS_ICON = { Blocked: AlertTriangle, Coverable: Users, Fit: CheckCircle2 };

// One icon per move type (redesign point 3's menu) — gives the list a quick
// visual rhythm even before reading the detail text.
const MOVE_TYPE_ICON = {
  Team: Users,
  Subcontract: Link2,
  'Narrow scope': Crosshair,
  Timing: RefreshCw,
  'Lead with strength': Sparkles,
  Decline: X,
  Custom: Pencil,
};

// A chosen move that needs someone outside the firm looped in before it's a
// real route, not just a label picked in the UI (2026-10-11 follow-up: "for
// blockers where external engagement is required... pop up a contact
// identifying/email generator to assist with the outreach"). Subcontract is
// always an external party by definition; Team sometimes means the same
// thing (the prompt's own Team moves read "Partner with X..." — see
// FIT_GAP rules above); any move that names a real partner counts
// regardless of its label, since that's the actual signal someone needs
// contacting. Decline/Narrow scope/Timing/Lead with strength/Custom (with
// no partner) stay internal — nothing to pop up for those.
function needsExternalEngagement(move) {
  return !!move && (move.type === 'Subcontract' || move.type === 'Team' || !!move.partner);
}

// Module-level (not an App-local closure) since EngagementAssistModal and
// SignalActionTracker, both top-level components, need the exact same key
// shape App's externalEngagements map uses.
function engagementKey(accountId, newsId, gapTag) {
  return `${accountId}:${newsId}:${gapTag}`;
}

const ENGAGEMENT_STATUS_LABEL = { needed: 'Not started', contacted: 'In progress', done: 'Done' };
const ENGAGEMENT_STATUS_STYLE = {
  needed: 'text-[var(--ss-muted)] border-[var(--ss-border)] bg-[var(--ss-rule-soft)]',
  contacted: 'text-[var(--ss-accent-wash-ink)] border-[var(--ss-accent)]/35 bg-[var(--ss-accent-wash)]',
  done: 'text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]',
};

// The moves menu for one gap (redesign point 3: "offer a menu of moves, not
// one mitigation"). Selecting a move is how the user picks "the route we're
// actually taking" for this gap — carried through to the proposal, email
// and go/no-go downstream (point 4); see selectedMoves in App. Purely
// display/selection here — onSelect just reports the clicked index, same
// toggle-to-unpick behavior App's selectMove implements.
function MovesMenu({ moves, selectedIndex, onSelect }) {
  return (
    <div className="mt-2 space-y-1.5">
      {moves.map((m, i) => {
        const Icon = MOVE_TYPE_ICON[m.type] || Users;
        const active = selectedIndex === i;
        return (
          <button
            key={i}
            type="button"
            onClick={() => onSelect(i)}
            aria-pressed={active}
            className={`w-full flex items-start gap-2 text-left rounded-[var(--ss-radius)] border px-2.5 py-1.5 transition-colors ${
              active
                ? 'border-[var(--ss-inverse)] bg-[var(--ss-inverse)]'
                : 'border-[var(--ss-border)] bg-[var(--ss-surface)] hover:border-[var(--ss-frame)]'
            }`}
          >
            <Icon size={12} className={`shrink-0 mt-0.5 ${active ? 'text-[var(--ss-inverse-text)]' : 'text-[var(--ss-muted)]'}`} />
            <span className="text-[11px] leading-snug">
              <span className={`font-[var(--ss-font-mono)] uppercase text-[10px] mr-1 ${active ? 'text-[var(--ss-inverse-muted)]' : 'text-[var(--ss-muted)]'}`}>{m.type}</span>
              <span className={active ? 'text-[var(--ss-inverse-text)]' : 'text-[var(--ss-text-2)]'}>
                {m.partner ? `${m.partner} — ` : ''}
                {m.detail}
              </span>
            </span>
            {active && <CheckCircle2 size={12} className="shrink-0 mt-0.5 text-[var(--ss-inverse-text)] ml-auto" />}
          </button>
        );
      })}
    </div>
  );
}

// "Or describe your own option" (added 2026-10-10, direct feedback: "the
// user should select from the suggested blocker solutions (or an
// override)") — a collapsed link until clicked, so it doesn't compete with
// the suggested moves above it. Submitting calls onAddOverride(gapTag,
// text), which both appends it to this gap's own `moves` list and
// auto-selects it (see addOverrideMove in App) — there's deliberately no
// separate "select" step here since typing it out is already the act of
// choosing it.
function GapOverrideInput({ gapTag, onAddOverride }) {
  const [open, setOpen] = useState(false);
  const [text, setText] = useState('');
  if (!onAddOverride) return null;
  if (!open) {
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="mt-1.5 flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]"
      >
        <Plus size={11} /> Or describe your own option
      </button>
    );
  }
  function submit() {
    if (!text.trim()) return;
    onAddOverride(gapTag, text.trim());
    setText('');
    setOpen(false);
  }
  return (
    <div className="mt-1.5 flex items-center gap-1.5">
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && submit()}
        placeholder="Describe how you'll actually handle this…"
        className={`${inputClass} flex-1 text-[11px] py-1`}
      />
      <button type="button" onClick={submit} className="text-[11px] text-[var(--ss-accent)] hover:underline shrink-0">
        Add
      </button>
      <button
        type="button"
        onClick={() => {
          setOpen(false);
          setText('');
        }}
        className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] shrink-0"
      >
        Cancel
      </button>
    </div>
  );
}

// One gap card — the severity/scope/evidence header plus its moves menu.
// Shared between FitGapPanel (selectable) and ProposalPanel's read-only
// "Delivery model" rendering (see showMoves prop) below, so the two never
// drift apart on how a gap's own fields are displayed.
// `onProceed`/`onAddOverride`/`confirmed` added 2026-10-10, direct
// feedback: "the user should select from the suggested blocker solutions
// (or an override) and click to proceed. If the suggestion they proceed
// with is to decline, it should archive that signal. The other options
// should proceed with whatever considerations are relevant given the
// blocker and the user's choice." Only rendered in editable mode
// (onSelectMove truthy) — the read-only branch (ProposalPanel) just shows
// whichever route is already chosen, same as before.
// Confirmed/proceed state used to live here, one button per gap — moved up
// a level to SignalGapGroup below (2026-10-10 follow-up: "any matched
// signal with multiple blockers or coverables should interact and have a
// single move forward button that incorporates the user's chosen
// approaches"), so this card is now just the gap's own detail + its moves
// menu, same in every context it's reused (grouped editable mode, the
// orphan "General" fallback, and ProposalPanel's read-only rendering).
function GapCard({ g, selectedIndex, onSelectMove, onAddOverride }) {
  const chosen = typeof selectedIndex === 'number' ? g.moves[selectedIndex] : null;
  return (
    <div className={`rounded-[var(--ss-radius)] border px-3 py-2 ${GAP_SEVERITY_STYLE[g.severity]}`}>
      <div className="flex items-start justify-between gap-2">
        <span className="text-[12px] font-medium">{g.gap}</span>
        <span className="text-[10px] font-[var(--ss-font-mono)] uppercase shrink-0">{g.severity}</span>
      </div>
      <div className="text-[10px] opacity-75 mt-0.5">
        Blocks: {g.scope} · Confidence: {g.confidence}
      </div>
      <div className="mt-1">
        {g.evidence.map((id) => (
          <Citation key={id} sourceId={id} />
        ))}
      </div>
      {onSelectMove ? (
        <>
          <MovesMenu moves={g.moves} selectedIndex={selectedIndex} onSelect={(i) => onSelectMove(g.tag, i)} />
          <GapOverrideInput gapTag={g.tag} onAddOverride={onAddOverride} />
        </>
      ) : (
        <div className="text-[11px] mt-1.5 opacity-90">
          {chosen ? (
            <>
              <span className="font-[var(--ss-font-mono)] uppercase text-[10px] mr-1">{chosen.type}</span>
              {chosen.partner ? `${chosen.partner} — ` : ''}
              {chosen.detail}
            </>
          ) : (
            <span className="text-[var(--ss-faint)]">No route chosen yet — pick one in Fit &amp; gaps above.</span>
          )}
        </div>
      )}
    </div>
  );
}

// One matched signal's own card inside Fit & gaps (2026-10-10 follow-up:
// "the fit and gaps box should be organized by matched signal so it's
// clearer... any matched signal with multiple blockers or coverables
// should interact and have a single move forward button that incorporates
// the user's chosen approaches"). Lists every gap this signal's evidence
// cites, each still individually selectable via its own moves menu, but
// ONE button at the bottom moves forward with whatever's chosen across all
// of them at once — "require a route for every gap shown here, then
// confirm them together" rather than one proceed click per gap.
function SignalGapGroup({ match, gaps, selectedMoves, onSelectMove, onAddOverride, isConfirmed, onProceed, onUndo }) {
  const moveFor = (g) => {
    const idx = selectedMoves ? selectedMoves[g.tag] : undefined;
    return typeof idx === 'number' ? g.moves[idx] : null;
  };
  const allChosen = gaps.every((g) => !!moveFor(g));
  const allConfirmed = gaps.every((g) => isConfirmed(g.tag));
  const anyDeclineChosen = gaps.some((g) => {
    const m = moveFor(g);
    return m && m.type === 'Decline';
  });
  const worst = worstGapOf(gaps);
  const WorstIcon = worst.severity === 'Blocker' ? AlertTriangle : worst.severity === 'Coverable' ? Users : AlertTriangle;
  const worstTextClass = GAP_SEVERITY_TEXT[worst.severity];
  // A plain divided section, not another bordered/backgrounded card — a gap
  // already carries its own severity-colored card below; nesting a second
  // colored card around a group of them just to label the signal was pure
  // card-in-card clutter. One hairline rule between signals reads as
  // cleanly without competing with the color that actually matters.
  return (
    <div id={`fitgap-signal-${match.newsId}`}>
      <div className="flex items-center gap-1.5 mb-2">
        <WorstIcon size={12} className={`shrink-0 ${worstTextClass}`} />
        <span className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-faint)]">{match.news.tag}</span>
        <span className="text-[12px] font-medium text-[var(--ss-text-2)]">{match.news.headline}</span>
        <span className="text-[10px] font-[var(--ss-font-mono)] uppercase ml-auto shrink-0 text-[var(--ss-faint)]">
          {gaps.length} gap{gaps.length === 1 ? '' : 's'}
        </span>
      </div>
      <div className="space-y-2">
        {gaps.map((g) => (
          <GapCard key={g.tag} g={g} selectedIndex={selectedMoves ? selectedMoves[g.tag] : undefined} onSelectMove={onSelectMove} onAddOverride={onAddOverride} />
        ))}
      </div>
      {onProceed && (
        <div className="flex items-center gap-2 mt-2.5">
          <button
            type="button"
            onClick={onProceed}
            disabled={!allChosen || allConfirmed}
            title={!allChosen ? `Choose a route for all ${gaps.length} gap${gaps.length === 1 ? '' : 's'} above first` : undefined}
            className={`flex items-center gap-1.5 text-[11px] font-medium rounded-[var(--ss-radius)] px-2.5 py-1.5 transition-colors ${
              allConfirmed
                ? 'text-[var(--ss-positive)] border border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)] cursor-default'
                : allChosen
                ? 'text-[var(--ss-accent-text)] bg-[var(--ss-accent)] hover:brightness-110'
                : 'text-[var(--ss-faint)] border border-[var(--ss-border)] bg-[var(--ss-ground)] cursor-not-allowed'
            }`}
          >
            {allConfirmed ? (
              <>
                <CheckCircle2 size={12} /> Moving forward — route{gaps.length === 1 ? '' : 's'} confirmed
              </>
            ) : (
              `Move forward with this signal${gaps.length > 1 ? "'s routes" : ''}`
            )}
          </button>
          {allConfirmed && onUndo && (
            <button type="button" onClick={onUndo} className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] hover:underline">
              <Undo2 size={11} /> Undo
            </button>
          )}
          {allConfirmed && anyDeclineChosen && <span className="text-[10px] text-[var(--ss-faint)]">Signal archived — see Matched signals.</span>}
        </div>
      )}
    </div>
  );
}

// Compact sidebar dock for Fit & gaps (2026-10-11 direct follow-up
// feedback: "once the user decides on fit and gap check actions, this can
// be minimized on the side so the next immediate actions are clearer") —
// rendered by AccountPage in place of the full FitGapPanel once minimized.
// Stays glanceable (counts + checked date, same numbers the full panel's
// own header shows) without any of the route-picking UI; Expand scrolls
// the full panel back into view once it returns to the main column.
function FitGapDockedSummary({ fitGap, onExpand }) {
  function expand() {
    onExpand();
    window.setTimeout(() => {
      const el = document.getElementById('fitgap-panel-main');
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 50);
  }
  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5">
      <div className="flex items-center justify-between gap-2 mb-2">
        <div className={sectionLabel}>Fit &amp; gaps</div>
        <button onClick={expand} className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
          <ChevronDown size={11} /> Expand
        </button>
      </div>
      <div className="flex items-center gap-3 text-[12px] flex-wrap">
        <span className="flex items-center gap-1 text-[var(--ss-positive)]">
          <CheckCircle2 size={12} /> {fitGap.fit.fits} fit
        </span>
        <span className="flex items-center gap-1 text-[var(--ss-gap)]">
          <Users size={12} /> {fitGap.fit.coverable} coverable
        </span>
        <span className="flex items-center gap-1 text-[var(--ss-danger)]">
          <AlertTriangle size={12} /> {fitGap.fit.blockers} blocker{fitGap.fit.blockers === 1 ? '' : 's'}
        </span>
      </div>
      <div className="text-[10px] text-[var(--ss-faint)] mt-1">Checked {formatDate(fitGap.checkedAt)}</div>
    </div>
  );
}

function FitGapPanel({ matches, fitGap, generating, error, onCheck, selectedMoves, onSelectMove, confirmedMitigations = {}, accountId, onAddOverride, onProceedSignal, onUndoSignal, onSetMinimized }) {
  const isConfirmed = (tag) => !!confirmedMitigations[`${accountId}:${tag}`];
  // Minimize (added 2026-10-11, direct feedback: "a compact panel docked
  // at the side, still clickable to expand") — originally an in-place
  // collapse here; direct follow-up feedback asked for an actual dock to
  // the sidebar, so `minimized` is now controlled by AccountPage
  // (onSetMinimized(true/false)), which also decides WHERE this renders:
  // AccountPage stops rendering this component at all in the main column
  // while minimized and renders `FitGapDockedSummary` in the sidebar
  // instead — see AccountPage's own comment. This component now only
  // ever renders expanded.
  if (!matches.length) {
    return (
      <div className="rounded-[var(--ss-radius)] border border-dashed border-[var(--ss-border)] bg-[var(--ss-surface)] p-4">
        <div className={sectionLabel}>Fit &amp; gaps</div>
        <div className="text-[12px] text-[var(--ss-muted)]">No matched signals yet — fit &amp; gaps can be checked once at least one signal is connected to this account.</div>
      </div>
    );
  }
  // Explicit "confirm everything at once" action (2026-10-11 direct follow-up
  // feedback: "there should be a button to confirm the chosen fit and gap
  // approaches that onclick minimizes in on the sidebar") — each signal still
  // has its own "Move forward with this signal" button for confirming one at
  // a time, but once every gap on every signal has a route picked, this one
  // button commits whichever signals aren't confirmed yet (same
  // onProceedSignal call each of their own buttons would make — same
  // archiving/external-engagement side effects) and docks the panel.
  const pendingSignals = fitGap
    ? matches.filter((m) => !m.archived).filter((m) => {
        const signalGaps = fitGap.gaps.filter((g) => g.evidence.includes(m.newsId));
        if (!signalGaps.length) return false;
        const allChosen = signalGaps.every((g) => typeof (selectedMoves && selectedMoves[g.tag]) === 'number');
        const allConfirmed = signalGaps.every((g) => isConfirmed(g.tag));
        return allChosen && !allConfirmed;
      })
    : [];
  function confirmApproach() {
    pendingSignals.forEach((m) => onProceedSignal(m.newsId));
    if (onSetMinimized) onSetMinimized(true);
  }
  return (
    // Header promoted from the shared 11px sectionLabel to the page's own
    // heading weight, and the border turned up to --border-strong (2026-10-13,
    // /impeccable bolder per the critique's P2: this panel is the product's
    // actual credibility bet — honest gap-flagging against real capability
    // data — but previously read as identical chrome to a routine sidebar
    // card). Both are moves the system already owns (h1's text-lg/font-medium,
    // --border-strong already used on hover states throughout the file) at
    // full strength for this one panel, not a new visual language.
    <div id="fitgap-panel-main" className="rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-4">
      <div className="flex items-center justify-between gap-3 mb-0.5">
        <div className="flex items-center gap-1.5 font-[var(--ss-font-display)] text-[22px] text-[var(--ss-ink)]">
          <Crosshair size={15} className="text-[var(--ss-accent)]" /> Fit &amp; gaps
        </div>
        <div className="flex items-center gap-3">
          {fitGap && onProceedSignal && pendingSignals.length > 0 && (
            <Button variant="primary" size="sm" icon={CheckCircle2} onClick={confirmApproach} className="h-7 px-2.5 text-[11px]">
              Confirm approach{pendingSignals.length > 1 ? ` (${pendingSignals.length})` : ''}
            </Button>
          )}
          {fitGap && onSetMinimized && (
            <button
              onClick={() => onSetMinimized(true)}
              className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]"
            >
              <ChevronUp size={11} /> Minimize
            </button>
          )}
          <button
            onClick={onCheck}
            disabled={generating}
            {...(generating ? {} : shortcutButtonProps('f', fitGap ? 'Re-check fit & gaps' : 'Check fit & gaps'))}
            className="flex items-center gap-1.5 text-[11px] text-[var(--ss-accent)] hover:underline disabled:opacity-60"
          >
            {generating ? (
              <>
                <Loader2 size={11} className="animate-spin" /> Checking…
              </>
            ) : fitGap ? (
              <>
                <RefreshCw size={11} /> Re-check
              </>
            ) : (
              <>
                <Crosshair size={11} /> Check fit &amp; gaps
              </>
            )}
          </button>
        </div>
      </div>
      {fitGap && (
        <div className="text-[11px] text-[var(--ss-faint)] mb-2">Your delivery model: what we can own now, and the honest gaps in the rest — the proposal's own gap section is drawn straight from here.</div>
      )}
      <ErrorText message={error} className="text-[12px] text-[var(--ss-danger)] mb-2" />
      {!fitGap && !generating && (
        <div className="text-[12px] text-[var(--ss-muted)]">
          Run a check against {matches.length} matched signal{matches.length === 1 ? '' : 's'} to see what we can and can't deliver here, before drafting anything.
        </div>
      )}
      {fitGap && (
        <div className="space-y-3">
          {/* Scorecard pills — same border/wash/text badge the opportunity-
              breakdown pills below already use per severity
              (PART_STATUS_STYLE), just applied one level up to this panel's
              own summary row instead of plain inline icon+text, so the
              headline verdict reads as a scorecard rather than a caption. */}
          <div className="flex items-center gap-2 text-[12px] flex-wrap">
            <span className="flex items-center gap-1 px-2 py-1 rounded-[var(--ss-radius-sm)] border font-medium text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]">
              <CheckCircle2 size={12} /> {fitGap.fit.fits} fit
            </span>
            <span className="flex items-center gap-1 px-2 py-1 rounded-[var(--ss-radius-sm)] border font-medium text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]">
              <Users size={12} /> {fitGap.fit.coverable} coverable
            </span>
            <span className="flex items-center gap-1 px-2 py-1 rounded-[var(--ss-radius-sm)] border font-medium text-[var(--ss-danger)] border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]">
              <AlertTriangle size={12} /> {fitGap.fit.blockers} blocker{fitGap.fit.blockers === 1 ? '' : 's'}
            </span>
            <span className="text-[10px] text-[var(--ss-faint)] ml-auto">Checked {formatDate(fitGap.checkedAt)}</span>
          </div>

          <div className="space-y-3">

          {/* Opportunity breakdown (redesign point 1) — "blocker" is always
              relative to a specific part of the opportunity, never the
              account as a whole, so this is the first thing shown: which
              pieces of the work we can credibly own right now. */}
          {fitGap.opportunityParts && fitGap.opportunityParts.length > 0 && (
            <div>
              <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-1">Opportunity breakdown</div>
              <div className="flex flex-wrap gap-1.5">
                {fitGap.opportunityParts.map((p) => {
                  const Icon = PART_STATUS_ICON[p.status] || CheckCircle2;
                  return (
                    <span key={p.name} className={`flex items-center gap-1 text-[10px] px-2 py-1 rounded-[var(--ss-radius-sm)] border ${PART_STATUS_STYLE[p.status]}`}>
                      <Icon size={10} /> {p.name}
                    </span>
                  );
                })}
              </div>
            </div>
          )}

          {/* Entry point (redesign point 2) — "how do we win this" starts
              with the smallest credible engagement, not a flat yes/no. */}
          {fitGap.entryPoint ? (
            <div className="rounded-[var(--ss-radius)] border border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)] px-3 py-2">
              <div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--ss-positive)]">
                <Flag size={11} /> Where you can win now: {fitGap.entryPoint.part}
              </div>
              <div className="text-[11px] text-[var(--ss-text-2)] mt-0.5">{fitGap.entryPoint.rationale}</div>
            </div>
          ) : (
            fitGap.opportunityParts && fitGap.opportunityParts.length > 0 && (
              <div className="text-[11px] text-[var(--ss-muted)]">No clean entry point yet — every part of this opportunity has at least one unresolved blocker below.</div>
            )
          )}

          {fitGap.gaps.length === 0 ? (
            <div className="text-[12px] text-[var(--ss-positive)]">Every matched signal maps cleanly to a listed capability, partnership or past project.</div>
          ) : (
            <div className="divide-y divide-[var(--ss-rule)] [&>*]:py-3 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
              {/* Grouped by matched signal (2026-10-10 follow-up: "the fit
                  and gaps box should be organized by matched signal so it's
                  clearer") rather than one flat list of gaps — a gap whose
                  evidence cites more than one signal appears under each,
                  sharing the same selectedMoves entry, which is exactly how
                  "these should interact" plays out: picking a route for it
                  under one signal is immediately reflected under the other.
                  Archived signals are left out — they're already resolved,
                  nothing left to move forward on. Any gap whose evidence
                  doesn't resolve to a currently active matched signal (an
                  edge case — e.g. the signal it cited was since unlinked)
                  falls into a "General" group below rather than silently
                  disappearing. */}
              {matches
                .filter((m) => !m.archived)
                .map((m) => {
                  const signalGaps = fitGap.gaps.filter((g) => g.evidence.includes(m.newsId));
                  if (!signalGaps.length) return null;
                  return (
                    <SignalGapGroup
                      key={m.newsId}
                      match={m}
                      gaps={signalGaps}
                      selectedMoves={selectedMoves}
                      onSelectMove={onSelectMove}
                      onAddOverride={onAddOverride}
                      isConfirmed={isConfirmed}
                      onProceed={
                        onProceedSignal
                          ? () => {
                              // P1 item 4 (feedback brief), refined by direct
                              // follow-up feedback ("there's no longer a
                              // button to commit the fit and gap decisions"):
                              // minimizing (now docking the whole panel away
                              // to the sidebar, see onSetMinimized) is only
                              // appropriate once EVERY signal's gaps are
                              // resolved — auto-minimizing right after just
                              // this one signal's confirm was hiding any
                              // other signal's still-unconfirmed gaps (and
                              // their own "Move forward" button) behind an
                              // extra Expand click. Only fires when every
                              // OTHER gap in the panel is already confirmed,
                              // i.e. this confirm is genuinely the last one.
                              const otherGapsAllConfirmed = fitGap.gaps.filter((g) => !signalGaps.includes(g)).every((g) => isConfirmed(g.tag));
                              onProceedSignal(m.newsId);
                              if (onSetMinimized && otherGapsAllConfirmed) onSetMinimized(true);
                            }
                          : null
                      }
                      onUndo={onUndoSignal ? () => onUndoSignal(m.newsId) : null}
                    />
                  );
                })}
              {(() => {
                const coveredIds = new Set(matches.filter((m) => !m.archived).map((m) => m.newsId));
                const orphanGaps = fitGap.gaps.filter((g) => !g.evidence.some((id) => coveredIds.has(id)));
                if (!orphanGaps.length) return null;
                return (
                  <div>
                    <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-1.5">General — not tied to an active matched signal</div>
                    <div className="space-y-2">
                      {orphanGaps.map((g) => (
                        <GapCard key={g.tag} g={g} selectedIndex={selectedMoves ? selectedMoves[g.tag] : undefined} onSelectMove={onSelectMove} onAddOverride={onAddOverride} />
                      ))}
                    </div>
                  </div>
                );
              })()}
            </div>
          )}

          </div>
        </div>
      )}
    </div>
  );
}

function OpportunityRow(props) {
  const { account, expanded, onToggle, groupBy, customGroups, accountGroup, onSetAccountGroup, onOpenAccount } = props;
  // `tierIndex` (1/2/3) is only ever passed from a TierLane — it means this
  // row is already sitting on that tier's own lane/row tint, so the row
  // drops its own card border and the per-row TierMarker (the lane already
  // carries tier identity) and instead sizes the name by tier, same as the
  // reference design. Every other grouping leaves it undefined.
  const { tierIndex } = props;
  const inLane = tierIndex != null;
  const ringBg = inLane ? `var(--ss-row-${tierIndex}-bg)` : 'var(--bg-panel-60)';
  const nameClass = tierIndex === 1
    ? 'text-[28px] font-[var(--ss-font-display)] leading-tight'
    : tierIndex === 2
    ? 'text-[24px] font-[var(--ss-font-display)] leading-tight'
    : tierIndex === 3
    ? 'text-[22px] font-[var(--ss-font-display)] leading-tight'
    : 'text-sm font-medium';
  // Keyboard row focus (2026-10-14) — `focused` is owned by Overview (so
  // j/k, a click and a Tab into the row all agree on one cursor); this just
  // renders the ring and keeps the focused row on screen. block:'nearest'
  // means a click on an already-visible row doesn't yank the page around.
  const { focused = false, onFocusRow } = props;
  const rowRef = useRef(null);
  useEffect(() => {
    if (focused && rowRef.current) rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [focused]);
  // The bar/label reflect the rollup across the account's own baseline stage
  // and every matched signal's independent opportunity stage (2026-10-06,
  // see accountRollupStage) — so a signal that's outpaced the account's own
  // research-stage baseline still shows as progress here. "Next" stays tied
  // to the account's own baseline action (STAGE_ACTIONS[account.stage]),
  // since that's what's still actionable account-wide; per-signal next
  // steps live in each opportunity's own card, inside the expanded panel.
  const rollupStage = accountRollupStage(account);
  const idx = STAGES.indexOf(rollupStage);
  const matches = accountMatches(account.id);
  const topMatch = matches[0];
  const nextAction = STAGE_ACTIONS[account.stage];
  const { research, fitGap, fitGapGenerating, onCheckFitGaps, fitChanged } = props;
  const progress = account.stage === 'Identified' ? researchProgress(research.plan, research.report) : null;

  return (
    <div
      ref={rowRef}
      onFocusCapture={onFocusRow}
      onMouseDown={onFocusRow}
      className={`${inLane ? '' : 'rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--bg-panel-60)]'} ${
        focused ? 'ring-2 ring-[var(--ss-border)] rounded-[var(--ss-radius)]' : ''
      }`}
    >
      {groupBy === 'custom' && (
        <div className="flex items-center justify-end gap-1.5 px-4 pt-2">
          <span className="text-[10px] text-[var(--ss-muted)]">Group</span>
          <select
            value={accountGroup}
            onChange={(e) => onSetAccountGroup(e.target.value)}
            className="text-[11px] bg-[var(--ss-ground)] border border-[var(--ss-border)] rounded-[var(--ss-radius-sm)] px-1.5 py-0.5 text-[var(--ss-text-2)] focus:outline-none focus:border-[var(--ss-frame)] focus:ring-2 focus:ring-[var(--ss-border)]"
          >
            <option value="">Ungrouped</option>
            {customGroups.map((g) => (
              <option key={g} value={g}>
                {g}
              </option>
            ))}
          </select>
        </div>
      )}
      {/* Edit sits as a flex sibling next to the toggle button, not above it
          and not nested inside it (a <button> can't contain another button)
          — a sibling strip would add height above the row and throw off the
          toggle button's own vertical centering of the name/sector block. */}
      <div className="flex items-stretch">
        <button
          onClick={onToggle}
          className="flex-1 min-w-0 text-left grid grid-cols-1 sm:grid-cols-[200px_1fr] items-start sm:items-center gap-2 sm:gap-4 py-3 px-4 hover:bg-[var(--ss-surface)] transition-colors rounded-[var(--ss-radius)]"
        >
        <div className="flex items-start gap-1.5 min-w-0">
          {expanded ? (
            <ChevronDown size={14} className="text-[var(--ss-muted)] mt-0.5 shrink-0" />
          ) : (
            <ChevronRight size={14} className="text-[var(--ss-muted)] mt-0.5 shrink-0" />
          )}
          <div className="min-w-0">
            <div className={`text-[var(--ss-ink)] truncate flex items-center gap-1.5 ${nameClass}`}>
              {!inLane && <TierMarker tier={account.tier} className="shrink-0" />}
              {account.name}
              {props.archived && (
                <span className="flex items-center gap-1 text-[10px] font-mono uppercase text-[var(--ss-danger)] bg-[var(--ss-danger-wash)] border border-[var(--ss-border)] rounded-[var(--ss-radius-sm)] px-1.5 py-0.5">
                  <Archive size={9} /> Archived
                </span>
              )}
            </div>
            <div className={`text-[11px] text-[var(--ss-muted)] truncate ${inLane ? 'font-[var(--ss-font-mono)] uppercase tracking-[0.04em]' : ''}`}>{account.sector}</div>
            {account.opportunityNumber && (
              <div className="text-[10px] font-mono text-[var(--ss-muted)] truncate mt-0.5">{formatOpportunityNumber(account.opportunityNumber)}</div>
            )}
            {account.accountManager && (
              <div className="flex items-center gap-1 text-[10px] text-[var(--ss-muted)] truncate mt-0.5">
                <User size={10} /> {account.accountManager}
              </div>
            )}
          </div>
        </div>

        <div className="min-w-0">
          <GanttBar idx={idx} height="h-2" ringBg={ringBg} />
          {/* flex-wrap + min-w-0/flex-1 on the headline (added — previously
              neither had an explicit width, so a flex row with no shrink
              target doesn't actually truncate even with the `truncate`
              class: the stage/next block was shrink-0 and the headline had
              no min-width, so at a narrower column width the two pieces
              just overlapped instead of either shrinking or wrapping). Now
              the stage/next block keeps its natural size, the headline
              fills and truncates whatever's left, and if there genuinely
              isn't room for both the headline drops to its own line. */}
          <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 mt-1.5">
            <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 shrink-0">
              <span className="text-[10px] font-mono uppercase tracking-wide text-[var(--ss-muted)]">{rollupStage}</span>
              {nextAction ? (
                <span className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[10px] text-[var(--ss-accent)]">
                  <Flag size={10} /> Next: {nextAction.short}
                  {research.run ? (
                    <span className="text-[var(--ss-text-2)] flex items-center gap-1">
                      · <Loader2 size={10} className="animate-spin" /> researching
                    </span>
                  ) : progress && progress.hasReport ? (
                    <span className="text-[var(--ss-positive)] flex items-center gap-1">
                      · <FileText size={10} /> report ready
                    </span>
                  ) : progress && progress.mustTotal > 0 ? (
                    <span className="text-[var(--ss-muted)] font-mono">· checklist ready</span>
                  ) : null}
                </span>
              ) : (
                <span className="flex items-center gap-1 text-[10px] text-[var(--ss-positive)]">
                  <CheckCircle2 size={10} /> Complete
                </span>
              )}
            </span>
            {topMatch && (
              <span className="min-w-0 flex-1 text-[10px] text-[var(--ss-muted)] truncate">
                {topMatch.news.headline}
                {matches.length > 1 && ` +${matches.length - 1} more`}
              </span>
            )}
          </div>
          {/* Fit/gap indicator (Day 2b, demo-week-plan.md) — visible on the
              row itself, before anyone opens the account or clicks
              Generate, per the plan's D1 requirement. */}
          {matches.length > 0 && (
            <div className="mt-1 flex items-center gap-2">
              <FitGapBadge fitGap={fitGap} generating={fitGapGenerating} hasMatches={matches.length > 0} />
              {/* Day 4 (demo-week-plan.md) — a new Live feed match changed
                  this account's fit/gaps; see the full diff on its page. */}
              {fitChanged && (
                <span className="flex items-center gap-0.5 text-[10px] text-[var(--ss-ink)]">
                  <Sparkles size={10} /> Fit changed
                </span>
              )}
            </div>
          )}
        </div>
        </button>
        {/* Check fit & gaps and Open details share one trailing column,
            stacked rather than sitting side by side as flex siblings (per
            direct feedback: the two used to spread out across the row's
            available width, encroaching on the Gantt bar's own column
            instead of staying confined to a fixed-width strip at the row's
            end). Neither nests inside the row's own toggle button, since
            FitGapBadge above is display-only for exactly that reason. The
            row's own "Edit" button that used to sit here was removed
            (2026-10-11 direct feedback: "the edit account button on the
            overview tab should be deleted and can just be on the
            individual company page") — editing now lives on the account's
            own page (AccountPage), reached via "Open details"; "Manage
            accounts" in the header above still edits any account from
            Overview directly, so nothing is lost, just not duplicated per
            row. */}
        <div className="shrink-0 flex flex-col justify-center gap-0.5 py-1.5">
          {matches.length > 0 && (
            <button
              onClick={onCheckFitGaps}
              disabled={fitGapGenerating}
              aria-keyshortcuts={focused ? ariaKeyShortcuts('f') : undefined}
              title={`${fitGap ? 'Re-check fit & gaps' : 'Check fit & gaps'}${focused ? ' (F)' : ''}`}
              className="flex items-center gap-1 text-[10px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] px-3 py-0.5 hover:bg-[var(--ss-surface)] rounded-[var(--ss-radius-sm)] disabled:opacity-60 whitespace-nowrap"
            >
              <Crosshair size={11} /> {fitGap ? 'Re-check' : 'Check fit & gaps'}
            </button>
          )}
          <button
            onClick={onOpenAccount}
            aria-keyshortcuts={focused ? ariaKeyShortcuts('Enter') : undefined}
            title={`Open this account's own page${focused ? ' (↵)' : ''}`}
            className="flex items-center gap-1 text-[10px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] px-3 py-0.5 hover:bg-[var(--ss-surface)] rounded-[var(--ss-radius-sm)] whitespace-nowrap"
          >
            <ExternalLink size={11} /> Open details
          </button>
        </div>
      </div>

      {expanded && (
        <div className="border-t border-[var(--ss-border)] px-4 py-4">
          <OpportunitySummary account={account} fitGap={fitGap} onOpenAccount={onOpenAccount} onUnlinkSignal={props.onUnlinkSignal} />
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// OPPORTUNITY SUMMARY (2026-10-06) — the Overview row's expanded content.
// Deliberately read-only: a bigger labeled pipeline tracker, stated
// priorities, and matched signals, with no actions of their own. Every
// actionable step (confirming a milestone, research, the outreach plan,
// the email composer — everything `PlanPanel` still carries, see
// [features.md §5.1](docs/features.md)) now lives only on the standalone
// account page; "Next milestone" and each matched signal here are links to
// it, not controls. Replaces the old inline `PlanPanel` usage, at the
// user's explicit request to simplify the Overview tab down to a glance
// view and push every action to the individual company page.
// ---------------------------------------------------------------------------

function OpportunitySummary({ account, fitGap, onOpenAccount, onUnlinkSignal }) {
  // Archived signals (2026-10-10) stay off every matched-signal list, not
  // just the account page's own — see MatchedSignalsPanel's own comment.
  const matches = accountMatches(account.id).filter((m) => !m.archived);
  const accountIdx = STAGES.indexOf(account.stage);
  const nextAction = STAGE_ACTIONS[account.stage];

  return (
    <div className="space-y-5">
      {/* Headline tracker — this account's own overall pipeline position
          (not per matched signal — see "Matched signals" below), the same
          labeled bar the standalone page's header uses. */}
      <div>
        <div className="mb-2">
          <GanttHeader />
        </div>
        <GanttBar idx={accountIdx} />
      </div>

      {/* A single, unambiguous link to the full company page — distinct
          from "Next milestone"/a matched signal also being clickable, so
          there's always one obvious way in regardless of account state. */}
      <button
        onClick={onOpenAccount}
        className="w-full flex items-center justify-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-ink)] text-[13px] font-medium py-2 hover:border-[var(--ss-border)] hover:bg-[var(--ss-surface)] transition-colors"
      >
        <ExternalLink size={13} /> View full company page
      </button>

      {/* Next milestone — summary only. Confirming it happens on the
          account's own page, which this also opens. */}
      <button
        onClick={onOpenAccount}
        className={`w-full text-left rounded-[var(--ss-radius)] border p-3.5 transition-opacity hover:opacity-90 ${
          nextAction ? 'border-[var(--ss-border)] bg-[var(--ss-accent-wash)]' : 'border-[var(--ss-border)] bg-[var(--ss-positive-wash)]'
        }`}
      >
        <div className="flex items-center justify-between gap-3 mb-1.5">
          <div className="text-[11px] font-mono uppercase tracking-wide text-[var(--ss-accent)] flex items-center gap-1.5">
            <Flag size={12} /> Next milestone
          </div>
          <div className="flex items-center gap-2">
            <TierBadge tier={account.tier} />
            <span className="text-[11px] font-mono uppercase tracking-wide text-[var(--ss-muted)]">{account.stage}</span>
          </div>
        </div>
        {nextAction ? (
          <div className="flex items-center justify-between gap-3">
            <div className="text-sm text-[var(--ss-ink)]">Next: {nextAction.short}</div>
            <span className="flex items-center gap-1 text-[12px] font-medium text-[var(--ss-accent)] shrink-0">
              Open account <ChevronRight size={13} />
            </span>
          </div>
        ) : (
          <div className="text-sm text-[var(--ss-positive)] flex items-center gap-2">
            <CheckCircle2 size={15} /> This opportunity is closed.
          </div>
        )}
      </button>

      <div>
        <div className={sectionLabel}>Stated priorities</div>
        <ul className="space-y-1">
          {account.priorities.map((p, i) => (
            <li key={i} className="text-sm text-[var(--ss-text-2)] flex items-start gap-2">
              <Target size={13} className="text-[var(--ss-muted)] mt-0.5 shrink-0" />
              {p}
            </li>
          ))}
        </ul>
      </div>

      {/* Matched signals — name + a quick stage/next-step detail only, no
          per-signal Gantt bar (that level of detail is one click away, on
          the account page's own "Matched signals" — see §5.13). Each one's
          own confirm form lives there too. */}
      <div>
        <div className={sectionLabel}>Matched signals</div>
        <div className="space-y-2">
          {matches.length === 0 && <div className="text-[12px] text-[var(--ss-muted)]">No signals matched yet</div>}
          {matches.map((m, i) => {
            const sAction = STAGE_ACTIONS[m.stage];
            return (
              <div key={i} className={`rounded-[var(--ss-radius)] border px-3 py-2 ${signalCardTint(fitGap, m.newsId)}`}>
                <div className="flex items-center justify-between mb-1">
                  <div className="flex items-center gap-1.5">
                    <span className="text-[10px] font-mono uppercase tracking-wide text-[var(--ss-positive)]">{m.news.tag}</span>
                    <SignalGapBadge fitGap={fitGap} newsId={m.newsId} />
                    {m.auto && !m.reviewed && (
                      <span className="flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wide px-1 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-ink)]">
                        <Sparkles size={9} /> Auto-matched
                      </span>
                    )}
                  </div>
                  <div className="flex items-center gap-2">
                    <ConfidenceBar value={m.confidence} />
                    <button
                      onClick={() => onUnlinkSignal(m.newsId)}
                      title="Not relevant — unlink this signal"
                      className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-danger)]"
                    >
                      <Link2Off size={12} />
                    </button>
                  </div>
                </div>
                <button onClick={onOpenAccount} className="w-full text-left hover:opacity-90 transition-opacity flex items-center justify-between gap-3">
                  <span className="text-[13px] text-[var(--ss-ink)] truncate">{m.news.headline}</span>
                  {sAction ? (
                    <span className="flex items-center gap-1 text-[11px] font-medium text-[var(--ss-accent)] bg-[var(--ss-accent-wash)] border border-[var(--ss-border)] rounded-[var(--ss-radius-sm)] px-2 py-0.5 shrink-0">
                      <Flag size={10} /> Next: {sAction.short}
                    </span>
                  ) : (
                    <span className="flex items-center gap-1 text-[11px] font-medium text-[var(--ss-positive)] bg-[var(--ss-positive-wash)] border border-[var(--ss-border)] rounded-[var(--ss-radius-sm)] px-2 py-0.5 shrink-0">
                      <CheckCircle2 size={10} /> Complete
                    </span>
                  )}
                </button>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// NEXT MILESTONE — the stage only moves when the user confirms they've done
// the thing. Confirmation can record who, when and a note; it's all logged
// and the last step can be undone.
// ---------------------------------------------------------------------------

// A quick, dismissible interrupt (added 2026-10-10) before confirming a
// next step on a matched signal that has an unresolved Blocker gap — see
// blockerGapForSignal/startConfirm below. Advisory only, same spirit as
// Go/no-go: it never stops the action, it just makes moving forward on a
// known blocker a deliberate choice instead of an accident.
function BlockerStepWarning({ gap, onCancel, onContinue }) {
  useEscapeToClose(onCancel);
  const [dontShowAgain, setDontShowAgain] = useState(false);
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onCancel}>
      <div
        className="w-full max-w-sm rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-5 space-y-3"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--ss-danger)]">
          <AlertTriangle size={14} /> Unresolved blocker on this signal
        </div>
        <div className="text-[12px] text-[var(--ss-text-2)] leading-relaxed">
          <span className="font-medium">{gap.gap}</span> blocks {gap.scope}. Pick a route in Fit &amp; gaps before moving this forward, or continue anyway.
        </div>
        <label className="flex items-center gap-2 text-[11px] text-[var(--ss-muted)] cursor-pointer">
          <input type="checkbox" checked={dontShowAgain} onChange={(e) => setDontShowAgain(e.target.checked)} />
          Don't show this again
        </label>
        <div className="flex items-center gap-2 pt-1">
          <Button variant="primary" size="sm" onClick={() => onContinue(dontShowAgain)}>
            Continue anyway
          </Button>
          <Button variant="ghost" size="sm" onClick={onCancel}>
            Cancel
          </Button>
        </div>
      </div>
    </div>
  );
}

// External engagement assist (added 2026-10-11, direct feedback: "for
// blockers where external engagement is required... pop up a contact
// identifying/email generator to assist with the outreach. This should
// then disappear and the tool can check in on the topic later.") — pops up
// right after proceedWithSignalRoute confirms a route that names a real
// external party (see needsExternalEngagement), and can also be reopened
// later from the sidebar's SignalActionTracker to follow up ("check in on
// the topic") on an engagement already in progress. Computes its own item
// list from the live fitGap/selectedMoves each render rather than a frozen
// snapshot, so reopening it later always reflects the current picture
// (a gap resolved or re-routed since the popup first fired just won't be
// listed anymore). Closing it (the backdrop, the dismiss button, or "I'll
// do this later") leaves every item's engagement entry exactly as it was —
// nothing here is lost, it just stops interrupting; the tracker is where
// the user "checks in" on it again.
function EngagementAssistModal({ accountId, newsId, account, match, items, engagements, draftingKey, draftErrors, onUpdateContact, onDraftEmail, onMarkContacted, onMarkDone, onClose }) {
  useEscapeToClose(onClose);
  if (!items.length) return null;
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
      <div
        className="w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-5 space-y-4"
        onClick={(e) => e.stopPropagation()}
      >
        <div>
          <div className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--ss-accent)]">
            <UserCheck size={14} /> This route needs someone outside the firm
          </div>
          <div className="text-[12px] text-[var(--ss-muted)] mt-0.5">
            {account ? account.name : ''}{match ? ` · ${match.news.headline}` : ''} — identify a contact and draft the outreach, or come back to this later from Matched signals.
          </div>
        </div>

        <div className="space-y-4">
          {items.map((item) => {
            const key = engagementKey(accountId, newsId, item.gapTag);
            const eng = engagements[key] || {};
            const drafting = draftingKey === key;
            const draft = eng.emailDraft;
            return (
              <div key={key} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] p-3 space-y-2.5">
                <div className="text-[12px] text-[var(--ss-text-2)]">{item.gap}</div>
                <div className="text-[11px] text-[var(--ss-muted)]">
                  <span className="font-[var(--ss-font-mono)] uppercase text-[10px] mr-1">{item.move.type}</span>
                  {item.move.partner ? `${item.move.partner} — ` : ''}
                  {item.move.detail}
                </div>
                <div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
                  <label className="block">
                    <span className="text-[11px] text-[var(--ss-muted)]">Contact name</span>
                    <input
                      value={eng.contactName || ''}
                      onChange={(e) => onUpdateContact(key, { contactName: e.target.value })}
                      placeholder={item.move.partner || 'Who are you reaching out to?'}
                      className={inputClass}
                    />
                  </label>
                  <label className="block">
                    <span className="text-[11px] text-[var(--ss-muted)]">Email</span>
                    <input
                      value={eng.contactEmail || ''}
                      onChange={(e) => onUpdateContact(key, { contactEmail: e.target.value })}
                      placeholder="name@partner-firm.com"
                      className={inputClass}
                    />
                  </label>
                </div>

                {draft ? (
                  <div className="space-y-1.5">
                    <input value={draft.subject} onChange={(e) => onUpdateContact(key, { emailDraft: { ...draft, subject: e.target.value } })} className={`${inputClass} font-medium`} />
                    <textarea value={draft.body} onChange={(e) => onUpdateContact(key, { emailDraft: { ...draft, body: e.target.value } })} rows={5} className={`${inputClass} leading-relaxed`} />
                    <a
                      href={buildMailtoHref({ email: eng.contactEmail }, draft)}
                      className="inline-flex items-center gap-1.5 text-[11px] text-[var(--ss-positive)] hover:underline"
                    >
                      <Mail size={11} /> Open in mail client
                    </a>
                  </div>
                ) : (
                  <div>
                    <button
                      onClick={() => onDraftEmail(item)}
                      disabled={drafting}
                      className="flex items-center gap-1.5 text-[11px] text-[var(--ss-accent)] border border-[var(--ss-accent)]/40 rounded-[var(--ss-radius)] px-2.5 py-1.5 hover:bg-[var(--ss-accent-wash)] disabled:opacity-60"
                    >
                      {drafting ? (
                        <>
                          <Loader2 size={11} className="animate-spin" /> Drafting…
                        </>
                      ) : (
                        <>
                          <Sparkles size={11} /> Draft outreach email
                        </>
                      )}
                    </button>
                    <ErrorText message={draftErrors[key]} className="text-[11px] text-[var(--ss-danger)] mt-1.5" />
                  </div>
                )}

                <div className="flex items-center gap-2 pt-0.5">
                  <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${ENGAGEMENT_STATUS_STYLE[eng.status || 'needed']}`}>
                    {ENGAGEMENT_STATUS_LABEL[eng.status || 'needed']}
                  </span>
                  {eng.status !== 'contacted' && eng.status !== 'done' && (
                    <button onClick={() => onMarkContacted(key)} className="text-[11px] text-[var(--ss-accent)] hover:underline">
                      Mark as contacted
                    </button>
                  )}
                  {eng.status !== 'done' && (
                    <button onClick={() => onMarkDone(key)} className="text-[11px] text-[var(--ss-positive)] hover:underline">
                      Mark confirmed
                    </button>
                  )}
                </div>
              </div>
            );
          })}
        </div>

        <div className="flex items-center justify-end">
          <button onClick={onClose} className="text-[13px] text-[var(--ss-faint)] hover:text-[var(--ss-text-2)] px-2 py-1.5">
            I'll do this later
          </button>
        </div>
      </div>
    </div>
  );
}

// `stage`/`tier` replaced a single `account` prop (2026-10-06) so this same
// component can drive either the account's own baseline milestone (pass
// account.stage/account.tier, as before — this still gates the research
// workflow, see isResearchStage in PlanPanel) or one specific matched
// signal's independent opportunity track (pass that match's own stage —
// see setMatchStage/accountRollupStage). `opportunityId`/`opportunityLabel`,
// when set, get included on every onRecord call so App's recordAction knows
// which track to advance instead of the account as a whole, and get stamped
// onto the activity entry so the shared log can say what it was about.
// `compact` drops the eyebrow/tier chrome and the inline activity list —
// used when several of these are stacked inside one account's matched-
// signals list, where repeating that chrome per signal would be noisy and
// the combined activity log is shown once, above all of them.
// `blockerGap`/`outcome`/`onSetOutcome` added 2026-10-10: `blockerGap` (the
// Blocker-severity gap scoped to this one matched signal, from
// blockerGapForSignal — null for the account's own baseline tracker, which
// isn't tied to a specific signal) gates a one-time-dismissible warning
// before the confirm form opens; `outcome`/`onSetOutcome` back the Won/
// Lost/Paused picker shown once this track reaches Closed.
function NextMilestone({
  stage, tier, contacts, activity, onRecord, onUndo, progress, opportunityId = null, opportunityLabel = null, compact = false,
  managerMode = false, delegatedTo = null, onDelegate = null, onClearDelegate = null,
  blockerGap = null, outcome = null, onSetOutcome = null, onArchive = null,
  // "Generating a proposal shouldn't be an option until the user has added
  // post-meeting notes — they can skip this if they want" (2026-10-11
  // direct follow-up feedback) — same shape as responseLogged above, only
  // gates the Meeting stage's own confirm.
  notesReady = true,
  // Meeting outcome pills (2026-10-12 direct follow-up): "based on the
  // user's response, the action button needs to change" — once a meeting
  // outcome is picked (see meetingOutcomeFlags in App), the Meeting
  // stage's own confirm button reacts to it instead of always reading
  // "Confirm & send proposal": 'proposal' keeps the normal confirm path
  // (notesReady-gated, as before); 'awaiting'/'follow_up' replace the
  // button with a message instead of letting the user confirm past an
  // outcome that isn't actually "ready for proposal" yet; 'not_moving'
  // needs no special handling here since the archive confirm (in
  // MeetingTrack) already takes over before this ever shows again.
  meetingOutcomeFlag = null,
  // "Point the user towards the generation actions before pushing them to
  // confirm" (2026-10-11 direct follow-up feedback) — see
  // generationPointerForStage above.
  generationPointer = null,
  // Research go/no-go (added 2026-10-12, direct request): once research
  // is complete (researchReady below), the account's own baseline track
  // (opportunityId null — this gate is specifically about leaving the
  // research stage for outreach, not a per-signal thing) is asked to
  // explicitly go or no-go instead of walking straight into the normal
  // confirm form. `archived`/`onArchiveOpportunity`/`onUnarchiveOpportunity`
  // back that — see archiveAccount/unarchiveAccount in App.
  archived = false, archivedReason = null, onArchiveOpportunity = null, onUnarchiveOpportunity = null, onAssignOpportunityNumber = null,
  // mod+Enter (2026-10-14) is registered by only ONE instance of this
  // component — the account's own baseline tracker at the top of PlanPanel.
  // Every other instance is a per-signal tracker (MatchedSignalsPanel,
  // GoNoGoCheck), and several of those are on screen at once, so a binding
  // on all of them would be ambiguous about which track it advances.
  enableShortcut = false,
}) {
  const idx = STAGES.indexOf(stage);
  const action = STAGE_ACTIONS[stage];
  const nextStage = STAGES[idx + 1];
  const [confirming, setConfirming] = useState(false);
  const [contactId, setContactId] = useState('');
  const [date, setDate] = useState(todayISO());
  const [note, setNote] = useState('');
  const [showBlockerWarning, setShowBlockerWarning] = useState(false);
  const [celebrate, setCelebrate] = useState(false);
  // Hover toast on the grayed-out Meeting-stage confirm button (2026-10-13
  // direct feedback), see blockedReason below.
  const [showBlockedTip, setShowBlockedTip] = useState(false);
  const researchSummary =
    progress && progress.hasReport
      ? `Research report: ${progress.withEvidence} question${progress.withEvidence === 1 ? '' : 's'} answered with evidence, ${progress.notFound} not found (${progress.mustDone}/${progress.mustTotal} must-answer researched).`
      : '';
  const researchReady = progress && progress.hasReport && progress.mustDone === progress.mustTotal;
  // Only the account's own baseline track (never a per-signal opportunity)
  // gates on go/no-go — see onArchiveOpportunity's comment on NextMilestone.
  const showGoNoGo = !archived && !opportunityId && stage === 'Identified' && researchReady;
  // Meeting outcome gating (2026-10-12 direct follow-up): "awaiting"
  // (waiting on client feedback) and "follow_up" (another meeting is
  // being scheduled) both mean this track isn't actually ready to confirm
  // toward Proposal yet, so the confirm button is replaced with a message
  // instead of staying clickable (or merely disabled) — "not_moving"
  // doesn't need a case here since MeetingTrack's own archive confirm
  // already takes over the moment that outcome is actually set.
  const meetingBlocked = stage === 'Meeting' && (meetingOutcomeFlag === 'awaiting' || meetingOutcomeFlag === 'follow_up');
  // What to say in the hover toast on the grayed-out confirm button
  // (2026-10-13 direct feedback) — covers both reasons the Meeting-stage
  // button can be disabled: an outcome that blocks moving to Proposal at
  // all (meetingBlocked), or a non-blocking outcome (e.g. "Client inviting
  // proposal") where the button is only waiting on meeting notes. null
  // means the button isn't disabled for a reason worth explaining here
  // (e.g. the Proposal stage's own missing-outcome gate keeps its existing,
  // unrelated behavior).
  const blockedReason =
    stage !== 'Meeting'
      ? null
      : meetingOutcomeFlag === 'awaiting'
      ? "Waiting on the client's feedback — update the meeting outcome above to proceed."
      : meetingOutcomeFlag === 'follow_up'
      ? "Another meeting is being scheduled — update the meeting outcome above to proceed."
      : !notesReady
      ? 'Add your meeting notes (or skip) below before sending the proposal.'
      : null;

  function openConfirmForm() {
    if (stage === 'Identified' && researchSummary) setNote(researchSummary);
    setConfirming(true);
  }

  // "Selecting a next step" on a blocked matched signal pauses for an
  // explicit choice instead of letting the confirm form open straight away
  // — see BlockerStepWarning below and the don't-show-again flag at the
  // top of the file.
  function startConfirm() {
    if (blockerGap && !blockerPopupDismissed()) {
      setShowBlockerWarning(true);
      return;
    }
    openConfirmForm();
  }

  // Same entry point the confirm button uses — deliberately startConfirm,
  // not confirm(): a blocked signal's "are you sure" step (BlockerStepWarning)
  // and the contact/date/note form both still happen. The shortcut can never
  // skip a confirmation the click path would show.
  const confirmBlocked =
    meetingBlocked || (stage === 'Proposal' && onSetOutcome && !outcome) || (stage === 'Meeting' && !notesReady);
  useShortcut(enableShortcut ? 'mod+Enter' : null, startConfirm, {
    label: action ? `Confirm "${action.confirm}"` : 'Confirm the next milestone',
    group: 'Account',
    enabled: !!action && !archived && !showGoNoGo && !confirming && !confirmBlocked,
  });

  function confirm() {
    onRecord({ action: action.activity, to: nextStage, contactId: contactId || null, date, note: note.trim(), opportunityId, opportunityLabel });
    setConfirming(false);
    setContactId('');
    setNote('');
    setDate(todayISO());
    setCelebrate(true);
    window.setTimeout(() => setCelebrate(false), 900);
  }

  // "Go" on the research go/no-go: same transition "Research done" would
  // make, just without the contact/date form — the decision itself is the
  // point here, not who it was logged with.
  function goToOutreach() {
    onRecord({ action: action.activity, to: nextStage, contactId: null, date: todayISO(), note: researchSummary, opportunityId, opportunityLabel });
    if (onAssignOpportunityNumber) onAssignOpportunityNumber();
    setCelebrate(true);
    window.setTimeout(() => setCelebrate(false), 900);
  }

  const contactName = (id) => contacts.find((c) => c.id === id)?.name;
  const movesLabel = opportunityId ? 'this signal' : 'this account';

  return (
    <div className="space-y-3">
      <div
        className={`rounded-[var(--ss-radius)] border ${compact ? 'p-2.5' : 'p-3.5'} ${celebrate ? 'gl-milestone-advance' : ''} ${
          action ? 'border-[var(--ss-accent)]/35 bg-[var(--ss-accent-wash)]' : 'border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]'
        }`}
      >
        {!compact && (
          <div className="flex items-center justify-between gap-3 mb-1.5">
            <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-accent-wash-ink)] flex items-center gap-1.5">
              <Flag size={12} /> Next milestone
            </div>
            <div className="flex items-center gap-2">
              <TierBadge tier={tier} />
              <span className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">{stage}</span>
            </div>
          </div>
        )}

        {archived ? (
          <div className="space-y-2">
            <div className="text-sm text-[var(--ss-danger)] flex items-center gap-2">
              <Archive size={15} /> This opportunity was archived{archivedReason ? ` — ${archivedReason}` : ''}.
            </div>
            {onUnarchiveOpportunity && (
              <button
                onClick={onUnarchiveOpportunity}
                className="flex items-center gap-1.5 text-[11px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] border border-[var(--ss-border)] hover:border-[var(--ss-frame)] rounded-[var(--ss-radius-sm)] px-2 py-1 transition-colors"
              >
                <Undo2 size={12} /> Unarchive — reopen opportunity
              </button>
            )}
          </div>
        ) : showGoNoGo ? (
          <div className="space-y-2">
            <div className="text-sm text-[var(--ss-ink)]">Research is done. Go or no-go on this opportunity?</div>
            <div className="text-[11px] text-[var(--ss-muted)] mt-0.5">
              Go moves this account to <span className="text-[var(--ss-text-2)]">Researched</span> and opens up outreach. No-go archives it instead — reversible any time from Overview.
            </div>
            {progress && (
              <div className="text-[11px] text-[var(--ss-positive)]">
                {`Every must-answer question is in the report. Review it, then decide.`}
              </div>
            )}
            <div className="flex items-center gap-2 pt-1">
              <Button variant="primary" size="sm" icon={CheckCircle2} onClick={goToOutreach}>
                Go
              </Button>
              <Button variant="danger" size="sm" icon={Archive} onClick={() => onArchiveOpportunity && onArchiveOpportunity()}>
                No-go
              </Button>
            </div>
          </div>
        ) : !action ? (
          <div className="space-y-2">
            <div className="text-sm text-[var(--ss-positive)] flex items-center gap-2">
              <CheckCircle2 size={15} className={celebrate ? 'gl-check-pop' : ''} /> {opportunityId ? 'This signal is closed.' : 'This opportunity is closed.'}
            </div>
            {onSetOutcome && (
              <div className="flex items-center gap-2">
                <span className="text-[11px] text-[var(--ss-muted)]">Outcome:</span>
                <FilterDropdown
                  value={outcome || 'All'}
                  allLabel="Select outcome…"
                  options={DEAL_OUTCOMES}
                  onChange={(v) => onSetOutcome(v === 'All' ? null : v)}
                  width={140}
                />
                {outcome && (
                  <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${DEAL_OUTCOME_STYLE[outcome]}`}>{outcome}</span>
                )}
              </div>
            )}
            {/* Archive (added 2026-10-10, direct feedback: "if a signal is
                closed, the user selects the outcome and there should be a
                button to click to archive and take it off this screen") —
                only once an outcome is actually picked, and only for a
                matched signal (opportunityId set; there's no equivalent
                "off screen" for the account's own baseline track, which
                always stays in Overview). See archiveSignal in App. */}
            {/* Reopen (P1 item 11, feedback brief: "closing a signal's
                process should be reversible — add Undo/reopen, consistent
                with the app") — reuses the same undoLastAction path the
                "Undo last" activity-log control already calls for this
                track, just surfaced here too since once closed, the
                activity log that usually hosts "Undo last" isn't the most
                obvious place to look for it. Distinct from Archive below,
                which is a separate, also-reversible "off screen" step. */}
            <div className="flex items-center gap-2">
              {onUndo && (
                <button
                  onClick={onUndo}
                  className="flex items-center gap-1.5 text-[11px] text-[var(--ss-text-2)] hover:text-[var(--ss-ink)] border border-[var(--ss-border)] hover:border-[var(--ss-frame)] rounded-[var(--ss-radius-sm)] px-2 py-1 transition-colors"
                >
                  <Undo2 size={12} /> Reopen
                </button>
              )}
              {onArchive && opportunityId && outcome && (
                <button
                  onClick={onArchive}
                  className="flex items-center gap-1.5 text-[11px] text-[var(--ss-text-2)] hover:text-[var(--ss-danger)] border border-[var(--ss-border)] hover:border-[var(--ss-danger)]/50 rounded-[var(--ss-radius-sm)] px-2 py-1 transition-colors"
                >
                  <Archive size={12} /> Archive — remove from this page
                </button>
              )}
            </div>
          </div>
        ) : !confirming ? (
          // items-start (was items-center, 2026-10-13 direct feedback: "the
          // move to proposal button should stay in the same location") —
          // items-center re-centers the button against whatever height the
          // left column happens to be, so the button visibly moved up/down
          // as the blocked-state explanation text appeared/disappeared.
          // Anchoring both to the top keeps the button's position fixed
          // regardless of how much explanatory text is showing on the left.
          // That alone wasn't enough, though: the left column had no width
          // constraint, so a long unbroken explanation line (e.g. the
          // "awaiting" message) pushed the row past its container width
          // before the text ever wrapped, tripping flex-wrap and dropping
          // the button onto its own line below instead of leaving it beside
          // the text — a textbook flex min-width:auto trap. flex-1 min-w-0
          // on the left column forces it to wrap its own text within the
          // space actually available, so the button never gets pushed.
          <div className="flex items-start justify-between gap-3 flex-wrap">
            <div className="flex-1 min-w-0">
              <div className={compact ? 'text-[13px] text-[var(--ss-ink)]' : 'text-sm text-[var(--ss-ink)]'}>
                {/* "On first open, instead of showing next milestone for
                    research, phrase it so it's clear: 'start researching
                    this account'" (2026-10-11 direct follow-up feedback) —
                    `progress` is null until a research plan or report
                    exists, so this only applies before anything's started.
                    Everywhere else, this reads as a plain "Next: X" label
                    (2026-10-12 direct feedback: "the next milestone
                    shouldn't be phrased as a question — it should just
                    read outreach or whatever stage; phrasing it as a
                    question makes it confusing what step the user is on"),
                    the same non-question pattern Overview's own row labels
                    already use (`Next: {nextAction.short}`), rather than
                    the old "Have you finished X?" wording. */}
                {stage === 'Identified' && !progress ? 'Start researching this account.' : `Next: ${action.short}`}
              </div>
              <div className="text-[11px] text-[var(--ss-muted)] mt-0.5">
                Confirming moves {movesLabel} to <span className="text-[var(--ss-text-2)]">{nextStage}</span>.
                {!compact && action.hint ? ` ${action.hint}` : ''}
              </div>
              {/* "Point the user towards the generation actions before
                  pushing them to confirm they've taken action — the
                  generation resources available need to be clear"
                  (2026-10-11 direct follow-up feedback). */}
              {generationPointer && (
                <button
                  onClick={() => {
                    const el = document.getElementById(generationPointer.anchorId);
                    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
                  }}
                  className="flex items-center gap-1 text-[11px] text-[var(--ss-accent)] hover:underline mt-1"
                >
                  <Sparkles size={11} /> {generationPointer.label} below first
                </button>
              )}
              {progress && (
                <div className={`text-[11px] mt-1 ${researchReady ? 'text-[var(--ss-positive)]' : 'text-[var(--ss-gap)]'}`}>
                  {researchReady
                    ? 'Every must-answer question is in the report. Review it, then confirm.'
                    : progress.hasReport
                    ? `${progress.mustDone} of ${progress.mustTotal} must-answer questions are in the report.`
                    : 'No research report yet.'}
                </div>
              )}
              {/* P1 item 11 (feedback brief): "after a proposal has been
                  sent and its outcome logged, show a final Close process
                  button" — outcome gets picked here, before closing,
                  rather than after (the reverse of how it worked before
                  this pass: outcome used to only appear once already
                  Closed). Proposal-stage only; every other stage keeps the
                  plain confirm flow above unchanged. */}
              {stage === 'Proposal' && onSetOutcome && (
                <div className="flex items-center gap-2 mt-1.5">
                  <span className="text-[11px] text-[var(--ss-muted)]">Outcome:</span>
                  <FilterDropdown
                    value={outcome || 'All'}
                    allLabel="Select outcome…"
                    options={DEAL_OUTCOMES}
                    onChange={(v) => onSetOutcome(v === 'All' ? null : v)}
                    width={140}
                  />
                  {outcome && <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${DEAL_OUTCOME_STYLE[outcome]}`}>{outcome}</span>}
                </div>
              )}
              {/* "Generating a proposal and clicking proposal sent shouldn't
                  be an option until the user has added post-meeting notes —
                  they can skip this if they want" (2026-10-11 direct
                  follow-up feedback) — the skip link itself lives on the
                  notes form in MeetingTrack, not here; this just explains
                  why the button below is disabled until one or the other
                  happens. */}
              {stage === 'Meeting' && !notesReady && !meetingBlocked && (
                <div className="text-[11px] text-[var(--ss-gap)] mt-1">
                  Add your meeting notes (or skip) below before sending the proposal.
                </div>
              )}
              {/* "Based on the user's response, the action button needs to
                  change" (2026-10-12 direct follow-up on the meeting
                  outcome pills). Originally hid the confirm button entirely
                  while blocked; changed 2026-10-13 (direct feedback) to keep
                  it visible but grayed out instead, with a hover toast (see
                  blockedReason/showBlockedTip below) carrying a shorter
                  version of this same explanation — this persisted text
                  stays too, for touch/keyboard users who can't hover. */}
              {meetingBlocked && (
                <div className={`text-[11px] mt-1 ${meetingOutcomeFlag === 'awaiting' ? 'text-[var(--ss-gap)]' : 'text-[var(--ss-text-2)]'}`}>
                  {meetingOutcomeFlag === 'awaiting'
                    ? "Waiting on the client's feedback — update the meeting outcome above once you hear back, going forward or not."
                    : "Another meeting is being scheduled — log it in Meeting prep & follow-up below. Come back here once you're ready to send a proposal."}
                </div>
              )}
            </div>
            {/* relative/shrink-0 wrapper (2026-10-13 direct feedback) hosts
                the hover toast below and keeps the button from being
                squeezed by the left column's width — a plain `title`
                doesn't match this app's own floating-panel look (see
                ApiKeyControl), so blockedReason drives a small custom toast
                instead, shown only while actually hovering a disabled
                button with something to explain. */}
            <div
              className="relative shrink-0"
              onMouseEnter={() => setShowBlockedTip(true)}
              onMouseLeave={() => setShowBlockedTip(false)}
            >
              <Button
                variant="primary"
                onClick={startConfirm}
                disabled={confirmBlocked}
                {...(enableShortcut && !confirmBlocked
                  ? shortcutButtonProps('mod+Enter', stage === 'Proposal' && onSetOutcome ? 'Close process' : action.confirm)
                  : {})}
                icon={CheckCircle2}
              >
                {stage === 'Proposal' && onSetOutcome ? 'Close process' : action.confirm}
                {enableShortcut && !confirmBlocked && <ShortcutHint keys="mod+Enter" variant="accent" />}
              </Button>
              {showBlockedTip && blockedReason && (
                <div className="absolute right-0 bottom-full mb-2 w-60 p-2.5 rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] z-20 text-left">
                  <div className="flex items-start gap-1.5 text-[11px] text-[var(--ss-text-2)] leading-relaxed">
                    <Info size={12} className="mt-0.5 shrink-0 text-[var(--ss-muted)]" /> {blockedReason}
                  </div>
                </div>
              )}
            </div>
            {/* Manager mode (added 2026-10-08) — demo-only "delegate this
                action" control, see MANAGER_COLLEAGUES above. Delegating
                doesn't block the confirm button above; a manager can still
                do it themselves. */}
            {managerMode && (
              <div className="w-full flex items-center gap-2 pt-1">
                {delegatedTo ? (
                  <>
                    <span className="flex items-center gap-1 text-[11px] text-[var(--ss-text-2)] bg-[var(--ss-rule-soft)] border border-[var(--ss-border)] rounded-[var(--ss-radius-sm)] px-2 py-0.5">
                      <UserCheck size={11} /> Delegated to {delegatedTo}
                    </span>
                    <button onClick={onClearDelegate} className="text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-danger)]">
                      Clear
                    </button>
                  </>
                ) : (
                  <FilterDropdown value="All" allLabel="Delegate to…" options={MANAGER_COLLEAGUES} onChange={(v) => v !== 'All' && onDelegate(v)} width={150} />
                )}
              </div>
            )}
          </div>
        ) : (
          <div className="space-y-2.5 mt-2">
            <div className="text-sm text-[var(--ss-ink)]">Log it: {action.activity.toLowerCase()}</div>
            <div className="grid gap-2.5" style={{ gridTemplateColumns: 'minmax(0, 1fr) 150px' }}>
              <label className="block">
                <span className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">With</span>
                <select value={contactId} onChange={(e) => setContactId(e.target.value)} className={inputClass}>
                  <option value="">Not recorded</option>
                  {contacts.map((c) => (
                    <option key={c.id} value={c.id}>{c.name} — {c.role}</option>
                  ))}
                </select>
              </label>
              <label className="block">
                <span className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">Date</span>
                <input type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} />
              </label>
            </div>
            <label className="block">
              <span className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">Note (optional)</span>
              <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="e.g. They asked for a follow-up after Q4 budgets" className={inputClass} />
            </label>
            <div className="flex items-center gap-2">
              <Button variant="primary" size="sm" onClick={confirm}>
                Confirm & move to {nextStage}
              </Button>
              <Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
                Cancel
              </Button>
            </div>
          </div>
        )}
      </div>

      {/* Activity (2026-10-10, direct feedback: "update all the activity
          panels on the company pages to ensure they're matching with the
          status shown on the gantt") — now shown in compact (per-signal)
          instances too, not just the account's own baseline panel; the
          caller is responsible for pre-filtering `activity` to exactly
          this track via activityForTrack, so what's listed here always
          corresponds to the Gantt bar this NextMilestone is attached to.
          "Undo last" is hidden when every entry shown is a synthesized
          cascade marker (see activityForTrack) — there's nothing real to
          undo for a track that only ever moved as a side effect of the
          account's own confirm. */}
      {activity.length > 0 && (
        <div>
          <div className="flex items-center justify-between mb-2">
            <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Activity</div>
            {activity.some((e) => !e.synthetic) && (
              <button onClick={onUndo} className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
                <Undo2 size={11} /> Undo last
              </button>
            )}
          </div>
          <ul className="space-y-1.5">
            {[...activity].reverse().map((e) => (
              <li key={e.id} className="text-[12px] text-[var(--ss-text-2)] flex items-start gap-2">
                <span className="font-[var(--ss-font-mono)] text-[var(--ss-faint)] shrink-0 w-20">{e.date}</span>
                <span>
                  <span className={e.synthetic ? 'text-[var(--ss-text-2)] italic' : 'text-[var(--ss-ink)]'}>{e.action}</span>
                  {e.contactId && contactName(e.contactId) ? ` with ${contactName(e.contactId)}` : ''}
                  {e.to ? <span className="text-[var(--ss-faint)]"> · {e.from} → {e.to}</span> : null}
                  {e.opportunityLabel ? <span className="text-[var(--ss-faint)]"> · on "{e.opportunityLabel}"</span> : null}
                  {e.note ? <span className="block text-[var(--ss-muted)]">{e.note}</span> : null}
                </span>
              </li>
            ))}
          </ul>
        </div>
      )}

      {showBlockerWarning && blockerGap && (
        <BlockerStepWarning
          gap={blockerGap}
          onCancel={() => setShowBlockerWarning(false)}
          onContinue={(dontShowAgain) => {
            if (dontShowAgain) dismissBlockerPopupForever();
            setShowBlockerWarning(false);
            openConfirmForm();
          }}
        />
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// PLAN PANEL — the standalone account page's main column: next milestone,
// research workflow, plan generation with grounded citations, and outreach
// resources (email composer, address book, framework). Stated priorities
// and matched signals used to live here too; moved out to `StatedPriorities`
// /`MatchedSignalsPanel` (2026-10-07) so the account page can run them down
// a sidebar alongside this column instead of stacking everything in one.
// Only ever rendered by `AccountPage` now — the Overview row's own expand
// uses the separate, read-only `OpportunitySummary` (2026-10-06).
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// GO / NO-GO (Day 3b, demo-week-plan.md) — a computed (not AI-generated)
// recommendation shown at the Proposal stage, right before the "Close the
// deal" confirm. Deliberately advisory only: per the plan's own rule
// ("stages never auto-advance"), nothing here disables or gates the real
// confirm button in NextMilestone below — it's decision support, not a
// gate. Reads the same fitGap result FitGapPanel/ProposalPanel do.
// ---------------------------------------------------------------------------

// Go/no-go now checks whether the chosen ROUTE held up (redesign point 4),
// not just whether a gap was acknowledged: a blocker needs a move actually
// picked in Fit & gaps above (team up, narrow scope, decline, etc.) AND
// that route confirmed, in that order — picking "Decline" and confirming
// it is this design's equivalent of the old "acknowledge as out of scope."
// `matches`/`contacts`/`onRecord`/`onUndo`/`dealOutcomes`/`onSetOutcome`
// added 2026-10-10 (direct feedback: "the go/no-go box should have the next
// step for each matched signal that is at the go/no-go stage") — a matched
// signal reaching Proposal stage is otherwise only actionable by expanding
// it in the Matched signals sidebar, easy to miss right when this box is
// telling you whether it's safe to close. Rendered inline here via the same
// NextMilestone component (compact), one per matched signal at Proposal.
function GoNoGoCheck({ account, fitGap, confirmedMitigations, onToggleMitigation, selectedMoves, matches = [], contacts = [], activity = [], onRecord, onUndo, dealOutcomes = {}, onSetOutcome, onArchiveSignal }) {
  if (!fitGap) {
    return (
      <div className="rounded-[var(--ss-radius)] border border-dashed border-[var(--ss-border)] bg-[var(--ss-surface)] p-4">
        <div className={sectionLabel}>Go / no-go</div>
        <div className="text-[12px] text-[var(--ss-muted)]">Run "Check fit &amp; gaps" above before closing, so this reflects the account's actual gaps.</div>
      </div>
    );
  }
  const routes = selectedMoves || {};
  const isConfirmed = (tag) => !!confirmedMitigations[`${account.id}:${tag}`];
  const hasRoute = (g) => typeof routes[g.tag] === 'number';
  const resolved = (g) => hasRoute(g) && isConfirmed(g.tag);
  const blockers = fitGap.gaps.filter((g) => g.severity === 'Blocker');
  const coverable = fitGap.gaps.filter((g) => g.severity === 'Coverable' || g.severity === 'Minor');
  const noRouteBlockers = blockers.filter((g) => !hasRoute(g));
  const unconfirmedBlockers = blockers.filter((g) => hasRoute(g) && !resolved(g));
  const unresolvedCoverable = coverable.filter((g) => !resolved(g));

  let rec, recColor, RecIcon;
  if (noRouteBlockers.length > 0) {
    rec = `Hold — pick a route in Fit &amp; gaps for ${noRouteBlockers.length} blocker${noRouteBlockers.length === 1 ? '' : 's'} before this can proceed.`;
    recColor = 'text-[var(--ss-danger)] border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]';
    RecIcon = AlertTriangle;
  } else if (unconfirmedBlockers.length > 0) {
    rec = `Hold — confirm the chosen route for ${unconfirmedBlockers.length} blocker${unconfirmedBlockers.length === 1 ? '' : 's'} below.`;
    recColor = 'text-[var(--ss-danger)] border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)]';
    RecIcon = AlertTriangle;
  } else if (unresolvedCoverable.length > 0) {
    rec = `Proceed with caution — confirm the chosen route for ${unresolvedCoverable.length} gap${unresolvedCoverable.length === 1 ? '' : 's'} before signing.`;
    recColor = 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]';
    RecIcon = Flag;
  } else {
    rec = 'Proceed — every gap has a chosen, confirmed route.';
    recColor = 'text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]';
    RecIcon = CheckCircle2;
  }

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-4 space-y-3">
      <div className={sectionLabel}>Go / no-go</div>
      <div className={`flex items-center gap-2 text-[13px] font-medium rounded-[var(--ss-radius)] border px-3 py-2 ${recColor}`}>
        <RecIcon size={14} /> {rec}
      </div>
      {fitGap.gaps.length === 0 ? (
        <div className="text-[12px] text-[var(--ss-positive)]">No gaps on file — every matched signal is a fit.</div>
      ) : (
        <div className="space-y-2">
          {fitGap.gaps.map((g) => {
            const chosen = hasRoute(g) ? g.moves[routes[g.tag]] : null;
            return (
              <div key={g.tag} className="text-[12px]">
                <div>
                  <span className={`font-[var(--ss-font-mono)] uppercase text-[10px] mr-1 ${g.severity === 'Blocker' ? 'text-[var(--ss-danger)]' : 'text-[var(--ss-gap)]'}`}>{g.severity}</span>
                  {g.gap} <span className="text-[var(--ss-faint)]">({g.scope})</span>
                </div>
                {g.evidence.map((id) => (
                  <Citation key={id} sourceId={id} />
                ))}
                {chosen ? (
                  <label className="flex items-start gap-2 mt-1 cursor-pointer pl-1">
                    <input type="checkbox" checked={isConfirmed(g.tag)} onChange={() => onToggleMitigation(account.id, g.tag)} className="mt-0.5" />
                    <span className="text-[var(--ss-text-2)]">
                      Route: <span className="font-[var(--ss-font-mono)] uppercase text-[10px]">{chosen.type}</span> {chosen.partner ? `${chosen.partner} — ` : ''}
                      {chosen.detail} — <span className="text-[var(--ss-muted)]">{isConfirmed(g.tag) ? 'confirmed' : 'confirm this route'}</span>
                    </span>
                  </label>
                ) : (
                  <div className="text-[var(--ss-faint)] mt-1 pl-1">No route chosen yet — pick one in Fit &amp; gaps above.</div>
                )}
              </div>
            );
          })}
        </div>
      )}

      {matches.filter((m) => m.stage === 'Proposal').length > 0 && (
        <div className="space-y-2.5 pt-2 border-t border-[var(--ss-rule)]">
          <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">Next step — matched signals at go/no-go</div>
          {matches.filter((m) => m.stage === 'Proposal').map((m) => (
            <div key={m.newsId} id={`proposal-signal-${m.newsId}`}>
              <div className="text-[12px] text-[var(--ss-text-2)] mb-1 truncate">{m.news.headline}</div>
              <NextMilestone
                stage={m.stage}
                tier={account.tier}
                contacts={contacts}
                activity={activityForTrack(activity, m.newsId)}
                onRecord={onRecord}
                onUndo={() => onUndo(m.newsId)}
                progress={null}
                opportunityId={m.newsId}
                opportunityLabel={m.news.headline}
                compact
                outcome={dealOutcomes[m.newsId]}
                onSetOutcome={onSetOutcome ? (v) => onSetOutcome(m.newsId, v) : null}
                blockerGap={blockerGapForSignal(fitGap, m.newsId)}
                onArchive={onArchiveSignal ? () => onArchiveSignal(m.newsId) : null}
                generationPointer={generationPointerForStage(m.stage)}
              />
            </div>
          ))}
        </div>
      )}

      <div className="text-[11px] text-[var(--ss-faint)]">Advisory only — closing the deal below still always needs your own explicit confirmation.</div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// MEETING STAGE (P1 item 10, feedback brief) — three ordered pieces, scoped
// per track (the account's own baseline, or one matched signal, each with
// an independent stage — see setMatchStage/accountRollupStage). Reusing
// STAGE_ACTIONS.Contacted's "Hold a meeting" / STAGE_ACTIONS.Meeting's
// "Send a proposal": a track at Contacted is BEFORE its meeting (plan
// generator); a track that's already reached Meeting means the meeting
// already happened (notes upload/parsing, then the outcomes tracker for
// any further meetings before Proposal). This doesn't duplicate the
// existing stage-confirm button (NextMilestone, shown for the account
// baseline above and for each matched signal in the sidebar) — it's
// additive content alongside that confirm flow, not a replacement for it.
// ---------------------------------------------------------------------------

// Meeting outcome pills — one shared label map so a collapsed slot's
// summary line and anything else that needs to say what was picked all
// read it the same way.
const MEETING_OUTCOME_LABEL = {
  proposal: 'Client inviting proposal',
  awaiting: 'Awaiting client feedback',
  follow_up: 'Follow-up scheduled',
  not_moving: 'Not moving forward',
};

// One meeting's worth of notes-capture, structured identically whether
// it's the first meeting or a later follow-up (2026-10-12 direct
// feedback: "structure the meeting prep and follow up section with
// discrete sections that collapse as the user moves through... the
// fields should also match with the first meeting (pills, file
// attachment, process notes)"). Collapses to a one-line summary the
// moment it resolves (a processed report, or an explicit skip) — that's
// also exactly when MeetingTrack decides whether to open the next slot
// (see its own effect), so "the window collapses and the next meeting's
// notes can be inputted" falls out of the same state change.
function MeetingSlot({
  index, opportunityId,
  notesReport, onSubmitNotes, notesSubmitting, notesError,
  notesSkipped, onSkipNotes,
  outcomeFlag, onSetOutcomeFlag,
  onFlagNotMovingForward,
  onRemove,
}) {
  const [notes, setNotes] = useState('');
  const [file, setFile] = useState(null);
  const fileInputRef = useRef(null);
  const [confirmingNotMoving, setConfirmingNotMoving] = useState(false);
  // "An option to delete a meeting if it's generated erroneously"
  // (2026-10-12 direct feedback) — same confirm/cancel pattern as the
  // archive flow just below, since this permanently drops the slot's
  // notes/outcome and can't be undone.
  const [confirmingRemove, setConfirmingRemove] = useState(false);
  const resolved = !!notesReport || notesSkipped;
  const [expanded, setExpanded] = useState(!resolved);
  useEffect(() => {
    if (resolved) setExpanded(false);
  }, [resolved]);

  function submitNotes() {
    if (!notes.trim() && !file) return;
    onSubmitNotes({ notes: notes.trim(), file });
  }

  const label = index === 1 ? 'First meeting' : `Meeting ${index}`;

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] p-3 space-y-2.5">
      <div className="flex items-center justify-between gap-2">
        <button onClick={() => setExpanded((v) => !v)} className="flex-1 min-w-0 flex items-center justify-between gap-2 hover:opacity-80">
          <span className="flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">
            {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {label}
          </span>
          {!expanded && (
            <span className="text-[11px] text-[var(--ss-muted)] truncate max-w-[65%]">
              {[notesSkipped ? 'Skipped' : notesReport ? 'Notes processed' : null, outcomeFlag ? MEETING_OUTCOME_LABEL[outcomeFlag] : null]
                .filter(Boolean)
                .join(' · ')}
            </span>
          )}
        </button>
        {onRemove && (
          <button
            onClick={() => setConfirmingRemove(true)}
            title="Delete this meeting — e.g. if it was added erroneously"
            className="shrink-0 text-[var(--ss-faint)] hover:text-[var(--ss-danger)]"
          >
            <Trash2 size={13} />
          </button>
        )}
      </div>
      {confirmingRemove && (
        <div className="space-y-2 rounded-[var(--ss-radius)] border border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)] p-2.5">
          <div className="text-[12px] text-[var(--ss-text-2)]">
            Delete {label.toLowerCase()}? Its notes and outcome will be removed, and the previous meeting's "Scheduling follow-up meeting" outcome will be cleared so it doesn't just come back.
          </div>
          <div className="flex items-center gap-2">
            <Button variant="danger" size="sm" icon={Trash2} onClick={() => { onRemove(); setConfirmingRemove(false); }}>
              Delete meeting
            </Button>
            <Button variant="ghost" size="sm" onClick={() => setConfirmingRemove(false)}>
              Cancel
            </Button>
          </div>
        </div>
      )}
      {expanded && (
        <div className="space-y-3">
          {(onSetOutcomeFlag || onFlagNotMovingForward) && (
            <div className="space-y-1.5">
              <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">Meeting outcome</div>
              <div className="flex flex-wrap gap-1.5">
                {onSetOutcomeFlag && (
                  <>
                    <button
                      onClick={() => {
                        setConfirmingNotMoving(false);
                        onSetOutcomeFlag('proposal');
                      }}
                      className={`text-[12px] font-medium px-2.5 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
                        outcomeFlag === 'proposal'
                          ? 'border-[var(--ss-positive)] bg-[var(--ss-positive)] text-[var(--ss-accent-text)]'
                          : 'border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)] text-[var(--ss-positive)] hover:border-[var(--ss-positive)]'
                      }`}
                    >
                      Client inviting proposal
                    </button>
                    <button
                      onClick={() => {
                        setConfirmingNotMoving(false);
                        onSetOutcomeFlag('awaiting');
                      }}
                      className={`text-[12px] font-medium px-2.5 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
                        outcomeFlag === 'awaiting'
                          ? 'border-[var(--ss-gap)] bg-[var(--ss-gap)] text-[var(--ss-accent-text)]'
                          : 'border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)] text-[var(--ss-gap)] hover:border-[var(--ss-gap)]'
                      }`}
                    >
                      Awaiting further client feedback
                    </button>
                    <button
                      onClick={() => {
                        setConfirmingNotMoving(false);
                        onSetOutcomeFlag('follow_up');
                      }}
                      className={`text-[12px] font-medium px-2.5 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
                        outcomeFlag === 'follow_up'
                          ? 'border-[var(--ss-inverse)] bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]'
                          : 'border-[var(--ss-border)] bg-[var(--ss-surface)] text-[var(--ss-text-2)] hover:border-[var(--ss-frame)]'
                      }`}
                    >
                      Scheduling follow-up meeting
                    </button>
                  </>
                )}
                {onFlagNotMovingForward && (
                  <button
                    onClick={() => setConfirmingNotMoving(true)}
                    className={`text-[12px] font-medium px-2.5 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
                      outcomeFlag === 'not_moving'
                        ? 'border-[var(--ss-danger)] bg-[var(--ss-danger)] text-[var(--ss-accent-text)]'
                        : 'border-[var(--ss-danger)]/35 bg-[var(--ss-danger-wash)] text-[var(--ss-danger)] hover:border-[var(--ss-danger)]'
                    }`}
                  >
                    Client not moving forward
                  </button>
                )}
              </div>
              {confirmingNotMoving && (
                <div className="space-y-2 pt-1">
                  <div className="text-[12px] text-[var(--ss-text-2)]">
                    Archive {opportunityId ? 'this signal' : 'this opportunity'} as not moving forward? Reversible any time from {opportunityId ? 'Matched signals' : 'Overview'}.
                  </div>
                  <div className="flex items-center gap-2">
                    <Button variant="danger" size="sm" icon={Archive} onClick={() => { onFlagNotMovingForward(); setConfirmingNotMoving(false); }}>
                      Archive — client not moving forward
                    </Button>
                    <button onClick={() => setConfirmingNotMoving(false)} className="text-[12px] text-[var(--ss-faint)] hover:text-[var(--ss-text-2)] px-2 py-1.5">
                      Cancel
                    </button>
                  </div>
                </div>
              )}
            </div>
          )}

          {!notesReport ? (
            <div className="space-y-2">
              <div className="text-[12px] text-[var(--ss-muted)]">
                Add your notes from {index === 1 ? 'the meeting' : 'this meeting'} — paste them, attach a file, or both.
              </div>
              <textarea
                value={notes}
                onChange={(e) => setNotes(e.target.value)}
                disabled={notesSubmitting}
                rows={4}
                placeholder="Paste meeting notes…"
                className={inputClass}
              />
              <div className="flex items-center gap-2 flex-wrap">
                <input
                  ref={fileInputRef}
                  type="file"
                  accept=".txt,.md,.markdown,.csv,.eml,.log,.pdf,text/plain,text/markdown,text/csv,application/pdf"
                  disabled={notesSubmitting}
                  onChange={(e) => setFile(e.target.files && e.target.files[0] ? e.target.files[0] : null)}
                  className="hidden"
                />
                <button
                  onClick={() => fileInputRef.current && fileInputRef.current.click()}
                  disabled={notesSubmitting}
                  className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
                >
                  <FileText size={13} /> {file ? 'Change file' : 'Attach a file'}
                </button>
                {file && (
                  <span className="text-[12px] text-[var(--ss-text-2)] flex items-center gap-1.5">
                    {file.name}
                    <button onClick={() => setFile(null)} disabled={notesSubmitting} className="text-[var(--ss-faint)] hover:text-[var(--ss-text-2)]">
                      ✕
                    </button>
                  </span>
                )}
              </div>
              <Button variant="primary" onClick={submitNotes} disabled={notesSubmitting || (!notes.trim() && !file)} className="w-full">
                {notesSubmitting ? (
                  <>
                    <Loader2 size={15} className="animate-spin" /> Processing notes…
                  </>
                ) : (
                  <>
                    <Sparkles size={14} /> Process meeting notes
                  </>
                )}
              </Button>
              <ErrorText message={notesError} className="text-[12px] text-[var(--ss-danger)]" />
              {onSkipNotes &&
                (notesSkipped ? (
                  <div className="text-[11px] text-[var(--ss-faint)]">Skipped — the proposal can be generated without notes.</div>
                ) : (
                  <button onClick={onSkipNotes} className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
                    Skip — proceed without notes
                  </button>
                ))}
            </div>
          ) : (
            <div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
              <ReportList title="Next steps" items={notesReport.nextSteps} />
              <ReportList title="Actions" items={notesReport.actions} />
              <ReportList title="Key intelligence" items={notesReport.keyIntelligence} />
            </div>
          )}
        </div>
      )}
    </div>
  );
}

function MeetingTrack({
  account, trackKey, trackLabel, stage, opportunityId,
  meetingPlan, onGeneratePlan, planGenerating, planError, onUpdateMeetingPlan,
  // Meeting slots (2026-10-12 direct follow-up — see MeetingSlot above and
  // meetingSlotKey/meetingSlotCounts/addMeetingSlot in App): `slotCount`
  // meeting slots render in order, each with its own identical notes/
  // pills/attach/process UI. The getters all take a slot's own key (slot 1
  // = `trackKey` itself; slot N>1 = `trackKey__meetingN`) so this
  // component doesn't need separate single-bound props per slot.
  slotCount = 1, onAddSlot, onRemoveSlot,
  getNotesReport, onSubmitNotes, isNotesSubmitting, getNotesError, isNotesSkipped, onSkipNotes,
  getOutcomeFlag, onSetOutcomeFlag,
  responses = [], onLogResponse,
  correspondence = [], onAddCorrespondence, correspondenceSubmitting, correspondenceError,
  // "Give the user a way to indicate the client does not want to move
  // forward — if the client isn't ready, let them archive the signal"
  // (2026-10-12 direct feedback, meeting stage) — `onFlagNotMovingForward`
  // is pre-bound by MeetingPanel to whichever archive path fits this
  // track (archiveSignal for a matched signal, archiveAccount for the
  // account's own baseline/general-engagement track), so this component
  // doesn't need to know which.
  onFlagNotMovingForward,
}) {
  // "The user should also be able to make additions to the plan"
  // (2026-10-11 follow-up feedback) — a plain add/remove list, same
  // minimal convention as CompetencyListEditor's rows, not a rich editor.
  const [newTopic, setNewTopic] = useState('');

  function addTopic() {
    if (!newTopic.trim()) return;
    onUpdateMeetingPlan({ topics: [...((meetingPlan && meetingPlan.topics) || []), newTopic.trim()] });
    setNewTopic('');
  }

  function removeTopic(idx) {
    onUpdateMeetingPlan({ topics: (meetingPlan.topics || []).filter((_, i) => i !== idx) });
  }
  // Step 2 of outreach (2026-10-11 follow-up feedback): "the first step of
  // outreach should be logging a sent [markSent, EmailComposer], with the
  // next step being separate for logging the response/conversation" — this
  // is that separate step, living here since a track at Contacted is
  // exactly "outreach sent, awaiting the next real interaction." The
  // textarea is deliberately roomier than a one-line note (the "more
  // substantive box for outcomes" ask) since a response/conversation is
  // often more than one sentence.
  const [loggingResponse, setLoggingResponse] = useState(false);
  const [responseDate, setResponseDate] = useState(todayISO());
  const [responseNotes, setResponseNotes] = useState('');
  // "When logging a response to the initial outreach, an email/chain
  // should be attached" (2026-10-11 direct feedback) — same attach-a-file
  // affordance as OutreachCorrespondenceIntake, scoped to this one
  // response entry rather than the account-wide correspondence pile, since
  // a specific reply is naturally tied to the response it came with.
  const [responseFile, setResponseFile] = useState(null);
  const responseFileInputRef = useRef(null);
  const [savingResponse, setSavingResponse] = useState(false);
  const [responseError, setResponseError] = useState(null);
  // "Meeting prep and follow-up card should be able to be minimized"
  // (2026-10-11 direct follow-up feedback) — plain local collapse, no
  // persistence; re-opens on its own next time this card mounts fresh
  // (e.g. a different track reaching Contacted/Meeting), same as every
  // other collapse toggle in this file (ResearchWorkspace's report view,
  // etc.) that doesn't need to survive a refresh.
  const [minimized, setMinimized] = useState(false);
  // "Client isn't moving forward" confirm step (2026-10-12 direct
  // feedback) — a one-click archive is too easy to hit by accident on a
  // live opportunity, so this mirrors the confirm/cancel pattern every
  // other destructive-but-reversible action in this file uses (e.g. the
  // blocker-step warning) rather than archiving straight from the button.
  // Meeting slots auto-advance (2026-10-12 direct follow-up): once the
  // latest slot resolves (a processed report, or an explicit skip) while
  // its own outcome is "Scheduling follow-up meeting," open the next
  // slot — same moment the latest slot's own collapse (see MeetingSlot)
  // reveals empty space for it. Guarded by a ref so this only fires once
  // per slot count, not on every re-render while the condition holds.
  const advancedForSlotRef = useRef(0);
  const latestSlotKey = meetingSlotKey(trackKey, slotCount);
  const latestResolved = !!(getNotesReport && getNotesReport(latestSlotKey)) || !!(isNotesSkipped && isNotesSkipped(latestSlotKey));
  const latestFlag = getOutcomeFlag && getOutcomeFlag(latestSlotKey);
  useEffect(() => {
    if (!onAddSlot) return;
    if (latestResolved && latestFlag === 'follow_up' && advancedForSlotRef.current < slotCount) {
      advancedForSlotRef.current = slotCount;
      onAddSlot();
    }
  }, [slotCount, latestResolved, latestFlag, onAddSlot]);

  async function saveResponse() {
    if (!responseNotes.trim() && !responseFile) return;
    setSavingResponse(true);
    setResponseError(null);
    try {
      await onLogResponse({ date: responseDate, notes: responseNotes.trim(), file: responseFile });
      setResponseNotes('');
      setResponseFile(null);
      setResponseDate(todayISO());
      setLoggingResponse(false);
    } catch (e) {
      setResponseError(e.message || "Couldn't save that — try again.");
    } finally {
      setSavingResponse(false);
    }
  }

  // "Nothing generated yet" shading (2026-10-11 direct feedback: "if
  // nothing has been generated, the card should be shaded to attract
  // attention") — the same amber pending-action treatment Next milestone
  // already uses for its own open action, applied to this whole card
  // while its current stage's generation action hasn't produced anything.
  const needsAttention = stage === 'Contacted' ? !meetingPlan : stage === 'Meeting' ? !latestResolved : false;

  return (
    <div
      id={opportunityId ? `meeting-signal-${opportunityId}` : 'meeting-account'}
      className={`rounded-[var(--ss-radius)] border p-4 space-y-3 ${needsAttention ? 'border-[var(--ss-accent)]/35 bg-[var(--ss-accent-wash)]' : 'border-[var(--ss-border)] bg-[var(--ss-surface)]'}`}
    >
      <div className="flex items-center justify-between gap-2">
        <button onClick={() => setMinimized((v) => !v)} className="flex items-center gap-1.5 hover:opacity-80">
          {minimized ? <ChevronRight size={12} className="text-[var(--ss-faint)]" /> : <ChevronDown size={12} className="text-[var(--ss-faint)]" />}
          <div className={sectionLabel}>Meeting — {trackLabel}</div>
        </button>
        <span className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">{stage === 'Contacted' ? 'Before the meeting' : 'After the meeting'}</span>
      </div>

      {!minimized && stage === 'Contacted' && (
        <div className="space-y-2">
          <Button
            variant="secondary"
            onClick={onGeneratePlan}
            disabled={planGenerating}
            className="w-full border-[var(--ss-accent)]/40 text-[var(--ss-accent)] hover:bg-[var(--ss-accent-wash)]"
          >
            {planGenerating ? (
              <>
                <Loader2 size={15} className="animate-spin" /> Building meeting plan…
              </>
            ) : meetingPlan ? (
              <>
                <RefreshCw size={14} /> Regenerate meeting plan
              </>
            ) : (
              'Generate meeting plan'
            )}
          </Button>
          <ErrorText message={planError} className="text-[12px] text-[var(--ss-danger)]" />
          {meetingPlan && (
            <div className="space-y-2">
              <div className="flex items-center justify-between gap-2">
                <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Topics to cover</div>
                <button
                  onClick={() => downloadMeetingSlidePack(meetingPlan, account, trackLabel)}
                  disabled={!meetingPlan.topics || !meetingPlan.topics.length}
                  className="flex items-center gap-1 text-[11px] text-[var(--ss-accent)] hover:underline disabled:opacity-40"
                >
                  <Download size={11} /> Export slide pack
                </button>
              </div>
              <ul className="space-y-1.5">
                {(meetingPlan.topics || []).map((t, i) => (
                  <li key={i} className="text-[13px] text-[var(--ss-text-2)] flex items-start justify-between gap-2">
                    <span className="flex gap-2">
                      <span className="text-[var(--ss-faint)] shrink-0">•</span>
                      <span>{t}</span>
                    </span>
                    <button onClick={() => removeTopic(i)} className="shrink-0 text-[var(--ss-faint)] hover:text-[var(--ss-danger)]">
                      ✕
                    </button>
                  </li>
                ))}
              </ul>
              <div className="flex items-center gap-2">
                <input
                  value={newTopic}
                  onChange={(e) => setNewTopic(e.target.value)}
                  onKeyDown={(e) => e.key === 'Enter' && addTopic()}
                  placeholder="Add your own topic…"
                  className={inputClass}
                />
                <button
                  onClick={addTopic}
                  disabled={!newTopic.trim()}
                  className="shrink-0 flex items-center gap-1 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
                >
                  <Plus size={13} /> Add
                </button>
              </div>
            </div>
          )}
        </div>
      )}

      {!minimized && stage === 'Meeting' && (
        <div className="space-y-3">
          {/* Discrete meeting slots (2026-10-12 direct follow-up:
              "structure the meeting prep and follow up section with
              discrete sections that collapse as the user moves through...
              the fields should also match with the first meeting (pills,
              file attachment, process notes)") — one `MeetingSlot` per
              meeting, in order; slot 1 is always the original meeting,
              slot N>1 exists only once an earlier slot's own "Scheduling
              follow-up meeting" outcome resolved (see the auto-advance
              effect above). Each slot owns its own pills/notes/attach/
              process UI and collapses to a one-line summary once resolved
              — the active (not-yet-resolved) slot is always the one left
              expanded by default. */}
          {Array.from({ length: slotCount }, (_, i) => i + 1).map((index) => {
            const slotKey = meetingSlotKey(trackKey, index);
            const isLatest = index === slotCount;
            return (
              <MeetingSlot
                key={slotKey}
                index={index}
                opportunityId={opportunityId}
                notesReport={getNotesReport && getNotesReport(slotKey)}
                onSubmitNotes={(payload) => onSubmitNotes(slotKey, payload)}
                notesSubmitting={!!(isNotesSubmitting && isNotesSubmitting(slotKey))}
                notesError={getNotesError && getNotesError(slotKey)}
                notesSkipped={!!(isNotesSkipped && isNotesSkipped(slotKey))}
                onSkipNotes={onSkipNotes ? () => onSkipNotes(slotKey) : null}
                outcomeFlag={getOutcomeFlag && getOutcomeFlag(slotKey)}
                onSetOutcomeFlag={onSetOutcomeFlag ? (flag) => onSetOutcomeFlag(slotKey, flag) : null}
                // Only the latest slot can still archive — an earlier,
                // already-resolved slot's "client not moving forward"
                // pill would be confusing to re-trigger from (and its own
                // outcomeFlag already shows what was picked at the time).
                onFlagNotMovingForward={isLatest ? onFlagNotMovingForward : null}
                // Only the latest slot can be deleted, and only a
                // follow-up slot (index > 1) — slot 1 is the meeting that
                // got this track into the Meeting stage in the first
                // place, not something "generated" that can be erroneous
                // in the same sense (see removeMeetingSlot in App).
                onRemove={isLatest && index > 1 && onRemoveSlot ? () => onRemoveSlot(index) : null}
              />
            );
          })}

          {/* Logging correspondence/response moved here from the Contacted
              substage (2026-10-11 direct follow-up feedback): it shouldn't
              already be a requirement to enter Meeting — it's an optional
              action, available any time after the meeting has been held
              and before confirming the move to Proposal. Not gated like
              notes (no disable on the Proposal confirm), just available. */}
          {(onAddCorrespondence || onLogResponse) && (
            <div className="pt-2 border-t border-[var(--ss-rule)] space-y-2">
              <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">Correspondence (optional)</div>
              {onAddCorrespondence && (
                <OutreachCorrespondenceIntake
                  account={account}
                  items={correspondence}
                  submitting={correspondenceSubmitting}
                  error={correspondenceError}
                  onAdd={onAddCorrespondence}
                />
              )}

              {responses.length > 0 && (
                <div className="space-y-1.5">
                  {responses.map((r, i) => (
                    <div key={i} className="text-[12px] text-[var(--ss-text-2)]">
                      <span className="font-[var(--ss-font-mono)] text-[var(--ss-faint)]">{formatDate(r.date)}</span> — {r.notes}
                      {r.attachmentName && (
                        <span className="flex items-center gap-1 text-[var(--ss-faint)] mt-0.5">
                          <FileText size={11} /> {r.attachmentName}
                        </span>
                      )}
                    </div>
                  ))}
                </div>
              )}

              {onLogResponse &&
                (loggingResponse ? (
                  <div className="space-y-2">
                    <input type="date" value={responseDate} onChange={(e) => setResponseDate(e.target.value)} disabled={savingResponse} className={inputClass} />
                    <textarea
                      value={responseNotes}
                      onChange={(e) => setResponseNotes(e.target.value)}
                      disabled={savingResponse}
                      rows={4}
                      placeholder="What did they say? Tone, questions raised, next steps implied…"
                      className={inputClass}
                    />
                    <div className="flex items-center gap-2 flex-wrap">
                      <input
                        ref={responseFileInputRef}
                        type="file"
                        accept=".txt,.md,.markdown,.csv,.eml,.log,.pdf,text/plain,text/markdown,text/csv,application/pdf"
                        disabled={savingResponse}
                        onChange={(e) => setResponseFile(e.target.files && e.target.files[0] ? e.target.files[0] : null)}
                        className="hidden"
                      />
                      <button
                        onClick={() => responseFileInputRef.current && responseFileInputRef.current.click()}
                        disabled={savingResponse}
                        className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
                      >
                        <FileText size={13} /> {responseFile ? 'Change file' : 'Attach email / chain'}
                      </button>
                      {responseFile && (
                        <span className="text-[12px] text-[var(--ss-text-2)] flex items-center gap-1.5">
                          {responseFile.name}
                          <button onClick={() => setResponseFile(null)} disabled={savingResponse} className="text-[var(--ss-faint)] hover:text-[var(--ss-text-2)]">
                            ✕
                          </button>
                        </span>
                      )}
                    </div>
                    {responseError && <div className="text-[12px] text-[var(--ss-danger)]">{responseError}</div>}
                    <div className="flex items-center gap-2">
                      <Button variant="primary" size="sm" onClick={saveResponse} disabled={savingResponse || (!responseNotes.trim() && !responseFile)}>
                        {savingResponse && <Loader2 size={13} className="animate-spin" />} Save
                      </Button>
                      <Button variant="ghost" size="sm" onClick={() => setLoggingResponse(false)} disabled={savingResponse}>
                        Cancel
                      </Button>
                    </div>
                  </div>
                ) : (
                  <button
                    onClick={() => setLoggingResponse(true)}
                    className="flex items-center gap-1 text-[11px] text-[var(--ss-positive)] hover:underline"
                  >
                    <Plus size={11} /> Log response / conversation
                  </button>
                ))}
            </div>
          )}

        </div>
      )}
    </div>
  );
}

function MeetingPanel({
  account, matches,
  meetingPlans, onGenerateMeetingPlan, meetingPlanGeneratingKey, meetingPlanErrors, onUpdateMeetingPlan,
  meetingNotesReports, onSubmitMeetingNotes, meetingNotesSubmittingKey, meetingNotesErrors,
  meetingNotesSkipped = {}, onSkipMeetingNotes,
  meetingOutcomes, onLogMeetingOutcome,
  meetingOutcomeFlags = {}, onSetMeetingOutcomeFlag,
  meetingSlotCounts = {}, onAddMeetingSlot, onRemoveMeetingSlot,
  outreachResponses = {}, onLogOutreachResponse,
  outreachCorrespondence = [], onAddOutreachCorrespondence, outreachCorrespondenceSubmitting, outreachCorrespondenceError,
  // "Client isn't moving forward" archive (2026-10-12 direct feedback) —
  // onArchiveSignal archives a matched signal (same `(newsId, opts) =>`
  // shape FitGapPanel's own Decline route already uses, including the
  // autoClose+Lost-outcome convention); onArchiveOpportunity archives the
  // account's own baseline/general-engagement track (same path the
  // research go/no-go gate's "No-go" button uses, just with a different
  // reason string).
  onArchiveSignal, onArchiveOpportunity,
}) {
  // "Two generate meeting plan boxes come up (one for the company and one
  // for the matched signal) — there should only be one for the matched
  // signal unless the user is pursuing general engagement not linked to a
  // specific signal" (2026-10-11 direct follow-up feedback). With exactly
  // one non-archived matched signal, the account-baseline sync above
  // (recordAction) now keeps the account's own stage and that signal's
  // stage moving together — so once both independently qualify
  // (Contacted/Meeting), they're the same underlying engagement and the
  // baseline track is just a redundant duplicate of the signal's own, more
  // specific card. If the account qualifies but the lone signal doesn't
  // (e.g. the account was advanced leading with their stated priorities,
  // not this signal), that genuinely is "general engagement not linked to
  // a specific signal" — keep the account-level card in that case, since
  // it's the only one that qualifies at all.
  const soleMatch = matches.filter((m) => !m.archived).length === 1 ? matches.find((m) => !m.archived) : null;
  const accountQualifies = account.stage === 'Contacted' || account.stage === 'Meeting';
  const soleMatchQualifies = soleMatch && (soleMatch.stage === 'Contacted' || soleMatch.stage === 'Meeting');
  const suppressAccountTrack = accountQualifies && soleMatchQualifies;
  const tracks = [
    ...(suppressAccountTrack ? [] : [{ trackKey: account.id, trackLabel: account.name, stage: account.stage, opportunityId: null }]),
    ...matches
      .filter((m) => !m.archived && (m.stage === 'Contacted' || m.stage === 'Meeting'))
      .map((m) => ({ trackKey: `${account.id}:${m.newsId}`, trackLabel: m.news.headline, stage: m.stage, opportunityId: m.newsId })),
  ].filter((t) => t.stage === 'Contacted' || t.stage === 'Meeting');
  if (!tracks.length) return null;
  return (
    <div id="meeting-prep-section" className="space-y-3">
      <div className={sectionLabel}>Meeting prep &amp; follow-up</div>
      {tracks.map((t) => {
        // Meeting slots (2026-10-12 direct follow-up: "structure the
        // meeting prep and follow up section with discrete sections that
        // collapse as the user moves through... the fields should also
        // match with the first meeting") — slot 1 is this track's own key
        // (unchanged); each "Scheduling follow-up meeting" outcome adds
        // one more, via meetingSlotKey/meetingSlotCounts (App). The latest
        // slot's own outcome is what drives the archive flag below and
        // what NextMilestone reads for its own confirm-button gating.
        const slotCount = meetingSlotCounts[t.trackKey] || 1;
        const latestSlotKey = meetingSlotKey(t.trackKey, slotCount);
        return (
          <MeetingTrack
            key={t.trackKey}
            account={account}
            trackKey={t.trackKey}
            trackLabel={t.trackLabel}
            stage={t.stage}
            opportunityId={t.opportunityId}
            meetingPlan={meetingPlans[t.trackKey]}
            onGeneratePlan={() => onGenerateMeetingPlan(account, t.trackKey, t.trackLabel)}
            planGenerating={meetingPlanGeneratingKey === t.trackKey}
            planError={meetingPlanErrors[t.trackKey]}
            onUpdateMeetingPlan={(patch) => onUpdateMeetingPlan(t.trackKey, patch)}
            slotCount={slotCount}
            onAddSlot={onAddMeetingSlot ? () => onAddMeetingSlot(t.trackKey) : null}
            onRemoveSlot={onRemoveMeetingSlot ? (index) => onRemoveMeetingSlot(t.trackKey, index) : null}
            getNotesReport={(slotKey) => meetingNotesReports[slotKey]}
            onSubmitNotes={(slotKey, payload) => onSubmitMeetingNotes(account, slotKey, t.trackLabel, payload)}
            isNotesSubmitting={(slotKey) => meetingNotesSubmittingKey === slotKey}
            getNotesError={(slotKey) => meetingNotesErrors[slotKey]}
            isNotesSkipped={(slotKey) => !!meetingNotesSkipped[slotKey]}
            onSkipNotes={onSkipMeetingNotes ? (slotKey) => onSkipMeetingNotes(slotKey) : null}
            getOutcomeFlag={(slotKey) => meetingOutcomeFlags[slotKey] && meetingOutcomeFlags[slotKey].flag}
            onSetOutcomeFlag={onSetMeetingOutcomeFlag ? (slotKey, flag) => onSetMeetingOutcomeFlag(slotKey, flag) : null}
            responses={outreachResponses[t.trackKey] || []}
            onLogResponse={onLogOutreachResponse ? (entry) => onLogOutreachResponse(t.trackKey, entry) : null}
            correspondence={outreachCorrespondence}
            onAddCorrespondence={onAddOutreachCorrespondence ? (payload) => onAddOutreachCorrespondence(account.id, payload) : null}
            correspondenceSubmitting={outreachCorrespondenceSubmitting}
            correspondenceError={outreachCorrespondenceError}
            onFlagNotMovingForward={
              t.opportunityId
                ? onArchiveSignal
                  ? () => {
                      onArchiveSignal(t.opportunityId, { autoClose: true, outcome: 'Lost' });
                      if (onSetMeetingOutcomeFlag) onSetMeetingOutcomeFlag(latestSlotKey, 'not_moving');
                    }
                  : null
                : onArchiveOpportunity
                ? () => {
                    onArchiveOpportunity('client not moving forward at the Meeting stage');
                    if (onSetMeetingOutcomeFlag) onSetMeetingOutcomeFlag(latestSlotKey, 'not_moving');
                  }
                : null
            }
          />
        );
      })}
    </div>
  );
}

// ---------------------------------------------------------------------------
// PROPOSAL (Day 3a, demo-week-plan.md) — available once the account has
// reached the Meeting stage or later. The "Delivery model & honest gaps"
// section is deliberately not editable prose here: it's rendered straight
// from the account's own fitGap result (same component logic as
// FitGapPanel above), so it can never say something that contradicts the
// structured check Day 2 already ran. understanding/approach/next steps are
// freely editable; experience is a toggle over real PAST_WORK entries only
// (never free text), so nothing fabricated can slip in through editing.
// ---------------------------------------------------------------------------

function ProposalPanel({ account, proposal, generating, error, onGenerate, onUpdate, fitGap, selectedMoves, stage }) {
  const canGenerate = !!fitGap;
  // "Once it's moved to Closed, there shouldn't be any more generation
  // action prompts on the screen" (2026-10-11 direct follow-up feedback) —
  // the proposal document itself stays visible/exportable once closed (the
  // whole reason this panel is kept mounted past Meeting at all), just the
  // Generate/Regenerate button — the one actual "take an action" prompt
  // here — goes away once there's nothing left to confirm.
  const locked = stage === 'Closed';

  return (
    <div className="pt-3 border-t border-[var(--ss-rule)] space-y-4">
      <div className="flex items-center justify-between gap-3">
        <div className={sectionLabel}>Proposal</div>
        {proposal && <span className="text-[10px] text-[var(--ss-faint)]">Generated {formatDate(proposal.generatedAt)}</span>}
      </div>

      {locked ? null : !canGenerate ? (
        <div className="text-[12px] text-[var(--ss-muted)]">Run "Check fit &amp; gaps" above first — the proposal's delivery-model section is built directly from that check.</div>
      ) : (
        <>
          <Button
            variant="secondary"
            onClick={onGenerate}
            disabled={generating}
            className="w-full border-[var(--ss-accent)]/40 text-[var(--ss-accent)] hover:bg-[var(--ss-accent-wash)]"
          >
            {generating ? (
              <>
                <Loader2 size={15} className="animate-spin" /> Drafting proposal…
              </>
            ) : proposal ? (
              <>
                <RefreshCw size={14} /> Regenerate proposal
              </>
            ) : (
              'Generate proposal'
            )}
          </Button>
          <ErrorText message={error} className="text-[12px] text-[var(--ss-danger)]" />
        </>
      )}

      {proposal && (
        <div className="space-y-4">
          <div>
            <div className="text-[11px] text-[var(--ss-muted)] mb-1.5">Understanding of the client's need</div>
            <textarea
              value={proposal.understanding}
              onChange={(e) => onUpdate({ understanding: e.target.value })}
              rows={3}
              className={`${inputClass} leading-relaxed`}
            />
          </div>

          <div>
            <div className="text-[11px] text-[var(--ss-muted)] mb-1.5">Proposed approach — one per line</div>
            <textarea
              value={listToText(proposal.approach)}
              onChange={(e) => onUpdate({ approach: textToList(e.target.value) })}
              rows={4}
              className={`${inputClass} leading-relaxed`}
            />
          </div>

          <div>
            <div className="text-[11px] text-[var(--ss-gap)] mb-1.5 flex items-center gap-1.5">
              <AlertTriangle size={12} /> Delivery model &amp; honest gaps
            </div>
            {/* 2026-10-10, direct feedback: the full blocker/moves detail
                belongs in the Fit & gaps box (above, at the top of the
                page — see FitGapPanel) so the user can see it immediately
                without scrolling to the proposal, not duplicated down
                here. This is a condensed pointer back to it: entry point +
                one line per gap with whichever route is chosen, no
                re-rendered evidence/moves menu. Still rendered straight
                from fitGap/selectedMoves, never model-authored, so it
                can't drift from the structured check (redesign point 4:
                "the proposal names the teaming arrangement"). */}
            {fitGap.entryPoint && (
              <div className="rounded-[var(--ss-radius)] border border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)] px-3 py-2 mb-2 text-[11px] text-[var(--ss-positive)]">
                <span className="font-medium">Entry point:</span> {fitGap.entryPoint.part} — {fitGap.entryPoint.rationale}
              </div>
            )}
            {fitGap.gaps.length === 0 ? (
              <div className="text-[12px] text-[var(--ss-positive)]">Every matched signal maps cleanly to a listed capability, partnership or past project.</div>
            ) : (
              <div className="space-y-1.5">
                {fitGap.gaps.map((g) => {
                  const idx = selectedMoves ? selectedMoves[g.tag] : undefined;
                  const chosen = typeof idx === 'number' ? g.moves[idx] : null;
                  return (
                    <div key={g.tag} className={`rounded-[var(--ss-radius)] border px-2.5 py-1.5 text-[11px] ${GAP_SEVERITY_STYLE[g.severity]}`}>
                      <span className="font-[var(--ss-font-mono)] uppercase text-[10px] mr-1">{g.severity}</span>
                      {g.gap} <span className="opacity-75">({g.scope})</span>
                      {chosen ? (
                        <span className="block mt-0.5 opacity-90">
                          Route: <span className="font-[var(--ss-font-mono)] uppercase text-[10px]">{chosen.type}</span> {chosen.partner ? `${chosen.partner} — ` : ''}
                          {chosen.detail}
                        </span>
                      ) : (
                        <span className="block mt-0.5 opacity-75">No route chosen yet.</span>
                      )}
                    </div>
                  );
                })}
              </div>
            )}
            <div className="text-[10px] text-[var(--ss-faint)] mt-1.5">
              Full evidence and the moves menu are in Fit &amp; gaps, above — re-check or pick a different route there to update this section.
            </div>
          </div>

          <div>
            <div className="text-[11px] text-[var(--ss-muted)] mb-1.5">Relevant experience</div>
            {PAST_WORK.length === 0 ? (
              <div className="text-[12px] text-[var(--ss-faint)]">No past work on file.</div>
            ) : (
              <div className="flex flex-col gap-1.5">
                {PAST_WORK.map((w) => (
                  <TogglePill
                    key={w.id}
                    active={proposal.experienceIds.includes(w.id)}
                    onClick={() =>
                      onUpdate({
                        experienceIds: proposal.experienceIds.includes(w.id)
                          ? proposal.experienceIds.filter((id) => id !== w.id)
                          : [...proposal.experienceIds, w.id],
                      })
                    }
                  >
                    {w.clientLabel}: {w.title} ({w.year})
                  </TogglePill>
                ))}
              </div>
            )}
          </div>

          <div>
            <div className="text-[11px] text-[var(--ss-muted)] mb-1.5">Indicative next steps</div>
            <div className="space-y-2">
              {proposal.nextSteps.map((s, i) => (
                <div key={s.id || i} className="flex items-center gap-1.5">
                  <input
                    value={s.step}
                    onChange={(e) => {
                      const next = [...proposal.nextSteps];
                      next[i] = { ...next[i], step: e.target.value };
                      onUpdate({ nextSteps: next });
                    }}
                    placeholder="Step"
                    className={`${inputClass} flex-1`}
                  />
                  <input
                    value={s.timeframe}
                    onChange={(e) => {
                      const next = [...proposal.nextSteps];
                      next[i] = { ...next[i], timeframe: e.target.value };
                      onUpdate({ nextSteps: next });
                    }}
                    placeholder="Timeframe"
                    className={`${inputClass} w-28`}
                  />
                  <input
                    value={s.owner}
                    onChange={(e) => {
                      const next = [...proposal.nextSteps];
                      next[i] = { ...next[i], owner: e.target.value };
                      onUpdate({ nextSteps: next });
                    }}
                    placeholder="Owner"
                    className={`${inputClass} w-28`}
                  />
                  <button
                    onClick={() => onUpdate({ nextSteps: proposal.nextSteps.filter((_, j) => j !== i) })}
                    aria-label="Remove this step"
                    className="text-[var(--ss-faint)] hover:text-[var(--ss-danger)] -m-1.5 p-1.5"
                  >
                    <X size={14} />
                  </button>
                </div>
              ))}
              <button
                onClick={() => onUpdate({ nextSteps: [...proposal.nextSteps, { id: `manual-${Date.now().toString(36)}`, step: '', timeframe: '', owner: '' }] })}
                className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]"
              >
                <Plus size={12} /> Add step
              </button>
            </div>
          </div>

          <button
            onClick={() => downloadProposalPdf(proposal, fitGap, account, selectedMoves)}
            className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[13px] px-3 py-1.5 hover:border-[var(--ss-frame)]"
          >
            <Download size={14} /> Export PDF
          </button>
        </div>
      )}
    </div>
  );
}

function PlanPanel({
  account, contacts, activity, plan, draft, generating, error, onGenerate, onRecord, onUndo, onAddContact, onDraftChange,
  research, managerMode, delegatedTo, onDelegate, onClearDelegate,
  fitGap, fitGapGenerating, fitGapError, onCheckFitGaps,
  fitChangeNotice, onDismissFitChangeNotice,
  proposal, proposalGenerating, proposalError, onGenerateProposal, onUpdateProposal, onUpdatePlan,
  confirmedMitigations, onToggleMitigation, selectedMoves, onSelectMove,
  dealOutcomes = {}, onSetOutcome, onArchiveSignal, onAddOverrideMove, onProceedSignalRoute, onUndoSignalRoute,
  archived, archivedReason, onArchiveOpportunity, onUnarchiveOpportunity, onAssignOpportunityNumber,
  meetingPlans = {}, onGenerateMeetingPlan, meetingPlanGeneratingKey, meetingPlanErrors = {}, onUpdateMeetingPlan,
  meetingNotesReports = {}, onSubmitMeetingNotes, meetingNotesSubmittingKey, meetingNotesErrors = {},
  meetingNotesSkipped = {}, onSkipMeetingNotes,
  meetingOutcomes = {}, onLogMeetingOutcome,
  meetingOutcomeFlags = {}, onSetMeetingOutcomeFlag,
  meetingSlotCounts = {}, onAddMeetingSlot, onRemoveMeetingSlot,
  fitGapMinimized, onSetFitGapMinimized,
  outreachCorrespondence = [], onAddOutreachCorrespondence, outreachCorrespondenceSubmitting, outreachCorrespondenceError,
  outreachResponses = {}, onLogOutreachResponse,
  // Outreach tool pills (2026-10-11 direct feedback: "move these pills to
  // the sidebar") — openChip/outreachMinimized now live in AccountPage
  // (same lift as fitGapMinimized before them) so the sidebar's pill row
  // and this main column agree on what's open/visible without two separate
  // copies of the same state.
  openChip, onSetOpenChip, outreachMinimized, onSetOutreachMinimized, showOutreach,
  signalActions = {}, onAddSignalActionsFromPlan, onToggleSignalAction,
  thoughtLeadershipUploads,
}) {
  const [showResearch, setShowResearch] = useState(false);
  const [showPlanDetails, setShowPlanDetails] = useState(false);
  const [showPriorCorrespondence, setShowPriorCorrespondence] = useState(false);
  const isResearchStage = account.stage === 'Identified';
  const progress = researchProgress(research.plan, research.report);
  const matches = accountMatches(account.id);
  // "A clearer action... once the outreach plan is generated" (2026-10-11
  // direct feedback) — a 5-second confirmation popup after the primary
  // outreach-plan CTA (below) both seeds the matched signal's sidebar
  // checklist and opens the email composer. Cleared on a timer rather than
  // by the next click, so it reads as a transient toast, not a banner that
  // sits there until dismissed.
  const [actionsToast, setActionsToast] = useState(null);
  const actionsToastTimer = useRef(null);
  useEffect(() => () => window.clearTimeout(actionsToastTimer.current), []);

  function addActionsAndOpenEmail() {
    const lead = matches.find((m) => !m.archived) || null;
    let count = 0;
    // The new top-of-section card (2026-10-11 direct follow-up feedback)
    // makes this reachable before a plan has ever been generated — guard
    // on `plan` too, not just `lead`: addSignalActionsFromPlan reads
    // plan.contacts_to_engage/talking_points and has nothing to seed from
    // when there isn't one yet.
    if (lead && plan && onAddSignalActionsFromPlan) {
      count = onAddSignalActionsFromPlan(`${account.id}:${lead.newsId}`, plan);
    }
    window.clearTimeout(actionsToastTimer.current);
    setActionsToast(lead && plan ? { count, label: lead.news.headline } : { count: 0, label: null });
    actionsToastTimer.current = window.setTimeout(() => setActionsToast(null), 5000);
    onSetOpenChip('email');
    window.setTimeout(() => {
      const el = document.getElementById('email-composer');
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 50);
  }

  // `e` = the same "Generate email" CTA below (seeds the matched signal's
  // checklist, opens the composer, scrolls to it). Only live once the
  // outreach section itself is showing, so it can't open a composer for an
  // account still at the research stage.
  useShortcut('e', addActionsAndOpenEmail, {
    label: 'Draft outreach — generate the email',
    group: 'Account',
    // Exactly the condition that puts the "Generate email" button on screen
    // (see the outreach section below) — past the outreach stage the section
    // is gone entirely, and a shortcut that opens a composer nobody can see
    // would just look broken.
    enabled: !!showOutreach && !outreachMinimized,
  });

  // "Flag missing information needed to send the email (e.g. contacts) and
  // point them to address book" (2026-10-11 direct follow-up feedback) —
  // the Address book pill/content lives in the sidebar (OutreachToolsSidebar),
  // not here, so this just opens that chip and scrolls it into view.
  function openAddressBook() {
    onSetOpenChip('contacts');
    window.setTimeout(() => {
      const el = document.getElementById('outreach-tools-sidebar');
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 50);
  }

  // At the Identified stage the user first picks how they want to work this
  // account: the full research workflow, or straight to outreach on their
  // own notes/file (see IntakeChoice / ManualResearchIntake below). Once
  // they've supplied something that way, the outreach generator opens up
  // even though the account hasn't formally left the Identified stage yet —
  // the stage itself still only moves on an explicit "Research done" confirm.
  const needsIntakeChoice = isResearchStage && !research.intakeChoice;
  const showResearchWorkflow = isResearchStage && research.intakeChoice === 'full';
  const showManualIntake = isResearchStage && research.intakeChoice === 'skip';
  // Proposal generator — "the meeting prep and follow-up shouldn't be open
  // at the same time as proposal stuff" (2026-10-11 direct follow-up
  // feedback). Previously shown from Meeting stage onward, which meant it
  // sat right alongside MeetingPanel's own "after the meeting" card for the
  // whole time an account/signal was at Meeting — now gated to Proposal
  // stage or later, so the two never overlap: MeetingPanel's tracks filter
  // (Contacted/Meeting only) naturally stops covering a track once it
  // reaches Proposal, right when this flips on.
  // "Generating a proposal... shouldn't be an option until the user has
  // added post-meeting notes — they can skip this if they want" (2026-10-11
  // direct follow-up feedback) is enforced per-track at the NextMilestone
  // confirm button that actually crosses Meeting->Proposal (see
  // `notesReady` passed to each NextMilestone below) — since that's the
  // only way to reach Proposal at all, showProposal itself doesn't need a
  // second, broader notes check.
  const showProposal = STAGES.indexOf(account.stage) >= STAGES.indexOf('Proposal');

  // P1 item 7 (feedback brief): "recommended next step -> add it to the
  // outreach plan or task list" — the only existing thing shaped like a
  // task list is the generated plan's own timeline (plan.timeline, an
  // array of {step, timeframe, owner}), so this appends there rather than
  // inventing a second list. A step added before any plan exists seeds one
  // with just a timeline, which the plan-display JSX already renders fine
  // (every other field defaults to empty/undefined-safe reads).
  function onAddPlanStep(text) {
    onUpdatePlan(account.id, { timeline: [...((plan && plan.timeline) || []), { step: text, timeframe: 'TBD', owner: 'You' }] });
  }

  // P1 item 6 (feedback brief): "Integrate into outreach" is the research
  // report's primary CTA once it exists — it's not a new stage transition,
  // just "treat research as done and go to the outreach step": if the
  // account hasn't formally left Identified yet, confirm research done
  // (same action the Next milestone button would take) so showOutreach
  // flips on; either way, expand+scroll to the outreach section, which by
  // now is guaranteed to be shown.
  function onIntegrateResearch() {
    if (isResearchStage) {
      onRecord({ action: STAGE_ACTIONS.Identified.activity, to: 'Researched', contactId: null, date: todayISO(), note: 'Report integrated into outreach.', opportunityId: null, opportunityLabel: null });
    }
    onSetOutreachMinimized(false);
    window.setTimeout(() => {
      const el = document.getElementById('outreach-section');
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 50);
  }

  return (
    <div className="space-y-5">
      <NextMilestone
        enableShortcut
        stage={account.stage}
        tier={account.tier}
        contacts={contacts}
        activity={activityForTrack(activity, null)}
        onRecord={onRecord}
        onUndo={() => onUndo(null)}
        progress={isResearchStage ? progress : null}
        managerMode={managerMode}
        delegatedTo={delegatedTo}
        onDelegate={onDelegate}
        onClearDelegate={onClearDelegate}
        outcome={dealOutcomes[account.id]}
        onSetOutcome={onSetOutcome ? (v) => onSetOutcome(account.id, v) : null}
        notesReady={(() => {
          // Bug (2026-10-13 direct follow-up): this used to check
          // meetingNotesReports/meetingNotesSkipped under account.id alone,
          // which is only ever slot 1's key. Once a "Scheduling follow-up
          // meeting" outcome adds slot 2+ (see addMeetingSlot/meetingSlotKey
          // above), that stale slot-1 "skipped" flag kept notesReady true
          // for the whole account — so the confirm button ("Move to
          // proposal stage") went clickable again as soon as the new slot
          // opened, before its own meeting outcome had even been picked,
          // while the new slot's own outcome pills still sat unselected.
          // Must track the same latest-slot key meetingOutcomeFlag (below)
          // already uses.
          const latestKey = meetingSlotKey(account.id, meetingSlotCounts[account.id] || 1);
          return !!meetingNotesReports[latestKey] || !!meetingNotesSkipped[latestKey];
        })()}
        meetingOutcomeFlag={(() => {
          const latestKey = meetingSlotKey(account.id, meetingSlotCounts[account.id] || 1);
          return meetingOutcomeFlags[latestKey] && meetingOutcomeFlags[latestKey].flag;
        })()}
        generationPointer={generationPointerForStage(account.stage)}
        archived={archived}
        archivedReason={archivedReason}
        onArchiveOpportunity={onArchiveOpportunity}
        onUnarchiveOpportunity={onUnarchiveOpportunity}
        onAssignOpportunityNumber={onAssignOpportunityNumber}
      />

      {/* Meeting prep & follow-up (2026-10-11 direct feedback: "once
          outreach is done, that section should disappear and the generate
          meeting plan... action should move right below the activity
          section so the user is more directed to it") — moved up to sit
          directly below Next milestone's own Activity list (above), ahead
          of Fit & gaps/the outreach workflow, since for a track that's
          actually at Contacted or Meeting this IS the next directed
          action; MeetingPanel itself returns null when no track qualifies,
          so nothing changes for an account that hasn't reached Contacted
          yet. See its second render call lower down for why it's gone from
          there. */}
      <MeetingPanel
        account={account}
        matches={matches}
        meetingPlans={meetingPlans}
        onGenerateMeetingPlan={onGenerateMeetingPlan}
        meetingPlanGeneratingKey={meetingPlanGeneratingKey}
        meetingPlanErrors={meetingPlanErrors}
        onUpdateMeetingPlan={onUpdateMeetingPlan}
        meetingNotesReports={meetingNotesReports}
        onSubmitMeetingNotes={onSubmitMeetingNotes}
        meetingNotesSubmittingKey={meetingNotesSubmittingKey}
        meetingNotesErrors={meetingNotesErrors}
        meetingNotesSkipped={meetingNotesSkipped}
        onSkipMeetingNotes={onSkipMeetingNotes}
        meetingOutcomes={meetingOutcomes}
        onLogMeetingOutcome={onLogMeetingOutcome}
        meetingOutcomeFlags={meetingOutcomeFlags}
        onSetMeetingOutcomeFlag={onSetMeetingOutcomeFlag}
        meetingSlotCounts={meetingSlotCounts}
        onAddMeetingSlot={onAddMeetingSlot}
        onRemoveMeetingSlot={onRemoveMeetingSlot}
        outreachResponses={outreachResponses}
        onLogOutreachResponse={onLogOutreachResponse}
        outreachCorrespondence={outreachCorrespondence}
        onAddOutreachCorrespondence={onAddOutreachCorrespondence}
        onArchiveSignal={onArchiveSignal}
        onArchiveOpportunity={onArchiveOpportunity}
        outreachCorrespondenceSubmitting={outreachCorrespondenceSubmitting}
        outreachCorrespondenceError={outreachCorrespondenceError}
      />

      {/* Fit changed (Day 4, demo-week-plan.md) — above Fit & gaps itself,
          so a market move that actually altered this account's delivery
          picture is the first thing visible. */}
      <FitChangeNotice notice={fitChangeNotice} onDismiss={onDismissFitChangeNotice} />

      {/* Fit & gaps (Day 2b, demo-week-plan.md) — near the top, above the
          research/outreach workflow below, per the plan's D1 requirement
          that this be visible before anyone clicks Generate. Direct
          follow-up feedback on the P1 minimize (2026-10-11): once minimized
          it should actually dock to the sidebar, not just collapse in
          place, so the next immediate action reads clearly — so this only
          renders here while expanded (or while there's nothing to dock
          yet); AccountPage renders the compact `FitGapDockedSummary` in
          the sidebar the rest of the time.
          Ordering (2026-10-11 direct follow-up, "the box hierarchy... should
          be: Gantt, Next milestone, Fit and gaps check, How do you want to
          work on this account, other ongoing actions"): this now renders
          ABOVE the "how do you want to work this account" choice below,
          reversing the 2026-10-10 distill pass that put the choice first —
          seeing the fit/gap picture is meant to inform that choice, not the
          other way around. */}
      {(!fitGapMinimized || !fitGap) && (
        <FitGapPanel
          matches={matches}
          fitGap={fitGap}
          generating={fitGapGenerating}
          error={fitGapError}
          onCheck={onCheckFitGaps}
          selectedMoves={selectedMoves}
          onSelectMove={(gapTag, moveIndex) => onSelectMove(account.id, gapTag, moveIndex)}
          confirmedMitigations={confirmedMitigations}
          accountId={account.id}
          onAddOverride={onAddOverrideMove}
          onProceedSignal={onProceedSignalRoute}
          onUndoSignal={onUndoSignalRoute}
          onSetMinimized={onSetFitGapMinimized}
        />
      )}

      {/* "How do you want to work this account?" — now rendered after Fit &
          gaps (see comment above). */}
      {needsIntakeChoice && (
        <div id="research-section">
          <IntakeChoice onChoose={research.onSetIntakeChoice} />
        </div>
      )}

      {/* Go / no-go (Day 3b, demo-week-plan.md) — at the account's own
          Proposal stage, or whenever any matched signal has independently
          reached Proposal (2026-10-10: a signal can get there well ahead
          of the account's own baseline — see accountRollupStage — and
          this box is exactly where its next step belongs, not just
          buried in the Matched signals sidebar). */}
      {(account.stage === 'Proposal' || matches.some((m) => m.stage === 'Proposal')) && (
        <GoNoGoCheck
          account={account}
          fitGap={fitGap}
          confirmedMitigations={confirmedMitigations}
          onToggleMitigation={onToggleMitigation}
          selectedMoves={selectedMoves}
          matches={matches}
          contacts={contacts}
          activity={activity}
          onRecord={onRecord}
          onUndo={onUndo}
          dealOutcomes={dealOutcomes}
          onSetOutcome={onSetOutcome}
          onArchiveSignal={onArchiveSignal}
        />
      )}

      {showResearchWorkflow && (
        <div id="research-section" className="space-y-4">
          <div className="flex items-center justify-between gap-3">
            <div className={sectionLabel}>Research workflow</div>
            <button onClick={() => research.onSetIntakeChoice(null)} className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
              <ChevronLeft size={12} /> Back
            </button>
          </div>
          <div>
            <Button
              variant="secondary"
              onClick={research.onGeneratePlan}
              disabled={research.generating || !!research.run}
              className="w-full border-[var(--ss-accent)]/40 text-[var(--ss-accent)] hover:bg-[var(--ss-accent-wash)]"
            >
              {research.generating ? (
                <>
                  <Loader2 size={15} className="animate-spin" /> Building research plan…
                </>
              ) : research.plan ? (
                <>
                  <RefreshCw size={14} /> Regenerate research plan
                </>
              ) : (
                <>
                  <Search size={14} /> Generate research plan
                </>
              )}
            </Button>
            {research.plan && !research.generating && (
              <div className="text-[11px] text-[var(--ss-faint)] mt-1.5">Regenerating replaces the checklist. Anything already in the report is kept.</div>
            )}
            <ErrorText message={research.error} className="text-[12px] text-[var(--ss-danger)] mt-2" />
          </div>
          {research.plan && <ResearchWorkspace account={account} research={research} progress={progress} onAddContact={onAddContact} onAddPlanStep={onAddPlanStep} onIntegrateResearch={onIntegrateResearch} />}
          <div className="text-[11px] text-[var(--ss-faint)]">
            The outreach plan unlocks once research is confirmed, so it can build on the report.
          </div>
        </div>
      )}

      {showManualIntake && (
        <div id="research-section" className="space-y-3">
          <div className="flex items-center justify-between gap-3">
            <div className={sectionLabel}>Your own notes</div>
            <button onClick={() => research.onSetIntakeChoice(null)} className="flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)]">
              <ChevronLeft size={12} /> Back
            </button>
          </div>
          <ManualResearchIntake account={account} research={research} />
        </div>
      )}

      {/* 2026-10-10, direct feedback: "many of the actions like generate
          outreach plan or proposal are confusing because it's not obvious
          what signal they're matched to." Research/outreach/proposal below
          have always operated on the account as a whole, never one
          specific matched signal (each matched signal has its own
          independent tracker — see Matched signals in the sidebar) — this
          just says so, instead of leaving it to be inferred. Moved to sit
          directly above this block (distill pass, 2026-10-10) rather than
          above Fit & gaps, which is now itself organized by matched signal
          — leaving the note there made it read as contradicting the
          section right below it. Skipped when there are no matched signals
          yet, since there's nothing to disambiguate from.
          "Does this line still need to be there?" (2026-10-11 direct
          follow-up feedback) — with exactly one non-archived matched
          signal, recordAction now keeps the account's own stage and that
          signal's stage moving together (see the single-signal sync
          comment above), so there genuinely is no "which track is this"
          ambiguity to flag in that case anymore. Now only shown once
          there are 2+ independent signals, where the disambiguation is
          still real. */}
      {showOutreach && matches.filter((m) => !m.archived).length > 1 && (
        <div className="flex items-start gap-1.5 text-[11px] text-[var(--ss-faint)]">
          <Info size={12} className="mt-0.5 shrink-0" />
          Research, outreach and the proposal below are account-level — covering {account.name} as a whole, not any one matched signal. Each matched signal in "Matched signals" (sidebar) has its own independent next step.
        </div>
      )}

      {/* 2026-10-11 direct follow-up feedback: "once outreach is done, that
          section should disappear" — the P1 minimize above used to leave a
          collapsed-but-still-there header behind with a manual Expand
          toggle; this now renders nothing at all once outreachMinimized
          flips (the same auto-trigger as before, on the Researched->
          Contacted crossing — see the effect above), since Meeting prep &
          follow-up has moved up to be the thing that replaces it as the
          obvious next action, not a thing the user re-expands this to find. */}
      {showOutreach && !outreachMinimized && (
      <div id="outreach-section">
      <div className="flex items-center justify-between gap-3 mb-2">
        <div className={sectionLabel}>Outreach</div>
      </div>
      <div className="space-y-5">

      {/* "There needs to be a clearer action for the user to take once the
          outreach stage is reached... add an action card at the top of the
          outreach section to generate an email. If there's missing
          information needed to send the email (e.g. contacts), flag this
          to the user and point them to address book" (2026-10-11 direct
          follow-up feedback). This replaces the old bottom-of-section CTA
          (same addActionsAndOpenEmail — still seeds the matched signal's
          sidebar checklist from the plan when one exists, and still shows
          the 5-second toast) as the first thing in the section: drafting
          the email is the actual next action here, not a reward at the
          end of a long read. */}
      {/* Card chrome demoted from amber to neutral (2026-10-13, /impeccable
          layout per the critique's P1): "Next milestone" above this already
          says "Next: Send first outreach" in its own amber box the moment
          this section is showing (outreachMinimized is false exactly while
          that's true) — a second identical-amber card repeating the same
          cue read as noise, not emphasis. The "Generate email" button below
          stays solid amber: it's still the one actual click target in this
          section, so the weight moves from "the whole card is urgent" to
          "this specific button is the next click," which is the real
          hierarchy here. */}
      <div className="relative rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-4 space-y-3">
        <div className="flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">
          <Mail size={12} /> Next step
        </div>
        <div className="text-sm text-[var(--ss-text-2)]">Draft and send the first outreach email to {account.name}.</div>
        {contacts.length === 0 && (
          <div className="flex items-start gap-1.5 text-[12px] text-[var(--ss-gap)]">
            <AlertTriangle size={12} className="mt-0.5 shrink-0" />
            <span>
              No contacts on file for {account.name} yet — the email will be role-neutral until you{' '}
              <button onClick={openAddressBook} className="underline hover:opacity-80">
                add someone in the address book
              </button>
              .
            </span>
          </div>
        )}
        <Button
          variant="primary"
          onClick={addActionsAndOpenEmail}
          {...shortcutButtonProps('e', 'Generate email')}
          icon={Sparkles}
          className="w-full"
        >
          Generate email
          <ShortcutHint keys="e" variant="accent" />
        </Button>
        {actionsToast && (
          <div className="rounded-[var(--ss-radius)] border border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)] text-[var(--ss-positive)] text-[12px] px-3 py-2 flex items-center gap-1.5">
            <CheckCircle2 size={13} className="shrink-0" />
            {actionsToast.label
              ? actionsToast.count > 0
                ? `Added ${actionsToast.count} action${actionsToast.count === 1 ? '' : 's'} to "${actionsToast.label}" in the sidebar.`
                : `Already tracked in "${actionsToast.label}"'s sidebar checklist.`
              : 'No matched signal to attach actions to yet — opening the email composer.'}
          </div>
        )}

        {/* The composer's own form (2026-10-11 direct follow-up feedback:
            "the email generator doesn't need to be in the sidebar, it can
            be in the main window in the same box with the generate email
            box") — opens right here, inside the same card as the "Generate
            email" button above, instead of in the sidebar (where the pill
            that toggles `openChip` still lives) or further down the page. */}
        <div id="email-composer">
          {openChip === 'email' && (
            <EmailComposer
              account={account}
              plan={plan}
              research={researchFindingsText(research.report)}
              contacts={contacts}
              draft={draft}
              onDraftChange={onDraftChange}
              onRecord={onRecord}
              onOpenAddressBook={openAddressBook}
              fitGap={fitGap}
              selectedMoves={selectedMoves}
              thoughtLeadershipUploads={thoughtLeadershipUploads}
              onSent={() => onSetOpenChip(null)}
            />
          )}
        </div>
      </div>

      {(research.report || research.plan) && (
        <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)]">
          <button
            onClick={() => setShowResearch((v) => !v)}
            className="w-full flex items-center justify-between gap-2 px-3.5 py-2.5 text-left hover:bg-[var(--ss-rule-soft)] rounded-[var(--ss-radius)]"
          >
            <span className="flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">
              {showResearch ? <ChevronDown size={12} /> : <ChevronRight size={12} />} <FileText size={12} /> Research report
            </span>
            <span className="text-[11px] text-[var(--ss-faint)]">
              {research.report ? `Updated ${formatDate(research.report.updatedAt)} · feeds the outreach plan and email` : 'No report yet'}
            </span>
          </button>
          {showResearch && (
            <div className="px-3.5 pb-3.5">
              <ResearchWorkspace account={account} research={research} progress={progress} onAddContact={onAddContact} onAddPlanStep={onAddPlanStep} onIntegrateResearch={onIntegrateResearch} />
            </div>
          )}
        </div>
      )}

      <div>
        {/* Demoted from amber-bordered to neutral alongside the "Next step"
            card above (same 2026-10-13 layout pass) — this is an optional
            secondary action (it sharpens the email, but "Generate email"
            above doesn't require it first), so it shouldn't carry the same
            accent weight as the one actual next click. */}
        <Button variant="secondary" onClick={onGenerate} disabled={generating} className="w-full">
          {generating ? (
            <>
              <Loader2 size={15} className="animate-spin" /> Generating plan…
            </>
          ) : plan ? (
            <>
              <RefreshCw size={14} /> Regenerate plan
            </>
          ) : (
            'Generate outreach plan'
          )}
        </Button>
        <ErrorText message={error} className="text-[12px] text-[var(--ss-danger)] mt-2" />
      </div>

      {/* "Only show the correspondence window if the user checks a box
          saying they've been in touch with the client before and would
          like to upload context" (2026-10-11 direct follow-up feedback) —
          was previously a quiet link that revealed the form; now an actual
          checkbox, worded as the condition it gates. The full intake still
          also renders automatically in MeetingTrack's Contacted section
          once outreach has actually been sent (see below) — this is only
          for the less common case of correspondence that predates using
          this tool at all. */}
      <label className="flex items-center gap-2 text-[12px] text-[var(--ss-text-2)] cursor-pointer select-none">
        <input
          type="checkbox"
          checked={outreachCorrespondence.length > 0 || showPriorCorrespondence}
          onChange={(e) => setShowPriorCorrespondence(e.target.checked)}
          className="rounded-[var(--ss-radius-sm)] border-[var(--ss-border)]"
        />
        I've corresponded with them before and want to upload context
      </label>
      {(outreachCorrespondence.length > 0 || showPriorCorrespondence) && (
        <OutreachCorrespondenceIntake
          account={account}
          items={outreachCorrespondence}
          submitting={outreachCorrespondenceSubmitting}
          error={outreachCorrespondenceError}
          onAdd={(payload) => onAddOutreachCorrespondence(account.id, payload)}
        />
      )}

      {/* "Let the email composer do the heavy lifting that the overview
          section is doing (talking points, timeline, etc.)" (2026-10-11
          direct follow-up feedback) — the generated plan's own breakdown
          used to be the dominant thing in this section; it's still here
          (nothing from the gap-flagging the product is built on gets
          hidden), just collapsed behind the same report-toggle pattern as
          "Research report" above, so the email composer — which already
          reads this same plan data to draft from — is what the user
          actually acts on. */}
      {plan && (
        <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)]">
          <button
            onClick={() => setShowPlanDetails((v) => !v)}
            className="w-full flex items-center justify-between gap-2 px-3.5 py-2.5 text-left hover:bg-[var(--ss-rule-soft)] rounded-[var(--ss-radius)]"
          >
            <span className="flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">
              {showPlanDetails ? <ChevronDown size={12} /> : <ChevronRight size={12} />} <ListChecks size={12} /> Generated plan details
            </span>
            <span className="text-[11px] text-[var(--ss-faint)]">Feeds the email composer below</span>
          </button>
          {showPlanDetails && (
            <div className="px-3.5 pb-3.5 space-y-5">
              <div>
                <div className={sectionLabel}>Overview</div>
                <p className="text-sm text-[var(--ss-text-2)] leading-relaxed">{plan.overview}</p>
              </div>

              <div>
                <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-1 flex items-center gap-1.5">
                  <Users size={12} /> Contacts to engage
                </div>
                <div className="text-[11px] text-[var(--ss-faint)] mb-2">Named where someone in the address book fits; otherwise role-level.</div>
                <ul className="space-y-1">
                  {(plan.contacts_to_engage || []).map((c, i) => (
                    <li key={i} className="text-sm text-[var(--ss-text-2)]">{c}</li>
                  ))}
                </ul>
              </div>

              <div>
                <div className={sectionLabel}>Talking points</div>
                <ul className="space-y-2.5">
                  {(plan.talking_points || []).map((p, i) => (
                    <li key={i} className="text-sm text-[var(--ss-text-2)] flex gap-2">
                      <span className="text-[var(--ss-accent)] shrink-0">→</span>
                      <div>
                        <div>{p.text}</div>
                        <Citation sourceId={p.source} />
                      </div>
                    </li>
                  ))}
                </ul>
              </div>

              {plan.capability_gaps && plan.capability_gaps.length > 0 && (
                <div>
                  <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-gap)] mb-2 flex items-center gap-1.5">
                    <AlertTriangle size={12} /> Capability gaps
                  </div>
                  <ul className="space-y-2.5">
                    {plan.capability_gaps.map((g, i) => (
                      <li key={i} className="text-sm text-[var(--ss-text-2)]">
                        <div>{g.text}</div>
                        <Citation sourceId={g.source} />
                      </li>
                    ))}
                  </ul>
                </div>
              )}

              <div>
                <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-2 flex items-center gap-1.5">
                  <Calendar size={12} /> Timeline
                </div>
                <div className="space-y-2">
                  {(plan.timeline || []).map((t, i) => (
                    <div key={i} className="flex items-start gap-3 text-sm">
                      <span className="text-[11px] font-[var(--ss-font-mono)] text-[var(--ss-faint)] shrink-0 w-16">{t.timeframe}</span>
                      <div>
                        <div className="text-[var(--ss-ink)]">{t.step}</div>
                        <div className="text-[11px] text-[var(--ss-faint)]">{t.owner}</div>
                      </div>
                    </div>
                  ))}
                </div>
              </div>
            </div>
          )}
        </div>
      )}
      </div>
      </div>
      )}

      {showProposal && (
        <div id="proposal-section">
          <ProposalPanel
            account={account}
            proposal={proposal}
            generating={proposalGenerating}
            error={proposalError}
            onGenerate={() => onGenerateProposal(account)}
            onUpdate={(patch) => onUpdateProposal(account.id, patch)}
            fitGap={fitGap}
            selectedMoves={selectedMoves}
            stage={account.stage}
          />
        </div>
      )}

    </div>
  );
}

// ---------------------------------------------------------------------------
// STATED PRIORITIES / MATCHED SIGNALS PANEL (split out of PlanPanel,
// 2026-10-07) — the account page's sidebar, next to PlanPanel's main
// column. Matched signals keeps the full interactive version (its own
// per-signal confirm, via a compact NextMilestone) — the Overview row's
// read-only version is the separate `OpportunitySummary` (2026-10-06).
// ---------------------------------------------------------------------------

// Outreach tool pills, lifted out of PlanPanel into the sidebar (2026-10-11
// direct feedback: "move these pills to the sidebar below stated
// priorities" — a long generated outreach plan in the main column was
// pushing them out of view). Address book and Outreach framework's content
// renders right here too (both are short lists, fine at 360px). The Email
// composer's own form used to stay central in PlanPanel instead, since it
// didn't fit the sidebar's width as comfortably — reversed by further
// direct feedback the same day ("the email generator should spawn in the
// same box as the user prompt for generate email, not at the bottom of the
// page"): it now opens right here, in the same box as the pill that opens
// it, instead of down in PlanPanel's main column.
// Email composer's own form (2026-10-11 direct follow-up feedback: "the
// email generator doesn't need to be in the sidebar, it can be in the main
// window in the same box with the generate email box") no longer renders
// here — it moved back into PlanPanel's "Next step" card, the box that
// actually has the "Generate email" button, right in the main column. This
// sidebar still owns the pill that opens/closes it (`openChip`, lifted to
// AccountPage) and scrolls that box into view, since the pill itself lives
// here while the box it opens lives in the other column.
function OutreachToolsSidebar({ contacts, showOutreach, outreachMinimized, openChip, onSetOpenChip, onAddContact }) {
  function toggleChip(name) {
    onSetOpenChip((prev) => (prev === name ? null : name));
    if (name === 'email') {
      window.setTimeout(() => {
        const el = document.getElementById('email-composer');
        if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }, 50);
    }
  }
  const chipClass = (name) =>
    `flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-[var(--ss-radius)] border transition-colors ${
      openChip === name ? 'border-[var(--ss-inverse)] bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]' : 'border-[var(--ss-border)] text-[var(--ss-text-2)] hover:border-[var(--ss-frame)]'
    }`;
  return (
    <div id="outreach-tools-sidebar">
      <div className="flex items-center gap-2 flex-wrap mb-3">
        {showOutreach && !outreachMinimized && (
          <button onClick={() => toggleChip('email')} className={chipClass('email')}>
            <Mail size={12} /> Email composer
          </button>
        )}
        <button onClick={() => toggleChip('contacts')} className={chipClass('contacts')}>
          <BookOpen size={12} /> Address book ({contacts.length})
        </button>
        {showOutreach && !outreachMinimized && (
          <button onClick={() => toggleChip('framework')} className={chipClass('framework')}>
            Outreach framework
          </button>
        )}
      </div>

      {openChip === 'contacts' && <AddressBook contacts={contacts} onAdd={onAddContact} />}

      {openChip === 'framework' && (
        <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5">
          <ol className="space-y-1.5 list-decimal list-inside">
            {FRAMEWORK_STEPS.map((s, i) => (
              <li key={i} className="text-[13px] text-[var(--ss-text-2)]">
                {s}
              </li>
            ))}
          </ol>
        </div>
      )}
    </div>
  );
}

function StatedPriorities({ account }) {
  return (
    <div>
      <div className={sectionLabel}>Stated priorities</div>
      <ul className="space-y-1">
        {account.priorities.map((p, i) => (
          <li key={i} className="text-sm text-[var(--ss-text-2)] flex items-start gap-2">
            <Target size={13} className="text-[var(--ss-faint)] mt-0.5 shrink-0" />
            {p}
          </li>
        ))}
      </ul>
    </div>
  );
}

// A small per-signal checklist (added 2026-10-11, direct feedback: "try
// putting in a little actions tracker so it's clear what's in progress
// (ex: reaching out to a subcontractor), what is done (ex: research), and
// what needs to be started (ex: outreach)") — three states per item, done/
// in-progress/not-started, rendered as a row of small pills rather than a
// full checklist UI; this sits in the signal's own card in the sidebar, so
// it has to stay glanceable next to everything else already there. An
// item with an `onClick` (external-engagement items only) reopens
// EngagementAssistModal to "check in" on it — the one interactive piece.
function SignalActionTracker({ items }) {
  return (
    <div className="flex flex-wrap gap-1.5">
      {items.map((it, i) => {
        // "User's actions" (2026-10-11 direct feedback: "contacting a
        // subcontractor etc. need to be more obvious compared to the
        // standard research and outreach ones") — items of kind 'engagement'
        // are something the user has to go do themselves outside the tool,
        // not a system-tracked step like Research/Outreach, so they get
        // their own ink-frame treatment regardless of status (not the
        // shared muted/gap/positive scheme below) to read as categorically
        // different at a glance, not just a different progress state — a
        // stronger border rather than a one-off color, so it stays inside
        // the house palette instead of adding a category-specific hue. Once
        // actually done it still converges on the shared "done" look — no
        // reason to keep flagging a resolved action.
        const isEngagement = it.kind === 'engagement' && it.status !== 'done';
        const style = isEngagement
          ? 'text-[var(--ss-ink)] border-[var(--ss-frame)] bg-[var(--ss-surface)] font-semibold'
          : it.status === 'done'
            ? 'text-[var(--ss-positive)] border-[var(--ss-positive)]/35 bg-[var(--ss-positive-wash)]'
            : it.status === 'in-progress'
            ? 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]'
            : 'text-[var(--ss-faint)] border-[var(--ss-border)] bg-[var(--ss-rule-soft)]';
        const Icon = it.status === 'done' ? CheckCircle2 : it.status === 'in-progress' ? Clock : Circle;
        const Tag = it.onClick ? 'button' : 'span';
        return (
          <Tag
            key={i}
            type={it.onClick ? 'button' : undefined}
            onClick={it.onClick}
            title={it.onClick ? 'Check in on this' : undefined}
            className={`flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${style} ${it.onClick ? 'hover:brightness-110 cursor-pointer' : ''}`}
          >
            <Icon size={9} /> {it.label}
          </Tag>
        );
      })}
    </div>
  );
}

function MatchedSignalsPanel({
  account, contacts, activity = [], onRecord, onUndo, onUnlinkSignal, onReviewSignal,
  managerMode = false, delegations = {}, onDelegate = null, onClearDelegate = null, onFlagSignal = null, onClearSignalFlag = null,
  fitGap = null, selectedMoves = {}, externalEngagements = {}, onOpenEngagementAssist = null,
  dealOutcomes = {}, onSetOutcome = null, onArchiveSignal = null, onUnarchiveSignal = null,
  outreachResponses = {},
  meetingNotesReports = {}, meetingNotesSkipped = {},
  meetingOutcomeFlags = {},
  meetingSlotCounts = {},
  signalActions = {}, onToggleSignalAction = null,
}) {
  const allMatches = accountMatches(account.id);
  // Archived (added 2026-10-10, direct feedback: "take it off this
  // screen") — kept out of the active list by default, same "hide, don't
  // delete" treatment as `readLiveIds` elsewhere in this file; a small
  // toggle below keeps them reachable and reversible (onUnarchiveSignal)
  // rather than gone without a trace.
  const matches = allMatches.filter((m) => !m.archived);
  const archivedMatches = allMatches.filter((m) => m.archived);
  const [showArchived, setShowArchived] = useState(false);
  const [openOpportunity, setOpenOpportunity] = useState(null); // newsId of the matched signal whose own milestone panel is expanded
  // "When the outcome is selected, the opportunity should have an animation
  // to be permanently minimized in an archive on the company page and clear
  // the way for other matched signals" (2026-10-11 direct follow-up
  // feedback) — reuses the same gl-signal-unclip fade/collapse LiveFeed's
  // "unclip" already uses (see unclippingId there), timed to the same
  // 140ms animation-duration, before the real (reversible — see
  // onUnarchiveSignal) archive happens.
  const [archivingId, setArchivingId] = useState(null);
  function archiveWithAnimation(newsId) {
    setArchivingId(newsId);
    window.setTimeout(() => {
      onArchiveSignal(newsId);
      setArchivingId(null);
    }, 140);
  }

  // P1 item 11 (feedback brief): "closing marks the signal as closed,
  // resets the workflow UI, and returns the user to their other matched
  // signals" — auto-collapses the open card the moment its OWN stage
  // transitions into Closed (via the new "Close process" button in
  // NextMilestone), so the next signal becomes the obvious thing to look
  // at. Tracked per-signal so opening an already-closed card to check its
  // outcome/Reopen controls doesn't immediately snap shut again.
  const prevStagesRef = useRef({});
  useEffect(() => {
    if (openOpportunity) {
      const m = matches.find((x) => x.newsId === openOpportunity);
      if (m) {
        const prevStage = prevStagesRef.current[openOpportunity];
        if (prevStage && prevStage !== 'Closed' && m.stage === 'Closed') setOpenOpportunity(null);
        prevStagesRef.current[openOpportunity] = m.stage;
      }
    }
  }, [openOpportunity, matches]);

  return (
    <div>
      <div className="flex items-center justify-between mb-2">
        <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">Matched signals</div>
        {matches.length > 1 && <div className="text-[11px] text-[var(--ss-faint)]">Each one has its own pipeline progress</div>}
      </div>
      <div className="space-y-2.5">
        {matches.length === 0 && <div className="text-[12px] text-[var(--ss-faint)]">No signals matched yet</div>}
        {matches.map((m, i) => {
          const sIdx = STAGES.indexOf(m.stage);
          const sAction = STAGE_ACTIONS[m.stage];
          const open = openOpportunity === m.newsId;
          // Actions tracker (2026-10-11, direct feedback above) — Research
          // and Outreach read straight off this signal's own independent
          // stage (same STAGES list the Gantt bar above already walks);
          // an "Engage <partner>" item is added per gap on this signal
          // whose chosen route needs someone outside the firm, status read
          // from externalEngagements (seeded by proceedWithSignalRoute).
          const signalGaps = fitGap ? fitGap.gaps.filter((g) => g.evidence.includes(m.newsId)) : [];
          const chosenFor = (g) => {
            const idx = selectedMoves[g.tag];
            return typeof idx === 'number' ? g.moves[idx] : null;
          };
          const engageGaps = signalGaps.filter((g) => needsExternalEngagement(chosenFor(g)));
          const trackerItems = [
            { label: 'Research', status: sIdx >= STAGES.indexOf('Researched') ? 'done' : 'not-started' },
            ...engageGaps.map((g) => {
              const eng = externalEngagements[engagementKey(account.id, m.newsId, g.tag)];
              const status = eng && eng.status === 'done' ? 'done' : eng && eng.status === 'contacted' ? 'in-progress' : 'not-started';
              return {
                label: `Engage ${(chosenFor(g) && chosenFor(g).partner) || 'subcontractor'}`,
                status,
                kind: 'engagement',
                onClick: onOpenEngagementAssist ? () => onOpenEngagementAssist(m.newsId) : undefined,
              };
            }),
            { label: 'Outreach', status: sIdx >= STAGES.indexOf('Contacted') ? 'done' : 'not-started' },
          ];
          return (
            <div
              key={i}
              id={`signal-${m.newsId}`}
              className={`rounded-[var(--ss-radius)] border p-3 ${signalCardTint(fitGap, m.newsId)} ${archivingId === m.newsId ? 'gl-signal-unclip' : ''}`}
            >
              <div className="flex items-center justify-between mb-1.5">
                <div className="flex items-center gap-1.5">
                  <span className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">{m.news.tag}</span>
                  <SignalGapBadge fitGap={fitGap} newsId={m.newsId} />
                  {m.auto && !m.reviewed && (
                    <span className="flex items-center gap-0.5 text-[9px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-text-2)]">
                      <Sparkles size={9} /> Auto-matched
                    </span>
                  )}
                </div>
                <div className="flex items-center gap-2">
                  <ConfidenceBar value={m.confidence} />
                  <button
                    onClick={() => onUnlinkSignal(m.newsId)}
                    title="Not relevant — unlink this signal"
                    className="flex items-center gap-1 text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-danger)]"
                  >
                    <Link2Off size={12} />
                  </button>
                </div>
              </div>
              <div className="text-[13px] text-[var(--ss-text-2)] mb-2">{m.news.headline}</div>
              <div className="mb-2">
                <SignalActionTracker items={trackerItems} />
              </div>

              {/* Outreach-plan checklist (2026-10-11 direct feedback:
                  "based on the contacts to engage and talking points, it
                  should add these actions... to the matched signal on the
                  sidebar") — seeded by PlanPanel's "Track these actions &
                  draft the email" button via addSignalActionsFromPlan,
                  ticked off here as the user actually does each one. */}
              {(signalActions[`${account.id}:${m.newsId}`] || []).length > 0 && (
                <ul className="mb-2 space-y-1">
                  {signalActions[`${account.id}:${m.newsId}`].map((a) => (
                    <li key={a.id}>
                      <button
                        onClick={() => onToggleSignalAction && onToggleSignalAction(`${account.id}:${m.newsId}`, a.id)}
                        className="w-full flex items-start gap-1.5 text-left text-[12px] group"
                      >
                        {a.done ? (
                          <CheckCircle2 size={12} className="text-[var(--ss-positive)] shrink-0 mt-0.5" />
                        ) : (
                          <Circle size={12} className="text-[var(--ss-faint)] shrink-0 mt-0.5 group-hover:text-[var(--ss-muted)]" />
                        )}
                        <span className={a.done ? 'text-[var(--ss-faint)] line-through' : 'text-[var(--ss-text-2)]'}>
                          {a.kind === 'contact' ? 'Engage: ' : ''}
                          {a.text}
                        </span>
                      </button>
                    </li>
                  ))}
                </ul>
              )}

              {/* Manager mode (added 2026-10-08) — demo-only "flag this
                  signal for a colleague's review" control, separate from
                  delegating a stage action below: this is about the signal
                  match itself, not any one action on it. Stored on the same
                  PROMOTED_MATCHES entry as `reviewed`/`auto` (see
                  flagSignalForReview in App). */}
              {managerMode && (
                <div className="flex items-center gap-2 mb-2">
                  {m.flaggedFor ? (
                    <>
                      <span className="flex items-center gap-1 text-[11px] text-[var(--ss-danger)] bg-[var(--ss-danger-wash)] border border-[var(--ss-danger)]/35 rounded-[var(--ss-radius-sm)] px-2 py-0.5">
                        <Flag size={10} /> Flagged for {m.flaggedFor}
                      </span>
                      <button
                        onClick={() => onClearSignalFlag(m.newsId, account.id)}
                        className="text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-ink)]"
                      >
                        Clear
                      </button>
                    </>
                  ) : (
                    <FilterDropdown
                      value="All"
                      allLabel="Flag for review by…"
                      options={MANAGER_COLLEAGUES}
                      onChange={(v) => v !== 'All' && onFlagSignal(m.newsId, account.id, v)}
                      width={170}
                    />
                  )}
                </div>
              )}

              {/* This signal's own pipeline progress — independent of the
                  account's own stage and every other matched signal
                  (2026-10-06). Sits ahead of the rationale text: where
                  this stands and what's next outranks why it matched.
                  Expanding it is also the "I looked at this" signal that
                  clears an auto-matched signal's own badge above — see
                  onReviewSignal/reviewMatch. */}
              <button
                onClick={() => {
                  const next = open ? null : m.newsId;
                  setOpenOpportunity(next);
                  if (next && m.auto && !m.reviewed) onReviewSignal(m.newsId, account.id);
                  // Jump the main column to this signal's own stage content
                  // too, not just expand its compact milestone card here —
                  // see scrollToSignalMain above.
                  if (next) scrollToSignalMain(m.newsId, m.stage, signalGaps.length > 0);
                }}
                className="w-full text-left rounded-[var(--ss-radius)] -mx-1 px-1 py-1 hover:bg-[var(--ss-rule-soft)] transition-colors"
              >
                <GanttBar idx={sIdx} height="h-2" />
                <div className="flex items-center justify-between mt-1.5 gap-2">
                  <Badge tone="ink">{SIGNAL_STAGE_LABEL[m.stage] || m.stage}</Badge>
                  {sAction ? (
                    <span className="flex items-center gap-1 text-[11px] font-medium text-[var(--ss-accent-wash-ink)] bg-[var(--ss-accent-wash)] border border-[var(--ss-accent)]/30 rounded-[var(--ss-radius-sm)] px-2 py-0.5">
                      <Flag size={10} /> Next: {sAction.short}
                    </span>
                  ) : (
                    <span className="flex items-center gap-1 text-[11px] font-medium text-[var(--ss-positive)] bg-[var(--ss-positive-wash)] border border-[var(--ss-positive)]/30 rounded-[var(--ss-radius-sm)] px-2 py-0.5">
                      <CheckCircle2 size={10} /> Complete
                    </span>
                  )}
                  {open ? <ChevronDown size={12} className="text-[var(--ss-faint)] shrink-0" /> : <ChevronRight size={12} className="text-[var(--ss-faint)] shrink-0" />}
                </div>
              </button>
              <div className="text-[12px] text-[var(--ss-muted)] mt-2">{m.rationale}</div>
              {open && (
                <div className="mt-2.5">
                  <NextMilestone
                    stage={m.stage}
                    tier={account.tier}
                    contacts={contacts}
                    activity={activityForTrack(activity, m.newsId)}
                    onRecord={onRecord}
                    onUndo={() => onUndo(m.newsId)}
                    progress={null}
                    opportunityId={m.newsId}
                    opportunityLabel={m.news.headline}
                    compact
                    managerMode={managerMode}
                    delegatedTo={delegations[m.newsId]}
                    onDelegate={(colleague) => onDelegate(m.newsId, colleague)}
                    onClearDelegate={() => onClearDelegate(m.newsId)}
                    outcome={dealOutcomes[m.newsId]}
                    onSetOutcome={onSetOutcome ? (v) => onSetOutcome(m.newsId, v) : null}
                    blockerGap={blockerGapForSignal(fitGap, m.newsId)}
                    onArchive={onArchiveSignal ? () => archiveWithAnimation(m.newsId) : null}
                    notesReady={(() => {
                      // Same stale-slot-1 bug as the account-level
                      // NextMilestone above — must read the latest meeting
                      // slot's own key, not the base trackKey.
                      const latestKey = meetingSlotKey(`${account.id}:${m.newsId}`, meetingSlotCounts[`${account.id}:${m.newsId}`] || 1);
                      return !!meetingNotesReports[latestKey] || !!meetingNotesSkipped[latestKey];
                    })()}
                    meetingOutcomeFlag={(() => {
                      const latestKey = meetingSlotKey(`${account.id}:${m.newsId}`, meetingSlotCounts[`${account.id}:${m.newsId}`] || 1);
                      return meetingOutcomeFlags[latestKey] && meetingOutcomeFlags[latestKey].flag;
                    })()}
                    generationPointer={generationPointerForStage(m.stage)}
                  />
                </div>
              )}
            </div>
          );
        })}
      </div>

      {archivedMatches.length > 0 && (
        <div className="mt-3">
          <button
            onClick={() => setShowArchived((v) => !v)}
            className="flex items-center gap-1 text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-ink)]"
          >
            {showArchived ? <ChevronDown size={11} /> : <ChevronRight size={11} />} {archivedMatches.length} archived
          </button>
          {showArchived && (
            <div className="space-y-2 mt-2">
              {archivedMatches.map((m) => (
                <div key={m.newsId} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-ground)] px-3 py-2 opacity-75">
                  <div className="flex items-center justify-between gap-2">
                    <span className="text-[12px] text-[var(--ss-text-2)] truncate">{m.news.headline}</span>
                    <div className="flex items-center gap-1.5 shrink-0">
                      {dealOutcomes[m.newsId] && (
                        <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${DEAL_OUTCOME_STYLE[dealOutcomes[m.newsId]]}`}>
                          {dealOutcomes[m.newsId]}
                        </span>
                      )}
                      {onUnarchiveSignal && (
                        <button
                          onClick={() => onUnarchiveSignal(m.newsId)}
                          className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-positive)]"
                        >
                          Restore
                        </button>
                      )}
                    </div>
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// INTAKE CHOICE — before committing to the full research workflow, the user
// picks how they want to work this account. Not everyone needs to run
// research on a firm they already know well.
// ---------------------------------------------------------------------------

function IntakeChoice({ onChoose }) {
  // Only mounted while the choice is actually pending (see needsIntakeChoice
  // in PlanPanel), so 1/2 can safely be bare digits — they're unbound the
  // rest of the time.
  useShortcut('1', () => onChoose('full'), { label: 'Research it first', group: 'Account' });
  useShortcut('2', () => onChoose('skip'), { label: 'Skip to outreach', group: 'Account' });
  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5">
      <div className={sectionLabel}>Before you start</div>
      <p className="text-sm text-[var(--ss-text-2)] mb-3">How do you want to work this account?</p>
      <div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
        <button
          onClick={() => onChoose('full')}
          {...shortcutButtonProps('1', 'Research it first')}
          className="text-left rounded-[var(--ss-radius)] border border-[var(--ss-border)] hover:border-[var(--ss-accent)] bg-[var(--ss-ground)] p-3 transition-colors"
        >
          <div className="flex items-center gap-1.5 text-sm text-[var(--ss-ink)] font-medium mb-1">
            <Search size={14} className="text-[var(--ss-accent)]" /> Research it first
            <ShortcutHint keys="1" className="ml-auto" />
          </div>
          <div className="text-[12px] text-[var(--ss-muted)]">Build a research plan, tick questions, let Claude dig in before outreach.</div>
        </button>
        <button
          onClick={() => onChoose('skip')}
          {...shortcutButtonProps('2', 'Skip to outreach')}
          className="text-left rounded-[var(--ss-radius)] border border-[var(--ss-border)] hover:border-[var(--ss-frame)] bg-[var(--ss-ground)] p-3 transition-colors"
        >
          <div className="flex items-center gap-1.5 text-sm text-[var(--ss-ink)] font-medium mb-1">
            <Sparkles size={14} className="text-[var(--ss-positive)]" /> Skip to outreach
            <ShortcutHint keys="2" className="ml-auto" />
          </div>
          <div className="text-[12px] text-[var(--ss-muted)]">Already know this account — hand over your own notes or a file instead.</div>
        </button>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// MANUAL RESEARCH INTAKE — the "skip to outreach" path: the user types notes
// and/or uploads a file instead of running the AI research workflow. Claude
// organizes only what's actually in that material into the same report
// shape the automated flow produces (see buildManualReport), so the outreach
// plan generator and email composer work from it identically either way.
// ---------------------------------------------------------------------------

function ManualResearchIntake({ account, research }) {
  const [notes, setNotes] = useState('');
  const [file, setFile] = useState(null);
  const fileInputRef = useRef(null);
  const { manualSubmitting, manualError, report } = research;

  function submit() {
    if (!notes.trim() && !file) return;
    research.onSubmitManual({ notes: notes.trim(), file });
  }

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5 space-y-3">

      <textarea
        value={notes}
        onChange={(e) => setNotes(e.target.value)}
        disabled={manualSubmitting}
        rows={5}
        placeholder="Paste anything useful — what you already know about this account, notes from a call, an email chain, meeting takeaways..."
        className={inputClass}
      />

      <div className="flex items-center gap-2 flex-wrap">
        <input
          ref={fileInputRef}
          type="file"
          accept=".txt,.md,.markdown,.csv,.eml,.log,.pdf,text/plain,text/markdown,text/csv,application/pdf"
          disabled={manualSubmitting}
          onChange={(e) => setFile(e.target.files && e.target.files[0] ? e.target.files[0] : null)}
          className="hidden"
          id={`intake-file-${account.id}`}
        />
        <button
          onClick={() => fileInputRef.current && fileInputRef.current.click()}
          disabled={manualSubmitting}
          className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
        >
          <FileText size={13} /> {file ? 'Change file' : 'Attach a file'}
        </button>
        {file && (
          <span className="text-[12px] text-[var(--ss-text-2)] flex items-center gap-1.5">
            {file.name}
            <button onClick={() => setFile(null)} disabled={manualSubmitting} className="text-[var(--ss-faint)] hover:text-[var(--ss-text-2)]">
              ✕
            </button>
          </span>
        )}
      </div>
      <div className="text-[11px] text-[var(--ss-faint)]">
        This prototype reads plain text, Markdown, CSV and PDF files directly. For Word or Excel files, paste the relevant text into the notes above instead.
      </div>

      <Button variant="primary" onClick={submit} disabled={manualSubmitting || (!notes.trim() && !file)} className="w-full">
        {manualSubmitting ? (
          <>
            <Loader2 size={15} className="animate-spin" /> Reading your notes…
          </>
        ) : report && report.method === 'manual' ? (
          <>
            <RefreshCw size={14} /> Update from notes
          </>
        ) : (
          <>
            <Sparkles size={14} /> Use this for outreach
          </>
        )}
      </Button>
      <ErrorText message={manualError} className="text-[12px] text-[var(--ss-danger)]" />
      {report && report.method === 'manual' && !manualSubmitting && (
        <div className="text-[12px] text-[var(--ss-positive)] flex items-center gap-1.5">
          <CheckCircle2 size={13} /> Saved — confirm "Research done" above when you're ready, or generate the outreach plan below.
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// OUTREACH CORRESPONDENCE — 2026-10-11 direct follow-up feedback: "the
// outreach stage should include an option to upload files/email chains
// that the AI can evaluate in subsequent stages." Same upload UI as
// ManualResearchIntake above, but deliberately no AI call at upload time —
// this just builds an archive of what's been attached; generateMeetingPlan
// reads it as grounding context later (see buildMeetingPlanPrompt).
// ---------------------------------------------------------------------------

function OutreachCorrespondenceIntake({ account, items = [], submitting, error, onAdd }) {
  const [notes, setNotes] = useState('');
  const [file, setFile] = useState(null);
  const fileInputRef = useRef(null);
  // "Add option to close the correspondence window and minimize somewhere"
  // (2026-10-11 direct follow-up feedback) — self-contained so either call
  // site (the pre-send toggle in PlanPanel, or the always-on render in
  // MeetingTrack once a track is Contacted) gets it with no extra plumbing.
  // "The correspondence window should open on the first log only and then
  // minimized afterwards" (2026-10-12 direct follow-up) — starts open only
  // when there's nothing logged yet (so the first log has somewhere to go
  // without an extra click); starts minimized if this track already has
  // correspondence on it (e.g. reopening the page later). The user can
  // still freely re-expand it any time via the header toggle below.
  const [minimized, setMinimized] = useState(items.length > 0);

  function submit() {
    if (!notes.trim() && !file) return;
    onAdd({ notes: notes.trim(), file });
    // First log just went in — collapse so the next thing on screen is the
    // (now non-empty) list, not a blank form inviting a second entry right
    // away.
    if (items.length === 0) setMinimized(true);
    setNotes('');
    setFile(null);
  }

  return (
    // Outlined in the same --ss-gap used for the "log the response" alert in
    // NextMilestone (2026-10-11 direct feedback: "the correspondence window
    // should be outlined in the same purple color... so it's clear what
    // that's for" — violet isn't in the house palette, so both now share
    // --ss-gap instead) — both are about the same thing (what came back from
    // the client), so they still read as visually paired, just on a house
    // token instead of a one-off hue.
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)] p-3.5 space-y-3">
      <button onClick={() => setMinimized((v) => !v)} className="w-full flex items-center justify-between gap-2 hover:opacity-80">
        <span className="flex items-center gap-1.5 text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-gap)]">
          {minimized ? <ChevronRight size={12} /> : <ChevronDown size={12} />} Correspondence
          {items.length > 0 && <span className="text-[var(--ss-faint)] normal-case">({items.length})</span>}
        </span>
      </button>
      {minimized ? null : (
      <>
      <div className="text-[11px] text-[var(--ss-faint)]">
        Attach files or paste an email chain — later steps (like the meeting plan) can draw on it as context.
      </div>
      {items.length > 0 && (
        <ul className="space-y-1">
          {items.map((it) => (
            <li key={it.id} className="text-[12px] text-[var(--ss-text-2)] flex items-center gap-1.5">
              <FileText size={12} className="text-[var(--ss-faint)]" />
              {it.name || 'Pasted note'} <span className="text-[var(--ss-faint)]">· {formatDate(it.uploadedAt)}</span>
            </li>
          ))}
        </ul>
      )}
      <textarea
        value={notes}
        onChange={(e) => setNotes(e.target.value)}
        disabled={submitting}
        rows={3}
        placeholder="Paste an email chain or other correspondence…"
        className={inputClass}
      />
      <div className="flex items-center gap-2 flex-wrap">
        <input
          ref={fileInputRef}
          type="file"
          accept=".txt,.md,.markdown,.csv,.eml,.log,.pdf,text/plain,text/markdown,text/csv,application/pdf"
          disabled={submitting}
          onChange={(e) => setFile(e.target.files && e.target.files[0] ? e.target.files[0] : null)}
          className="hidden"
          id={`outreach-correspondence-file-${account.id}`}
        />
        <button
          onClick={() => fileInputRef.current && fileInputRef.current.click()}
          disabled={submitting}
          className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
        >
          <FileText size={13} /> {file ? 'Change file' : 'Attach a file'}
        </button>
        {file && (
          <span className="text-[12px] text-[var(--ss-text-2)] flex items-center gap-1.5">
            {file.name}
            <button onClick={() => setFile(null)} disabled={submitting} className="text-[var(--ss-faint)] hover:text-[var(--ss-text-2)]">
              ✕
            </button>
          </span>
        )}
        <Button variant="primary" size="sm" onClick={submit} disabled={submitting || (!notes.trim() && !file)}>
          {submitting ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />} Attach
        </Button>
      </div>
      {error && <div className="text-[12px] text-[var(--ss-danger)]">{error}</div>}
      </>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// RESEARCH — a checklist of questions the user ticks and submits; Claude then
// researches each one (with web search where available) and the results are
// written up as a report that can be read here or downloaded as a PDF.
// ---------------------------------------------------------------------------

function ResearchWorkspace({ account, research, progress, onAddContact, onAddPlanStep, onIntegrateResearch }) {
  const { plan, report, run } = research;
  const [view, setView] = useState(report ? 'report' : 'checklist');
  const seenUpdate = useRef(report ? report.updatedAt : null);

  // Jump to the report whenever a run finishes and updates it.
  useEffect(() => {
    if (report && report.updatedAt !== seenUpdate.current) {
      seenUpdate.current = report.updatedAt;
      setView('report');
    }
  }, [report]);

  const tabs = [plan && ['checklist', 'Checklist'], report && ['report', 'Report']].filter(Boolean);
  if (!tabs.length) return null;
  const current = tabs.some((t) => t[0] === view) ? view : tabs[0][0];

  return (
    <div className="space-y-3">
      {tabs.length > 1 && (
        <div className="gl-noprint inline-flex rounded-[var(--ss-radius)] border border-[var(--ss-border)] p-0.5">
          {tabs.map(([key, label]) => (
            <button
              key={key}
              onClick={() => setView(key)}
              className={`flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-[var(--ss-radius-sm)] transition-colors ${
                current === key ? 'bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)]' : 'text-[var(--ss-muted)] hover:text-[var(--ss-text-2)]'
              }`}
            >
              {key === 'checklist' ? <ListChecks size={13} /> : <FileText size={13} />}
              {label}
              {key === 'report' && progress && <span className="font-[var(--ss-font-mono)]">{Object.keys(report.findings).length}</span>}
            </button>
          ))}
        </div>
      )}
      {current === 'checklist' ? (
        <ResearchChecklist research={research} onShowReport={() => setView('report')} />
      ) : (
        <ResearchReport
          account={account}
          report={report}
          run={run}
          onRegenerateSummary={research.onRegenerateSummary}
          onAddContact={onAddContact}
          onAddPlanStep={onAddPlanStep}
          onIntegrateResearch={onIntegrateResearch}
        />
      )}
    </div>
  );
}

function RunStatus({ status, finding }) {
  if (status === 'running') {
    return (
      <span className="flex items-center gap-1 text-[10px] text-[var(--ss-accent)]">
        <Loader2 size={11} className="animate-spin" /> Researching
      </span>
    );
  }
  if (status === 'queued') return <span className="text-[10px] text-[var(--ss-muted)]">Queued</span>;
  if (status === 'failed') return <span className="text-[10px] text-[var(--ss-danger)]">Failed</span>;
  if (status === 'done') return <span className="flex items-center gap-1 text-[10px] text-[var(--ss-positive)]"><CheckCircle2 size={11} /> Done</span>;
  if (finding && finding.status === 'done') {
    return (
      <span className={`text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${CONFIDENCE_STYLE[finding.confidence]}`}>
        In report · {finding.confidence}
      </span>
    );
  }
  if (finding && finding.status === 'failed') return <span className="text-[10px] text-[var(--ss-danger)]">Failed last run</span>;
  return null;
}

function ResearchChecklist({ research, onShowReport }) {
  const { plan, report, run, selection, onToggle, onSelect, onAddQuestion, onRun } = research;
  const [draft, setDraft] = useState('');
  const running = !!run;
  const statuses = (run && run.statuses) || {};
  const findings = (report && report.findings) || {};

  const ordered = [
    ...plan.questions.filter((q) => q.priority === 'Must'),
    ...plan.questions.filter((q) => q.priority !== 'Must' && !q.custom),
    ...plan.questions.filter((q) => q.custom),
  ];
  const selected = plan.questions.filter((q) => selection.includes(q.id));
  const minutes = selected.reduce((sum, q) => sum + q.minutes, 0);
  const refreshing = selected.filter((q) => findings[q.id] && findings[q.id].status === 'done').length;
  const runTotal = Object.keys(statuses).length;
  const runDone = Object.values(statuses).filter((s) => s === 'done' || s === 'failed').length;

  function addQuestion() {
    const text = draft.trim();
    if (!text) return;
    onAddQuestion(text);
    setDraft('');
  }

  const quick = [
    ['Must-answer', () => onSelect(plan.questions.filter((q) => q.priority === 'Must').map((q) => q.id))],
    ['All', () => onSelect(plan.questions.map((q) => q.id))],
    ['None', () => onSelect([])],
  ];

  return (
    <div className="space-y-4">
      <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5">
        <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-1.5 flex items-center gap-1.5">
          <Crosshair size={12} /> Objective
        </div>
        <p className="text-sm text-[var(--ss-text-2)] leading-relaxed">{plan.objective}</p>
        {plan.known.length > 0 && (
          <div className="mt-3">
            <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)] mb-1">Already known, so not researched</div>
            <ul className="space-y-1.5">
              {plan.known.map((k, i) => (
                <li key={i} className="text-[12px] text-[var(--ss-text-2)] flex gap-2">
                  <CheckCircle2 size={12} className="text-[var(--ss-faint)] mt-0.5 shrink-0" />
                  <div>
                    <div>{k.text}</div>
                    <Citation sourceId={k.source} />
                  </div>
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>

      <div>
        <div className="flex items-center justify-between gap-3 mb-2">
          <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-faint)]">
            Questions to research <span className="text-[var(--ss-muted)]">· {selected.length} of {plan.questions.length} selected</span>
          </div>
          <div className="flex items-center gap-2 text-[11px]">
            <span className="text-[var(--ss-faint)]">Select</span>
            {quick.map(([label, fn]) => (
              <button key={label} onClick={fn} disabled={running} className="text-[var(--ss-accent)] hover:underline disabled:opacity-40">
                {label}
              </button>
            ))}
          </div>
        </div>

        <div className="space-y-1.5">
          {ordered.map((q) => {
            const checked = selection.includes(q.id);
            return (
              <label
                key={q.id}
                className={`flex items-start gap-3 rounded-[var(--ss-radius)] border p-3 transition-colors ${running ? 'cursor-default' : 'cursor-pointer'} ${
                  checked ? 'border-[var(--ss-accent)]/40 bg-[var(--ss-accent-wash)]' : 'border-[var(--ss-border)] bg-[var(--ss-surface)] hover:border-[var(--ss-frame)]'
                }`}
              >
                <input
                  type="checkbox"
                  checked={checked}
                  disabled={running}
                  onChange={() => onToggle(q.id)}
                  className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--ss-accent)]"
                />
                <div className="flex-1 min-w-0">
                  <div className="flex items-start justify-between gap-3">
                    <div className="text-[13px] text-[var(--ss-ink)]">{q.question}</div>
                    <div className="flex items-center gap-2 shrink-0">
                      <RunStatus status={statuses[q.id]} finding={findings[q.id]} />
                      <span
                        className={`text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${
                          q.custom
                            ? 'text-[var(--ss-text-2)] border-[var(--ss-border)]'
                            : q.priority === 'Must'
                            ? 'text-[var(--ss-accent)] border-[var(--ss-accent)]/40'
                            : 'text-[var(--ss-muted)] border-[var(--ss-border)]'
                        }`}
                      >
                        {q.custom ? 'Yours' : q.priority === 'Must' ? 'Must' : 'Nice to have'}
                      </span>
                    </div>
                  </div>
                  {q.why && !q.custom && <div className="text-[12px] text-[var(--ss-muted)] mt-0.5">{q.why}</div>}
                  {q.sources.length > 0 && (
                    <div className="flex flex-wrap items-center gap-1 mt-1.5">
                      {q.sources.map((s, i) => (
                        <span key={i} className="text-[10px] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] bg-[var(--ss-rule-soft)] text-[var(--ss-text-2)]">
                          {s}
                        </span>
                      ))}
                      <span className="text-[10px] text-[var(--ss-faint)] ml-1 flex items-center gap-1">
                        <Clock size={10} /> ~{q.minutes} min by hand
                      </span>
                    </div>
                  )}
                </div>
              </label>
            );
          })}
        </div>

        <div className="flex items-center gap-2 mt-2">
          <input
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') addQuestion();
            }}
            disabled={running}
            placeholder="Add your own question, e.g. Who are their EPC contractors?"
            className={inputClass}
          />
          <button
            onClick={addQuestion}
            disabled={running || !draft.trim()}
            className="flex items-center gap-1 shrink-0 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[12px] px-2.5 py-1.5 hover:border-[var(--ss-frame)] disabled:opacity-40"
          >
            <Plus size={13} /> Add
          </button>
        </div>
      </div>

      <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5 space-y-2.5">
        <Button variant="primary" onClick={onRun} disabled={running || selected.length === 0} className="w-full">
          {running ? (
            <>
              <Loader2 size={15} className="animate-spin" />
              {run.phase === 'summary' ? 'Writing the report…' : `Researching ${runDone} of ${runTotal}…`}
            </>
          ) : (
            <>
              <Sparkles size={14} /> Research {selected.length || ''} selected question{selected.length === 1 ? '' : 's'}
            </>
          )}
        </Button>
        {running ? (
          <div className="h-1 rounded-[1px] bg-[var(--ss-rule-soft)] overflow-hidden">
            <div
              className="h-full bg-[var(--ss-accent)] transition-all"
              style={{ width: `${run.phase === 'summary' ? 100 : runTotal ? Math.max(6, (runDone / runTotal) * 100) : 6}%` }}
            />
          </div>
        ) : (
          <div className="text-[11px] text-[var(--ss-muted)] leading-relaxed">
            Claude searches the web for each question, keeps only sources its searches actually returned, and writes the answers up as a report you can download as a PDF.
            {selected.length > 0 && <> That's roughly <span className="text-[var(--ss-text-2)]">{minutes} minutes</span> of desk research by hand.</>}
            {refreshing > 0 && <> {refreshing} already in the report will be refreshed.</>}
          </div>
        )}
        {report && !running && (
          <button onClick={onShowReport} className="text-[12px] text-[var(--ss-positive)] hover:underline flex items-center gap-1">
            <FileText size={12} /> View the current report
          </button>
        )}
      </div>
    </div>
  );
}

function ReportList({ title, items, tone = 'default' }) {
  if (!items || !items.length) return null;
  return (
    <div>
      <div className={`text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] mb-2 ${tone === 'warn' ? 'text-[var(--ss-gap)]' : 'text-[var(--ss-muted)]'}`}>{title}</div>
      <ul className="space-y-1.5">
        {items.map((t, i) => (
          <li key={i} className="text-[13px] text-[var(--ss-text-2)] flex gap-2">
            <span className="text-[var(--ss-faint)] shrink-0">•</span>
            <span>{t}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

// P1 item 7 (feedback brief): "recommended next steps" and "people to
// identify" get callout-card styling (so they stand out from the rest of
// the report) plus, where the data supports it, a one-click action —
// adding a role to the address book as a placeholder contact, or adding a
// step to the outreach plan's timeline. `onAction`/`actionLabel` are both
// optional; when absent, the item just renders as highlighted guidance
// with no button, per the brief ("items with no executable action stay as
// highlighted, non-clickable guidance").
function ActionableReportList({ title, items, onAction, actionLabel, actionIcon: ActionIcon }) {
  const [added, setAdded] = useState({});
  if (!items || !items.length) return null;
  return (
    <div>
      <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-accent)] mb-2">{title}</div>
      <div className="space-y-1.5">
        {items.map((t, i) => (
          <div key={i} className="flex items-start justify-between gap-2 rounded-[var(--ss-radius)] border border-[var(--ss-accent)]/35 bg-[var(--ss-accent-wash)] px-2.5 py-2">
            <span className="text-[13px] text-[var(--ss-text-2)]">{t}</span>
            {onAction && (
              <button
                onClick={() => {
                  onAction(t);
                  setAdded((prev) => ({ ...prev, [i]: true }));
                }}
                disabled={!!added[i]}
                className="shrink-0 flex items-center gap-1 text-[11px] text-[var(--ss-accent)] hover:underline disabled:text-[var(--ss-positive)] disabled:no-underline"
              >
                {added[i] ? (
                  <>
                    <CheckCircle2 size={11} /> Added
                  </>
                ) : (
                  <>
                    {ActionIcon && <ActionIcon size={11} />} {actionLabel}
                  </>
                )}
              </button>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

function ResearchReport({ account, report, run, onRegenerateSummary, onAddContact, onAddPlanStep, onIntegrateResearch }) {
  const [pdfError, setPdfError] = useState(null);
  const { list: sources, index: sourceIndex } = reportSources(report);
  const findings = orderedFindings(report);
  const done = findings.filter((f) => f.status === 'done');
  const withEvidence = done.filter((f) => f.confidence !== 'Not found').length;
  const minutes = done.reduce((sum, f) => sum + (f.minutes || 0), 0);
  const summary = report.summary;
  const busy = !!run;

  function download() {
    setPdfError(null);
    try {
      downloadReportPdf(report, account);
    } catch (e) {
      setPdfError("Couldn't build the PDF here — try again, or use your browser's own print-to-PDF.");
    }
  }

  const stats = [
    ['Questions researched', done.length],
    ['Answered with evidence', withEvidence],
    ['Web sources cited', sources.length],
    ['Desk time saved', `~${minutes} min`],
  ];

  return (
    <div className="gl-print rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-5 space-y-5">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.18em] text-[var(--ss-accent)] mb-1">Research report</div>
          <div className="font-[var(--ss-font-display)] text-[23px] text-[var(--ss-ink)]">{account.name}</div>
          <div className="text-[12px] text-[var(--ss-muted)] mt-0.5 flex items-center gap-2 flex-wrap">
            <span>{account.sector} · {account.tier} account · Prepared {formatDate(report.updatedAt)}</span>
            <span
              className={`text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${
                report.method === 'web' ? 'text-[var(--ss-positive)] border-[var(--ss-positive)]/40' : 'text-[var(--ss-gap)] border-[var(--ss-gap-border)]'
              }`}
            >
              {METHOD_LABEL[report.method]}
            </span>
          </div>
        </div>
        {/* P1 item 6 (feedback brief): "Integrate into outreach" is the
            primary call to action once a report exists — Print is gone
            (nobody asked to read this on paper), and Download drops to a
            secondary/outline treatment next to it. */}
        <div className="gl-noprint flex items-center gap-2">
          <button
            onClick={download}
            className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[13px] px-3 py-1.5 hover:border-[var(--ss-frame)]"
          >
            <Download size={14} /> Download PDF
          </button>
          {onIntegrateResearch && (
            <Button variant="primary" onClick={onIntegrateResearch} size="sm" icon={ArrowRight}>
              Integrate into outreach
            </Button>
          )}
        </div>
      </div>
      {pdfError && <div className="gl-noprint text-[12px] text-[var(--ss-danger)]">{pdfError}</div>}
      {report.method !== 'web' && (
        <div className="text-[12px] text-[var(--ss-gap)] rounded-[var(--ss-radius)] border border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)] px-3 py-2">
          {report.method === 'model'
            ? "Web search wasn't available here, so this report comes from our internal records and Claude's general knowledge only. Check it before relying on it."
            : 'Some questions were researched without web search. Those findings cite no web sources and need checking.'}
        </div>
      )}

      <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
        {stats.map(([label, value]) => (
          <div key={label} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] px-3 py-2">
            <div className="text-[18px] font-[var(--ss-font-mono)] text-[var(--ss-ink)]">{value}</div>
            <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)]">{label}</div>
          </div>
        ))}
      </div>

      {summary ? (
        <div className="space-y-2">
          {summary.headline && <div className="font-[var(--ss-font-display)] text-[22px] text-[var(--ss-ink)] leading-snug">{summary.headline}</div>}
          {summary.executive_summary && <p className="text-[13px] text-[var(--ss-text-2)] leading-relaxed">{summary.executive_summary}</p>}
        </div>
      ) : (
        <div className="gl-noprint text-[12px] text-[var(--ss-text-2)] flex items-center gap-2">
          {busy && run.phase === 'summary' ? (
            <>
              <Loader2 size={12} className="animate-spin" /> Writing the summary…
            </>
          ) : (
            <>
              The summary couldn't be written this time.
              <button onClick={onRegenerateSummary} disabled={busy} className="text-[var(--ss-positive)] hover:underline disabled:opacity-40">
                Retry summary
              </button>
            </>
          )}
        </div>
      )}

      <div>
        <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2">Key findings</div>
        <div className="space-y-2.5">
          {findings.map((f, i) => (
            <div key={f.id} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] p-3.5" style={{ breakInside: 'avoid' }}>
              <div className="flex items-start justify-between gap-3">
                <div className="text-[13px] text-[var(--ss-ink)] font-medium">
                  {i + 1}. {f.question}
                </div>
                {f.status === 'done' ? (
                  <span className={`shrink-0 text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${CONFIDENCE_STYLE[f.confidence]}`}>
                    {f.confidence === 'Not found' ? 'Not found' : `${f.confidence} confidence`}
                  </span>
                ) : (
                  <span className="shrink-0 text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-danger)]">Failed</span>
                )}
              </div>
              {f.status !== 'done' ? (
                <p className="text-[12px] text-[var(--ss-muted)] mt-1.5">Research failed for this question. Select it again in the checklist to retry.</p>
              ) : (
                <>
                  <p className="text-[13px] text-[var(--ss-text-2)] mt-1.5 leading-relaxed">{f.answer}</p>
                  {f.key_facts.length > 0 && (
                    <ul className="mt-2 space-y-1">
                      {f.key_facts.map((k, j) => (
                        <li key={j} className="text-[12px] text-[var(--ss-text-2)] flex gap-2">
                          <span className="text-[var(--ss-faint)] shrink-0">–</span>
                          <span>{k}</span>
                        </li>
                      ))}
                    </ul>
                  )}
                  {f.implication && (
                    <div className="text-[12px] text-[var(--ss-accent)] mt-2">
                      <span className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-accent)] mr-1.5">For outreach</span>
                      {f.implication}
                    </div>
                  )}
                  {f.sources.length > 0 && (
                    <div className="flex flex-wrap gap-1.5 mt-2">
                      {f.sources.map((s, j) =>
                        s.url ? (
                          <a
                            key={j}
                            href={s.url}
                            target="_blank"
                            rel="noopener noreferrer"
                            title={s.title}
                            className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] bg-[var(--ss-rule-soft)] text-[var(--ss-ink)] underline decoration-[var(--ss-border)] hover:decoration-[var(--ss-ink)]"
                          >
                            [{sourceIndex.get(s.url)}] {hostOf(s.url)} <ExternalLink size={10} />
                          </a>
                        ) : (
                          <span key={j} className="text-[11px] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] bg-[var(--ss-rule-soft)] text-[var(--ss-text-2)]">
                            Our records
                          </span>
                        )
                      )}
                    </div>
                  )}
                </>
              )}
            </div>
          ))}
        </div>
      </div>

      {summary && (
        <div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
          <ReportList title="Implications for outreach" items={summary.implications} />
          <ActionableReportList
            title="Recommended next steps"
            items={summary.next_steps}
            onAction={onAddPlanStep ? (text) => onAddPlanStep(text) : null}
            actionLabel="Add to outreach plan"
            actionIcon={Plus}
          />
          <ReportList title="Still open" items={summary.open_questions} tone="warn" />
        </div>
      )}

      <div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
        <ReportList title="What we already knew" items={(report.known || []).map((k) => k.text)} />
        <ActionableReportList
          title="People to identify"
          items={report.people_to_identify}
          onAction={
            onAddContact
              ? (text) => onAddContact({ name: '(To identify)', role: text, email: '', relationship: 'Cold', lastTouch: '', notes: 'Added from the research report — name not yet known.' })
              : null
          }
          actionLabel="Add as contact"
          actionIcon={Plus}
        />
        <ReportList title="Watch-outs" items={(report.watch_outs || []).map((w) => w.text)} tone="warn" />
      </div>

      {sources.length > 0 && (
        <div>
          <div className="text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2">Sources</div>
          <ol className="space-y-1">
            {sources.map((s, i) => (
              <li key={s.url} className="text-[12px] text-[var(--ss-text-2)] flex gap-2 min-w-0">
                <span className="font-[var(--ss-font-mono)] text-[var(--ss-faint)] shrink-0">[{i + 1}]</span>
                <a href={s.url} target="_blank" rel="noopener noreferrer" className="text-[var(--ss-ink)] underline decoration-[var(--ss-border)] hover:decoration-[var(--ss-ink)] break-all">
                  {s.title || hostOf(s.url)}
                </a>
                <span className="text-[var(--ss-faint)] shrink-0">{hostOf(s.url)}</span>
              </li>
            ))}
          </ol>
        </div>
      )}

      <div className="text-[11px] text-[var(--ss-faint)] border-t border-[var(--ss-rule)] pt-3">
        {report.method === 'model'
          ? 'Generated by Claude without web access. Verify before using any of it with a client.'
          : 'Generated by Claude using web search. Only URLs those searches returned are cited. Check key facts against the sources before using them with a client.'}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// ADDRESS BOOK — people at the target firm, with relationship and notes that
// feed both the plan's contacts and the email composer.
// ---------------------------------------------------------------------------

function AddressBook({ contacts, onAdd }) {
  const empty = { name: '', role: '', email: '', relationship: 'Cold', lastTouch: '', notes: '' };
  const [adding, setAdding] = useState(false);
  const [form, setForm] = useState(empty);
  const set = (k) => (e) => setForm((prev) => ({ ...prev, [k]: e.target.value }));

  function save() {
    if (!form.name.trim() || !form.role.trim()) return;
    onAdd({ ...form, name: form.name.trim(), role: form.role.trim() });
    setForm(empty);
    setAdding(false);
  }

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5 space-y-3">
      {contacts.length === 0 && <div className="text-[12px] text-[var(--ss-muted)]">No contacts on file for this firm yet.</div>}
      {contacts.map((c) => (
        <div key={c.id} className="border-b border-[var(--ss-rule)] last:border-0 pb-3 last:pb-0">
          <div className="flex items-center justify-between gap-2">
            <div className="text-[13px] text-[var(--ss-ink)] font-medium">{c.name}</div>
            <RelationshipBadge value={c.relationship} />
          </div>
          <div className="text-[12px] text-[var(--ss-text-2)]">{c.role}{c.email ? ` · ${c.email}` : ''}</div>
          {c.lastTouch && <div className="text-[11px] text-[var(--ss-muted)] mt-0.5">Last touch: {c.lastTouch}</div>}
          {c.notes && <div className="text-[12px] text-[var(--ss-muted)] mt-1">{c.notes}</div>}
        </div>
      ))}

      {adding ? (
        <div className="space-y-2 pt-1">
          <div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
            <input value={form.name} onChange={set('name')} placeholder="Name *" className={inputClass} />
            <input value={form.role} onChange={set('role')} placeholder="Role *" className={inputClass} />
            <input value={form.email} onChange={set('email')} placeholder="Email" className={inputClass} />
            <select value={form.relationship} onChange={set('relationship')} className={inputClass}>
              {Object.keys(RELATIONSHIP_STYLE).map((r) => (
                <option key={r} value={r}>{r}</option>
              ))}
            </select>
          </div>
          <input value={form.lastTouch} onChange={set('lastTouch')} placeholder="Last touch point (e.g. Met at conference, Mar 2026)" className={inputClass} />
          <textarea value={form.notes} onChange={set('notes')} rows={2} placeholder="Notes — responsibilities, preferences, what they care about" className={inputClass} />
          <div className="flex items-center gap-2">
            <Button variant="primary" size="sm" onClick={save} disabled={!form.name.trim() || !form.role.trim()}>
              Save contact
            </Button>
            <Button variant="ghost" size="sm" onClick={() => setAdding(false)}>
              Cancel
            </Button>
          </div>
        </div>
      ) : (
        <button onClick={() => setAdding(true)} className="flex items-center gap-1.5 text-[12px] text-[var(--ss-accent)] hover:underline">
          <Plus size={13} /> Add contact
        </button>
      )}
    </div>
  );
}

// Builds a mailto: link pre-filled with the current draft — a zero-dependency
// way to open the user's own mail client (Gmail, Outlook, Apple Mail,
// whatever's set as the OS/browser default) with the message ready to send.
// This only opens a compose window; it can't send on its own or confirm the
// click actually happened, so "Mark as sent" (or the verification flow next
// to it) is still the explicit, separate record of the outreach having gone
// out — same "nothing changes without an explicit action" rule as the rest
// of the app. Some mail clients truncate very long bodies passed this way;
// there's no way to detect that from here, so treat this as a convenience,
// not a guarantee of what arrives in the draft.
function buildMailtoHref(contact, draft) {
  const to = contact?.email ? encodeURIComponent(contact.email) : '';
  const params = [];
  if (draft?.subject) params.push(`subject=${encodeURIComponent(draft.subject)}`);
  if (draft?.body) params.push(`body=${encodeURIComponent(draft.body)}`);
  return `mailto:${to}${params.length ? '?' + params.join('&') : ''}`;
}

// ---------------------------------------------------------------------------
// EMAIL COMPOSER — the user picks the ingredients (recipient, lead signal,
// thought leadership, previous work, tone) and Claude drafts from only those.
// The draft is editable. If the account has an assigned account manager,
// sending routes through a "send for verification" step before it can be
// logged as sent — see markSent/sendForVerification below.
// ---------------------------------------------------------------------------

function EmailComposer({ account, plan, research, contacts, draft, onDraftChange, onRecord, onOpenAddressBook, fitGap, selectedMoves, thoughtLeadershipUploads = [], onSent }) {
  const matches = accountMatches(account.id);
  const matchedIds = matches.map((m) => m.newsId);

  const [contactId, setContactId] = useState(contacts[0]?.id || '');
  const [emphasisIds, setEmphasisIds] = useState(matches[0] ? [matches[0].newsId] : []);
  const [insightIds, setInsightIds] = useState([]);
  const [workIds, setWorkIds] = useState([]);
  // Capabilities to feature (added 2026-10-11 direct feedback: "a section
  // in the email generator for the user's company's capabilities") — same
  // opt-in toggle-list pattern as thought leadership/previous work below;
  // CAPABILITIES is a flat string list (no stable ids, see "Your Company"),
  // so the string itself is the key.
  const [capabilityChoices, setCapabilityChoices] = useState([]);
  const toggleCapability = (c) => setCapabilityChoices((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
  const [tone, setTone] = useState('Warm');
  const [extra, setExtra] = useState('');
  const [generating, setGenerating] = useState(false);
  const [error, setError] = useState(null);
  const [sentMsg, setSentMsg] = useState(null);
  // True once "Send for verification" has been clicked for the current
  // draft — gates markSent when the account has an assigned manager.
  // Resets on every redraft, since a new draft needs its own sign-off.
  const [verificationSent, setVerificationSent] = useState(false);
  // True while the final "mark as sent" confirmation (naming the actual
  // recipient and company) is showing, gating markSent() itself — added
  // per the 2026-10-13 AccountPage critique (P0): "mark as sent" used to
  // fire markSent() directly on click with no distinct confirm step.
  // Resets on redraft/recipient change so a stale confirmation can't be
  // accepted against a different draft or contact.
  const [confirmingSend, setConfirmingSend] = useState(false);
  // "Once the drafted email is marked as sent, that big window should
  // minimize again and disappear" (2026-10-11 direct feedback) — a brief
  // delay so sentMsg is actually visible before onSent (PlanPanel closing
  // this chip) unmounts the whole form.
  const sentCollapseTimer = useRef(null);
  useEffect(() => () => window.clearTimeout(sentCollapseTimer.current), []);

  const contact = contacts.find((c) => c.id === contactId) || null;
  const relevant = (item) => isRelevantTo(item, account, matchedIds);
  // Uploaded thought leadership (added 2026-10-11, see "Your Company") —
  // shown and selected alongside the fixture FIRM_INSIGHTS list, same
  // {type, title, date} shape; `_upload` carries the raw file through to
  // generate() below, for a text excerpt or an attached PDF document block.
  const uploadInsights = thoughtLeadershipUploads.map((u) => ({
    id: u.id,
    type: u.kind === 'pdf' ? 'PDF' : 'Upload',
    title: u.name,
    date: formatDate(u.uploadedAt),
    summary: u.kind === 'pdf' ? 'Uploaded PDF — attached in full when selected.' : 'Uploaded file.',
    _upload: u,
  }));
  const insights = [...FIRM_INSIGHTS, ...uploadInsights].sort((a, b) => relevant(b) - relevant(a));
  const ownWork = PAST_WORK.filter((w) => w.accountId === account.id);
  const otherWork = PAST_WORK.filter((w) => w.accountId !== account.id).sort((a, b) => relevant(b) - relevant(a));

  const toggleIn = (setter) => (id) => setter((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
  const toggleInsight = toggleIn(setInsightIds);
  const toggleWork = toggleIn(setWorkIds);

  async function generate() {
    setGenerating(true);
    setError(null);
    setSentMsg(null);
    setVerificationSent(false);
    setConfirmingSend(false);
    // Point 4 of the gap-flagging redesign: the email opens with the part
    // we're strong in, not just whatever the model guesses — threaded
    // straight from the account's own fit/gap check and any route already
    // chosen for each gap (see selectedMoves in App).
    const gapsSummary = fitGap && fitGap.gaps.length
      ? fitGap.gaps
          .map((g) => {
            const idx = selectedMoves && selectedMoves[g.tag];
            const chosen = typeof idx === 'number' ? g.moves[idx] : null;
            return `- ${g.gap} (${g.severity}, blocks: ${g.scope}). ${chosen ? `Chosen route: ${chosen.type}${chosen.partner ? ` — ${chosen.partner}` : ''}: ${chosen.detail}` : 'No route chosen.'}`;
          })
          .join('\n')
      : '';
    // Selected thought leadership: fixture FIRM_INSIGHTS entries pass
    // through as-is; a selected upload's text is excerpted straight into
    // the same `summary` field buildEmailPrompt already reads (same
    // UPLOAD_EXCERPT_CHARS cap generatePlan/fetchFitGaps use for uploads),
    // and a selected PDF upload rides along as its own `document` content
    // block below rather than as text — same mechanism those two already
    // use for the "Your Company" past-project PDF uploads.
    const UPLOAD_EXCERPT_CHARS = 6000;
    const selectedInsights = insights
      .filter((i) => insightIds.includes(i.id))
      .map((i) => {
        if (!i._upload || i._upload.kind !== 'text') return i;
        const text = i._upload.text || '';
        return { ...i, summary: `${text.slice(0, UPLOAD_EXCERPT_CHARS)}${text.length > UPLOAD_EXCERPT_CHARS ? ' [truncated]' : ''}` };
      });
    const selectedPdfUploads = insights.filter((i) => insightIds.includes(i.id) && i._upload && i._upload.kind === 'pdf').map((i) => i._upload);

    const prompt = buildEmailPrompt({
      account,
      contact,
      leads: matches.filter((m) => emphasisIds.includes(m.newsId)),
      others: matches.filter((m) => !emphasisIds.includes(m.newsId)),
      insights: selectedInsights,
      work: PAST_WORK.filter((w) => workIds.includes(w.id)),
      plan,
      research,
      tone,
      extra: extra.trim(),
      entryPoint: fitGap && fitGap.entryPoint,
      gapsSummary,
      emphasizedCapabilities: capabilityChoices,
    });
    const content = selectedPdfUploads.length
      ? [
          { type: 'text', text: prompt },
          ...selectedPdfUploads.map((u) => ({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: u.base64 } })),
        ]
      : prompt;
    try {
      const parsed = await callClaudeJSON(content, 1000);
      onDraftChange({ subject: parsed.subject || '', body: parsed.body || '' });
    } catch (e) {
      // Day 5b reliability fallback — see DEMO_CACHED_EMAIL above.
      const cached = DEMO_MODE && DEMO_CACHED_EMAIL[account.id];
      if (cached) {
        onDraftChange(cached);
      } else {
        setError(friendlyApiError(e, "Couldn't draft the email just now — try again."));
      }
    } finally {
      setGenerating(false);
    }
  }

  // The account has an assigned manager, so outreach routes through them
  // first — this just logs the hand-off (no stage change: a draft being
  // sent for sign-off isn't outreach having happened). There's no real
  // multi-user auth in this prototype for a different person to actually
  // click the approval themselves, so the step the manager would take is
  // represented by the "<manager> verified — mark as sent" button that
  // appears next, same as every other stage confirmation in this app being
  // a self-attested record rather than an enforced permission.
  function sendForVerification() {
    onRecord({
      action: `Draft sent to ${account.accountManager} for verification`,
      contactId: contact ? contact.id : null,
      note: draft?.subject ? `Subject: ${draft.subject}` : '',
    });
    setVerificationSent(true);
    setSentMsg(null);
  }

  function markSent() {
    setConfirmingSend(false);
    const action = account.accountManager ? `Outreach email sent (verified by ${account.accountManager})` : 'Outreach email sent';
    const note = draft?.subject ? `Subject: ${draft.subject}` : '';
    const leadMatches = matches.filter((m) => emphasisIds.includes(m.newsId));
    if (leadMatches.length > 0) {
      // Selected lead signal(s), 2026-10-06: advance only the opportunities
      // this email actually led with to Contacted, independently of each
      // other and of the account's own baseline stage — see
      // setMatchStage/accountRollupStage. An email with no selected leads
      // (role-neutral, their priorities only) still advances the account
      // as a whole, below.
      const before = leadMatches.some((m) => STAGES.indexOf(m.stage) < STAGES.indexOf('Contacted'));
      leadMatches.forEach((m) => {
        onRecord({ action, to: 'Contacted', contactId: contact ? contact.id : null, note, opportunityId: m.newsId, opportunityLabel: m.news.headline });
      });
      setSentMsg(
        before
          ? `Logged as sent — ${leadMatches.length > 1 ? `${leadMatches.length} opportunities moved` : 'opportunity moved'} to Contacted.`
          : 'Logged as sent in the activity feed.'
      );
    } else {
      const before = STAGES.indexOf(account.stage) < STAGES.indexOf('Contacted');
      onRecord({ action, to: 'Contacted', contactId: contact ? contact.id : null, note });
      setSentMsg(before ? 'Logged as sent — account moved to Contacted.' : 'Logged as sent in the activity feed.');
    }
    if (onSent) {
      window.clearTimeout(sentCollapseTimer.current);
      sentCollapseTimer.current = window.setTimeout(() => onSent(), 1800);
    }
  }

  const subLabel = 'text-[11px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-muted)] mb-1.5';

  return (
    <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3.5 space-y-4">
      {/* Recipient */}
      <div>
        <div className={subLabel}>To</div>
        <select value={contactId} onChange={(e) => { setContactId(e.target.value); setConfirmingSend(false); }} className={inputClass}>
          <option value="">No specific contact (role-neutral)</option>
          {contacts.map((c) => (
            <option key={c.id} value={c.id}>{c.name} — {c.role}</option>
          ))}
        </select>
        {contact ? (
          <div className="text-[11px] text-[var(--ss-muted)] mt-1.5 flex items-start gap-2">
            <RelationshipBadge value={contact.relationship} />
            <span>{contact.notes}</span>
          </div>
        ) : (
          <button onClick={onOpenAddressBook} className="text-[11px] text-[var(--ss-accent)] hover:underline mt-1.5">
            Add someone to the address book
          </button>
        )}
      </div>

      {/* Lead signal(s) */}
      <div>
        <div className={subLabel}>Lead with {emphasisIds.length > 1 ? `(${emphasisIds.length} selected)` : ''}</div>
        {matches.length === 0 ? (
          <div className="text-[12px] text-[var(--ss-faint)]">No matched signals — the email will lead with their priorities.</div>
        ) : (
          <div className="flex flex-col gap-1.5">
            {matches.map((m) => {
              const active = emphasisIds.includes(m.newsId);
              return (
                <TogglePill
                  key={m.newsId}
                  active={active}
                  onClick={() => setEmphasisIds((prev) => (prev.includes(m.newsId) ? prev.filter((id) => id !== m.newsId) : [...prev, m.newsId]))}
                >
                  {m.news.headline} <span className={`font-[var(--ss-font-mono)] ${active ? 'text-[var(--ss-inverse-muted)]' : 'text-[var(--ss-faint)]'}`}>· {((m.confidence || 0) / 100).toFixed(2)}</span>
                </TogglePill>
              );
            })}
          </div>
        )}
      </div>

      {/* Our capabilities (2026-10-11 direct feedback: "a section in the
          email generator for the user's company's capabilities," then "our
          capabilities should be right below the lead with section") —
          opt-in, same pattern as thought leadership/previous work below:
          picking one or two here tells the draft what to anchor on instead
          of scanning the full list (see CAPABILITIES in "Your Company" and
          buildEmailPrompt's emphasizedCapabilities). */}
      <div>
        <div className={subLabel}>Our capabilities</div>
        {CAPABILITIES.length === 0 ? (
          <div className="text-[12px] text-[var(--ss-muted)]">No capabilities on file — add some in "Your Company."</div>
        ) : (
          <div className="flex flex-col gap-1.5">
            {CAPABILITIES.map((c) => (
              <TogglePill key={c} active={capabilityChoices.includes(c)} onClick={() => toggleCapability(c)}>
                {c}
              </TogglePill>
            ))}
          </div>
        )}
      </div>

      {/* Thought leadership */}
      <div>
        <div className={subLabel}>Our thought leadership</div>
        <div className="flex flex-col gap-1.5">
          {insights.map((i) => {
            const active = insightIds.includes(i.id);
            return (
              <TogglePill key={i.id} active={active} onClick={() => toggleInsight(i.id)}>
                <span className={active ? 'text-[var(--ss-inverse-muted)]' : 'text-[var(--ss-muted)]'}>{i.type}:</span> {i.title}
                {relevant(i) && <span className={`ml-1.5 text-[10px] font-[var(--ss-font-mono)] uppercase ${active ? 'text-[var(--ss-inverse-text)]' : 'text-[var(--ss-positive)]'}`}>relevant</span>}
              </TogglePill>
            );
          })}
        </div>
      </div>

      {/* Previous work */}
      <div>
        <div className={subLabel}>Previous work</div>
        {ownWork.length > 0 && (
          <div className="mb-2">
            <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-accent)] mb-1">With {account.name}</div>
            <div className="flex flex-col gap-1.5">
              {ownWork.map((w) => {
                const active = workIds.includes(w.id);
                return (
                  <TogglePill key={w.id} active={active} onClick={() => toggleWork(w.id)}>
                    {w.title} ({w.year}) <span className={active ? 'text-[var(--ss-inverse-muted)]' : 'text-[var(--ss-muted)]'}>— {w.outcome}</span>
                  </TogglePill>
                );
              })}
            </div>
          </div>
        )}
        <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.05em] text-[var(--ss-faint)] mb-1">Elsewhere (client kept anonymous)</div>
        <div className="flex flex-col gap-1.5">
          {otherWork.map((w) => {
            const active = workIds.includes(w.id);
            return (
              <TogglePill key={w.id} active={active} onClick={() => toggleWork(w.id)}>
                {w.clientLabel}: {w.title} ({w.year})
                {relevant(w) && <span className={`ml-1.5 text-[10px] font-[var(--ss-font-mono)] uppercase ${active ? 'text-[var(--ss-inverse-text)]' : 'text-[var(--ss-positive)]'}`}>relevant</span>}
              </TogglePill>
            );
          })}
        </div>
      </div>

      {/* Tone + extra */}
      <div className="grid gap-3" style={{ gridTemplateColumns: 'auto 1fr' }}>
        <div>
          <div className={subLabel}>Tone</div>
          <div className="flex gap-1">
            {Object.keys(TONES).map((t) => (
              <TogglePill key={t} active={tone === t} onClick={() => setTone(t)}>
                {t}
              </TogglePill>
            ))}
          </div>
        </div>
        <div>
          <div className={subLabel}>Anything else?</div>
          <input value={extra} onChange={(e) => setExtra(e.target.value)} placeholder="e.g. Mention we'll be at their regional conference in October" className={inputClass} />
        </div>
      </div>

      <Button variant="primary" onClick={generate} disabled={generating} className="w-full">
        {generating ? (
          <>
            <Loader2 size={15} className="animate-spin" /> Drafting…
          </>
        ) : (
          <>
            <Sparkles size={14} /> {draft ? 'Redraft email' : 'Draft email'}
          </>
        )}
      </Button>
      <ErrorText message={error} className="text-[12px] text-[var(--ss-danger)]" />
      {!plan && !draft && (
        <div className="text-[11px] text-[var(--ss-faint)]">Tip: generating the outreach plan first gives the email more to work with.</div>
      )}

      {/* Editable draft */}
      {draft && (
        <div className="space-y-2 pt-1">
          <div>
            <div className={subLabel}>Subject</div>
            <input value={draft.subject} onChange={(e) => onDraftChange({ ...draft, subject: e.target.value })} className={inputClass} />
          </div>
          <div>
            <div className={subLabel}>Body — edit freely</div>
            <textarea
              value={draft.body}
              onChange={(e) => onDraftChange({ ...draft, body: e.target.value })}
              rows={12}
              className={`${inputClass} leading-relaxed`}
            />
          </div>
          <div className="flex items-center justify-between gap-3 flex-wrap">
            <a
              href={buildMailtoHref(contact, draft)}
              className="flex items-center gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] text-[var(--ss-text-2)] text-[13px] px-3 py-1.5 hover:border-[var(--ss-frame)]"
            >
              <Mail size={14} /> Open in mail client
            </a>
            {!confirmingSend && (
              account.accountManager ? (
                verificationSent ? (
                  <Button variant="secondary" size="sm" icon={CheckCircle2} onClick={() => setConfirmingSend(true)} className="border-[var(--ss-positive)]/40 text-[var(--ss-positive)] hover:bg-[var(--ss-positive-wash)]">
                    {account.accountManager} verified — mark as sent
                  </Button>
                ) : (
                  <Button variant="secondary" size="sm" icon={UserCheck} onClick={sendForVerification} className="border-[var(--ss-accent)]/40 text-[var(--ss-accent)] hover:bg-[var(--ss-accent-wash)]">
                    Send to {account.accountManager} for verification
                  </Button>
                )
              ) : (
                <Button variant="secondary" size="sm" icon={CheckCircle2} onClick={() => setConfirmingSend(true)} className="border-[var(--ss-positive)]/40 text-[var(--ss-positive)] hover:bg-[var(--ss-positive-wash)]">
                  Mark as sent
                </Button>
              )
            )}
          </div>
          {confirmingSend && (
            <div className="rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-ground)] p-3 space-y-2">
              <div className="text-[13px] text-[var(--ss-text-2)]">
                Mark as sent to{' '}
                <span className="font-medium">
                  {contact ? `${contact.name} at ${account.name}` : `${account.name} (no specific contact — role-neutral)`}
                </span>
                ?
              </div>
              <div className="flex items-center gap-2">
                <Button variant="primary" size="sm" icon={CheckCircle2} onClick={markSent}>
                  Confirm & mark as sent
                </Button>
                <Button variant="ghost" size="sm" onClick={() => setConfirmingSend(false)}>
                  Cancel
                </Button>
              </div>
            </div>
          )}
          {account.accountManager && verificationSent && !sentMsg && !confirmingSend && (
            <div className="text-[11px] text-[var(--ss-accent)]">Awaiting verification from {account.accountManager}.</div>
          )}
          {!account.accountManager && !confirmingSend && !sentMsg && (
            <div className="text-[11px] text-[var(--ss-faint)]">
              No account manager set for this account — set one in Manage accounts to require their verification before outreach is logged as sent.
            </div>
          )}
          {sentMsg && <div className="text-[12px] text-[var(--ss-positive)]">{sentMsg}</div>}
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// LIVE FEED TAB — load real news from gridline_news.json (News Scraper/),
// filter it, and flag items worth pursuing. Stays separate from the Signal
// Map unless the user explicitly promotes an item with "Add to Signal Map"
// (LiveFeedPromote, right below) — see the comment above
// normalizeLiveNewsItem for the loading/merge side, and above
// allNewsItems/allMatches for how a promoted item actually reaches the
// Signal Map.
// ---------------------------------------------------------------------------

// The confirm step for turning one Live feed item into a real signal: pick
// an account (accounts matchingAccountsForLiveItem finds mentioned in the
// item's own text are suggested first), write why it matters and what to do
// next, and add it. Deliberately manual rather than AI-drafted — see
// claude.md §5.9/§9 for why, and as the natural next step if that changes.
function LiveFeedPromote({
  item, accounts, onAddToSignalMap, onUnlinkSignal,
  relevantAccountIds = [], openForAccountId, onOpenedForAccount,
  // True while this card is the one LiveFeed's j/k cursor is on — see
  // LiveFeedCard. Only used to decide whether `s` is bound here.
  focused = false,
}) {
  const [open, setOpen] = useState(false);
  const [accountId, setAccountId] = useState('');
  const [confidence, setConfidence] = useState(90);
  const [rationale, setRationale] = useState('');
  const [nextStep, setNextStep] = useState('');
  // Which badge (by account id) is mid "unclip" — kept in the DOM a beat
  // past the actual click so its exit animation can play before the real
  // unlink (and the badge's removal from `promotedFor`) happens. The add
  // side doesn't need matching state: a newly-added badge is a genuine new
  // DOM mount (stable `key={a.id}`, only created when that account is
  // actually added), so its entrance animation is just a className that
  // plays once on mount, no extra bookkeeping required.
  const [unclippingId, setUnclippingId] = useState(null);

  // `suggested` now leads with the AI-assessed relevant accounts (from
  // FundingBidEvaluation, when this is a Funding item) ahead of the plain
  // text-mention match — added 2026-10-11, direct feedback: "the companies
  // that are identified as being relevant should be automatically filled in
  // on the add to signal map form." Text-matched accounts not already
  // covered by the AI assessment still follow, so nothing text-matching
  // used to surface disappears.
  const textMatched = matchingAccountsForLiveItem(item, accounts);
  const relevantSet = new Set(relevantAccountIds);
  const relevantOnes = relevantAccountIds.map((id) => accounts.find((a) => a.id === id)).filter(Boolean);
  const suggested = [...relevantOnes, ...textMatched.filter((a) => !relevantSet.has(a.id))];
  const promotedFor = promotedAccountsForLiveItem(item.id, accounts);
  const promotedIds = new Set(promotedFor.map((a) => a.id));
  const remaining = accounts.filter((a) => !promotedIds.has(a.id));

  function startOpen(preferredAccountId) {
    const preferred = preferredAccountId && accounts.find((a) => a.id === preferredAccountId && !promotedIds.has(a.id));
    const first = preferred || suggested.find((a) => !promotedIds.has(a.id)) || remaining[0];
    setAccountId(first ? first.id : '');
    setConfidence(90);
    setRationale(item.summary || item.headline);
    setNextStep('');
    setOpen(true);
  }

  // `s` (save) on the focused card — opens this same "Add to Signal Map"
  // form the button does, pre-filled the same way. It deliberately opens
  // the form rather than promoting straight away: the account, confidence
  // and next step are all still the user's call (see §5.9's
  // "deliberately manual, not AI-drafted" note).
  useShortcut('s', () => startOpen(null), {
    label: 'Add the focused signal to the Signal Map',
    group: 'Signals',
    enabled: focused && !open && remaining.length > 0,
  });

  // One-shot trigger from FundingBidEvaluation's per-account "Add to Signal
  // Map" link (item 6 of the same feedback) — opens the form pre-selected
  // on that account, same as clicking "Add to Signal Map" normally would
  // once this account is already the top suggestion.
  useEffect(() => {
    if (!openForAccountId) return;
    startOpen(openForAccountId);
    if (onOpenedForAccount) onOpenedForAccount();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [openForAccountId]);

  function submit() {
    if (!accountId || !nextStep.trim()) return;
    onAddToSignalMap(item, accountId, {
      confidence: Math.max(0, Math.min(100, Number(confidence) || 70)),
      rationale: rationale.trim() || item.headline,
      nextStep: nextStep.trim(),
    });
    setOpen(false);
  }

  function unclip(unlinkAccountId) {
    setUnclippingId(unlinkAccountId);
    setTimeout(() => {
      onUnlinkSignal(`live-${item.id}`, unlinkAccountId);
      setUnclippingId(null);
    }, 140); // matches .gl-signal-unclip's animation-duration
  }

  return (
    <div className="mt-2 space-y-2">
      {promotedFor.length > 0 && (
        <div className="flex items-center gap-1.5 flex-wrap">
          {promotedFor.map((a) => (
            <span
              key={a.id}
              className={`flex items-center gap-1 text-[11px] font-mono pl-2 pr-1 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-positive)] bg-[var(--ss-positive-wash)] text-[var(--ss-positive)] ${
                unclippingId === a.id ? 'gl-signal-unclip' : 'gl-signal-snap-in'
              }`}
            >
              <Link2 size={11} /> On Signal Map · {a.name}
              <button
                onClick={() => unclip(a.id)}
                disabled={unclippingId === a.id}
                title={`Not relevant — unlink from ${a.name}'s Signal Map`}
                className="flex items-center text-[var(--ss-positive)] hover:text-[var(--ss-danger)] active:scale-90 transition-transform ml-0.5"
              >
                <Link2Off size={11} />
              </button>
            </span>
          ))}
        </div>
      )}

      {!open ? (
        // Once an item is on the Signal Map for any account, it's done —
        // no further "Add to Signal Map" prompt, even if other accounts
        // remain. Keeps a connected item from reading as still-actionable.
        promotedFor.length === 0 &&
        remaining.length > 0 && (
          <Button
            variant="secondary"
            size="sm"
            icon={Link2}
            onClick={() => startOpen()}
            aria-keyshortcuts={focused ? ariaKeyShortcuts('s') : undefined}
            title={focused ? 'Add to Signal Map (S)' : undefined}
          >
            Add to Signal Map
          </Button>
        )
      ) : (
        <div className="space-y-2 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3">
          <div className="grid gap-2" style={{ gridTemplateColumns: 'minmax(0, 1fr) 90px' }}>
            <label className="block">
              <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Account</span>
              <select value={accountId} onChange={(e) => setAccountId(e.target.value)} className={inputClass}>
                {suggested.length > 0 && (
                  <optgroup label={relevantOnes.length > 0 ? 'Suggested' : 'Mentioned in this item'}>
                    {suggested
                      .filter((a) => !promotedIds.has(a.id))
                      .map((a) => (
                        <option key={a.id} value={a.id}>{a.name}</option>
                      ))}
                  </optgroup>
                )}
                <optgroup label="All accounts">
                  {remaining.map((a) => (
                    <option key={a.id} value={a.id}>{a.name}</option>
                  ))}
                </optgroup>
              </select>
            </label>
            <label className="block">
              <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Confidence</span>
              <input
                type="number"
                min="0"
                max="100"
                value={confidence}
                onChange={(e) => setConfidence(e.target.value)}
                className={inputClass}
              />
            </label>
          </div>
          <label className="block">
            <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Why it matters</span>
            <textarea value={rationale} onChange={(e) => setRationale(e.target.value)} rows={2} className={inputClass} />
          </label>
          <label className="block">
            <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Next step</span>
            <input
              value={nextStep}
              onChange={(e) => setNextStep(e.target.value)}
              placeholder="e.g. Share this with the account team and propose a call"
              className={inputClass}
            />
          </label>
          <div className="flex items-center gap-1.5 flex-wrap">
            <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Suggestions:</span>
            {nextStepSuggestions(item).map((s, i) => (
              <button
                key={i}
                type="button"
                onClick={() => setNextStep(s)}
                title={s}
                className="text-[11px] px-2 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-muted)] hover:border-[var(--ss-frame)] hover:text-[var(--ss-ink)] max-w-[220px] truncate"
              >
                {s}
              </button>
            ))}
          </div>
          <div className="flex items-center gap-2">
            <Button variant="primary" size="sm" onClick={submit} disabled={!accountId || !nextStep.trim()}>
              Add to Signal Map
            </Button>
            <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
              Cancel
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}

// Builds the prompt for "Evaluate for bid relevance" (FundingBidEvaluation,
// below) — judges which accounts have a credible reason to pursue a
// specific funding scheme or tender, based only on their stated sector/tier/
// priorities. Same honesty rule as the rest of the app's AI-assisted
// features (generatePlan's gap-checking rule, the research workflow's
// "Not found" handling): never pad the list, return nothing rather than a
// stretched match, and never claim or imply a relationship with the account
// around this specific opportunity that isn't actually known.
function buildFundingEvaluationPrompt(item, accounts) {
  const accountLines = accounts
    .map((a) => `[${a.id}] ${a.name} — ${a.sector || 'sector unknown'} (tier: ${a.tier}). Stated priorities: ${a.priorities.join('; ') || 'none on file'}.`)
    .join('\n');
  // P1 item 8 (feedback brief): "do not assume the user wants to bid —
  // present a funding call as an engagement opportunity to raise with the
  // client." Reworded from the original "credible reason to bid" framing;
  // the fit/rationale JSON shape is unchanged since the actual judgment
  // (is this worth bringing to this account at all) hasn't changed, only
  // what we're judging it's worth doing about it.
  return `You are helping a renewables-sector business development team spot which of our accounts have a credible reason to be told about a specific funding scheme or tender — as a client engagement opportunity worth raising with them, not something we would bid on ourselves. Respond ONLY with valid JSON, no markdown code fences, no preamble, matching exactly this shape:
{"assessments": [{"accountId": "a1", "fit": 72, "rationale": "..."}]}

Rules:
- fit: 0-100, how credible it is that this account would want to hear about this opportunity, based only on their stated sector/priorities below — never on anything not given here.
- rationale: one sentence, under 25 words, naming the specific priority or sector angle that connects them to this opportunity.
- Only include an account if there is a genuine, specific connection — don't pad the list to cover every account. If truly none of the accounts below have a credible connection, return an empty assessments array. This should hold up to scrutiny, not be a guess dressed up as an assessment.
- Never state or imply the account has already decided to pursue this, has applied before, or that we have an existing relationship with them around this specific opportunity — only that it looks worth raising with them given their stated priorities.
- Sort assessments by fit, descending.

Funding opportunity:
Headline: ${item.headline}
Summary: ${item.summary || 'None given.'}
Topics: ${(item.topics || []).join(', ') || 'None'}
Region: ${item.region || 'Unspecified'}
${item.deadline ? `Deadline: ${item.deadline}` : 'Deadline: none given'}

Our accounts:
${accountLines}`;
}

// The AI-assisted half of funding/tender tracking: for a Funding-kind item,
// judges which accounts might want to pursue it and why, so the user isn't
// left guessing which of 8+ accounts a given scheme could matter to. Reports
// its result up via `onResult` (added per direct feedback — see below) so
// the sibling `LiveFeedPromote` on the same card can prioritize and pre-fill
// the AI-assessed accounts instead of only the plain text-mention match it
// used before; a user who wants to link the opportunity to an account can
// still use the ordinary "Add to Signal Map" control, now with a direct
// "Add to Signal Map" link per assessed account too (`onPickAccount`).
//
// Auto-runs on mount for every Funding item (added 2026-10-11, per direct
// feedback: "for any funding signals, automatically check which accounts to
// raise them with instead of making the user do it") — a `ranRef` guard
// keeps it to exactly one call per card even across re-renders, same
// one-shot-effect pattern used elsewhere in this file (e.g. TabTourSpotlight's
// measure effect). Still requires an API key like every other live AI call;
// with none set it fails silently into the same friendly error state a
// manual click would have hit, with no key-missing banner until the user
// actually looks at this card (consistent with every other feature's
// "nothing fires before a key exists" rule) — the one exception being the
// still-present "Re-check" control, which doubles as a manual retry.
function FundingBidEvaluation({ item, accounts, onResult, onPickAccount }) {
  const [result, setResult] = useState(null); // { assessments } | null
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const ranRef = useRef(false);

  async function evaluate() {
    setLoading(true);
    setError(null);
    try {
      const parsed = await callClaudeJSON(buildFundingEvaluationPrompt(item, accounts), 700);
      const assessments = (parsed.assessments || [])
        .map((a) => ({ ...a, account: accounts.find((acc) => acc.id === a.accountId) }))
        .filter((a) => a.account && Number.isFinite(a.fit))
        .sort((a, b) => b.fit - a.fit);
      setResult({ assessments });
      if (onResult) onResult(assessments);
    } catch (e) {
      setError(friendlyApiError(e, "Couldn't evaluate this opportunity just now — try again."));
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    if (ranRef.current || !hasAnthropicApiKey()) return;
    ranRef.current = true;
    evaluate();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="mt-2.5 pt-2.5 border-t border-[var(--ss-rule)] space-y-2">
      {!result && (
        <Button
          variant="secondary"
          size="sm"
          icon={loading ? Loader2 : Crosshair}
          onClick={evaluate}
          disabled={loading}
          className={loading ? '[&_svg]:animate-spin' : ''}
        >
          {loading ? 'Checking against accounts…' : 'Check which accounts to raise this with'}
        </Button>
      )}
      {error && <div className="text-[11px] text-[var(--ss-danger)]">{error}</div>}
      {result && (
        <div className="space-y-2">
          <div className="flex items-center justify-between">
            <div className="text-[10px] font-[var(--ss-font-mono)] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Worth raising with</div>
            <button
              onClick={evaluate}
              disabled={loading}
              className="flex items-center gap-1 text-[10px] text-[var(--ss-faint)] hover:text-[var(--ss-ink)]"
            >
              <RefreshCw size={10} className={loading ? 'animate-spin' : ''} /> Re-check
            </button>
          </div>
          {/* P1 item 8 (feedback brief): the bid-writing-capability flag,
              set in Your Capabilities — shown once per evaluation, not per
              account, since it's a general firm capability, not something
              specific to any one of them. */}
          {BID_WRITING_CAPABLE && result.assessments.length > 0 && (
            <div className="flex items-center gap-1.5 text-[11px] text-[var(--ss-positive)]">
              <CheckCircle2 size={11} /> Your team has bid-writing capability — you could offer bid support.
            </div>
          )}
          {result.assessments.length === 0 ? (
            <div className="text-[11px] text-[var(--ss-muted)]">No account has an obvious reason to raise this one.</div>
          ) : (
            result.assessments.map((a) => (
              <div key={a.accountId} className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-2.5">
                <div className="flex items-center justify-between gap-2 mb-1">
                  <span className="text-[12px] text-[var(--ss-ink)] font-medium">{a.account.name}</span>
                  <ScoreBar value={a.fit / 100} className="w-[104px]" />
                </div>
                <p className="text-[11px] text-[var(--ss-text-2)] leading-relaxed">{a.rationale}</p>
                {/* Direct link to the promote form below, pre-filled on this
                    account (added 2026-10-11, direct feedback: "the list of
                    matching companies should include a link to automatically
                    add it to the signal map") — skips hunting through the
                    "Add to Signal Map" form's own account dropdown for the
                    account already named right here. */}
                {onPickAccount && (
                  <button
                    type="button"
                    onClick={() => onPickAccount(a.accountId)}
                    className="flex items-center gap-1 text-[11px] text-[var(--ss-accent)] hover:underline mt-1.5"
                  >
                    <Link2 size={11} /> Add to Signal Map
                  </button>
                )}
              </div>
            ))
          )}
        </div>
      )}
    </div>
  );
}

// Quick-add a brand-new tracked company ("watchlist" is just this app's name
// for the accounts list — see "Watchlist mentions only"/mentionsTrackedAccount
// above) straight from a Live feed card, for a company a headline mentions
// that isn't tracked yet. Added 2026-10-07, per explicit request for a
// button to do this on each signal. Deliberately a lightweight quick-add,
// not the full "Add account" form (sector/priorities/account manager) —
// reuses the same `onAddAccounts` (`addAccountsBulk`) the CSV/manual Overview
// flows already call, which already defaults tier to 'Prospect' and handles
// duplicate names, so there's no second add-account code path to maintain.
function LiveFeedWatchlistAdd({ onAddAccounts }) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState('');
  const [aliasesText, setAliasesText] = useState('');
  const [result, setResult] = useState(null); // { added, duplicates } | null

  function submit() {
    const trimmed = name.trim();
    if (!trimmed) return;
    const aliases = aliasesText.split(',').map((a) => a.trim()).filter(Boolean);
    const res = onAddAccounts([{ name: trimmed, aliases }]);
    setResult(res);
    if (res.added > 0) {
      setTimeout(() => {
        setOpen(false);
        setName('');
        setAliasesText('');
        setResult(null);
      }, 1200);
    }
  }

  return (
    <div className="mt-2">
      {!open ? (
        <Button variant="secondary" size="sm" icon={Plus} onClick={() => setOpen(true)}>
          Track a company from this story
        </Button>
      ) : (
        <div className="space-y-2 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3">
          <div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
            <label className="block">
              <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Company name</span>
              <input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Acciona Energía" className={inputClass} />
            </label>
            <label className="block">
              <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">Aliases (optional, comma-separated)</span>
              <input value={aliasesText} onChange={(e) => setAliasesText(e.target.value)} placeholder="e.g. Acciona" className={inputClass} />
            </label>
          </div>
          <p className="text-[10px] text-[var(--ss-muted)]">
            Added to the Overview tab as a new Prospect-tier account (no sector/priorities yet) — fill those in any time via "Manage accounts."
          </p>
          <div className="flex items-center gap-2">
            <Button variant="primary" size="sm" onClick={submit} disabled={!name.trim()}>
              Add to watchlist
            </Button>
            <Button variant="ghost" size="sm" onClick={() => { setOpen(false); setResult(null); }}>
              Cancel
            </Button>
            {result && (
              <span className={`text-[11px] ${result.added > 0 ? 'text-[var(--ss-positive)]' : 'text-[var(--ss-faint)]'}`}>
                {result.added > 0 ? 'Added to the watchlist.' : 'Already tracked under that name.'}
              </span>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// One Live feed item, in full. `onReject` is only passed for a card shown
// in the Highlights box (see LiveFeed) — it dismisses the item from that
// list only, back down into "All loaded items"; it never touches the
// underlying loaded data or any Signal Map promotion.
function LiveFeedCard({
  item, isNew, accounts, onAddToSignalMap, onUnlinkSignal, matchReasons, onReject,
  isRead, isExiting, isEntering, onToggleRead, onAddAccounts,
  // Keyboard cursor (2026-10-14) — owned by LiveFeed (j/k), same single-
  // cursor arrangement as Overview's rows.
  focused = false, onFocusCard,
}) {
  const it = item;
  const dLeft = it.kind === 'Funding' ? daysUntil(it.deadline) : null;
  const mentionedAccounts = matchingAccountsForLiveItem(it, accounts);
  // Bridges FundingBidEvaluation's AI-assessed accounts into the
  // LiveFeedPromote form below (added 2026-10-11, direct feedback — see
  // both components' own comments): `fundingAssessments` feeds the form's
  // suggested-account list, and `openPromoteFor` is a one-shot trigger set
  // by an assessment's own "Add to Signal Map" link.
  const [fundingAssessments, setFundingAssessments] = useState([]);
  const [openPromoteFor, setOpenPromoteFor] = useState(null);
  const cardRef = useRef(null);
  useEffect(() => {
    if (focused && cardRef.current) cardRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [focused]);

  // `x` = dismiss, mapped to whichever dismissal this card actually has:
  // "Mark as read" in the full list, "Not relevant" on a search highlight
  // (highlights have no read state of their own). Same handlers as the
  // buttons. `s` (save) lives in LiveFeedPromote, next to its own control.
  const dismiss = onToggleRead || onReject || null;
  // Registered by every card but only enabled on the focused one — that way
  // it still appears (greyed) in the shortcuts sheet before anything is
  // focused, instead of the Signals group looking half empty.
  useShortcut('x', () => dismiss && dismiss(), {
    label: 'Dismiss the focused signal (mark read / not relevant)',
    group: 'Signals',
    enabled: focused && !!dismiss && !isExiting,
  });

  return (
    <div
      ref={cardRef}
      onFocusCapture={onFocusCard}
      onMouseDown={onFocusCard}
      className={`rounded-[var(--ss-radius)] border bg-[var(--ss-surface)] p-3.5 ${
        focused ? 'border-[var(--ss-frame)] ring-1 ring-[var(--ss-frame)]' : 'border-[var(--ss-border)]'
      } ${isRead && !isExiting ? 'opacity-60' : ''} ${isExiting ? 'gl-signal-unclip' : ''} ${
        isEntering ? 'gl-signal-snap-in' : ''
      }`}
    >
      <div className="flex items-start justify-between gap-3 mb-1.5 flex-wrap">
        <div className="flex items-center gap-1.5 flex-wrap">
          <Badge tone="outline">
            <KindGlyph kind={it.kind} className="mr-1 text-[9px]" />{it.kind}
          </Badge>
          {it.topics.slice(0, 3).map((t) => (
            <span key={t} className="font-[var(--ss-font-mono)] text-[10px] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] bg-[var(--ss-rule-soft)] text-[var(--ss-muted)]">
              {t}
            </span>
          ))}
          {isNew && <Badge tone="accent">New</Badge>}
          {publishedWithinPastDay(it.published) && (
            <span className="flex items-center gap-0.5 font-[var(--ss-font-mono)] text-[9px] uppercase tracking-[0.06em] px-1 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-border)] text-[var(--ss-muted)]">
              <Clock size={9} /> Past 24h
            </span>
          )}
        </div>
        <div className="flex items-center gap-1.5 shrink-0">
          {it.kind === 'Funding' && it.deadline && (
            <span
              className={`shrink-0 font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${
                dLeft !== null && dLeft <= 14
                  ? 'text-[var(--ss-gap)] border-[var(--ss-gap-border)] bg-[var(--ss-gap-wash)]'
                  : 'text-[var(--ss-muted)] border-[var(--ss-border)]'
              }`}
            >
              {dLeft !== null && dLeft < 0 ? `Closed ${formatDate(it.deadline)}` : `Closes ${formatDate(it.deadline)}`}
            </span>
          )}
          {onToggleRead && (
            <button
              onClick={onToggleRead}
              disabled={isExiting}
              aria-keyshortcuts={focused ? ariaKeyShortcuts('x') : undefined}
              title={`${
                isRead ? 'Mark as unread — move it back up with the rest' : 'Mark as read — moves it to the bottom of the list'
              }${focused ? ' (X)' : ''}`}
              className={`shrink-0 flex items-center gap-1 font-[var(--ss-font-mono)] text-[10px] px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border active:scale-90 transition-transform ${
                isRead
                  ? 'border-[var(--ss-border)] text-[var(--ss-faint)] hover:text-[var(--ss-ink)]'
                  : 'border-[var(--ss-positive)] text-[var(--ss-positive)] hover:bg-[var(--ss-positive-wash)]'
              }`}
            >
              {isRead ? <Undo2 size={11} /> : <CheckCircle2 size={11} />} {isRead ? 'Read' : 'Mark as read'}
            </button>
          )}
        </div>
      </div>

      {it.url ? (
        <a
          href={it.url}
          target="_blank"
          rel="noopener noreferrer"
          className="text-[14px] text-[var(--ss-ink)] font-medium leading-snug hover:text-[var(--ss-accent)] flex items-start gap-1.5"
        >
          {it.headline}
          <ExternalLink size={12} className="shrink-0 mt-1 text-[var(--ss-faint)]" />
        </a>
      ) : (
        <div className="text-[14px] text-[var(--ss-ink)] font-medium leading-snug">{it.headline}</div>
      )}

      <div className="font-[var(--ss-font-mono)] text-[11px] text-[var(--ss-muted)] mt-0.5">{[it.source, it.date, it.region].filter(Boolean).join(' · ')}</div>

      {it.summary && <p className="text-[12px] text-[var(--ss-text-2)] leading-relaxed mt-1.5">{it.summary}</p>}

      {/* A clear, highlighted callout (not just a quiet gray chip) for an
          item that mentions a company already on the watchlist/accounts
          list — the "still highlight relevant signals" half of auto-match
          (see the toggle's own copy above): whether or not auto-match is
          on, this always shows, so manual promotion stays easy to spot. */}
      {mentionedAccounts.length > 0 && (
        <div className="flex items-start gap-1.5 text-[11px] text-[var(--ss-accent-wash-ink)] bg-[var(--ss-accent-wash)] border border-[var(--ss-accent)]/40 rounded-[var(--ss-radius)] px-2.5 py-1.5 mt-2">
          <Target size={12} className="shrink-0 mt-0.5" />
          <span>
            Tracked compan{mentionedAccounts.length === 1 ? 'y' : 'ies'}:{' '}
            {mentionedAccounts.map((a) => a.name).join(', ')}
          </span>
        </div>
      )}

      {onAddAccounts && <LiveFeedWatchlistAdd onAddAccounts={onAddAccounts} />}

      {matchReasons && matchReasons.length > 0 && (
        <div className="flex items-start justify-between gap-2 mt-2">
          <div className="flex items-start gap-1.5 text-[11px] text-[var(--ss-gap)] bg-[var(--ss-gap-wash)] border border-[var(--ss-gap-border)] rounded-[var(--ss-radius)] px-2.5 py-1.5">
            <Sparkles size={12} className="shrink-0 mt-0.5" />
            <span>Matched because it {matchReasons.join('; ')}.</span>
          </div>
          {onReject && (
            <button
              onClick={onReject}
              aria-keyshortcuts={focused && !onToggleRead ? ariaKeyShortcuts('x') : undefined}
              title={focused && !onToggleRead ? 'Not relevant (X)' : undefined}
              className="shrink-0 text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-ink)] underline whitespace-nowrap mt-1.5"
            >
              Not relevant
            </button>
          )}
        </div>
      )}

      {it.kind === 'Funding' && (
        <FundingBidEvaluation
          item={it}
          accounts={accounts}
          onResult={(assessments) => setFundingAssessments(assessments)}
          onPickAccount={(accountId) => setOpenPromoteFor(accountId)}
        />
      )}

      <div className="mt-2.5 pt-2.5 border-t border-[var(--ss-rule)]">
        <LiveFeedPromote
          item={it}
          accounts={accounts}
          onAddToSignalMap={onAddToSignalMap}
          onUnlinkSignal={onUnlinkSignal}
          relevantAccountIds={fundingAssessments.map((a) => a.accountId)}
          openForAccountId={openPromoteFor}
          onOpenedForAccount={() => setOpenPromoteFor(null)}
          focused={focused}
        />
      </div>
    </div>
  );
}

// The "Run search" criteria picker. Topics come from whatever's actually in
// the loaded data (not a hardcoded taxonomy) so there's never a dead option
// with zero possible matches; kinds and accounts are the fixed, already-
// known lists. Search only runs when the user clicks Search — nothing here
// reweights or filters anything on its own.
function LiveSearchModal({ criteria, topics, accounts, onClose, onSearch, onClear }) {
  const [draft, setDraft] = useState(criteria);

  function toggle(key, value) {
    setDraft((prev) => {
      const list = prev[key];
      return { ...prev, [key]: list.includes(value) ? list.filter((v) => v !== value) : [...list, value] };
    });
  }

  const canSearch = hasAnySearchCriteria(draft);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
      <div
        className="w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-5 space-y-5"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-start justify-between gap-3">
          <div>
            <div className="font-[var(--ss-font-display)] text-[22px] text-[var(--ss-ink)]">Run search</div>
            <p className="text-[12px] text-[var(--ss-muted)] mt-1">
              Pick what you care about — every loaded item gets scored against this, and the best matches come back as highlights for you to approve.
            </p>
          </div>
          <button onClick={onClose} className="text-[var(--ss-muted)] hover:text-[var(--ss-ink)] shrink-0">
            <X size={16} />
          </button>
        </div>

        <div>
          <div className={sectionLabel}>Technology topics</div>
          {topics.length === 0 ? (
            <p className="text-[11px] text-[var(--ss-muted)]">No topics in the loaded data yet.</p>
          ) : (
            <div className="flex flex-wrap gap-1.5">
              {topics.map((t) => (
                <TogglePill key={t} active={draft.topics.includes(t)} onClick={() => toggle('topics', t)}>
                  {t}
                </TogglePill>
              ))}
            </div>
          )}
        </div>

        <div>
          <div className={sectionLabel}>Item kinds</div>
          <div className="flex flex-wrap gap-1.5">
            {LIVE_KIND_OPTIONS.map((k) => (
              <TogglePill key={k} active={draft.kinds.includes(k)} onClick={() => toggle('kinds', k)}>
                {k}
              </TogglePill>
            ))}
          </div>
        </div>

        <div>
          <div className={sectionLabel}>Priority clients</div>
          <div className="flex flex-wrap gap-1.5">
            {accounts.map((a) => (
              <TogglePill key={a.id} active={draft.accountIds.includes(a.id)} onClick={() => toggle('accountIds', a.id)}>
                {a.name}
              </TogglePill>
            ))}
          </div>
        </div>

        <div className="pt-2 border-t border-[var(--ss-rule)] space-y-2">
          {!canSearch && (
            <p className="text-[11px] text-[var(--ss-muted)]">Pick at least one topic, kind, or client to search — or clear the search and close.</p>
          )}
          <div className="flex items-center justify-between">
            <Button variant="ghost" size="sm" onClick={() => setDraft(SEARCH_CRITERIA_EMPTY)}>
              Clear
            </Button>
            {canSearch ? (
              <Button variant="primary" size="sm" icon={Search} onClick={() => onSearch(draft)}>
                Search
              </Button>
            ) : (
              <Button variant="secondary" size="sm" icon={X} onClick={onClear}>
                Clear search & close
              </Button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// Themed replacement for the Live feed's kind/topic/region filters (added
// 2026-10-07, per explicit request to "stylize them to the website's
// theme") — a native <select> can only be partially themed with CSS (the
// closed box, not its own dropdown list, which renders with the OS/browser's
// own chrome no matter what), so this is the same custom button+popover
// pattern Overview's `GroupByMenu` already uses, generalized to take a
// plain string option list instead of GroupByMenu's own {key,label} shape.
// Standing rule as of 2026-10-08 (CLAUDE.md §5): any new dropdown/select
// control reaches for this (or a popover in the same pattern) instead of a
// plain <select>, so it inherits the site's own theme rather than the
// browser's. Manager mode's delegate/flag pickers (§5.26) are the most
// recent example — picking a value fires onChange immediately, no separate
// confirm step. Not a mandate to retrofit the handful of pre-existing plain
// <select>s elsewhere in simple one-off form fields (tier/contact/
// relationship pickers) — migrate one only if you're already touching it.
function FilterDropdown({ value, options, allLabel, onChange, width }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  return (
    <div className="relative shrink-0" ref={ref} style={width ? { width } : undefined}>
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        className="w-full flex items-center justify-between gap-1.5 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] text-[12px] text-[var(--ss-ink)] px-2.5 py-1.5 hover:border-[var(--ss-frame)] active:scale-[0.98] transition-transform"
      >
        <span className="truncate">{value === 'All' ? allLabel : value}</span>
        <ChevronDown size={12} className="text-[var(--ss-faint)] shrink-0" />
      </button>
      {open && (
        <div className="absolute left-0 mt-1.5 z-10 min-w-full w-max max-w-[260px] max-h-64 overflow-y-auto rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface-raised)] py-1">
          <button
            type="button"
            onClick={() => {
              onChange('All');
              setOpen(false);
            }}
            className={`w-full text-left text-[12px] px-3 py-1.5 hover:bg-[var(--ss-rule-soft)] transition-colors ${
              value === 'All' ? 'text-[var(--ss-accent)]' : 'text-[var(--ss-ink)]'
            }`}
          >
            {allLabel}
          </button>
          {options.map((o) => (
            <button
              key={o}
              type="button"
              onClick={() => {
                onChange(o);
                setOpen(false);
              }}
              className={`w-full text-left text-[12px] px-3 py-1.5 hover:bg-[var(--ss-rule-soft)] transition-colors ${
                value === o ? 'text-[var(--ss-accent)]' : 'text-[var(--ss-ink)]'
              }`}
            >
              {o}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function LiveFeed({
  accounts, items, newIds, onLoadText, error, onAddToSignalMap, onUnlinkSignal, onOpenSources,
  readIds, onToggleRead, onAddAccounts, autoMatchEnabled, onToggleAutoMatch,
  highlightScrape, onClearHighlightScrape, onInjectMarketMovesTrigger,
}) {
  // Onboarding's last step ("Run your first scrape") lands here and asks
  // for a brief, obvious pulse on the real button — not a fake duplicate of
  // it — so the user's very first scrape is the genuine one, not something
  // onboarding simulated. Clears itself on a timer and immediately on
  // click, whichever comes first.
  useEffect(() => {
    if (!highlightScrape) return;
    const t = setTimeout(onClearHighlightScrape, 5000);
    return () => clearTimeout(t);
  }, [highlightScrape, onClearHighlightScrape]);

  // "Mark as read" (added 2026-10-07) sinks an item to the bottom of "All
  // loaded items" rather than hiding it — see readLiveIds in App. The
  // animation on the way there replicates the add/remove Signal Map chip's
  // own snap-in/unclip (§5.9's delight note): the card plays the exit
  // (unclip) in its current spot, *then* the read state actually flips (and
  // the list re-sorts, dropping it to the bottom), then it plays the
  // entrance (snap-in) in its new spot — same two-phase hold-then-commit
  // `unclip()` already uses in LiveFeedPromote, just scoped to a whole card
  // instead of one badge, and fired for either direction, not just removal.
  const [exitingReadId, setExitingReadId] = useState(null);
  const [enteringReadId, setEnteringReadId] = useState(null);

  function handleToggleRead(id) {
    setExitingReadId(id);
    setTimeout(() => {
      onToggleRead(id);
      setExitingReadId(null);
      setEnteringReadId(id);
      setTimeout(() => setEnteringReadId(null), 160); // matches .gl-signal-snap-in's duration
    }, 140); // matches .gl-signal-unclip's duration
  }

  const [scrapeLoading, setScrapeLoading] = useState(false);
  const [scrapeError, setScrapeError] = useState(null);
  const [kindFilter, setKindFilter] = useState('All');
  const [topicFilter, setTopicFilter] = useState('All');
  const [regionFilter, setRegionFilter] = useState('All');
  const [watchlistOnly, setWatchlistOnly] = useState(false);
  const [search, setSearch] = useState('');
  // mod+k / `/` (2026-10-14) — this is the app's only free-text search
  // field, so the conventional "focus search" pair is registered here (page
  // scope) rather than globally, where it would have nothing to focus on
  // the other tabs. select() so a second press replaces the old query.
  const searchInputRef = useRef(null);
  function focusSearch() {
    const el = searchInputRef.current;
    if (!el) return;
    el.focus();
    el.select();
  }
  useShortcut('mod+k', focusSearch, { label: 'Focus the search field', group: 'Signals', enabled: items.length > 0 });
  useShortcut('/', focusSearch, { label: 'Focus the search field', group: 'Signals', enabled: items.length > 0 });
  // Which signal card the keyboard cursor is on. Keyed with a list prefix
  // ('hl-'/'all-') because the same item can appear in both Highlights and
  // the full list, and those are two distinct cards on screen.
  const [focusedCardKey, setFocusedCardKey] = useState(null);

  // "Run search" workflow — separate from the plain list filters above.
  // searchResults is null until the user runs a search at least once;
  // after that it's the ranked shortlist (possibly empty) to approve.
  const [searchOpen, setSearchOpen] = useState(false);
  const [searchCriteria, setSearchCriteria] = useState(SEARCH_CRITERIA_EMPTY);
  const [searchResults, setSearchResults] = useState(null);
  // Ids the user explicitly dismissed from the current Highlights list via
  // "Not relevant" — local to this search, cleared on the next one. Never
  // touches `items` or any Signal Map promotion; the item just goes back to
  // being visible only in "All loaded items" below.
  const [rejectedIds, setRejectedIds] = useState(() => new Set());

  const topics = Array.from(new Set(items.flatMap((it) => it.topics))).sort((a, b) => a.localeCompare(b));
  const regions = Array.from(new Set(items.map((it) => it.region).filter(Boolean))).sort((a, b) => a.localeCompare(b));

  function runSearch(criteria) {
    const ranked = items
      .map((it) => ({ item: it, score: scoreLiveItemForSearch(it, criteria, accounts) }))
      .filter((x) => x.score > 0)
      .sort((a, b) => b.score - a.score)
      .slice(0, 10)
      .map((x) => x.item);
    setSearchCriteria(criteria);
    setSearchResults(ranked);
    setRejectedIds(new Set());
    setSearchOpen(false);
  }

  function rejectHighlight(itemId) {
    setRejectedIds((prev) => new Set(prev).add(itemId));
  }

  // Fully resets the search workflow — criteria, results, rejections — so
  // the Highlights box goes away entirely. Reachable from the modal (when
  // nothing's picked) and directly from the Highlights header, since
  // reopening the modal just to clear it was the reported friction.
  function clearSearch() {
    setSearchCriteria(SEARCH_CRITERIA_EMPTY);
    setSearchResults(null);
    setRejectedIds(new Set());
    setSearchOpen(false);
  }

  // Highlights shows only what the search actually matched — items that
  // mention a priority client, fit a picked kind/topic, or have a near
  // funding deadline (see scoreLiveItemForSearch) — minus anything already
  // on the Signal Map (nothing left to select) or explicitly rejected this
  // search. Everything stays in "All loaded items" below regardless; this
  // only controls what surfaces as a suggestion to act on.
  const displayedHighlights = (searchResults || []).filter(
    (it) => promotedAccountsForLiveItem(it.id, accounts).length === 0 && !rejectedIds.has(it.id)
  );

  // Results with a plausible account match that aren't already promoted —
  // the only ones "Accept all" can act on without guessing an account.
  const acceptableResults = displayedHighlights.filter((it) => matchingAccountsForLiveItem(it, accounts).length > 0);

  function acceptAllResults() {
    const priorityIds = new Set(searchCriteria.accountIds);
    acceptableResults.forEach((it) => {
      const ranked = rankAccountsByPriority(matchingAccountsForLiveItem(it, accounts), priorityIds);
      onAddToSignalMap(it, ranked[0].id, {
        confidence: 90,
        rationale: it.summary || it.headline,
        nextStep: SEARCH_DEFAULT_NEXT_STEP[it.kind] || 'Review with the account team and decide the next action.',
      });
    });
  }

  const filtered = items
    .filter((it) => kindFilter === 'All' || it.kind === kindFilter)
    .filter((it) => topicFilter === 'All' || it.topics.includes(topicFilter))
    .filter((it) => regionFilter === 'All' || it.region === regionFilter)
    .filter((it) => !watchlistOnly || mentionsTrackedAccount(it, accounts))
    .filter((it) => {
      const q = search.trim().toLowerCase();
      if (!q) return true;
      return it.headline.toLowerCase().includes(q) || it.summary.toLowerCase().includes(q);
    })
    .slice()
    .sort((a, b) => {
      // Read items sink to the bottom (as a group), newest-first within
      // each group — see readIds/onToggleRead ("Mark as read") above.
      // handleToggleRead deliberately doesn't flip readIds until after the
      // exit animation finishes, so an item mid-"unclip" stays put (still
      // sorted by its real, pre-toggle read state) until the real jump —
      // see handleToggleRead's comment.
      const aRead = readIds.has(a.id) ? 1 : 0;
      const bRead = readIds.has(b.id) ? 1 : 0;
      if (aRead !== bRead) return aRead - bRead;
      return new Date(b.published || 0) - new Date(a.published || 0);
    });

  // Keyboard cursor order = render order: Highlights first (when a search
  // has run), then "All loaded items" in its current filter/sort order.
  const cardOrder = [
    ...displayedHighlights.map((it) => `hl-${it.id}`),
    ...filtered.map((it) => `all-${it.id}`),
  ];
  useEffect(() => {
    if (focusedCardKey !== null && cardOrder.indexOf(focusedCardKey) === -1) setFocusedCardKey(null);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cardOrder.join(',')]);

  function moveCardFocus(delta) {
    if (!cardOrder.length) return;
    setFocusedCardKey((prev) => {
      const i = cardOrder.indexOf(prev);
      if (i === -1) return delta > 0 ? cardOrder[0] : cardOrder[cardOrder.length - 1];
      return cardOrder[Math.min(cardOrder.length - 1, Math.max(0, i + delta))];
    });
  }

  useShortcut('j', () => moveCardFocus(1), { label: 'Focus next signal', group: 'Signals', enabled: cardOrder.length > 0 });
  useShortcut('k', () => moveCardFocus(-1), { label: 'Focus previous signal', group: 'Signals', enabled: cardOrder.length > 0 });

  // Asks whatever's hosting this file to run the scraper (see
  // SCRAPE_TRIGGER_URL above) and loads the result straight in. Most hosts
  // (a plain static file, the claude.ai artifact sandbox, anything without
  // News Scraper/serve.py — see docs/architecture.md §7 — running behind
  // them) have nothing listening there at all, which shows up either as the
  // fetch itself throwing (no listener) or a 404 (a static host's own
  // not-found page) — both get the generic "nothing's wired up" message
  // below. A host that *does* have serve.py running can still legitimately
  // reject a run (cooldown, already running, scraper timeout/failure) with
  // a real plain-text body explaining why — that's shown verbatim instead
  // of being lumped in with "nothing's wired up here."
  async function handleRunScrape() {
    setScrapeLoading(true);
    setScrapeError(null);
    const notWiredUp = "No scrape endpoint is wired up here — this host doesn't have News Scraper/news_monitor.py running as a service yet.";
    let res;
    try {
      res = await fetch(SCRAPE_TRIGGER_URL, { method: 'POST' });
    } catch (e) {
      setScrapeError(notWiredUp);
      setScrapeLoading(false);
      return;
    }
    try {
      if (!res.ok) {
        if (res.status === 404) throw new Error(notWiredUp);
        const text = (await res.text().catch(() => '')).trim();
        throw new Error(text || `Scrape endpoint returned HTTP ${res.status}.`);
      }
      onLoadText(await res.text());
    } catch (e) {
      setScrapeError(e.message);
    } finally {
      setScrapeLoading(false);
    }
  }

  return (
    <div>
      <div className="flex items-start justify-between gap-3 mb-1">
        <p className="text-xs text-[var(--ss-muted)]">
          Load news collected by <span className="font-[var(--ss-font-mono)] text-[var(--ss-ink)]">News Scraper/news_monitor.py</span>, then run a search to surface what's worth acting on.
        </p>
        {/* Sources page is a frozen area during demo mode (D3/§2,
            demo-week-plan.md) — the link is hidden rather than the route
            removed, so turning demo mode off restores it unchanged. */}
        {!DEMO_MODE && (
          <button
            onClick={onOpenSources}
            className="shrink-0 flex items-center gap-1 text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] active:scale-95 transition-transform underline underline-offset-2"
          >
            <ListTree size={12} /> View all sources
          </button>
        )}
      </div>
      <p className="text-[11px] text-[var(--ss-faint)] mb-3">
        "Add to Signal Map" is the real thing — it adds the item as a signal for the account you pick, same as any other matched signal. With auto-match off, Run search (or "Add to Signal Map" on any item in the full list below, or a card's own tracked-company highlight) is you doing by hand what auto-match below does on its own.
      </p>

      <div className="flex items-start gap-3 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] px-3.5 py-2.5 mb-4">
        <div className="shrink-0">
          <TogglePill active={autoMatchEnabled} onClick={onToggleAutoMatch}>
            <span className="flex items-center gap-1.5 whitespace-nowrap">
              <Sparkles size={12} /> Auto-match on scrape
            </span>
          </TogglePill>
        </div>
        <p className="flex-1 text-[11px] text-[var(--ss-muted)] leading-relaxed">
          {autoMatchEnabled
            ? 'On — a freshly loaded item mentioning a tracked account is added to that account\'s Signal Map automatically, flagged "Auto-matched" on the Overview row and the account page until you look at it.'
            : 'Off — nothing is added automatically. A loaded item mentioning a tracked account still gets a highlighted "Tracked company" callout below, same as every card; promoting it is still your call via "Add to Signal Map."'}
        </p>
      </div>

      {/* Day 4 demo trigger (demo-week-plan.md) — demo-mode only, per D3/§2:
          "No new Live Feed features." This is the one allowed exception
          (explicitly called out in the plan as the Day 4 task), and it's
          gated behind DEMO_MODE the same way everything else frozen-area-
          adjacent is. Injects a real, held-back signal (see
          HELD_BACK_DEMO_SIGNAL) and promotes it exactly the way "Add to
          Signal Map" does, so the fit/gap re-check it triggers is a real
          exercise of the Day 4 mechanism on stage, not a simulation. */}
      {DEMO_MODE && (
        <div className="flex items-start gap-3 rounded-[var(--ss-radius)] border border-[var(--ss-accent)]/40 bg-[var(--ss-accent-wash)] px-3.5 py-2.5 mb-4">
          <div className="shrink-0">
            <Button variant="primary" size="sm" icon={Sparkles} onClick={onInjectMarketMovesTrigger}>
              Inject market-moves trigger
            </Button>
          </div>
          <p className="flex-1 text-[11px] text-[var(--ss-accent-wash-ink)] leading-relaxed">
            Demo only — adds one real, held-back news item to the feed and connects it to CIP, re-checking their fit &amp; gaps so you can show "the market moved → here's what it means for us" live.
          </p>
        </div>
      )}

      {/* Breaks out of the app's centered column to span the full browser
          window, same technique the Signal Map uses for its own full-bleed
          width (though that one breaks out at the App level, outside the
          max-w-6xl wrapper entirely — this is the one piece of a component
          that otherwise lives inside that wrapper, so it uses the CSS
          full-bleed trick instead: 100vw pulled back to the viewport's left
          edge regardless of this component's own nesting). Now the tab's
          only loading control (§5.9 — the file picker and URL field were
          removed 2026-10-05), so making it unmissable is the point. */}
      <div className="relative left-1/2 right-1/2 -mx-[50vw] w-screen mb-2">
        <button
          onClick={() => {
            if (highlightScrape) onClearHighlightScrape();
            handleRunScrape();
          }}
          disabled={scrapeLoading}
          title="Asks this environment to run News Scraper/news_monitor.py and load the result"
          className={`w-full flex items-center justify-center gap-1.5 bg-[var(--ss-accent)] text-[var(--ss-accent-text)] text-[13px] font-medium py-3 hover:brightness-110 active:scale-95 transition-transform disabled:opacity-40 disabled:active:scale-100 ${
            highlightScrape ? 'gl-onboard-pulse' : ''
          }`}
        >
          {scrapeLoading ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />} Run news scrape
        </button>
      </div>

      {(error || scrapeError) && (
        <div className="text-[12px] text-[var(--ss-danger)] mb-4">{error || scrapeError}</div>
      )}

      <div className="flex flex-col items-center justify-center text-center rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] py-6 px-4 mb-5">
        <Button variant="primary" icon={Search} onClick={() => setSearchOpen(true)} disabled={items.length === 0} title={items.length === 0 ? 'Run a news scrape first' : undefined}>
          Run search
        </Button>
        <p className="text-[12px] text-[var(--ss-muted)] mt-2 max-w-sm">
          Pick the topics, kinds, and clients you care about — the best-matching loaded items come back as highlights below for you to add to the Signal Map or mark not relevant.
        </p>
        {searchResults !== null && (
          <button onClick={() => setSearchOpen(true)} className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] mt-2 underline">
            Edit search criteria
          </button>
        )}
      </div>

      {searchOpen && (
        <LiveSearchModal
          criteria={searchCriteria}
          topics={topics}
          accounts={accounts}
          onClose={() => setSearchOpen(false)}
          onSearch={runSearch}
          onClear={clearSearch}
        />
      )}

      {searchResults !== null && (
        <div className="rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-accent-wash)] p-3.5 mb-5">
          <div className="flex items-center justify-between gap-3 flex-wrap mb-2.5">
            <div className="flex items-center gap-1.5">
              <Sparkles size={13} className="text-[var(--ss-accent-wash-ink)]" />
              <span className="font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.06em] text-[var(--ss-accent-wash-ink)]">
                Highlights{displayedHighlights.length > 0 ? ` (${displayedHighlights.length})` : ''}
              </span>
            </div>
            <div className="flex items-center gap-2">
              {acceptableResults.length > 0 && (
                <Button variant="ink" size="sm" icon={CheckCircle2} onClick={acceptAllResults}>
                  Accept all ({acceptableResults.length})
                </Button>
              )}
              <button
                onClick={clearSearch}
                title="Clear search and close Highlights"
                className="shrink-0 text-[var(--ss-accent-wash-ink)] hover:text-[var(--ss-ink)] p-1"
              >
                <X size={14} />
              </button>
            </div>
          </div>

          {displayedHighlights.length === 0 ? (
            <p className="text-[12px] text-[var(--ss-accent-wash-ink)]">
              {searchResults.length === 0
                ? 'No loaded items matched that search. Try widening your criteria.'
                : 'Everything that matched is already on the Signal Map or marked not relevant.'}
            </p>
          ) : (
            <div className="space-y-2.5">
              {displayedHighlights.map((it) => (
                <LiveFeedCard
                  key={it.id}
                  item={it}
                  isNew={newIds.has(it.id)}
                  accounts={accounts}
                  onAddToSignalMap={onAddToSignalMap}
                  onUnlinkSignal={onUnlinkSignal}
                  matchReasons={searchMatchReasons(it, searchCriteria, accounts)}
                  onReject={() => rejectHighlight(it.id)}
                  onAddAccounts={onAddAccounts}
                  focused={focusedCardKey === `hl-${it.id}`}
                  onFocusCard={() => setFocusedCardKey(`hl-${it.id}`)}
                />
              ))}
            </div>
          )}
        </div>
      )}

      <div className="font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2">All loaded items</div>

      {items.length === 0 ? (
        <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-5 text-center mt-3">
          <div className="text-sm text-[var(--ss-ink)] mb-1.5">No live news loaded yet</div>
          <div className="text-[12px] text-[var(--ss-muted)] max-w-md mx-auto">
            Click <span className="text-[var(--ss-ink)]">Run news scrape</span> above to load what{' '}
            <span className="text-[var(--ss-ink)]">News Scraper/news_monitor.py</span> finds.
          </div>
        </div>
      ) : (
        <>
          <div className="flex items-center gap-2 flex-wrap mt-3 mb-3">
            <FilterDropdown value={kindFilter} options={LIVE_KIND_OPTIONS} allLabel="All kinds" onChange={setKindFilter} width={120} />
            <FilterDropdown value={topicFilter} options={topics} allLabel="All topics" onChange={setTopicFilter} width={150} />
            <FilterDropdown value={regionFilter} options={regions} allLabel="All regions" onChange={setRegionFilter} width={150} />
            <TogglePill active={watchlistOnly} onClick={() => setWatchlistOnly((v) => !v)}>
              Watchlist mentions only
            </TogglePill>
            <input
              ref={searchInputRef}
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              placeholder="Search headline & summary"
              aria-keyshortcuts={ariaKeyShortcuts('mod+k')}
              title={`Search headline & summary (${shortcutLabel('mod+k')} or ${shortcutLabel('/')})`}
              className={inputClass}
              style={{ maxWidth: 200 }}
            />
          </div>

          <div className="text-[11px] text-[var(--ss-muted)] mb-3">
            {filtered.length} of {items.length} item{items.length === 1 ? '' : 's'}
            {filtered.length === 0 && ' · no items match these filters'}
          </div>

          <div className="space-y-2.5">
            {filtered.map((it) => (
              <LiveFeedCard
                key={it.id}
                item={it}
                isNew={newIds.has(it.id)}
                accounts={accounts}
                onAddToSignalMap={onAddToSignalMap}
                onUnlinkSignal={onUnlinkSignal}
                isRead={readIds.has(it.id)}
                isExiting={exitingReadId === it.id}
                isEntering={enteringReadId === it.id}
                onToggleRead={() => handleToggleRead(it.id)}
                onAddAccounts={onAddAccounts}
                focused={focusedCardKey === `all-${it.id}`}
                onFocusCard={() => setFocusedCardKey(`all-${it.id}`)}
              />
            ))}
          </div>
        </>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// SOURCES PAGE (added 2026-10-07) — read-only view of everything
// News Scraper/sources.json is configured to pull from, reached via "View
// all sources" on the Live feed tab (same drill-in pattern as AccountPage:
// not a tab, hides the tab bar, has its own back link — see the App render's
// tab !== 'sources' guard). Fetches the live config from the same backend
// SCRAPE_TRIGGER_URL already depends on (see SOURCES_ENDPOINT above), rather
// than hand-copying sources.json into this file — that duplication is
// exactly the "two places drift apart" failure architecture.md §7.1 already
// hit once with the old watchlist/accounts split, so this reads the one
// real file instead of mirroring it.
// ---------------------------------------------------------------------------

// One licensed/gated-publication source's row on SourcesPage, with an
// obvious "Connect" affordance (added 2026-10-07, same session, per explicit
// request that this needed "obvious UI guiding the user to add an API key"
// — the original row was a quiet, static line of gray text, easy to miss).
// "Connect" never calls out to anywhere: there's no write path back to
// sources.json ([features.md §5.14](features.md#514-sources-page--licensedgated-publication-sources-added-2026-10-07)
// is explicitly read-only), so this is a local-only command generator — the
// pasted key lives in this component's own state and is never sent
// anywhere, never persisted; it's only interpolated into the shell command
// shown below for the user to run themselves on whatever host actually runs
// the scraper.
function LicensedSourceConnect({ sourceKey, source }) {
  const [open, setOpen] = useState(false);
  const [apiKey, setApiKey] = useState('');
  const [copied, setCopied] = useState(false);

  const command = `export ${source.env_key}="${apiKey || '<your-api-key>'}"`;

  function copyCommand() {
    (navigator.clipboard?.writeText(command) || Promise.reject()).then(
      () => {
        setCopied(true);
        setTimeout(() => setCopied(false), 1500);
      },
      () => {}
    );
  }

  return (
    <div className="px-3.5 py-3">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <div className="min-w-0">
          <div className="text-[13px] text-[var(--ss-ink)] font-medium">{source.name || sourceKey}</div>
          <div className="text-[11px] text-[var(--ss-muted)]">Needs <span className="font-[var(--ss-font-mono)]">{source.env_key}</span> set to enable.</div>
        </div>
        <div className="flex items-center gap-2 shrink-0">
          {source.enabled ? (
            <Badge tone="outline" className="border-[var(--ss-positive)] text-[var(--ss-positive)]">Enabled</Badge>
          ) : (
            <Badge tone="outline">Template — not yet enabled</Badge>
          )}
          <Button variant="secondary" size="sm" icon={KeyRound} onClick={() => setOpen((v) => !v)}>
            {open ? 'Close' : 'Connect'}
          </Button>
        </div>
      </div>

      {open && (
        <div className="mt-3 rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3 space-y-2.5">
          <p className="text-[11px] text-[var(--ss-muted)]">
            Paste the API key your organization's {source.name || sourceKey} subscription gives you for subscriber feed/API access. This stays in your browser only — it's never sent anywhere or saved; it's just used to build the command below.
          </p>
          <input
            type="password"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
            placeholder="Paste your subscriber API key"
            className={inputClass}
          />
          <div className="rounded-[var(--ss-radius)] bg-[var(--ss-inverse)] text-[var(--ss-inverse-text)] px-3 py-2 flex items-center justify-between gap-2 overflow-x-auto">
            <code className="text-[11px] font-[var(--ss-font-mono)] whitespace-nowrap">{command}</code>
            <button
              onClick={copyCommand}
              className="shrink-0 text-[10px] px-2 py-0.5 rounded-[var(--ss-radius-sm)] border border-[var(--ss-inverse-rule)] hover:bg-white/10 active:scale-95 transition-transform"
            >
              {copied ? 'Copied' : 'Copy'}
            </button>
          </div>
          <ol className="text-[11px] text-[var(--ss-muted)] list-decimal list-inside space-y-1">
            <li>Run that export on whatever host runs <span className="font-[var(--ss-font-mono)]">News Scraper/news_monitor.py</span> (the same one <span className="font-[var(--ss-font-mono)]">serve.py</span> calls it from, if that's how your scrape button is wired up).</li>
            <li>In <span className="font-[var(--ss-font-mono)]">sources.json</span>'s <span className="font-[var(--ss-font-mono)]">licensed_sources.{sourceKey}</span>, set <span className="font-[var(--ss-font-mono)]">"enabled": true</span> and replace the placeholder <span className="font-[var(--ss-font-mono)]">url</span> with the real subscriber-API endpoint {source.name || sourceKey} gave you.</li>
            <li>Re-run the scraper — this source is picked up the same way any other feed is, no other changes needed.</li>
          </ol>
        </div>
      )}
    </div>
  );
}

function SourcesPage({ onBack }) {
  const [config, setConfig] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    const notWiredUp = "No sources endpoint is wired up here — this host doesn't have News Scraper/serve.py running as a service yet. Open News Scraper/sources.json directly to see the same config.";
    (async () => {
      try {
        const res = await fetch(SOURCES_ENDPOINT);
        if (!res.ok) throw new Error(res.status === 404 ? notWiredUp : `Sources endpoint returned HTTP ${res.status}.`);
        const data = await res.json();
        if (!cancelled) setConfig(data);
      } catch (e) {
        if (!cancelled) setError(e.message || notWiredUp);
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, []);

  const feeds = config?.feeds || [];
  const fundingApis = Object.entries(config?.funding_apis || {}).filter(([k]) => !k.startsWith('_'));
  const newsApis = Object.entries(config?.news_apis || {}).filter(([k]) => !k.startsWith('_'));
  const licensedSources = Object.entries(config?.licensed_sources || {}).filter(([k]) => !k.startsWith('_'));

  return (
    <div className="w-full">
      <button onClick={onBack} className="flex items-center gap-1 text-[12px] text-[var(--ss-muted)] hover:text-[var(--ss-ink)] mb-5 active:scale-95 transition-transform">
        <ChevronLeft size={13} /> Back to Live feed
      </button>

      <SectionHeader kicker="News Scraper" title="Sources" className="mb-3 max-w-2xl" />
      <p className="text-[12px] text-[var(--ss-muted)] mb-6 max-w-2xl">
        Every feed and API <span className="font-[var(--ss-font-mono)] text-[var(--ss-ink)]">News Scraper/news_monitor.py</span> is configured to pull from, read live from <span className="font-[var(--ss-font-mono)] text-[var(--ss-ink)]">sources.json</span> so this never drifts from what actually runs. Read-only — edit <span className="font-[var(--ss-font-mono)] text-[var(--ss-ink)]">sources.json</span> by hand to change it.
      </p>

      {loading && (
        <div className="flex items-center gap-1.5 text-[12px] text-[var(--ss-muted)]">
          <Loader2 size={13} className="animate-spin" /> Loading source config…
        </div>
      )}

      {!loading && error && (
        <div className="text-[12px] text-[var(--ss-danger)] rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3 max-w-xl">{error}</div>
      )}

      {!loading && config && (
        <div className="space-y-8">
          <section>
            <div className="flex items-center gap-1.5 font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2.5">
              <Rss size={12} /> RSS / Atom feeds ({feeds.length})
            </div>
            <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] divide-y divide-[var(--ss-rule)]">
              {feeds.map((f) => (
                <div key={f.name} className="flex items-center justify-between gap-3 px-3.5 py-2.5 flex-wrap">
                  <div className="min-w-0">
                    <div className="text-[13px] text-[var(--ss-ink)] font-medium">{f.name}</div>
                    <a href={f.url} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--ss-muted)] hover:text-[var(--ss-accent)] truncate block max-w-md">{f.url}</a>
                  </div>
                  <div className="flex items-center gap-1.5 shrink-0">
                    <Badge tone="outline">{f.region || 'Global'}</Badge>
                    <Badge tone="outline">{f.kind || 'News'}</Badge>
                    {f.enabled === false && <Badge tone="outline">Disabled</Badge>}
                  </div>
                </div>
              ))}
              {feeds.length === 0 && <div className="px-3.5 py-3 text-[12px] text-[var(--ss-muted)]">No feeds configured.</div>}
            </div>
          </section>

          {fundingApis.length > 0 && (
            <section>
              <div className="flex items-center gap-1.5 font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2.5">
                <Layers size={12} /> Funding/tender APIs
              </div>
              <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] divide-y divide-[var(--ss-rule)]">
                {fundingApis.map(([key, cfg]) => (
                  <div key={key} className="flex items-center justify-between gap-3 px-3.5 py-2.5 flex-wrap">
                    <div>
                      <div className="text-[13px] text-[var(--ss-ink)] font-medium">{key}</div>
                      {cfg._about && <div className="text-[11px] text-[var(--ss-muted)]">{cfg._about}</div>}
                    </div>
                    {cfg.enabled ? (
                      <Badge tone="outline" className="border-[var(--ss-positive)] text-[var(--ss-positive)]">Enabled</Badge>
                    ) : (
                      <Badge tone="outline">Disabled</Badge>
                    )}
                  </div>
                ))}
              </div>
            </section>
          )}

          {newsApis.length > 0 && (
            <section>
              <div className="flex items-center gap-1.5 font-[var(--ss-font-mono)] text-[11px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2.5">
                <Search size={12} /> Keyword news APIs
              </div>
              <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] divide-y divide-[var(--ss-rule)]">
                {newsApis.map(([key, cfg]) => (
                  <div key={key} className="flex items-center justify-between gap-3 px-3.5 py-2.5 flex-wrap">
                    <div>
                      <div className="text-[13px] text-[var(--ss-ink)] font-medium">{key}</div>
                      <div className="text-[11px] text-[var(--ss-muted)]">Needs <span className="font-[var(--ss-font-mono)]">{cfg.env_key}</span> set on the host running the scraper.</div>
                    </div>
                  </div>
                ))}
              </div>
            </section>
          )}

          <section>
            <div className="rounded-[var(--ss-radius)] border border-[var(--ss-accent)]/40 bg-[var(--ss-accent-wash)] p-4 mb-3">
              <div className="flex items-center gap-1.5 text-[13px] font-semibold text-[var(--ss-accent-wash-ink)] mb-1.5">
                <Lock size={14} /> Have a subscription to a gated publication?
              </div>
              <p className="text-[12px] text-[var(--ss-accent-wash-ink)] max-w-2xl">
                For a paywalled trade publication (Recharge News, Montel, Wood Mackenzie, Argus, etc.), Signal Scout never logs in with someone's personal account and scrapes it — that breaks most publishers' terms, and would mean re-serving one subscriber's paid content to every other tenant. If your subscription includes its own API or authenticated feed for your organization, hit <span className="font-medium">Connect</span> below on the matching source to set it up with your own API key — nothing is sent anywhere or stored; it just builds the command you run on whatever host runs the scraper.
              </p>
            </div>
            <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] divide-y divide-[var(--ss-rule)]">
              {licensedSources.map(([key, s]) => (
                <LicensedSourceConnect key={key} sourceKey={key} source={s} />
              ))}
              {licensedSources.length === 0 && (
                <div className="px-3.5 py-3 text-[12px] text-[var(--ss-muted)]">None configured — add one to <span className="text-[var(--ss-ink)]">News Scraper/sources.json</span>'s <span className="font-[var(--ss-font-mono)]">licensed_sources</span> list.</div>
              )}
            </div>
          </section>
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// SIGNAL MAP — bipartite mind map linking news signals to accounts
// ---------------------------------------------------------------------------

// Fit/gap severity ranking (Day 2b, demo-week-plan.md) — when a signal is
// evidence for gaps of different severities across several accounts, the
// Signal Map's single compact badge shows the worst one.
const GAP_SEVERITY_RANK = { Minor: 1, Coverable: 2, Blocker: 3 };

function worstGapForSignal(fitGaps, accountIds, newsId) {
  let severity = null;
  let anyChecked = false;
  for (const accountId of accountIds) {
    const fg = fitGaps[accountId];
    if (!fg) continue;
    anyChecked = true;
    // Worst across ALL of this account's gaps citing the signal, not just
    // the first one in the array — same fix as gapForSignal above, so a
    // second gap added by a later re-check can't hide behind an earlier,
    // less severe one.
    for (const gap of fg.gaps.filter((g) => g.evidence.includes(newsId))) {
      if (!severity || GAP_SEVERITY_RANK[gap.severity] > GAP_SEVERITY_RANK[severity]) severity = gap.severity;
    }
  }
  return { severity, anyChecked };
}

function SignalMap({ accounts, selectedNewsId, setSelectedNewsId, onOpenAccount, onUnlinkSignal, fitGaps }) {
  // The map fills whatever width its wrapper has (the whole window, since
  // this tab breaks out of the app's centered column — see the App return)
  // rather than a fixed pixel width; the gap between the two columns grows
  // with the window instead of the cards themselves.
  const wrapRef = useRef(null);
  const [availableWidth, setAvailableWidth] = useState(760);

  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const update = () => setAvailableWidth(el.clientWidth);
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Every signal here came from the Live feed via "Add to Signal Map" (see
  // allNewsItems/allMatches above PlanPanel) — empty until something's
  // promoted, not backed by any hand-authored mock data anymore.
  const newsItems = allNewsItems();
  const matches = allMatches();

  const ROW_H = 110;
  const CARD_H = 96; // taller than before to fit the source byline on news cards
  const TOP_PAD = 20;
  const CARD_W = 300;
  const MIN_CONTAINER_W = 640;
  const EXPLAINER_W = 260;
  const EXPLAINER_GAP = 32;
  const rowCount = Math.max(newsItems.length, accounts.length);
  const mapHeight = TOP_PAD * 2 + rowCount * ROW_H;
  const leftX = 0;

  const yFor = (i) => TOP_PAD + i * ROW_H + CARD_H / 2;
  const highlighted = selectedNewsId ? new Set(matches.filter((m) => m.newsId === selectedNewsId).map((m) => m.accountId)) : null;

  const selectedNews = selectedNewsId ? newsById(selectedNewsId) : null;
  const connectedMatches = selectedNewsId
    ? matches.filter((m) => m.newsId === selectedNewsId)
        .map((m) => ({ ...m, account: accounts.find((a) => a.id === m.accountId) }))
        .filter((m) => m.account)
        .sort((a, b) => b.confidence - a.confidence)
    : [];
  const explainerReserve = selectedNews ? EXPLAINER_GAP + EXPLAINER_W : 0;
  const totalW = Math.max(availableWidth, MIN_CONTAINER_W + explainerReserve);
  const CONTAINER_W = totalW - explainerReserve;
  const rightX = CONTAINER_W - CARD_W;
  const explainerX = CONTAINER_W + EXPLAINER_GAP;

  if (newsItems.length === 0) {
    return (
      <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-6 text-center">
        <div className="text-sm text-[var(--ss-ink)] mb-1.5">No signals yet</div>
        <div className="text-[12px] text-[var(--ss-muted)] max-w-md mx-auto">
          The Signal Map is built entirely from Live feed items you've added — load news and use "Add to Signal Map" (or Run search → Accept all) to connect a story to an account.
        </div>
      </div>
    );
  }

  return (
    <div ref={wrapRef}>
      <p className="text-xs text-[var(--ss-muted)] mb-4">
        Click a news signal to see why it matters and the recommended next step for each connected account. Click an account to open it in the Overview.
      </p>
      <div className="overflow-x-auto">
        <div className="relative" style={{ width: totalW, height: mapHeight, minWidth: totalW }}>
          <svg width={CONTAINER_W} height={mapHeight} viewBox={`0 0 ${CONTAINER_W} ${mapHeight}`} className="absolute top-0 left-0 pointer-events-none">
            {matches.map((m, i) => {
              const nIdx = newsItems.findIndex((n) => n.id === m.newsId);
              const aIdx = accounts.findIndex((a) => a.id === m.accountId);
              if (nIdx === -1 || aIdx === -1) return null;
              const isActive = selectedNewsId === m.newsId;
              const isDimmed = selectedNewsId && !isActive;
              const strokeW = 1 + (m.confidence / 100) * 3;
              return (
                <line
                  key={i}
                  x1={leftX + CARD_W}
                  y1={yFor(nIdx)}
                  x2={rightX}
                  y2={yFor(aIdx)}
                  style={{ stroke: isActive ? 'var(--ss-accent)' : 'var(--ss-faint)' }}
                  strokeWidth={strokeW}
                  strokeDasharray={isActive ? '6 4' : undefined}
                  className={isActive ? 'flow-line' : undefined}
                  opacity={isDimmed ? 0.06 : isActive ? 0.9 : 0.22}
                />
              );
            })}
          </svg>

          {newsItems.map((n, i) => {
            const isSelected = selectedNewsId === n.id;
            const isDimmed = selectedNewsId && !isSelected;
            // Fit/gap distinguishing (Day 2b) — which accounts this signal
            // is matched to, and whether any of their fit/gap checks flag
            // it as evidence for a gap. Unchecked accounts show no badge at
            // all, rather than a misleading "fit" default.
            const connectedAccountIds = matches.filter((m) => m.newsId === n.id).map((m) => m.accountId);
            const { severity: gapSeverity, anyChecked } = worstGapForSignal(fitGaps, connectedAccountIds, n.id);
            return (
              <button
                key={n.id}
                onClick={() => setSelectedNewsId(isSelected ? null : n.id)}
                className={`absolute text-left rounded-[var(--ss-radius)] border bg-[var(--ss-surface)] px-3.5 py-3 transition-opacity ${
                  isSelected ? 'border-[var(--ss-frame)]' : 'border-[var(--ss-border)] hover:border-[var(--ss-frame)]'
                }`}
                style={{ left: leftX, top: TOP_PAD + i * ROW_H, width: CARD_W, height: CARD_H, opacity: isDimmed ? 0.4 : 1 }}
              >
                <div className="flex items-center justify-between mb-1">
                  <span className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)]">{n.tag}</span>
                  <span className="font-[var(--ss-font-mono)] text-[10px] text-[var(--ss-faint)]">{n.date}</span>
                </div>
                <div className="text-[13px] leading-snug text-[var(--ss-text-2)] line-clamp-2">{n.headline}</div>
                <div className="flex items-center justify-between gap-2 mt-1">
                  <span className="text-[10px] text-[var(--ss-faint)] truncate">{n.source}</span>
                  {gapSeverity && (
                    <span className={`shrink-0 font-[var(--ss-font-mono)] text-[9px] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border ${GAP_SEVERITY_STYLE[gapSeverity]}`}>
                      {gapSeverity} gap
                    </span>
                  )}
                  {!gapSeverity && anyChecked && (
                    <span className="shrink-0 font-[var(--ss-font-mono)] text-[9px] uppercase px-1.5 py-0.5 rounded-[var(--ss-radius-sm)] border text-[var(--ss-positive)] border-[var(--ss-positive)] bg-[var(--ss-positive-wash)]">
                      Fit
                    </span>
                  )}
                </div>
              </button>
            );
          })}

          {accounts.map((a, i) => {
            const isHighlighted = highlighted && highlighted.has(a.id);
            const isDimmed = highlighted && !isHighlighted;
            const top = accountMatches(a.id)[0];
            return (
              <button
                key={a.id}
                onClick={() => onOpenAccount(a.id)}
                className={`absolute text-left rounded-[var(--ss-radius)] border bg-[var(--ss-surface)] px-3.5 py-3 transition-opacity hover:border-[var(--ss-frame)] ${
                  isHighlighted ? 'border-[var(--ss-frame)]' : 'border-[var(--ss-border)]'
                }`}
                style={{ left: rightX, top: TOP_PAD + i * ROW_H, width: CARD_W, height: CARD_H, opacity: isDimmed ? 0.35 : 1 }}
              >
                <div className="flex items-center justify-between mb-1">
                  <span className="text-[13px] text-[var(--ss-ink)] font-medium">{a.name}</span>
                  <TierBadge tier={a.tier} />
                </div>
                <div className="text-[11px] text-[var(--ss-muted)] mb-1.5">{a.sector}</div>
                {top && <ConfidenceBar value={top.confidence} />}
              </button>
            );
          })}

          {selectedNews && (
            <div className="absolute" style={{ left: explainerX, top: TOP_PAD, width: EXPLAINER_W }}>
              <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-2">Why it matters</div>

              {selectedNews.sources && selectedNews.sources.length > 0 && (
                <div className="rounded-[var(--ss-radius)] border border-[var(--ss-border)] bg-[var(--ss-surface)] p-3 mb-3">
                  <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-muted)] mb-1.5">
                    Source{selectedNews.sources.length > 1 ? 's' : ''}
                  </div>
                  <div className="space-y-1.5">
                    {selectedNews.sources.map((s, i) => (
                      <a
                        key={i}
                        href={s.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="flex items-start gap-1.5 text-[12px] text-[var(--ss-accent)] hover:underline"
                      >
                        <ExternalLink size={11} className="shrink-0 mt-0.5" />
                        <span className="break-words">{s.title}</span>
                      </a>
                    ))}
                  </div>
                </div>
              )}

              <div className="space-y-3">
                {connectedMatches.map((m) => {
                  const fg = fitGaps[m.accountId];
                  const gapHere = fg && fg.gaps.find((g) => g.evidence.includes(m.newsId));
                  return (
                  <div key={m.accountId} className="rounded-[var(--ss-radius)] border border-[var(--ss-frame)] bg-[var(--ss-surface)] p-3.5">
                    <div className="flex items-center justify-between gap-2 mb-1.5">
                      <span className="text-[12px] text-[var(--ss-ink)] font-medium truncate">{m.account.name}</span>
                      <TierBadge tier={m.account.tier} />
                    </div>
                    {/* Fit/gap distinguishing (Day 2b) — says which this
                        signal is for this specific account, not just a
                        generic rationale. Unchecked (no fg yet) shows
                        neither line. */}
                    {fg && (
                      <div className={`font-[var(--ss-font-mono)] text-[11px] uppercase px-2 py-1 rounded-[var(--ss-radius-sm)] mb-2 inline-block ${gapHere ? GAP_SEVERITY_STYLE[gapHere.severity] : 'text-[var(--ss-positive)] border-[var(--ss-positive)] bg-[var(--ss-positive-wash)] border'}`}>
                        {gapHere ? `${gapHere.severity} gap — ${gapHere.gap}` : 'Fit — covered by our capabilities'}
                      </div>
                    )}
                    <p className="text-[12px] text-[var(--ss-text-2)] leading-relaxed mb-2.5">{m.rationale}</p>
                    <div className="font-[var(--ss-font-mono)] text-[10px] uppercase tracking-[0.06em] text-[var(--ss-positive)] mb-1">Next step</div>
                    <p className="text-[12px] text-[var(--ss-ink)] leading-relaxed mb-2.5">{m.nextStep}</p>
                    <button
                      onClick={() => onUnlinkSignal(m.newsId, m.accountId)}
                      className="flex items-center gap-1 text-[11px] text-[var(--ss-faint)] hover:text-[var(--ss-danger)]"
                    >
                      <Link2Off size={11} /> Not relevant — unlink
                    </button>
                  </div>
                  );
                })}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}


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