// v3/app.jsx — State machine, layouts A/B/C for both modes, OSC drawer, tweaks

const pad = n => String(n).padStart(2,'0');
const fmtElapsed = s => `T+${pad(Math.floor(s/60))}:${pad(s%60)}`;
const fmtCountdown = r => r<=0?'EXPIRED':`T−${pad(Math.floor(r/60))}:${pad(r%60)}`;
// Scenario Z time derived from elapsed seconds (event DTG is 281430Z).
const fmtZulu = s => {
  const total = 14*60 + 30 + Math.floor(s/60);
  return `${pad(Math.floor(total/60)%24)}${pad(total%60)}Z`;
};

// ── Hash router ───────────────────────────────────────────────
// Pure helpers that translate between window.location.hash and the
// app's view-state primitives (master mode, Mission Admin sub-page,
// source-ATO filter chips, table search). The router intentionally
// sticks to hash routing so it works on `python -m http.server` and
// from `file://` URLs (single-file standalone build) without server
// changes. The shape is:
//
//   #/                                            → home
//   #/csar                                        → CSAR mode
//   #/admin                                       → Mission Admin dashboard
//   #/admin/atos                                  → reserved (deferred Manage ATOs table; routes to dashboard for now)
//   #/admin/atos/platforms?ato=<id>[,<id>]&q=<s>  → Platforms sub-page
//   #/admin/atos/objectives?ato=<id>[,<id>]&q=<s> → Objectives sub-page
//   #/admin/atos/assets?ato=<id>[,<id>]&q=<s>     → Assets sub-page
//   #/<other-mode-id>                             → stub master modes (threats / health / etc)
//
// The implemented mode set is dynamic — we just match against
// MASTER_MODES so this router stays correct as new modes ship.

function _urlEncodeIds(ids) {
  return Array.from(ids || []).filter(Boolean).join(',');
}
function _urlDecodeIds(str) {
  if (!str) return new Set();
  return new Set(String(str).split(',').filter(Boolean));
}

// Parse `window.location.hash` (or any hash string) into a view-state
// object. Defensive — unknown / malformed paths fall back to home.
function parseHashRoute(hashStr) {
  const out = { mode:'home', subview:'dashboard', atoIds:new Set(), search:'' };
  let h = String(hashStr || '');
  if (h.startsWith('#')) h = h.slice(1);
  if (h.startsWith('/')) h = h.slice(1);
  if (!h) return out;
  const [pathPart, queryPart] = h.split('?');
  const segs = pathPart.split('/').filter(Boolean);
  // Master mode = first segment. `manage-system` is a workspace target
  // that lives outside the master-mode strip (it's a small secondary
  // nav row in the drawer above the role selector), but it routes
  // through the same `activeMode` state, so we accept it here too.
  if (segs.length > 0) {
    const m = segs[0];
    const known = (window.MASTER_MODES || []).some(mm => mm.id === m);
    if (known || m === 'manage-system') out.mode = m;
  }
  // Mission Admin sub-routes — only valid when mode === 'admin'.
  if (out.mode === 'admin') {
    if (segs[1] === 'atos') {
      if (segs[2] === 'platforms')  out.subview = 'platforms';
      else if (segs[2] === 'objectives') out.subview = 'objectives';
      else if (segs[2] === 'assets')     out.subview = 'assets';
      else if (segs[2] === 'missions')   out.subview = 'missions';
      else                                out.subview = 'dashboard';   // /admin/atos → reserved → dashboard
    }
  }
  // Query params (?ato=…&q=…)
  if (queryPart) {
    queryPart.split('&').forEach(kv => {
      const [k, v] = kv.split('=').map(s => decodeURIComponent(s || ''));
      if (k === 'ato') out.atoIds = _urlDecodeIds(v);
      if (k === 'q')   out.search = v;
    });
  }
  return out;
}

// Build a hash string from a view-state object. Pure function. Empty
// query parts are dropped so the URL stays short when defaults apply.
//
// `?ato=…` and `?q=…` only ride the URL on the three admin table
// sub-pages (`platforms` / `objectives` / `assets`) — those are the
// only views that actually render a filter chip row + search input.
// Dashboard (`/admin`), home, and stub modes don't carry the params,
// even though App state may still hold them. (Sub-page state is
// sticky across master-mode toggles, by design — but the URL stays
// clean while the operator's away from the consuming view.)
function buildHashRoute(state) {
  const mode    = (state && state.mode)    || 'home';
  const subview = (state && state.subview) || 'dashboard';
  const atoIds  = (state && state.atoIds)  || new Set();
  const search  = (state && state.search)  || '';
  let path = '';
  let paramsApply = false;
  if (mode === 'home') path = '/';
  else if (mode === 'admin') {
    if (subview === 'platforms')       { path = '/admin/atos/platforms';  paramsApply = true; }
    else if (subview === 'objectives') { path = '/admin/atos/objectives'; paramsApply = true; }
    else if (subview === 'assets')     { path = '/admin/atos/assets';     paramsApply = true; }
    else if (subview === 'missions')   { path = '/admin/atos/missions';   paramsApply = true; }
    else                                 path = '/admin';
  } else if (mode === 'manage-system') {
    path = '/manage-system';
  } else {
    path = '/' + mode;
  }
  const params = [];
  if (paramsApply) {
    const idsCsv = _urlEncodeIds(atoIds);
    if (idsCsv) params.push('ato=' + encodeURIComponent(idsCsv));
    if (search) params.push('q='   + encodeURIComponent(search));
  }
  return '#' + path + (params.length ? '?' + params.join('&') : '');
}

// ── Classification banner ─────────────────────────────────────
// Pinned at the very top of the app shell across every workspace +
// every master mode. Solid green bar, white mono caps "UNCLASSIFIED",
// followed by the advisory string clarifying that all tactical data
// in this prototype is simulated (only the live ADS-B feed touches a
// real network). 20px tall — visible without dominating, unmissable
// without sacrificing screen real estate. Color is a generic ANSI-ish
// green (#0a6b2c) to match DoD UNCLASSIFIED conventions rather than
// the design-system accent — it should read as a header bar, not a UI
// element the operator can interact with.
// Height of the classification banner. Overlays that would otherwise
// tuck under it (the doc drawer) offset by this so nothing is clipped.
const CLASS_BAR_H = 20;

function ClassificationBanner() {
  return (
    <div style={{
      flexShrink:0, height:CLASS_BAR_H, background:'#0a6b2c',
      display:'flex', alignItems:'center', justifyContent:'center', gap:14,
      borderBottom:'1px solid #064a1d', userSelect:'none',
      position:'relative', zIndex:250,
    }}>
      <span style={{
        fontFamily:'Roboto Mono', fontSize:10, fontWeight:700,
        letterSpacing:'0.20em', color:'#ffffff',
      }}>UNCLASSIFIED</span>
      <span style={{
        fontFamily:'Roboto', fontSize:10, fontWeight:400,
        letterSpacing:'0.04em', color:'#cfeed8',
      }}>
        Prototype build · all tactical data is simulated except live ADS-B (OpenSky Network).
      </span>
      <span style={{
        fontFamily:'Roboto Mono', fontSize:10, fontWeight:700,
        letterSpacing:'0.20em', color:'#ffffff',
      }}>UNCLASSIFIED</span>
    </div>
  );
}

// ── Type-audit toggle (diagnostic only — not operator UI) ────
// Floating chip that swaps every Roboto Mono usage in the live DOM
// to design-system-compliant Roboto + strict-spec letter-spacing,
// so we can compare the current "data-pad" treatment against what
// the Figma CORE Design System ramp actually prescribes. Not part
// of the operator experience — only meaningful for design review.
//
// Approach: a single boolean state on App flips `data-type-audit="1"`
// on #root. A DOM walker (mounted in TypeAuditWalker below) scans
// every element with an inline `font-family: Roboto Mono` style,
// classifies it as `label` (alphabetic content → swap family) or
// `tabular` (numeric / DTG / coord content → keep Mono, just tighten
// spacing), and tags it with `data-typo` + `data-typo-size`. The
// CSS block in mission-commander-suite.html keys off those attrs.
//
// Re-runs on every render that grows the DOM (MutationObserver) so
// new markers, modal contents, etc. get classified as they appear.

// Classification: text content → one of four categories.
//
//   'label'       — short caps-only field/section header. Uppercase
//                   eyebrow. Roboto Medium 500. Caps stays.
//   'tabular'     — bare numeric readout (DTG, %, duration, coord,
//                   freq, pure number). Roboto Mono, no caps applied.
//   'value-id'    — structured identifier (callsign, MGRS, prefix-
//                   NNNN). Roboto Mono. Source casing wins (no forced
//                   uppercase, no forced lowercase).
//   'value-prose' — sentence-shaped field value. Roboto Regular 400.
//                   Source casing wins.
//
// The walker can't tell from a single element whether it's a label
// or a value — both are inline-styled `Roboto Mono` in JSX. So we
// classify by content shape. Priority order matters: tabular wins
// over label (so "47%" doesn't get tagged label just because it's
// short), and label wins over value-id (so "CASE" doesn't get tagged
// value-id just because all caps).
function _classifyTypoContent(text) {
  if (!text || typeof text !== 'string') return 'value-prose';
  const s = text.trim();
  if (!s) return 'value-prose';

  // ── TABULAR ─────────────────────────────────────────────────
  // Bare numeric readouts. Priority 1.
  // DTG: 281430Z APR 2026, 281430Z, 1430Z
  if (/\b\d{2,6}Z\b/.test(s)) return 'tabular';
  // Lat/lon decimal degrees: 34.567, -118.234
  if (/-?\d+\.\d{2,}/.test(s)) return 'tabular';
  // Percentage: 47%, 100%
  if (/^\s*\d+\s*%\s*$/.test(s)) return 'tabular';
  // Duration: 2h 14m, 14m, 47s, 3d 4h, T+18:42
  if (/^\s*T?\+?\s*\d+[dhms:]\s*(\d+[dhms:]?\s*)*$/.test(s)) return 'tabular';
  // Bearing / altitude / range readouts: "230°", "FL 250", "12NM", "3500FT"
  if (/^\s*\d+\s*(°|NM|FT|KT|FL|KFT|MI|KM)\s*$/i.test(s)) return 'tabular';
  // Pure number or split count: 12, 47, 12 / 24
  if (/^\s*\d+(\s*\/\s*\d+)?\s*$/.test(s)) return 'tabular';
  // Bracketed counts: [47], (12)
  if (/^\s*[\[(]\d+[\])]\s*$/.test(s)) return 'tabular';
  // Frequency: 243.000, 121.5
  if (/^\s*\d{2,3}\.\d{1,3}\s*$/.test(s)) return 'tabular';

  // ── LABEL ───────────────────────────────────────────────────
  // Short all-caps token that functions as an eyebrow / field name /
  // section header / status chip / format badge.
  // Caps-only letters + optional spaces + optional ·/·-/dot separator,
  // ≤ 16 chars, no digits, no parens, no slashes.
  // Catches: CASE, ISOP, TRACK, PHASE, ALPHA IP, BRAVO IP, SURVIVORS,
  //          MISSION READINESS, PRIMARY AOR, ACTIVE LIBRARY,
  //          CURRENT, EXPIRING, CLEAN, WARNINGS, XML, PDF, XLSX,
  //          UPLOAD, REPLACE, ARCHIVE.
  if (s.length <= 16
      && /^[A-Z][A-Z\s·]*$/.test(s)
      && !/[\/()\[\]0-9]/.test(s)) {
    return 'label';
  }
  // Slightly longer caps-only tokens that are still field/section
  // labels — bump the length cap to 26 if it's still letters+spaces
  // only. Catches: AWAITING OPERATOR CONFIRMATION-style strings only
  // when they're short enough; longer prose-shaped caps strings fall
  // through to value-prose.
  if (s.length <= 26
      && /^[A-Z][A-Z\s·]*$/.test(s)
      && !/[\/()\[\]0-9]/.test(s)
      && s.split(/\s+/).length <= 3) {
    return 'label';
  }

  // ── VALUE-ID / DATA ─────────────────────────────────────────
  // Structured identifier or short data-shaped readout. Mono family
  // stays — per DS guidance: "Roboto Mono is preferred in contexts
  // where data is presented. More specifically, it is highly suggested
  // for numerical data since this font distinguishes between the
  // number 0 and the letter O more clearly."
  //
  // Identifier patterns:
  // Prefix-NNNN: PR-2804-001, T-2804-01, AMA-15, JTBD-02, PERS-4421.
  if (/^\s*(?:REF\s*#?)?[A-Z]{1,8}[-\s][A-Z0-9.]+(?:[-\s][A-Z0-9.]+)*\s*$/.test(s)) {
    return 'value-id';
  }
  // Callsign shape: LETTERS-DIGITS optionally suffixed.
  // VIPER-21, RAVEN-7, PEDRO 61, DUKE 31.
  if (/^\s*[A-Z]{3,8}[-\s]\d{1,3}[A-Z]?\s*$/.test(s)) {
    return 'value-id';
  }
  // MGRS-ish: 38S MB 12345 67890 or 51RTH 8451 1234
  if (/\b\d{0,2}[A-Z]{1,4}\s+\d{4,5}\s+\d{4,5}\b/.test(s)) {
    return 'value-id';
  }

  // Short data-shaped readouts — operator reads as DATA, not prose.
  // The ≤ 5-word ceiling protects sentence prose with an embedded
  // number (e.g. "Phase 0 complete. New HCR queue seeded...") from
  // accidentally landing here.
  //
  // Pattern A: starts with digit + unit/symbol, optionally followed
  // by a short descriptor. "47% to IP", "12 / 24 platforms",
  // "2× CREW", "3500 FT MSL", "12 NM south".
  if (s.split(/\s+/).length <= 5
      && /^[\d]/.test(s)
      && /[\d]/.test(s)
      && s.length <= 40) {
    return 'value-id';
  }
  // Pattern B: starts with an all-caps label-word followed by a
  // number + unit. "BEARING 230°", "RANGE 12 km", "ALT FL 250",
  // "FUEL 47%", "SPEED 320 KT".
  if (s.split(/\s+/).length <= 5
      && /^[A-Z]{3,8}\s+\d/.test(s)
      && s.length <= 30) {
    return 'value-id';
  }
  // Pattern C: T+/T-/T− elapsed-or-countdown clock with optional
  // trailing word. fmtCountdown uses Unicode minus (U+2212) — match
  // ASCII +/- AND the typographic minus so both pre/post-zero states
  // classify correctly. "T+18:42", "T−00:36", "T+18:42 elapsed".
  if (/^T[+\-−]\d+:\d{2}/.test(s) && s.split(/\s+/).length <= 3) {
    return 'value-id';
  }

  // ── VALUE-PROSE ─────────────────────────────────────────────
  // Default for anything sentence-shaped, mixed-case, multi-word
  // prose, or strings that contain a mix of IDs and words.
  // Roboto Regular 400. No forced caps — source casing wins.
  return 'value-prose';
}

// Walker: tags elements with data-typo / data-typo-size.
//
// Two passes:
// 1. Elements with a JSX-authored `data-typo` keep their value
//    (manual tag wins). We still compute their size bucket so the CSS
//    can apply spec letter-spacing. Mark them with a marker attribute
//    `data-typo-src="jsx"` so toggle-off cleanup leaves them alone.
// 2. Elements with inline `Roboto Mono` but no manual tag get
//    auto-classified by content shape. Marked `data-typo-src="auto"`
//    so toggle-off cleanup strips them clean.
//
// Idempotent — re-runs are cheap. JSX tags are never overwritten.
function _typoSizeBucket(el) {
  const m = (el.getAttribute('style') || '').match(/font-size:\s*([\d.]+)px/);
  if (!m) return null;
  return String(Math.round(parseFloat(m[1])));
}
function _typoWalk(root) {
  if (!root) return;
  // Pass 1: JSX-authored tags. Just stamp size bucket; don't touch
  // the manual data-typo value.
  const manual = root.querySelectorAll('[data-typo]:not([data-typo-src])');
  for (let i = 0; i < manual.length; i++) {
    const el = manual[i];
    // If the walker has been here before (and toggled off then on),
    // an auto-classified element would already have data-typo-src
    // set. The query above excludes those. So everything in `manual`
    // is a JSX-authored tag we need to honor.
    el.setAttribute('data-typo-src', 'jsx');
    const size = _typoSizeBucket(el);
    if (size != null) el.setAttribute('data-typo-size', size);
  }
  // Pass 2: auto-classify Roboto Mono elements that aren't already tagged.
  const auto = root.querySelectorAll('[style*="Roboto Mono"]:not([data-typo])');
  for (let i = 0; i < auto.length; i++) {
    const el = auto[i];
    const text = (el.innerText || el.textContent || '').trim();
    const kind = _classifyTypoContent(text);
    el.setAttribute('data-typo', kind);
    el.setAttribute('data-typo-src', 'auto');
    const size = _typoSizeBucket(el);
    if (size != null) el.setAttribute('data-typo-size', size);
  }
}

function TypeAuditWalker({ active }) {
  React.useEffect(() => {
    const root = document.getElementById('root');
    if (!root) return;
    if (!active) {
      // Strip auto-classified tags so toggling off is clean. JSX-
      // authored tags (data-typo-src="jsx") stay in place — they're
      // inert when data-type-audit is absent from #root, so leaving
      // them costs nothing.
      const tagged = root.querySelectorAll('[data-typo-src="auto"]');
      tagged.forEach(el => {
        el.removeAttribute('data-typo');
        el.removeAttribute('data-typo-src');
        el.removeAttribute('data-typo-size');
      });
      // Clear the size bucket off JSX-tagged elements too (they get
      // re-stamped next time the toggle flips on).
      const jsxTagged = root.querySelectorAll('[data-typo-src="jsx"]');
      jsxTagged.forEach(el => {
        el.removeAttribute('data-typo-src');
        el.removeAttribute('data-typo-size');
      });
      root.removeAttribute('data-type-audit');
      return;
    }
    root.setAttribute('data-type-audit', '1');
    _typoWalk(root);
    // Re-walk on DOM changes (modal open, marker added, drawer slide, etc.)
    let raf = 0;
    const obs = new MutationObserver(() => {
      // Debounce via rAF so a burst of mutations triggers one walk.
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        _typoWalk(root);
      });
    });
    obs.observe(root, { childList:true, subtree:true, attributes:true,
      attributeFilter:['style'] });
    return () => {
      obs.disconnect();
      if (raf) cancelAnimationFrame(raf);
    };
  }, [active]);
  return null;
}

function TypeAuditToggle({ active, onToggle, visible }) {
  // Hidden by default for demos. Triple-tap `t` toggles visibility
  // (listener lives in App). The walker still runs based on App's
  // `typeAudit` state when the chip is shown and clicked.
  if (!visible) return null;
  return (
    <button
      onClick={() => onToggle(!active)}
      title="Diagnostic: swap Roboto Mono labels to Roboto Medium + strict letter-spacing per Figma CORE Design System"
      style={{
        position:'fixed', top:26, right:8, zIndex:9999,
        padding:'4px 10px', borderRadius:3,
        background: active ? '#39b7fa' : 'rgba(20,22,26,0.85)',
        color: active ? '#000' : '#c3c5c7',
        border: `1px dashed ${active ? '#39b7fa' : '#5a5a60'}`,
        cursor:'pointer',
        // Keep the toggle itself in Roboto so it doesn't change appearance
        // when ON — this is a meta-control that has to stay readable in
        // both states.
        fontFamily:'Roboto, sans-serif', fontSize:10, fontWeight:600,
        letterSpacing:'0.06em', textTransform:'uppercase',
        userSelect:'none', boxShadow:'0 2px 8px rgba(0,0,0,0.4)',
      }}>
      Type Audit · {active ? 'ON' : 'OFF'}
    </button>
  );
}

// ── Darkness score from day progress (0→1 over 180s) ─────────
function getDarkScore(dayProgress) {
  if (dayProgress < 0.2)  return 1; // DAYLIGHT
  if (dayProgress < 0.42) return 2; // LOW LIGHT
  if (dayProgress < 0.65) return 3; // OPERATIONAL DARK
  if (dayProgress < 0.85) return 4; // DEGRADED DARK
  return 5;                          // FULL DARK
}

// ── Clock cell ────────────────────────────────────────────────
function ClockCell({ label, deadline, elapsed, done }) {
  const rem = Math.max(0,deadline-elapsed);
  const exp = rem===0, urgent = rem<=20&&!exp;
  const color = done?C.normal:exp?C.critical:urgent?C.advisory:C.fg2;
  const bg    = done?C.normalM:exp?C.criticalM:urgent?C.advisoryM:'transparent';
  // SME-REVIEW: tooltip needs verification
  const tipMap = {
    'JPRC NOTIFY': 'JPRC NOTIFY — Joint Personnel Recovery Center notification clock. T+60s standard.',
    'OSC APPOINT': 'OSC APPOINT — On-Scene Commander appointment clock. T+120s standard.',
    'J3.5 CONVERT': 'J3.5 CONVERT — Future Ops to Current Ops handoff clock. Converts the recovery from planning to execution.',
  };
  const tip = tipMap[label] || `${label} — deadline T+${deadline}s.`;
  return (
    <div title={tip} style={{ display:'flex',alignItems:'center',gap:7,padding:'0 14px',borderLeft:`1px solid ${C.border}`,background:bg,transition:'background 0.4s' }}>
      <div style={{ display:'flex',flexDirection:'column',alignItems:'flex-end' }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>{label}</span>
        <span data-typo="value-id" style={{ fontFamily:'Roboto Mono',fontSize:15,fontWeight:500,color,letterSpacing:'0.05em',lineHeight:1.2,animation:urgent&&!done?'pulseFade 0.8s infinite':'none' }}>
          {done?'COMPLETE':fmtCountdown(rem)}
        </span>
      </div>
      {(exp||urgent)&&!done&&<div style={{ width:5,height:5,borderRadius:'50%',background:color,animation:'pulseFade 0.6s infinite' }}/>}
    </div>
  );
}

// ── Top bars ──────────────────────────────────────────────────
// Display-mode segmented control. Lives under the dark scale in the top
// bar so it visually clusters with the darkness state it controls. Two
// options: STD (satellite ↔ Carto Dark Matter crossfade with dayProgress)
// and NVG (satellite stays under a green window-treatment overlay whose
// intensity ramps with dayProgress — barely visible at noon, vivid at
// full dark, just like real night-vision goggles).
const NVG_OPTIONS = [
  { id:'std', label:'STD' },
  { id:'nvg', label:'NVG' },
];
function NvgSegmented({ value, onChange }) {
  return (
    <div role="radiogroup" aria-label="Display mode" style={{
      display:'inline-flex',
      alignSelf:'flex-start',
      border: `1px solid ${C.border2}`,
      borderRadius: 3,
      overflow:'hidden',
      height: 18,
    }}>
      {NVG_OPTIONS.map((t, i) => {
        const active = t.id === value;
        return (
          <button
            key={t.id}
            type="button"
            role="radio"
            aria-checked={active}
            onClick={()=>onChange(t.id)}
            title={t.id === 'nvg'
              ? 'NVG — Night-vision overlay. Green window-treatment ramps with darkness; preserves dark adaptation.'
              : 'STD — Standard display. Satellite imagery crossfades to Carto Dark Matter as darkness deepens.'}
            style={{
              display:'flex', alignItems:'center', justifyContent:'center',
              padding:'0 10px',
              background: active ? C.fg1 : 'transparent',
              border:'none',
              borderLeft: i === 0 ? 'none' : `1px solid ${C.border2}`,
              cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:8.5, fontWeight:500, letterSpacing:'0.10em',
              color: active ? C.s1 : C.fg2,
              transition:'background 0.15s, color 0.15s',
            }}>
            {t.label}
          </button>
        );
      })}
    </div>
  );
}

function SysStatus({ darkScore, nvgMode, onNvgModeChange }) {
  const dsColors = ['#ffc14c','#fbab16','#39b7fa','#0068a1','#004f7e']; // serious-300/400, accent-primary-300/600/700
  const dsLabels = ['DAYLIGHT','LOW LIGHT','OPER. DARK','DEGRAD. DARK','FULL DARK'];
  return (
    <>
      <div title="System status. Green indicates all C2 feeds (Link 16, JREAP, radios) reporting nominal." style={{ display:'flex',alignItems:'center',padding:'0 14px',borderLeft:`1px solid ${C.border}`,gap:6 }}>
        <div style={{ width:5,height:5,borderRadius:'50%',background:C.normal }} />
        <span style={{ fontFamily:'Roboto Mono',fontSize:10,color:C.normal }}>ALL SYSTEMS NOMINAL</span>
      </div>
      {/* Dark scale + label on top, night-tint segmented control beneath
          so it reads as "tint setting for current darkness". The whole
          cell is a column flex with two rows; the dot/scale + label sit
          on row 1, segmented control on row 2. */}
      <div title="Darkness scale (1–5). Drives map crossfade and NVG tint intensity. 1=Daylight, 5=Full Dark." style={{ display:'flex',flexDirection:'column',justifyContent:'center',padding:'4px 14px',borderLeft:`1px solid ${C.border}`,gap:3 }}>
        <div style={{ display:'flex',alignItems:'center',gap:6 }}>
          <div style={{ display:'flex',gap:3 }}>
            {[1,2,3,4,5].map(i=><div key={i} style={{ width:9,height:9,borderRadius:2,background:i<=darkScore?dsColors[i-1]+'33':C.s3,border:`1px solid ${i===darkScore?dsColors[darkScore-1]:C.border}` }} />)}
          </div>
          {/* Fixed-width container so the cell doesn't reflow as the label
              cycles between short ("DAYLIGHT") and long ("DEGRAD. DARK").
              Width is set to fit the longest label at 9px Roboto Mono. */}
          <span style={{ fontFamily:'Roboto Mono',fontSize:9,color:dsColors[darkScore-1],display:'inline-block',width:78,textAlign:'left' }}>{dsLabels[darkScore-1]}</span>
        </div>
        {typeof onNvgModeChange === 'function' && (
          <NvgSegmented value={nvgMode ? 'nvg' : 'std'} onChange={(id)=>onNvgModeChange(id === 'nvg')} />
        )}
      </div>
    </>
  );
}

// ── Hamburger button (replaces the legacy C2 brand square) ────
// Single trigger for the master-mode drawer. Optional badge for unread
// CSAR alerts when the operator is in another mode while a CSAR session
// is live. Sized to match the 26×26 footprint the C2 square used so the
// top-bar baseline doesn't shift.
function HamburgerButton({ onClick, onHover, badge = 0 }) {
  return (
    <button
      onClick={onClick}
      onMouseEnter={onHover}
      title="Hover to open, click to close"
      style={{
        width:26, height:26, borderRadius:3, background:C.s3, border:`1px solid ${C.border}`,
        display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
        position:'relative', padding:0, flexShrink:0,
      }}>
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke={C.fg1} strokeWidth="1.5" strokeLinecap="round">
        <line x1="2" y1="4"  x2="12" y2="4" />
        <line x1="2" y1="7"  x2="12" y2="7" />
        <line x1="2" y1="10" x2="12" y2="10" />
      </svg>
      {badge > 0 && (
        <div style={{ position:'absolute', top:-3, right:-3, minWidth:14, height:14, padding:'0 3px',
          borderRadius:7, background:C.critical, color:'#fff', fontFamily:'Roboto Mono', fontSize:8.5,
          fontWeight:600, display:'flex', alignItems:'center', justifyContent:'center', border:`1px solid ${C.s2}`,
          boxSizing:'border-box', lineHeight:1 }}>
          {badge > 9 ? '9+' : badge}
        </div>
      )}
    </button>
  );
}

// Drawing tools cell — lives in the top bar after the brand cell, only
// in non-CSAR modes. Hosts the same tool set the old AirspaceToolbar
// rendered in the left strip (Select / Corridor / Kill Box / Deception
// Rte / Strike Target / Measure). Suppressed in CSAR by virtue of not
// being rendered from CSARTopBar at all.
function DrawingToolsCell({ active, onChange }) {
  const tools = window.DRAWING_TOOLS || [];
  // SME-REVIEW: tooltip needs verification
  const toolTips = {
    select:    'Select — Default cursor. Pick / inspect tracks and shapes on the map.',
    corridor:  'Corridor — Draw an airspace corridor (linear lane with a width buffer).',
    killbox:   'Kill Box — Define a 3D kill-box airspace for fires deconfliction.',
    deception: 'Deception Route — Draw a feint or deception flight path.',
    target:    'Strike Target — Drop a strike aimpoint (DMPI) on the map.',
    measure:   'Measure — Range/bearing measurement tool. Click two points.',
  };
  return (
    <div style={{ display:'flex', alignItems:'center', padding:'0 8px',
      borderRight:`1px solid ${C.border}`, gap:2 }}>
      {tools.map(t => {
        const isActive = t.id === active;
        return (
          <button
            key={t.id}
            title={toolTips[t.id] || t.label}
            onClick={() => onChange(t.id)}
            style={{
              width:28, height:28, borderRadius:3, border:'none',
              background: isActive ? C.accentM : 'transparent',
              color: isActive ? C.accent : C.fg2,
              cursor:'pointer', fontSize:14, fontWeight:500,
              display:'flex', alignItems:'center', justifyContent:'center',
              transition:'all 0.15s', padding:0,
            }}
            onMouseEnter={(e)=>{ if (!isActive) e.currentTarget.style.background = C.s3; }}
            onMouseLeave={(e)=>{ if (!isActive) e.currentTarget.style.background = 'transparent'; }}>
            {t.icon}
          </button>
        );
      })}
    </div>
  );
}

function CSARTopBar({ elapsed, jprcDone, oscDone, phase, j35Done, darkScore, nvgMode, onNvgModeChange, onOpenDrawer, onToggleDrawer, badgeCount }) {
  const phaseLabel = phase===0 ? 'IMMEDIATE ACTIONS'
                   : phase===1 ? 'LOCATE / PLOT'
                   : phase===2 ? 'AIRSPACE / THREAT'
                   :             'TACTICAL EXECUTION';
  const phaseColor = phase===0 ? C.advisory
                   : phase===1 ? C.accent
                   : phase===2 ? C.advisory
                   :             C.accent2;
  return (
    <div style={{ height:46,display:'flex',alignItems:'stretch',background:C.s2,borderBottom:`1px solid ${C.border}`,flexShrink:0 }}>
      <div style={{ display:'flex',alignItems:'center',gap:9,padding:'0 14px',borderRight:`1px solid ${C.border}` }}>
        <HamburgerButton onClick={onToggleDrawer} onHover={onOpenDrawer} badge={badgeCount} />
        <div>
          <div style={{ fontFamily:'Roboto',fontWeight:700,fontSize:12,color:C.fg1,letterSpacing:'0.08em' }}>CSAR C2</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>MC/SL · {window.CASE.number}</div>
        </div>
      </div>
      <div title="PHASE — Current CSAR phase. 0:Immediate Actions, 1:Locate/Plot, 2:Airspace/Threat, 3:Tactical Execution." style={{ display:'flex',alignItems:'center',padding:'0 14px',borderRight:`1px solid ${C.border}`,gap:6 }}>
        <div style={{ width:5,height:5,borderRadius:'50%',background:phaseColor,animation:'pulseFade 1.5s infinite' }} />
        <div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>PHASE</div>
          <div style={{ fontFamily:'Roboto',fontWeight:500,fontSize:10.5,color:phaseColor }}>{phaseLabel}</div>
        </div>
      </div>
      <div title="CASE CLOCK — Elapsed time since this CSAR case was opened." style={{ display:'flex',alignItems:'center',padding:'0 14px',borderRight:`1px solid ${C.border}` }}>
        <div>
          <div data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>CASE CLOCK</div>
          <div data-typo="value-id" style={{ fontFamily:'Roboto Mono',fontSize:15,fontWeight:500,color:C.fg1,letterSpacing:'0.05em' }}>{fmtElapsed(elapsed)}</div>
        </div>
      </div>
      {!jprcDone && <ClockCell label="JPRC NOTIFY" deadline={60}  elapsed={elapsed} done={jprcDone} />}
      {!oscDone  && <ClockCell label="OSC APPOINT" deadline={120} elapsed={elapsed} done={oscDone}  />}
      {phase>=1 && !j35Done && <ClockCell label="J3.5 CONVERT" deadline={600} elapsed={elapsed} done={j35Done} />}
      <div style={{ flex:1 }} />
      <SysStatus darkScore={darkScore} nvgMode={nvgMode} onNvgModeChange={onNvgModeChange} />
      <div title="OPERATOR — Your current role at this console. Mission Commander / Senior Leader." style={{ display:'flex',alignItems:'center',padding:'0 14px',borderLeft:`1px solid ${C.border}` }}>
        <div style={{ textAlign:'right' }}>
          <div data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>OPERATOR</div>
          <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:10.5,color:C.accent }}>MISSION COMMANDER / SL</div>
        </div>
      </div>
    </div>
  );
}

// FLAT / GLOBE projection toggle — segmented pill, same visual
// language as the STD/NVG control in CSARTopBar. Only rendered in
// the non-CSAR top bar; CSAR is a tactical-zoom workspace where
// projection choice doesn't meaningfully change SA, and there's no
// space in the CSAR top bar anyway.
function ProjectionToggleCell({ projection, onChange }) {
  const isFlat = projection !== 'globe';
  const Pill = ({ label, active, onClick, tip }) => (
    <button onClick={onClick} title={tip} style={{
      padding:'4px 10px', borderRadius:2,
      background: active ? C.accent : 'transparent',
      color: active ? '#000' : C.fg2,
      border: 'none', cursor: active ? 'default' : 'pointer',
      fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
      letterSpacing:'0.10em', transition:'background 0.12s, color 0.12s',
    }}>{label}</button>
  );
  return (
    <div style={{ display:'flex',alignItems:'center',padding:'0 14px',gap:8 }} title="Map projection. FLAT preserves north-up Mercator; GLOBE shows the earth as a sphere for theater-wide SA.">
      <span style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em' }}>VIEW</span>
      <div style={{ display:'flex',background:C.s3,border:`1px solid ${C.border2}`,borderRadius:3,overflow:'hidden' }}>
        <Pill label="FLAT"  active={isFlat}  onClick={() => onChange('flat')}  tip="FLAT — Mercator projection. Better for measurement and local tactical detail." />
        <Pill label="GLOBE" active={!isFlat} onClick={() => onChange('globe')} tip="GLOBE — Spherical projection. Better for theater-wide SA and great-circle routes." />
      </div>
    </div>
  );
}

function NormalTopBar({ dtg, darkScore, nvgMode, onNvgModeChange, onOpenDrawer, onToggleDrawer, badgeCount, modeLabel, roleShort, activeTool, onActiveToolChange, hideMapChrome=false, projection='flat', onProjectionChange=()=>{}, openTabs=[], activeTabId='all', onTabChange=()=>{}, onTabClose=()=>{} }) {
  const brand = (modeLabel || 'MISSION COP').toUpperCase();
  const subline = `${roleShort || 'MC/SL'} · ${dtg}`;
  return (
    <div style={{ height:46,display:'flex',alignItems:'stretch',background:C.s2,borderBottom:`1px solid ${C.border}`,flexShrink:0 }}>
      <div style={{ display:'flex',alignItems:'center',gap:9,padding:'0 14px',borderRight:`1px solid ${C.border}` }}>
        <HamburgerButton onClick={onToggleDrawer} onHover={onOpenDrawer} badge={badgeCount} />
        <div>
          <div style={{ fontFamily:'Roboto',fontWeight:700,fontSize:12,color:C.fg1,letterSpacing:'0.08em' }}>{brand}</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:10,color:C.fg3,letterSpacing:'0.1em' }}>{subline}</div>
        </div>
      </div>
      <div style={{ flex:1 }} />
    </div>
  );
}

// ── Master-mode drawer (sidenav) ──────────────────────────────
// Slide-in from the left, overlay-only (does not push the workspace).
// Hand-rolled to mirror the @accelint/design-toolkit Sidenav structure
// (header avatar → content list → collapsible role group → footer) since
// this prototype runs on babel-standalone without a build step.
//
// Behavior:
//   - Backdrop renders at z=230 (above queueExpanded panels at ~210, below
//     the confirmation modal at ~250). Click on backdrop does NOT dismiss
//     — only the close button or hamburger toggle closes the drawer.
//   - Panel always rendered so the slide transition runs cleanly; pointer
//     events are disabled and visibility hidden when closed so the drawer
//     can't capture interactions from the workspace beneath.
//   - Active mode highlighted with a left rail accent + accent fill.
//   - Stubbed modes are dimmed but still clickable so the user can land
//     on the placeholder workspace and feel the switch work.
//   - CSAR row carries an unread-count chip when the operator is away.
function ModeRow({ mode, active, badge, onClick }) {
  const Icon = (window.MODE_ICONS || {})[mode.icon];
  const stub = !mode.implemented;
  return (
    <button onClick={onClick} title={`${mode.label}${mode.desc ? '\n' + mode.desc : ''}${stub ? '\n(Not yet implemented)' : ''}`} style={{
      display:'flex', alignItems:'center', gap:11, width:'100%', height:42,
      padding:'0 14px 0 11px', border:'none', background: active ? C.accentM : 'transparent',
      color: active ? C.accent : (stub ? C.fg3 : C.fg1),
      borderLeft: `3px solid ${active ? C.accent : 'transparent'}`,
      cursor:'pointer', textAlign:'left', fontFamily:'Roboto', fontSize:12, fontWeight:500,
      letterSpacing:'0.02em', transition:'background 0.12s, color 0.12s',
      opacity: stub ? 0.7 : 1,
    }}
    onMouseEnter={(e)=>{ if (!active) e.currentTarget.style.background = C.s3; }}
    onMouseLeave={(e)=>{ if (!active) e.currentTarget.style.background = 'transparent'; }}>
      <span style={{ width:20, height:20, display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
        {Icon ? <Icon size={14}/> : <span style={{ width:14, height:14, border:`1px solid ${C.fg3}`, borderRadius:2 }}/>}
      </span>
      <span style={{ flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {mode.label}
      </span>
      {!active && stub && !badge && (
        <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, letterSpacing:'0.08em' }}>STUB</span>
      )}
      {badge > 0 && (
        <span style={{ display:'flex', alignItems:'center', gap:4 }}>
          <span style={{ width:6, height:6, borderRadius:'50%', background:C.critical, animation:'pulseFade 1.4s infinite' }}/>
          <span style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600, color:C.critical }}>{badge > 9 ? '9+' : badge}</span>
        </span>
      )}
    </button>
  );
}

function RoleRow({ role, active, onClick }) {
  const stub = !role.implemented;
  return (
    <button onClick={onClick} disabled={false} title={`${role.label}${stub ? '\nRole switching not yet implemented — placeholder only.' : '\nActive operator role. Selecting your role tailors the workspace.'}`} style={{
      display:'flex', alignItems:'center', gap:10, width:'100%', height:32,
      padding:'0 14px 0 36px', border:'none',
      background: active ? C.accentM : 'transparent',
      color: active ? C.accent : (stub ? C.fg3 : C.fg2),
      cursor:'pointer', textAlign:'left', fontFamily:'Roboto', fontSize:11, fontWeight:500,
      transition:'background 0.12s, color 0.12s', opacity: stub ? 0.7 : 1,
    }}
    onMouseEnter={(e)=>{ if (!active) e.currentTarget.style.background = C.s3; }}
    onMouseLeave={(e)=>{ if (!active) e.currentTarget.style.background = 'transparent'; }}>
      <span style={{ flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {role.label}
      </span>
      {active && (
        <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.accent, letterSpacing:'0.08em' }}>YOU</span>
      )}
      {!active && stub && (
        <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, letterSpacing:'0.08em' }}>STUB</span>
      )}
    </button>
  );
}

function MasterModeDrawer({ open, activeMode, activeRole, csarUnreadCount, csarSessionActive, onClose, onSelectMode, onSelectRole, modes, roles, onRestartTour }) {
  const [roleExpanded, setRoleExpanded] = React.useState(false);
  // Fallback to the global lists if the parent didn't pass filtered ones.
  // Manage System's Config tab feeds filtered lists to hide modes/roles.
  const _modes = modes || window.MASTER_MODES || [];
  const _roles = roles || window.OPERATOR_ROLES || [];
  const activeModeMeta = (window.MASTER_MODES || []).find(m => m.id === activeMode);
  const activeRoleMeta = (window.OPERATOR_ROLES || []).find(r => r.id === activeRole);
  const isManageSystem = activeMode === 'manage-system';
  const csarBadge = (activeMode !== 'csar' && csarSessionActive) ? csarUnreadCount : 0;
  return (
    <>
      {/* Panel — always mounted so the transform transition runs cleanly. Starts below classification banner (top:20px). Auto-hides on mouse leave. */}
      <div
        data-tour="master-mode-drawer"
        onMouseLeave={onClose}
        style={{
          position:'fixed', left:0, top:20, bottom:0, width:264, zIndex:231,
          background:C.s2, borderRight:`1px solid ${C.border}`,
          display:'flex', flexDirection:'column',
          transform: open ? 'translateX(0)' : 'translateX(-264px)',
          transition: 'transform 180ms ease-out',
          pointerEvents: open ? 'auto' : 'none',
          boxShadow: open ? '6px 0 24px rgba(0,0,0,0.5)' : 'none',
        }}>
        {/* Header — matches top-bar height (46px) with hamburger close + brand */}
        <div style={{ height:46, display:'flex', alignItems:'center', gap:9, padding:'0 14px',
          borderBottom:`1px solid ${C.border}`, flexShrink:0 }}>
          <HamburgerButton onClick={onClose} />
          <div style={{ flex:1, minWidth:0 }}>
            <div style={{ fontFamily:'Roboto', fontWeight:700, fontSize:12, color:C.fg1, letterSpacing:'0.08em' }}>
              MISSION SUITE
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:7.5, color:C.fg3, letterSpacing:'0.1em',
              overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
              {activeRoleMeta ? activeRoleMeta.short : 'MC/SL'} · {activeMode === 'csar' ? window.CASE.number : 'NORMAL OPS'}
            </div>
          </div>
        </div>

        {/* MODES section */}
        <div style={{ padding:'12px 0 6px', flexShrink:0 }}>
          <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em',
            padding:'0 14px 6px' }}>APPS MAIN NAV</div>
          {_modes.map(mode => (
            <ModeRow key={mode.id} mode={mode}
              active={mode.id === activeMode}
              badge={mode.id === 'csar' ? csarBadge : 0}
              onClick={() => onSelectMode(mode.id)} />
          ))}
        </div>

        <div style={{ flex:1, minHeight:0 }}/>

        {/* MANAGE SYSTEM — secondary nav row above the role selector. Owns
            its own workspace ('manage-system' activeMode) but lives outside
            MASTER_MODES so it never appears as an icon in the strip. Smaller
            visual weight than ModeRow (28px height, mono caps label, no
            ACTIVE pill — accent color is the active indicator instead). */}
        <ManageSystemNavRow active={isManageSystem} onClick={() => onSelectMode('manage-system')} />

        {/* ROLE section — collapsible */}
        <div style={{ borderTop:`1px solid ${C.border}`, flexShrink:0 }}>
          <button onClick={()=>setRoleExpanded(v => !v)} style={{
            display:'flex', alignItems:'center', gap:11, width:'100%', height:42, padding:'0 14px 0 14px',
            border:'none', background:'transparent', color:C.fg1, cursor:'pointer', textAlign:'left',
            fontFamily:'Roboto', fontSize:12, fontWeight:500,
          }}>
            <div style={{ width:20, height:20, display:'flex', alignItems:'center', justifyContent:'center',
              color:C.accent, flexShrink:0 }}>
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
                <circle cx="7" cy="5" r="2.5"/>
                <path d="M2.5 12 C2.5 9.5 4.5 8.5 7 8.5 C9.5 8.5 11.5 9.5 11.5 12"/>
              </svg>
            </div>
            <div style={{ flex:1, minWidth:0 }}>
              <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>ROLE</div>
              <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1, fontWeight:500, marginTop:1,
                overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
                {activeRoleMeta ? activeRoleMeta.label : 'Mission Commander / SL'}
              </div>
            </div>
            <span style={{ color:C.fg3, fontSize:10, transition:'transform 0.18s',
              transform: roleExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}>▾</span>
          </button>
          {roleExpanded && (
            <div style={{ borderTop:`1px solid ${C.border}`, maxHeight:240, overflowY:'auto' }}>
              {_roles.map(role => (
                <RoleRow key={role.id} role={role}
                  active={role.id === activeRole}
                  onClick={() => onSelectRole(role.id)} />
              ))}
            </div>
          )}
        </div>

        {/* Restart-tour row — launches the onboarding tour on demand
            (independent of the localStorage first-load gate). */}
        {onRestartTour && (
          <button onClick={() => { onRestartTour(); onClose(); }}
            title="Restart the in-app guided tour"
            style={{
              display:'flex', alignItems:'center', gap:11, width:'100%', height:38,
              padding:'0 14px', border:'none', borderTop:`1px solid ${C.border}`,
              background:'transparent', color:C.fg1, cursor:'pointer', textAlign:'left',
              flexShrink:0,
            }}
            onMouseEnter={e => e.currentTarget.style.background = C.s3}
            onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
            <div style={{ width:20, height:20, display:'flex', alignItems:'center', justifyContent:'center',
              color:C.accent, flexShrink:0, fontSize:14 }}>✦</div>
            <span style={{ flex:1, fontFamily:'Roboto', fontSize:12, fontWeight:500 }}>
              Restart tour
            </span>
          </button>
        )}
        {/* Footer */}
        <div style={{ height:36, display:'flex', alignItems:'center', padding:'0 14px',
          borderTop:`1px solid ${C.border}`, fontFamily:'Roboto Mono', fontSize:8, color:C.fg4,
          letterSpacing:'0.1em', flexShrink:0, justifyContent:'space-between' }}>
          <span>SETTINGS</span>
        </div>
      </div>
    </>
  );
}

// ── Manage System ─────────────────────────────────────────────
// Drawer entry that sits between the master-mode list and the role
// selector. Smaller visual weight than ModeRow — 28px height, mono caps,
// no ACTIVE pill. Selecting it routes the operator to the Manage System
// workspace ('manage-system' activeMode) which lives outside the master-
// mode strip. The strip never shows this entry by design.
function ManageSystemNavRow({ active, onClick }) {
  return (
    <button onClick={onClick} title="Manage System" style={{
      display:'flex', alignItems:'center', gap:11, width:'100%', height:30,
      padding:'0 14px 0 11px', border:'none', background: active ? C.accentM : 'transparent',
      color: active ? C.accent : C.fg2,
      borderTop:`1px solid ${C.border}`, borderBottom:`1px solid ${C.border}`,
      borderLeft: `3px solid ${active ? C.accent : 'transparent'}`,
      cursor:'pointer', textAlign:'left', fontFamily:'Roboto Mono', fontSize:10, fontWeight:500,
      letterSpacing:'0.10em', transition:'background 0.12s, color 0.12s',
    }}
    onMouseEnter={(e)=>{ if (!active) e.currentTarget.style.background = C.s3; }}
    onMouseLeave={(e)=>{ if (!active) e.currentTarget.style.background = 'transparent'; }}>
      <span style={{ width:20, height:20, display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
        {/* Sliders / equalizer glyph — three horizontal tracks with offset
            handles. Reads as a "settings" affordance but visually distinct
            from a gear (which the SETTINGS footer uses) and from any of
            the master-mode glyphs. */}
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
          <path d="M2 3.5 H12"/>
          <path d="M2 7 H12"/>
          <path d="M2 10.5 H12"/>
          <circle cx="9" cy="3.5" r="1.5" fill={active ? C.accent : C.s1}/>
          <circle cx="5" cy="7"   r="1.5" fill={active ? C.accent : C.s1}/>
          <circle cx="10" cy="10.5" r="1.5" fill={active ? C.accent : C.s1}/>
        </svg>
      </span>
      <span style={{ flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        MANAGE SYSTEM
      </span>
    </button>
  );
}

// Status pill — mono caps + dot + colored ring. Used in System Data table
// cells and in the workspace header roll-up.
function _systemStatusColor(status) {
  if (status === 'up')       return C.normal;
  if (status === 'degraded') return C.advisory;
  if (status === 'down')     return C.critical;
  return C.fg3;
}
function SystemStatusPill({ status, size='sm' }) {
  const color = _systemStatusColor(status);
  const label = status === 'up' ? 'UP' : status === 'degraded' ? 'DEGRADED' : status === 'down' ? 'DOWN' : '—';
  const px = size === 'lg' ? '4px 9px' : '2px 7px';
  const fs = size === 'lg' ? 10 : 9;
  return (
    <span style={{
      display:'inline-flex', alignItems:'center', gap:6, padding:px, borderRadius:2,
      background:`${color}1f`, border:`1px solid ${color}55`,
      fontFamily:'Roboto Mono', fontSize:fs, fontWeight:500, letterSpacing:'0.10em', color,
    }}>
      <span style={{ width:6, height:6, borderRadius:'50%', background:color,
        animation: status === 'down' || status === 'degraded' ? 'pulseFade 1.8s infinite' : 'none' }}/>
      {label}
    </span>
  );
}

function SystemBearerChip({ bearer }) {
  // Color the bearer chip per family so the operator can fast-scan the
  // table by carriage type. Doesn't carry status — that's the pill's job.
  const palette = {
    'LINK 16':  C.accent,
    'LINK 11':  C.advisory,
    'LINK 22':  C.fg3,
    'VMF':      C.advisory,
    'JREAP':    C.accent,
    'SENSOR':   C.normal,
    'CIVIL':    '#a78bfa',
    'CoT':      '#a78bfa',
    'USMTF':    C.advisory,
    'GIS':      C.normal,
  };
  const color = palette[bearer] || C.fg3;
  return (
    <span style={{
      display:'inline-block', padding:'2px 7px', borderRadius:2,
      background:`${color}18`, border:`1px solid ${color}44`,
      fontFamily:'Roboto Mono', fontSize:8.5, fontWeight:500, letterSpacing:'0.10em', color,
      whiteSpace:'nowrap',
    }}>{bearer}</span>
  );
}

function _formatLastPulled(mins) {
  if (mins == null) return '—';
  if (mins === 0) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const h = Math.floor(mins/60); const m = mins % 60;
  if (h < 24) return m ? `${h}h ${m}m ago` : `${h}h ago`;
  return `${Math.floor(h/24)}d ago`;
}

// Manage System workspace — header roll-up + tab switcher + per-tab body.
function ManageSystemWorkspace({
  tab, onTabChange,
  hiddenModeIds, onHiddenModeIdsChange,
  hiddenRoleIds, onHiddenRoleIdsChange,
  activeMode, activeRole,
}) {
  const summary = (window.getSystemHealthSummary && window.getSystemHealthSummary()) || { total:0, up:0, degraded:0, down:0 };
  const tabs = [
    { id:'data',   label:'SYSTEM DATA' },
    { id:'config', label:'SYSTEM CONFIG' },
  ];
  return (
    <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:C.s1 }}>
      {/* Header */}
      <div style={{ padding:'18px 24px 14px', borderBottom:`1px solid ${C.border}` }}>
        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>MANAGE SYSTEM</div>
        <div style={{ display:'flex', alignItems:'baseline', gap:14, marginTop:2 }}>
          <div style={{ fontFamily:'Roboto', fontSize:18, fontWeight:600, color:C.fg1 }}>
            System Health & Configuration
          </div>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3, letterSpacing:'0.06em' }}>
            {summary.up} / {summary.total} feeds nominal
            {summary.degraded > 0 && <span style={{ color:C.advisory, marginLeft:10 }}>· {summary.degraded} degraded</span>}
            {summary.down > 0     && <span style={{ color:C.critical, marginLeft:10 }}>· {summary.down} down</span>}
          </div>
        </div>
      </div>
      {/* Tabs */}
      <div style={{ padding:'10px 24px 0', borderBottom:`1px solid ${C.border}`, display:'flex', gap:0 }}>
        {tabs.map(t => {
          const active = t.id === tab;
          return (
            <button key={t.id} onClick={()=>onTabChange(t.id)} style={{
              padding:'8px 16px 10px', background:'transparent', border:'none',
              borderBottom:`2px solid ${active ? C.accent : 'transparent'}`,
              color:active ? C.fg1 : C.fg3, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:10, fontWeight:500, letterSpacing:'0.10em',
              transition:'color 0.12s',
            }}>
              {t.label}
            </button>
          );
        })}
      </div>
      {/* Body */}
      <div style={{ flex:1, overflowY:'auto', minHeight:0 }}>
        {tab === 'data'   && <SystemDataPanel/>}
        {tab === 'config' && <SystemConfigPanel
          hiddenModeIds={hiddenModeIds} onHiddenModeIdsChange={onHiddenModeIdsChange}
          hiddenRoleIds={hiddenRoleIds} onHiddenRoleIdsChange={onHiddenRoleIdsChange}
          activeMode={activeMode} activeRole={activeRole}
        />}
      </div>
    </div>
  );
}

// ── System Data tab ───────────────────────────────────────────
// Static rows by design — health snapshot, not a live monitor. Real
// metrics would tick; this panel is the picture you show in a brief.
function SystemDataPanel() {
  const groups = window.SYSTEM_FEED_GROUPS || [];
  const feeds  = window.SYSTEM_FEEDS || [];
  return (
    <div style={{ padding:'18px 24px 28px' }}>
      {groups.map(group => {
        const groupFeeds = feeds.filter(f => f.group === group.id);
        if (groupFeeds.length === 0) return null;
        return (
          <div key={group.id} style={{ marginBottom:22 }}>
            <div style={{ display:'flex', alignItems:'baseline', gap:10, padding:'8px 0 6px',
              borderBottom:`1px solid ${C.border2}` }}>
              <div style={{ fontFamily:'Roboto', fontSize:13, fontWeight:600, color:C.fg1, letterSpacing:'0.06em' }}>
                {group.label}
              </div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>{group.desc}</div>
              <div style={{ flex:1 }}/>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
                {groupFeeds.length} {groupFeeds.length === 1 ? 'feed' : 'feeds'}
              </div>
            </div>
            {/* tableLayout:'fixed' + a shared <colgroup> forces every
                group-table to use the same column widths so the SOURCE
                / BEARER / STATUS / etc. columns line up vertically across
                TACTICAL DATA LINKS, SENSOR & RADAR, MARITIME & CIVIL
                AIR, and DOC & INTEL. Without this each table sized its
                columns independently against its own contents — Link 16
                pushed SOURCE wider than ESM/ELINT, ADS-B's LIVE chip
                shifted the BEARER column, and the eye couldn't fast-scan
                down a column. SOURCE gets a percentage so it absorbs
                width on wide screens; the rest are fixed pixels for
                consistent fast-scan rhythm. */}
            <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}>
              <colgroup>
                <col style={{ width:'24%' }} />              {/* SOURCE */}
                <col style={{ width:96 }} />                 {/* BEARER */}
                <col style={{ width:96 }} />                 {/* STATUS */}
                <col style={{ width:74 }} />                 {/* UPTIME */}
                <col style={{ width:108 }} />                {/* LAST PULLED */}
                <col style={{ width:96 }} />                 {/* CADENCE */}
                <col style={{ width:80 }} />                 {/* LATENCY */}
                <col style={{ width:74 }} />                 {/* ERROR */}
                <col style={{ width:108 }} />                {/* RECORDS 24H */}
                <col />                                      {/* FALLBACK — flex remainder */}
              </colgroup>
              <thead>
                <tr style={{ background:C.s2, borderBottom:`1px solid ${C.border}` }}>
                  {['SOURCE','BEARER','STATUS','UPTIME','LAST PULLED','CADENCE','LATENCY','ERROR','RECORDS 24H','FALLBACK'].map(h => (
                    <th key={h} style={{
                      padding:'7px 10px', textAlign:'left', fontFamily:'Roboto Mono',
                      fontSize:8, fontWeight:500, letterSpacing:'0.10em', color:C.fg3,
                    }}>{h}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {groupFeeds.map(f => <SystemFeedRow key={f.id} feed={f}/>)}
              </tbody>
            </table>
          </div>
        );
      })}
    </div>
  );
}

function SystemFeedRow({ feed }) {
  const isDown = feed.status === 'down';
  const errColor = (feed.errorPct == null) ? C.fg3 : (feed.errorPct >= 1 ? C.critical : feed.errorPct >= 0.2 ? C.advisory : C.fg2);
  const latColor = (feed.latencyMs == null) ? C.fg3 : (feed.latencyMs >= 1500 ? C.advisory : feed.latencyMs >= 800 ? C.fg2 : C.fg2);
  return (
    <tr style={{ borderBottom:`1px solid ${C.border}`, height:38 }}>
      {/* Width is governed by the parent's <colgroup> (tableLayout:fixed).
          Per-cell maxWidth is gone — the cell's column width is the limit
          now, so name + source line just need overflow hidden + ellipsis
          to handle anything that runs long. The flex row holds the LIVE
          chip on the same baseline as the name without wrapping it. */}
      <td style={{ padding:'7px 10px', overflow:'hidden' }}>
        <div style={{ display:'flex', alignItems:'center', gap:7, minWidth:0 }}>
          <span style={{ fontFamily:'Roboto', fontSize:11.5, fontWeight:500, color:C.fg1,
            overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', minWidth:0 }}>
            {feed.name}
          </span>
          {feed.isLive && (
            <span title="Live external feed" style={{
              padding:'1px 5px', borderRadius:2, background:`${C.normal}26`, color:C.normal,
              fontFamily:'Roboto Mono', fontSize:7.5, fontWeight:600, letterSpacing:'0.10em',
              flexShrink:0,
            }}>LIVE</span>
          )}
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, marginTop:2,
          overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
          {feed.source}
        </div>
      </td>
      <td style={{ padding:'7px 10px', overflow:'hidden' }}><SystemBearerChip bearer={feed.bearer}/></td>
      <td style={{ padding:'7px 10px', overflow:'hidden' }}><SystemStatusPill status={feed.status}/></td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:isDown ? C.critical : C.fg2,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {feed.uptime != null ? `${feed.uptime.toFixed(1)}%` : '—'}
      </td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:C.fg2,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {_formatLastPulled(feed.lastPulledMins)}
      </td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:C.fg3,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{feed.cadence || '—'}</td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:latColor,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {feed.latencyMs != null ? `${feed.latencyMs}ms` : '—'}
      </td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:errColor,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {feed.errorPct != null ? `${feed.errorPct.toFixed(2)}%` : '—'}
      </td>
      <td style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:10, color:C.fg2,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {feed.records24h != null ? feed.records24h.toLocaleString() : '—'}
      </td>
      {/* FALLBACK gets a title attribute so the full string surfaces on
          hover when the column truncates a long fallback path. */}
      <td title={feed.fallback || ''} style={{ padding:'7px 10px', fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{feed.fallback || '—'}</td>
    </tr>
  );
}

// ── System Config tab ─────────────────────────────────────────
// Master-mode visibility toggles + role roster. Operator-controlled
// feature flags. Hiding a master mode removes its strip icon and drawer
// row instantly; the workspace itself stays mounted in code so flipping
// the toggle back on is free. The currently-active mode/role can't be
// hidden — the toggle is force-on for the row that matches.
function SystemConfigPanel({ hiddenModeIds, onHiddenModeIdsChange, hiddenRoleIds, onHiddenRoleIdsChange, activeMode, activeRole }) {
  const allModes = window.MASTER_MODES || [];
  const allRoles = window.OPERATOR_ROLES || [];
  const toggleMode = (id) => {
    if (id === activeMode) return;  // can't hide the live workspace
    const next = new Set(hiddenModeIds);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onHiddenModeIdsChange(next);
  };
  const toggleRole = (id) => {
    if (id === activeRole) return;  // can't hide the active role
    const next = new Set(hiddenRoleIds);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onHiddenRoleIdsChange(next);
  };
  const visibleModeCount = allModes.length - hiddenModeIds.size;
  const visibleRoleCount = allRoles.length - hiddenRoleIds.size;
  return (
    <div style={{ padding:'18px 24px 28px' }}>
      {/* Master modes section */}
      <div style={{ marginBottom:24 }}>
        <div style={{ display:'flex', alignItems:'baseline', gap:10, padding:'8px 0 6px',
          borderBottom:`1px solid ${C.border2}` }}>
          <div style={{ fontFamily:'Roboto', fontSize:13, fontWeight:600, color:C.fg1, letterSpacing:'0.06em' }}>
            APPS MAIN NAV
          </div>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
            Toggle visibility of master-mode entries in the strip + drawer
          </div>
          <div style={{ flex:1 }}/>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
            {visibleModeCount} / {allModes.length} visible
          </div>
        </div>
        <div style={{ display:'flex', flexDirection:'column' }}>
          {allModes.map(mode => {
            const Icon = (window.MODE_ICONS || {})[mode.icon];
            const hidden = hiddenModeIds.has(mode.id);
            const isActive = mode.id === activeMode;
            return (
              <ConfigToggleRow
                key={mode.id}
                icon={Icon ? <Icon size={14}/> : null}
                title={mode.label}
                subtitle={mode.desc}
                badge={mode.implemented ? null : 'STUB'}
                lockedOn={isActive}
                lockedReason={isActive ? 'active workspace' : null}
                value={!hidden}
                onChange={() => toggleMode(mode.id)}
              />
            );
          })}
        </div>
      </div>
      {/* Roles section */}
      <div style={{ marginBottom:8 }}>
        <div style={{ display:'flex', alignItems:'baseline', gap:10, padding:'8px 0 6px',
          borderBottom:`1px solid ${C.border2}` }}>
          <div style={{ fontFamily:'Roboto', fontSize:13, fontWeight:600, color:C.fg1, letterSpacing:'0.06em' }}>
            OPERATOR ROLES
          </div>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
            Toggle visibility of roles in the role switcher
          </div>
          <div style={{ flex:1 }}/>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
            {visibleRoleCount} / {allRoles.length} visible
          </div>
        </div>
        <div style={{ display:'flex', flexDirection:'column' }}>
          {allRoles.map(role => {
            const hidden = hiddenRoleIds.has(role.id);
            const isActive = role.id === activeRole;
            return (
              <ConfigToggleRow
                key={role.id}
                icon={null}
                title={role.label}
                subtitle={role.short}
                badge={role.implemented ? null : 'STUB'}
                lockedOn={isActive}
                lockedReason={isActive ? 'your active role' : null}
                value={!hidden}
                onChange={() => toggleRole(role.id)}
              />
            );
          })}
        </div>
        <div style={{ padding:'14px 0 0', fontFamily:'Roboto Mono', fontSize:9, color:C.fg4, letterSpacing:'0.04em' }}>
          Future: add new role · password-gated · per-role workspace skinning
        </div>
      </div>
    </div>
  );
}

function ConfigToggleRow({ icon, title, subtitle, badge, lockedOn, lockedReason, value, onChange }) {
  const on = !!value || !!lockedOn;
  return (
    <div style={{
      display:'flex', alignItems:'center', gap:12, padding:'10px 12px',
      borderBottom:`1px solid ${C.border}`,
      background: lockedOn ? `${C.accent}0a` : 'transparent',
    }}>
      {icon && (
        <div style={{ width:22, height:22, display:'flex', alignItems:'center', justifyContent:'center',
          color: on ? C.accent : C.fg3, flexShrink:0 }}>
          {icon}
        </div>
      )}
      <div style={{ flex:1, minWidth:0 }}>
        <div style={{ display:'flex', alignItems:'center', gap:8 }}>
          <span style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color: on ? C.fg1 : C.fg3,
            overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
            {title}
          </span>
          {badge && (
            <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, letterSpacing:'0.10em',
              padding:'1px 5px', border:`1px solid ${C.border2}`, borderRadius:2 }}>{badge}</span>
          )}
          {lockedOn && (
            <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.accent, letterSpacing:'0.10em' }}>
              LOCKED · {lockedReason}
            </span>
          )}
        </div>
        {subtitle && (
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, marginTop:2,
            overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{subtitle}</div>
        )}
      </div>
      <ConfigToggleSwitch checked={on} disabled={!!lockedOn} onChange={onChange}/>
    </div>
  );
}

function ConfigToggleSwitch({ checked, disabled, onChange }) {
  return (
    <button onClick={() => { if (!disabled) onChange(); }}
      role="switch" aria-checked={!!checked}
      style={{
        width:34, height:18, borderRadius:9, position:'relative',
        border:`1px solid ${checked ? C.accent : C.border2}`,
        background: checked ? `${C.accent}33` : C.s3,
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.6 : 1, transition:'all 0.15s', flexShrink:0,
      }}>
      <span style={{
        position:'absolute', top:1, left: checked ? 17 : 1, width:14, height:14,
        borderRadius:'50%', background: checked ? C.accent : C.fg3,
        transition:'left 0.12s, background 0.12s',
      }}/>
    </button>
  );
}

// ── Stub mode workspace ───────────────────────────────────────
// Rendered as a full-bleed overlay above the underlying COP layout for
// any master mode whose `implemented` flag is false. The COP underneath
// stays mounted (display:flex below the overlay) so MapLibre's internal
// state is preserved across mode toggles — that's why we don't unmount
// the layout. The overlay is a simple dimmed backdrop with a centered
// card that names the mode and offers a one-click return path to CSAR
// when a case is live.
function StubModeWorkspace({ mode, onReturnCsar, csarSessionActive, onOpenDrawer }) {
  const Icon = (window.MODE_ICONS || {})[mode.icon];
  return (
    <div style={{
      position:'absolute', inset:0, top:46, // sit below the top bar
      zIndex:120, display:'flex', alignItems:'center', justifyContent:'center',
      background:'rgba(10,10,12,0.92)', backdropFilter:'blur(2px)',
      animation:'fadeIn 0.18s ease-out',
    }}>
      <div style={{ background:C.s2, border:`1px solid ${C.border2}`, borderRadius:6, padding:32,
        width:480, maxWidth:'92vw', textAlign:'center', boxShadow:'0 24px 64px rgba(0,0,0,0.6)' }}>
        <div style={{ width:48, height:48, margin:'0 auto 16px', display:'flex', alignItems:'center',
          justifyContent:'center', background:C.accentM, borderRadius:6, color:C.accent }}>
          {Icon ? <Icon size={22}/> : null}
        </div>
        <div style={{ fontFamily:'Roboto', fontWeight:600, fontSize:18, color:C.fg1, marginBottom:6 }}>
          {mode.label}
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.12em', marginBottom:18 }}>
          {mode.short} · NOT IMPLEMENTED
        </div>
        <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg2, lineHeight:1.5, marginBottom:18 }}>
          {mode.desc}
        </div>
        <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg3, lineHeight:1.45, marginBottom:24,
          padding:'0 8px' }}>
          The switcher, drawer, role selector, and cross-mode CSAR alerts all work end-to-end.
          Only this mode's workspace contents are stubbed.
        </div>
        <div style={{ display:'flex', justifyContent:'center', gap:10 }}>
          <DTButton onClick={onOpenDrawer} title="Open the master-mode drawer to switch workspaces." color="mono-muted" size="medium">Open mode menu</DTButton>
          {csarSessionActive && (
            <DTButton onClick={onReturnCsar} title="Return to the live CSAR case — your active recovery." color="accent" size="medium">
              Return to active CSAR case
            </DTButton>
          )}
        </div>
      </div>
    </div>
  );
}

// ── CSAR idle workspace ───────────────────────────────────────
// Rendered when the operator is on the CSAR master mode but no
// recovery is live (no J3.1 fired this session). Acts as a "ready
// room" — confirms the operator is in the right master mode, makes
// it explicit that there's nothing to action right now, and points
// at the rest of the suite for what they can do in the meantime.
// When a J3.1 alert fires later, the standard ack overlay replaces
// this view (csarAlertPending flips, isCsarIdle goes false), and
// after ack the live CSAR workspace takes over.
// ═══════════════════════════════════════════════════════════════
// TFC (Tactical Fluid Control) workspace — PR 4-A
// ═══════════════════════════════════════════════════════════════
//
// The TFC master mode is the live mission board for everything
// derived from the active ATO cycle. Sources from window.ATO.runtime
// (populated by the ATO Fragment Ingestion pipeline at boot, advanced
// by ATO.tick on every scenario clock tick).
//
// Layout for PR 4-A — three rails:
//   ┌────────────┬─────────────────────────────┬────────────┐
//   │ Cycle Board│ Contingency Console (top)   │ Mission    │
//   │ (mission   │                              │ Detail     │
//   │ list by    │ Body: cycle summary +       │ (selected  │
//   │ type)      │ live mission impact panel    │ mission)   │
//   │            │                              │            │
//   └────────────┴─────────────────────────────┴────────────┘
//
// COP rendering, TOT timeline, deadline alerts panel, and parser
// workbench are PR 4-B (next iteration). PR 4-A's goal: cascade
// demoable without console.
//
// Selected mission state lives in App (tfcSelectedMissionId).

function TfcWorkspace({ runtime, selectedMissionId, onSelectMission, atoGen, onActionFired, groupBy, onGroupByChange, viewedDay, onViewedDayChange }) {
  // `atoGen` is a generation counter that bumps whenever the pipeline
  // mutates runtime. We don't use it directly but having it in the
  // props list ensures React re-renders the subtree on cascade fires.
  if (!runtime) {
    return (
      <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
        background:C.s1, color:C.fg3, fontFamily:'Roboto', fontSize:13 }}>
        ATO pipeline not yet booted. Refresh the page to load the cycle.
      </div>
    );
  }
  const missions = runtime.missions || [];
  const selectedMission = selectedMissionId
    ? missions.find(m => m.id === selectedMissionId) || null
    : null;

  return (
    <div style={{ flex:1, display:'flex', flexDirection:'row', overflow:'hidden',
      minHeight:0, background:C.s1, position:'relative' }}>
      {/* Left column — stacked: Cycle Board header → ATO Details → Cycle
          Status / Operator Holds / Impacted Missions. Whole column
          scrolls as a single unit. */}
      <div data-tour="tfc-cycle-board" style={{ width:340, flexShrink:0, borderRight:`1px solid ${C.border}`,
        display:'flex', flexDirection:'column', overflow:'hidden' }}>
        <div style={{ flex:1, overflowY:'auto', minHeight:0 }}>
          <TfcCycleBoardHeader runtime={runtime} onSelectMission={onSelectMission} selectedMissionId={selectedMissionId} />
          <TfcAtoDetails runtime={runtime} />
          <TfcCycleSummary runtime={runtime} />
        </div>
      </div>
      {/* Center — Mission Gantt (PR 12). Replaces the prior list view.
          Time-windowed missions render as horizontal bars across a 24h
          cycle axis; alert-posture missions in a dedicated section at
          the bottom. Group-by toggle (mission/package) at the top. */}
      <div data-tour="tfc-gantt" style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden',
        minWidth:0 }}>
        <TfcMissionGantt
          runtime={runtime}
          selectedMissionId={selectedMissionId}
          onSelectMission={onSelectMission}
          groupBy={groupBy}
          onGroupByChange={onGroupByChange}
          viewedDay={viewedDay}
          onViewedDayChange={onViewedDayChange}
        />
      </div>
      {/* Mission Detail drawer — slides in from the right edge when a
          mission is selected. Replaces the previous right rail (PR 13).
          Closing the drawer clears the selection. Click another Gantt
          row while the drawer is open to swap content without close. */}
      <TfcMissionDetailDrawer
        mission={selectedMission}
        runtime={runtime}
        onSelectMission={onSelectMission}
        onActionFired={onActionFired}
        onClose={() => onSelectMission(null)}
      />
      {/* Hover-revealed contingency tray. Fixed-positioned, lives in the
          top stacking context so it overlays everything inside TFC. */}
      <TfcContingencyTray runtime={runtime} onActionFired={onActionFired} />
    </div>
  );
}

// Compute a bounding box that frames every geo touchpoint for the
// given mission list — dep, arr, target, CAP orbit center. Returns
// `[west, south, east, north]` or null if no usable coords. Adds a
// small margin so points on the edge aren't flush against the frame.
function _mmPackageBbox(missions) {
  // We frame the package's STRIKE GEOMETRY — targets first, then the
  // in-theater orbits / live airframe positions that sit close enough
  // to the target centroid to be operationally relevant. We
  // intentionally drop:
  //   • Dep/arr ICAO bases (B-52 from Guam, B-2 to Diego Garcia would
  //     otherwise pull the bbox across the whole Indo-Pacific).
  //   • AIRFRAME.base fallbacks for airframes not yet airborne (same
  //     bloat for long-range strikers parked at distant bases).
  //   • Anything > 1500 km from the target centroid (a hard cap that
  //     keeps stray orbits / live tracks from blowing the frame).
  // Result: the bbox always contains the targets, plus whatever
  // in-theater package elements happen to sit nearby.

  // Step 1 — collect all candidate points.
  const _afById = new Map((window.AIRFRAMES || []).map(af => [af.id, af]));
  const targets = [];
  const others  = [];
  (missions || []).forEach(m => {
    if (m.target && m.target.geo
        && Number.isFinite(m.target.geo.lat) && Number.isFinite(m.target.geo.lng)) {
      targets.push([m.target.geo.lng, m.target.geo.lat]);
    }
    if (m.capOrbitGeo
        && Number.isFinite(m.capOrbitGeo.centerLat) && Number.isFinite(m.capOrbitGeo.centerLng)) {
      others.push([m.capOrbitGeo.centerLng, m.capOrbitGeo.centerLat]);
    }
    // Live airframe positions — only when actually airborne (marker
    // exists). Skip base fallbacks entirely.
    const afIds = m.airframeIds || (m.flightLead ? [m.flightLead] : []);
    afIds.forEach(afId => {
      const af = _afById.get(afId);
      if (!af) return;
      const trackId = af.legacyId;
      const markers = window._tacticalMapMarkers || null;
      if (markers && trackId && markers[trackId] && markers[trackId].marker) {
        try {
          const ll = markers[trackId].marker.getLngLat();
          if (ll && Number.isFinite(ll.lat) && Number.isFinite(ll.lng)) {
            others.push([ll.lng, ll.lat]);
          }
        } catch(err) {}
      }
    });
  });

  // Step 2 — pick the anchor. Prefer the centroid of all targets; if
  // there are no targets, fall back to the centroid of orbits / live
  // tracks (the entire `others` list).
  const anchorPool = targets.length ? targets : others;
  if (anchorPool.length === 0) return null;
  let cLng = 0, cLat = 0;
  anchorPool.forEach(([lng, lat]) => { cLng += lng; cLat += lat; });
  cLng /= anchorPool.length;
  cLat /= anchorPool.length;

  // Step 3 — filter out any point more than 1500 km from the anchor.
  // ~1500 km ≈ the operational radius of a strike package (target +
  // tanker tracks + CAP). Anything further is almost certainly a base
  // or a long-leg recovery that shouldn't shape the camera.
  const MAX_KM = 1500;
  const _kmBetween = (lat1, lng1, lat2, lng2) => {
    const toRad = (d) => d * Math.PI / 180;
    const dLat = toRad(lat2 - lat1);
    const dLng = toRad(lng2 - lng1);
    const a = Math.sin(dLat/2)**2 +
              Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2;
    return 2 * 6371 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  };
  let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
  const push = (lng, lat) => {
    if (lng < w) w = lng; if (lng > e) e = lng;
    if (lat < s) s = lat; if (lat > n) n = lat;
  };
  // Targets always count (even if a single bizarre outlier — that's
  // operator intent). Others count only if close to the anchor.
  targets.forEach(([lng, lat]) => push(lng, lat));
  others.forEach(([lng, lat]) => {
    if (_kmBetween(cLat, cLng, lat, lng) <= MAX_KM) push(lng, lat);
  });
  if (!Number.isFinite(w) || !Number.isFinite(e)) return null;
  // Margin: ~10% of span on each side, min 0.5°.
  const lngSpan = Math.max(0.5, (e - w) * 0.10);
  const latSpan = Math.max(0.5, (n - s) * 0.10);
  let outW = w - lngSpan, outS = s - latSpan;
  let outE = e + lngSpan, outN = n + latSpan;
  // Minimum-zoom clamp — never frame tighter than ~3°×3° so packages
  // with a tiny working area (single-CAS, clustered SOF orbits) don't
  // zoom in past readable scale. Expand symmetrically around the bbox
  // center until the min span is satisfied.
  const MIN_SPAN = 3;
  const curLngSpan = outE - outW;
  const curLatSpan = outN - outS;
  if (curLngSpan < MIN_SPAN) {
    const cx = (outW + outE) / 2;
    outW = cx - MIN_SPAN / 2; outE = cx + MIN_SPAN / 2;
  }
  if (curLatSpan < MIN_SPAN) {
    const cy = (outS + outN) / 2;
    outS = cy - MIN_SPAN / 2; outN = cy + MIN_SPAN / 2;
  }
  return [outW, outS, outE, outN];
}

// ── MissionTypeWorkspace (PR 25) ───────────────────────────────
// Per-mission-type MM shell. Map fills the bulk; a collapsible bottom
// drawer carries the type-scoped Gantt; Mission Detail right-rail
// reuses the TFC drawer. Stamps the same layout onto every mission-
// type MM (OCA, DCA, DT, CAS, SOF, TACS) by changing the `mmId` prop —
// the data-layer's MM_PACKAGE_MEMBERS map drives the mission filter.
function MissionTypeWorkspace({
  mmId, mmMeta, runtime,
  selectedMissionId, onSelectMission,
  atoGen, onActionFired,
  viewedDay, onViewedDayChange,
  drawerOpen, onDrawerToggle,
}) {
  // Filter the runtime's mission list to this MM's package.
  const filterFn = window.getMmMissionFilter ? window.getMmMissionFilter(mmId) : null;
  const missions = React.useMemo(() => {
    const all = runtime?.missions || [];
    return filterFn ? all.filter(filterFn) : [];
  // atoGen bumps whenever the pipeline mutates runtime; recompute the
  // filtered list on each cascade so impact flags / outcomes propagate.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [runtime, atoGen, mmId]);

  // Build a synthetic runtime for the Gantt so TfcMissionGantt's
  // internal scoping logic still works against the filtered list.
  const scopedRuntime = React.useMemo(() => ({
    ...runtime,
    missions,
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }), [runtime, missions]);

  const selectedMission = selectedMissionId
    ? missions.find(m => m.id === selectedMissionId) || null
    : null;

  // This overlay sits on top of the main COP layout (which is rendered
  // underneath via mainContent). We keep the map zone transparent so
  // the singleton TacticalMap instance below stays visible — no
  // remount, no marker re-init. Only the header + drawer are opaque.
  return (
    <div style={{
      flex:1, display:'flex', flexDirection:'column', overflow:'hidden',
      minHeight:0, background:'transparent', position:'relative',
      pointerEvents:'none',  // map below stays interactive in the gap
    }}>
      {/* Workspace identity lives as a corner overlay inside TacticalMap
          itself — see map.jsx's "Corner info" block. Mission-type MMs
          mirror the CSAR pattern: small mono caps in the top-left, no
          full-width strip. The transparent middle below lets the
          operator pan/zoom the map directly. */}
      <div style={{ flex:1, minHeight:0 }}/>
      {/* Bottom Gantt drawer (collapsible) — opaque */}
      <div style={{ pointerEvents:'auto' }}>
        <MmGanttDrawer
          open={drawerOpen}
          onToggle={onDrawerToggle}
          mmMeta={mmMeta}
          scopedRuntime={scopedRuntime}
          missions={missions}
          selectedMissionId={selectedMissionId}
          onSelectMission={onSelectMission}
          viewedDay={viewedDay}
          onViewedDayChange={onViewedDayChange}
        />
      </div>
      {/* Mission Detail drawer (slides in from right when a row is
          clicked) — same component TFC uses. */}
      <div style={{ pointerEvents:'auto' }}>
        <TfcMissionDetailDrawer
          mission={selectedMission}
          runtime={runtime}
          onSelectMission={onSelectMission}
          onActionFired={onActionFired}
          onClose={() => onSelectMission(null)}
        />
      </div>
    </div>
  );
}

// MissionTypeWorkspaceHeader was retired when workspace identity
// moved into the map's corner-info overlay (top-left, mono caps,
// matches the CSAR pattern). See `Corner info` block in map.jsx.

function MmGanttDrawer({
  open, onToggle, mmMeta, scopedRuntime, missions,
  selectedMissionId, onSelectMission, viewedDay, onViewedDayChange,
}) {
  const HEIGHT_OPEN = 280;
  const HEIGHT_COLLAPSED = 32;
  const h = open ? HEIGHT_OPEN : HEIGHT_COLLAPSED;

  return (
    <div data-tour="mm-gantt-drawer" style={{
      flexShrink:0,
      height:h,
      borderTop:`1px solid ${C.border}`,
      background:C.s1,
      display:'flex', flexDirection:'column', overflow:'hidden',
      transition:'height 0.18s ease',
    }}>
      {/* Drawer handle — click to toggle */}
      <button
        onClick={onToggle}
        title={open ? 'Collapse mission timeline drawer' : 'Expand mission timeline drawer'}
        style={{
          height:32, flexShrink:0, padding:'0 14px',
          background:'transparent', border:'none', borderBottom:`1px solid ${C.border}`,
          display:'flex', alignItems:'center', gap:10, cursor:'pointer',
          fontFamily:'Roboto Mono', fontSize:9, color:C.fg2,
          letterSpacing:'0.10em', textTransform:'uppercase',
        }}
        onMouseEnter={e => { e.currentTarget.style.background = C.s2; }}
        onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}>
        <span style={{ color:C.fg3, fontSize:11 }}>{open ? '▼' : '▲'}</span>
        <span>Mission Timeline · {mmMeta.short} package</span>
        <div style={{ flex:1 }}/>
        <span data-typo="label" style={{ color:C.fg3 }}>
          {missions.length} ROW{missions.length === 1 ? '' : 'S'}
        </span>
      </button>
      {/* Gantt body — only renders when open */}
      {open && (
        <div style={{ flex:1, minHeight:0, overflow:'hidden', display:'flex', flexDirection:'column' }}>
          <TfcMissionGantt
            runtime={scopedRuntime}
            selectedMissionId={selectedMissionId}
            onSelectMission={onSelectMission}
            groupBy="mission"
            onGroupByChange={() => {}}
            viewedDay={viewedDay}
            onViewedDayChange={onViewedDayChange}
            compact={true}
          />
        </div>
      )}
    </div>
  );
}

// ── Cycle Board (left rail) ──────────────────────────────────
// Lists every mission in the cycle, grouped by type. Click any row
// to select it in the right-rail Mission Detail. The header shows
// the cycle clock + status; each group shows mission count + how
// many have any impact flags.
// Cycle Board header — cycle ID, day code, AOR, progress bar.
// Lives in the left column. Mission list moved to the center column
// in PR 8, then replaced with the Gantt in PR 12 (TfcMissionGantt).
function TfcCycleBoardHeader({ runtime, onSelectMission, selectedMissionId }) {
  const cycleSec = runtime.cycle && runtime.cycle.elapsedSec != null
    ? runtime.cycle.elapsedSec : 0;
  const totalSec = (runtime.cycle && runtime.cycle.totalSec) || (24*3600);
  const cyclePct = Math.min(100, Math.max(0, (cycleSec / totalSec) * 100));

  // PR 11 — Count missions with outstanding (unacted) decisions.
  // Pulls held coord/NLT alerts plus unacted impact flags.
  //
  // NOTE: NOT memoized — the runtime is mutated in place by cascade
  // actions (markAirframeLost, dismissImpactFlag, releaseBrief, etc.)
  // so the runtime reference never changes even when its contents do.
  // A useMemo([runtime]) would always return the cached array.
  // Recomputing on every render is O(missions) — cheap enough at
  // ~41 missions, and the parent component re-renders only when
  // atoGen bumps, which only happens after an action.
  const decisionMissions = (function () {
    if (!runtime.missions) return [];
    const ids = new Set();
    for (const m of runtime.missions) {
      const hasUnactedFlag = (m.impactFlags || []).some(f => !f.actedOn);
      if (hasUnactedFlag) ids.add(m.id);
    }
    for (const c of (runtime.heldCoordAlerts || [])) {
      if (!c.actedOn) ids.add(c.missionId);
    }
    for (const n of (runtime.heldNltBreaches || [])) {
      if (!n.actedOn) ids.add(n.missionId);
    }
    return Array.from(ids);
  })();

  // Cycle to next mission with a pending decision. If the currently-
  // selected mission is in the list, advance to the one after it
  // (wraps around). Otherwise pick the first.
  const cycleToNext = () => {
    if (decisionMissions.length === 0 || !onSelectMission) return;
    const curIdx = decisionMissions.indexOf(selectedMissionId);
    const nextIdx = curIdx === -1 ? 0 : (curIdx + 1) % decisionMissions.length;
    onSelectMission(decisionMissions[nextIdx]);
  };

  return (
    <div style={{ padding:'12px 14px', borderBottom:`1px solid ${C.border}`, flexShrink:0 }}>
      <div title="CYCLE BOARD — Current ATO cycle identifier. The 24h window of tasking now in execution." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>
        CYCLE BOARD · {runtime.cycle && runtime.cycle.id}
      </div>
      <div title="ATO Day code and primary AOR (Area of Responsibility) for this cycle." style={{ fontFamily:'Roboto', fontSize:14, fontWeight:600, color:C.fg1, marginTop:2 }}>
        Day {runtime.cycle && runtime.cycle.dayCode} — INDOPACOM
      </div>
      <div title="Cycle clock — percent of the 24-hour ATO cycle elapsed." style={{ marginTop:8, height:3, background:C.s3, borderRadius:2, overflow:'hidden' }}>
        <div style={{ width:cyclePct + '%', height:'100%', background:C.accent }}/>
      </div>
      <div style={{ display:'flex', justifyContent:'space-between', marginTop:4,
        fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
        <span title="DTG START — Date-Time Group when this ATO cycle began (Zulu).">{runtime.cycle && runtime.cycle.dtgStart}</span>
        <span title="DTG END — Date-Time Group when this ATO cycle closes (Zulu).">{runtime.cycle && runtime.cycle.dtgEnd}</span>
      </div>
      {decisionMissions.length > 0 && (
        <button
          onClick={cycleToNext}
          title="Jump to next mission with an outstanding decision"
          style={{
            marginTop:10, width:'100%',
            display:'flex', alignItems:'center', gap:8,
            padding:'7px 9px', border:`1px solid ${C.critical}66`,
            background:`${C.critical}1a`, borderRadius:3, cursor:'pointer',
            textAlign:'left',
            transition:'background 0.12s',
          }}
          onMouseEnter={e => { e.currentTarget.style.background = `${C.critical}30`; }}
          onMouseLeave={e => { e.currentTarget.style.background = `${C.critical}1a`; }}
        >
          <div style={{ width:7, height:7, borderRadius:'50%', background:C.critical,
            animation:'pulseFade 0.8s infinite', flexShrink:0 }}/>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
            color:C.critical, letterSpacing:'0.10em', flex:1 }}>
            {decisionMissions.length} DECISION{decisionMissions.length === 1 ? '' : 'S'} PENDING
          </span>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.critical,
            letterSpacing:'0.10em' }}>NEXT ▸</span>
        </button>
      )}
    </div>
  );
}

// ATO Details block (left column) — pulls the active ATO artifact
// from Mission Admin's MOCK_DOCS fixture and renders the same shape
// used in Mission Admin's doc card. Single ATO at a time (active
// cycle's INDOPACOM ATO). AOR is locked to INDOPACOM since that's
// where the active cycle lives.
function TfcAtoDetails({ runtime }) {
  // Active ATO = the non-archived, current INDOPACOM ATO doc.
  const docs = window.MOCK_DOCS || [];
  const atoDoc = docs.find(d =>
    d.type === 'ato' && d.aor === 'indopacom' && !d.isArchived
      && window.classifyDocState && window.classifyDocState(d) === 'current'
  ) || docs.find(d => d.type === 'ato' && d.aor === 'indopacom' && !d.isArchived);

  if (!atoDoc) {
    return (
      <div style={{ padding:'10px 14px', borderBottom:`1px solid ${C.border}`,
        fontFamily:'Roboto', fontSize:11, color:C.fg3 }}>
        No active ATO for INDOPACOM.
      </div>
    );
  }

  const state = window.classifyDocState ? window.classifyDocState(atoDoc) : 'current';
  const remainingColor = state === 'expired' ? C.critical
                       : state === 'expiring' ? C.advisory
                       : C.fg2;
  const remainingBg    = state === 'expired' ? `${C.critical}22`
                       : state === 'expiring' ? `${C.advisory}22`
                       : 'transparent';
  const parseColor = atoDoc.parseStatus === 'clean' ? C.normal
                   : atoDoc.parseStatus === 'warnings' ? C.advisory
                   : C.fg3;
  const parseLabel = (atoDoc.parseStatus || 'preview').toUpperCase();
  const aorMeta = (window.AORS || []).find(a => a.id === atoDoc.aor);

  return (
    <div style={{ padding:'12px 14px', borderBottom:`1px solid ${C.border}`,
      display:'flex', flexDirection:'column', gap:10 }}>
      {/* Header */}
      <div>
        <div title="ACTIVE ATO — Air Tasking Order currently in effect for this AOR." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          color:C.fg3, letterSpacing:'0.16em' }}>ACTIVE ATO</div>
        <div style={{ display:'flex', alignItems:'baseline', gap:6, marginTop:2 }}>
          <span title="ATO document name (typically Day code + cycle ID)." style={{ fontFamily:'Roboto', fontSize:13, fontWeight:600, color:C.fg1 }}>
            {atoDoc.name}
          </span>
          <span title="AOR — Area of Responsibility (geographic combatant command) this ATO covers." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
            color:C.fg3, letterSpacing:'0.10em' }}>
            {aorMeta ? aorMeta.short : atoDoc.aor}
          </span>
        </div>
      </div>

      {/* Effective range */}
      <div>
        <div title="EFFECTIVE — Validity window of this ATO. Start to end DTG (Zulu)." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>EFFECTIVE</div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg2,
          letterSpacing:'0.04em' }}>
          {fmtDocDateRange(atoDoc.start, atoDoc.end)}
        </div>
      </div>

      {/* Remaining + Parse, side-by-side */}
      <div style={{ display:'flex', gap:14 }}>
        <div>
          <div title="REMAINING — Time until this ATO's validity window closes." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
            color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>REMAINING</div>
          <span style={{
            display:'inline-block',
            padding: (state === 'expired' || state === 'expiring') ? '2px 7px' : '0',
            borderRadius:2, background:remainingBg, color:remainingColor,
            fontFamily:'Roboto Mono', fontSize:10.5, fontWeight:500,
            letterSpacing:'0.04em',
          }}>
            {window.formatTimeRemaining ? window.formatTimeRemaining(atoDoc) : '—'}
          </span>
        </div>
        <div>
          <div title="PARSE — Status of automated ingest of this document into the C2 picture. CLEAN means all fields parsed; WARNINGS means partial; PREVIEW means not yet ingested." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
            color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>PARSE</div>
          <span title={(atoDoc.parseDetails || []).join(' · ') || 'Parse status. CLEAN: full ingest. WARNINGS: partial fields parsed. PREVIEW: not yet ingested.'} style={{
            display:'inline-block',
            padding:'2px 7px', borderRadius:2,
            background:`${parseColor}22`, color:parseColor,
            fontFamily:'Roboto Mono', fontSize:9, fontWeight:500,
            letterSpacing:'0.10em',
            cursor: atoDoc.parseDetails ? 'help' : 'default',
          }}>
            {parseLabel}
          </span>
        </div>
      </div>

      {/* Uploaded — provenance footer */}
      <div style={{ paddingTop:8, borderTop:`1px solid ${C.border}` }}>
        <div title="UPLOADED — When this ATO was loaded into the library, and by whom." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>UPLOADED</div>
        <div style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2 }}>
          {fmtRelativeAgo(atoDoc.uploadedAt)}
          {' '}<span style={{ color:C.fg3 }}>by</span>{' '}
          <span style={{ color:C.fg1 }}>{atoDoc.uploader}</span>
        </div>
      </div>
    </div>
  );
}

// ── PR 12: TFC Mission Gantt ─────────────────────────────────
// Center-column Gantt view. Replaces the prior list view.
//
// Layout:
//   ┌─────────────────────────────────────────────────────────────┐
//   │ [BY MISSION] [BY PACKAGE]               (toggle, top-right)│
//   ├──────────────┬──────────────────────────────────────────────┤
//   │ row label    │ 281130Z  281400Z  281600Z  ...  291130Z      │
//   │              │   │  scenario now (vertical line)            │
//   │ DUKE flight  │       ████████  (NET → NLT bar)              │
//   │ PETER 11     │            ██                                │
//   │ ...          │   ...                                        │
//   ├──────────────┴──────────────────────────────────────────────┤
//   │ ALERT POSTURE                                                │
//   │ PEDRO 61 · alert                                             │
//   │ SANDY 1  · alert                                             │
//   └─────────────────────────────────────────────────────────────┘
//
// The label gutter is fixed-width (~180px). The bar area uses the
// remaining width and renders bars positioned by NET/NLT scenario
// seconds.
//
// PR 15 — axis switched from cycle window to a STATIC CALENDAR DAY.
// The visible window is always 0000Z → 2400Z of the operator-selected
// day. A day-selector (◀ 28APR · 29APR ▶) lets the operator walk
// forward/back. Bars are clipped to the visible 24h; bars that extend
// past midnight in either direction render `→` / `←` continuation
// markers indicating they continue into an adjacent day.
//
// Day-offset coordinate: viewedDay = 0 means "the day containing T+0"
// = 28 APR 2026. +1 = 29 APR. -1 = 27 APR. Scenario seconds for the
// start of day d are:
//   dayStart(d) = -52200 + d * 86400      // 280000Z scenario sec
// because T+0 = 281430Z = 14h30m past 280000Z = 52200s after day start.

const TFC_GANTT_LABEL_W = 180;     // fixed label gutter width
const TFC_GANTT_TYPES = ['OCA-AO', 'DCA', 'DT', 'AAR', 'ISR', 'CAS', 'SOF', 'TFC', 'INTEG-TACS', 'PR'];
const TFC_DAY_SEC = 24 * 3600;
const TFC_BASE_DAY_OFFSET_SEC = -52200;  // scenario seconds at 280000Z (= -14h30m from T+0)
// Demo override — pretend the scenario clock sits at 280730Z (7h before
// the real T+0 of 281430Z). Lets the Gantt show a believable "early
// morning" battle rhythm where the dawn pulse just wrapped, midday CAPs
// haven't launched yet, and most missions are still future. Pure-view
// shift; ATO state-machine clock is untouched.
const TFC_DEMO_NOW_SEC = -25200;   // 280730Z relative to T+0=281430Z

// Resolve a mission's `acftType` (e.g. "F-15E") to the COP's standalone
// icon-file key (e.g. "f15e"). Same mapping the map uses; reuses the
// `window.platformIconKey` resolver if available, otherwise inlines the
// table here as a fallback. Returns null for unmapped types — caller
// renders a small outlined fallback triangle.
function tfcResolvePlatformIcon(acftType) {
  if (!acftType) return null;
  if (window.platformIconKey) return window.platformIconKey({ type: acftType });
  const t = (acftType || '').toUpperCase();
  if (t === 'F-15E')  return 'f15e';
  if (t === 'F-15C')  return 'f15';
  if (t === 'F-16C')  return 'f16';
  if (t === 'F-22')   return 'fa22';
  if (t === 'F-18')   return 'fa18';
  if (t === 'B-52H')  return 'b52';
  if (t === 'B-2A' || t === 'B-2')  return 'b2';
  if (t === 'E-3C')   return 'e3';
  if (t === 'RC-135') return 'rc135';
  if (t === 'P-8')    return 'p8a';
  if (t === 'A-10C')  return 'a10';
  if (t === 'AC-130J')return 'ac130';
  if (t === 'HH-60W') return 'hh60';
  if (t === 'HC-130J' || t === 'MC-130J') return 'c130';
  if (t === 'KC-130J') return 'probe-and-drogue-tanker';
  if (t === 'KC-135') return 'kc135';
  if (t === 'KC-46')  return 'boom-tanker';
  if (t === 'MQ-9')   return 'mq9';
  return null;
}

// Renders the COP's filled-silhouette platform icon for an aircraft
// type. Fetches the SVG from `v3/icons/<key>.svg` once per platform
// (cached by map.jsx's loader), then inlines a fresh <svg> with
// `fill={color} stroke="none" fill-rule="nonzero"` — same recipe the
// map uses. While the path is loading we show a tiny arrowhead so
// rows don't reflow on icon arrival.
function TfcPlatformIcon({ acftType, size = 18, color, dim }) {
  const key  = tfcResolvePlatformIcon(acftType);
  const fill = color || (dim ? C.fg3 : C.fg1);
  const [entry, setEntry] = React.useState(() => {
    // If the COP has already cached this path, render synchronously.
    if (!key) return null;
    return (window._iconPathCache && window._iconPathCache[key]) || null;
  });
  React.useEffect(() => {
    if (!key || entry) return;
    if (!window.loadIconPath) return;  // map.jsx not loaded yet
    let cancelled = false;
    window.loadIconPath(key, (e) => { if (!cancelled) setEntry(e); });
    return () => { cancelled = true; };
  }, [key, entry]);

  const wrap = (content) => (
    <span title={acftType || ''} style={{
      display:'inline-flex', alignItems:'center', justifyContent:'center',
      width:size, height:size, flexShrink:0, lineHeight:0,
    }}>
      {content}
    </span>
  );

  // No mapped key → small filled arrowhead in the row's text color.
  if (!key) {
    return wrap(
      <svg width={size} height={size} viewBox="-12 -12 24 24" style={{ display:'block', overflow:'visible' }}>
        <polygon points="0,-8 5.2,5.2 0,2.4 -5.2,5.2" fill={fill}/>
      </svg>
    );
  }
  // Path not yet loaded → arrowhead placeholder (same as the COP).
  if (!entry || !entry.d) {
    return wrap(
      <svg width={size} height={size} viewBox="-12 -12 24 24" style={{ display:'block', overflow:'visible' }}>
        <polygon points="0,-8 5.2,5.2 0,2.4 -5.2,5.2" fill={fill}/>
      </svg>
    );
  }
  // Loaded silhouette — render with fill-only paint, no stroke.
  return wrap(
    <svg width={size} height={size} viewBox={entry.viewBox}
      xmlns="http://www.w3.org/2000/svg" style={{ display:'block' }}>
      <path d={entry.d} fill={fill} stroke="none" fillRule="nonzero"/>
    </svg>
  );
}
// Zebra-stripe background — translucent white so it lifts visibly off
// the s1 (#151517) workspace surface. (C.s2 is aliased to s1, so a
// hex-token zebra is invisible — see data.js line 12.)
const TFC_ZEBRA_BG = 'rgba(255,255,255,0.025)';
// Section-edge padding — air above the first row and below the last
// row of each type-band section so they don't crowd the section
// dividers.
const TFC_SECTION_VPAD = 6;

// PR 16 — Per-mission-type accent. Used by the type-band section
// header (left swatch + headline color) so each section reads
// distinctly at a glance. All values map to existing `C.*` tokens or
// near-neighbors in the design-system palette.
const TFC_TYPE_COLOR = {
  'OCA-AO':     C.critical,   // strike — vivid red
  'DCA':        C.accent,     // CAP — primary friendly blue
  'DT':         C.advisory,   // dynamic targeting — amber
  'AAR':        C.accent2,    // tanker — softer cyan
  'ISR':        C.normal,     // ISR — green
  'CAS':        '#ff6d68',    // close air support — softer red (isopPink token)
  'SOF':        '#a78bfa',    // special ops — purple (matches OPTASKLINK)
  'TFC':        C.fg2,        // tactical fluid control — neutral
  'INTEG-TACS': C.fg3,        // integrated theater air ctl — neutral
  'PR':         C.accent2,    // personnel recovery — special friendly
};
// Long-form labels for the type-band headline.
const TFC_TYPE_LONG = {
  'OCA-AO':     'Offensive Counter Air — Attack Ops',
  'DCA':        'Defensive Counter Air',
  'DT':         'Dynamic Targeting',
  'AAR':        'Air-to-Air Refueling',
  'ISR':        'Intelligence · Surveillance · Reconnaissance',
  'CAS':        'Close Air Support',
  'SOF':        'Special Operations Forces',
  'TFC':        'Tactical Fluid Control',
  'INTEG-TACS': 'Integrated Theater Air Control',
  'PR':         'Personnel Recovery',
};

// Format a day offset as "28APR 2026" / "29APR 2026" etc. Day 0 = 28 APR 2026.
function tfcFormatDayLabel(dayOffset) {
  const baseEpoch = Date.UTC(2026, 3, 28); // 28 APR 2026 0000Z
  const d = new Date(baseEpoch + dayOffset * 86400 * 1000);
  const dd = String(d.getUTCDate()).padStart(2, '0');
  const mon = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'][d.getUTCMonth()];
  const yyyy = d.getUTCFullYear();
  return { ddmon: `${dd}${mon}`, year: yyyy, full: `${dd} ${mon} ${yyyy}` };
}

function TfcMissionGantt({ runtime, selectedMissionId, onSelectMission, groupBy, onGroupByChange, viewedDay, onViewedDayChange, compact = false }) {
  const missions = runtime.missions || [];
  // Gantt-only demo clock — see TFC_DEMO_NOW_SEC. When null, fall back
  // to the live runtime clock.
  const nowSec   = (TFC_DEMO_NOW_SEC != null)
    ? TFC_DEMO_NOW_SEC
    : ((runtime.lastTickT != null) ? runtime.lastTickT : 0);

  // Viewed-day window — always exactly 24h starting at 0000Z of the
  // selected day. dayStart is the scenario second corresponding to that
  // 0000Z; daySpan is always 86400.
  const dayStart = TFC_BASE_DAY_OFFSET_SEC + viewedDay * TFC_DAY_SEC;
  const daySpan  = TFC_DAY_SEC;

  // Which day does the scenario clock currently sit in? Used by axis
  // (only render the now-pin when the viewed day matches) + by the day
  // selector ("TODAY" badge).
  const currentDay = Math.floor((nowSec - TFC_BASE_DAY_OFFSET_SEC) / TFC_DAY_SEC);

  // Partition missions into time-windowed vs alert-posture.
  const windowed = missions.filter(m =>
    !(m.status === 'alert' || m.netScenario == null || m.nltScenario == null)
  );
  const alertMissions = missions.filter(m =>
    m.status === 'alert' || m.netScenario == null || m.nltScenario == null
  );

  return (
    <div style={{ flex:1, display:'flex', flexDirection:'column',
      overflow:'hidden', minWidth:0, minHeight:0 }}>
      {/* Top strip — day selector + grouping toggle. Hidden in compact
          mode (mission-type MM drawers manage day-selection elsewhere
          and only show a single type, so the toggle is meaningless). */}
      {!compact && (
        <TfcGanttHeader
          groupBy={groupBy} onGroupByChange={onGroupByChange}
          viewedDay={viewedDay} onViewedDayChange={onViewedDayChange}
          currentDay={currentDay}
        />
      )}
      {/* Time axis (sticks under header) */}
      <TfcGanttAxis
        dayStart={dayStart} daySpan={daySpan} nowSec={nowSec}
        viewedDay={viewedDay} currentDay={currentDay}
      />
      {/* Scrollable body */}
      <div style={{ flex:1, overflowY:'auto', minHeight:0 }}>
        {groupBy === 'package' ? (
          <TfcGanttPackagePlaceholder/>
        ) : (
          <TfcGanttByMission
            missions={windowed}
            dayStart={dayStart} daySpan={daySpan} nowSec={nowSec}
            viewedDay={viewedDay} currentDay={currentDay}
            selectedMissionId={selectedMissionId}
            onSelectMission={onSelectMission}
            compact={compact}
          />
        )}
        {/* Alert posture section (rendered in both views) */}
        {alertMissions.length > 0 && (
          <TfcAlertPostureSection
            missions={alertMissions}
            selectedMissionId={selectedMissionId}
            onSelectMission={onSelectMission}
          />
        )}
      </div>
    </div>
  );
}

// ── Gantt header — day selector + grouping toggle ─────────────
// Day selector lives on the left: ◀ 28APR 2026 ▶ with a "TODAY" badge
// when the viewed day matches the scenario clock's current day.
// Grouping toggle stays on the right.
function TfcGanttHeader({ groupBy, onGroupByChange, viewedDay, onViewedDayChange, currentDay }) {
  const label = tfcFormatDayLabel(viewedDay);
  const isToday = viewedDay === currentDay;
  const goPrev = () => onViewedDayChange(viewedDay - 1);
  const goNext = () => onViewedDayChange(viewedDay + 1);
  const goToday = () => onViewedDayChange(currentDay);
  const arrowBtn = (dir, onClick) => (
    <button onClick={onClick} title={dir === 'prev' ? 'Previous day — shift the Gantt window back 24h.' : 'Next day — shift the Gantt window forward 24h.'} style={{
      width:24, height:22, padding:0, background:'transparent',
      border:`1px solid ${C.border2}`, color:C.fg2, cursor:'pointer',
      fontFamily:'Roboto Mono', fontSize:11, lineHeight:1,
      display:'flex', alignItems:'center', justifyContent:'center',
    }}
      onMouseEnter={e => { e.currentTarget.style.background = C.s3; e.currentTarget.style.color = C.fg1; }}
      onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = C.fg2; }}
    >
      {dir === 'prev' ? '◀' : '▶'}
    </button>
  );

  return (
    <div data-tour="tfc-day-selector" style={{
      padding:'8px 14px', borderBottom:`1px solid ${C.border}`,
      display:'flex', alignItems:'center', gap:10, flexShrink:0,
    }}>
      {/* Day selector */}
      <div style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
        {arrowBtn('prev', goPrev)}
        <div title="Viewed Gantt day — 0000Z to 2400Z window. Use arrows to scrub days; JUMP TO TODAY snaps back to current scenario day." style={{
          minWidth:124, textAlign:'center', padding:'3px 10px',
          border:`1px solid ${C.border2}`, borderRadius:3, background:C.s2,
        }}>
          <div data-typo="label" style={{
            fontFamily:'Roboto Mono', fontSize:10, fontWeight:600,
            color:C.fg1, letterSpacing:'0.10em',
          }}>{label.full}</div>
        </div>
        {arrowBtn('next', goNext)}
        {!isToday && (
          <button onClick={goToday} title="Jump to current scenario day" style={{
            padding:'3px 8px', background:'transparent',
            border:`1px solid ${C.accent}66`, borderRadius:3,
            color:C.accent, cursor:'pointer',
            fontFamily:'Roboto Mono', fontSize:8.5, fontWeight:600, letterSpacing:'0.10em',
          }}>JUMP TO TODAY</button>
        )}
        {isToday && (
          <span title="TODAY — Viewed day matches the current scenario day." style={{
            padding:'3px 7px', borderRadius:3, background:`${C.accent}22`,
            color:C.accent, fontFamily:'Roboto Mono', fontSize:8.5,
            fontWeight:600, letterSpacing:'0.12em',
          }}>TODAY</span>
        )}
      </div>
      <div style={{ flex:1 }}/>
      {/* Grouping toggle */}
      <div title="Gantt grouping. BY MISSION shows every sortie as its own row; BY PACKAGE groups related missions (escort, tanker, ISR) into a single composite row." style={{ display:'inline-flex', border:`1px solid ${C.border2}`,
        borderRadius:3, overflow:'hidden' }}>
        {[['mission','BY MISSION'], ['package','BY PACKAGE']].map(([k, lbl], i) => {
          const active = groupBy === k;
          return (
            <button
              data-typo="label"
              key={k}
              onClick={() => onGroupByChange(k)}
              title={k === 'mission' ? 'BY MISSION — one row per sortie (callsign).' : 'BY PACKAGE — group related sorties (strike + escort + tanker + ISR) into a single composite row.'}
              style={{
                padding:'5px 12px',
                background: active ? C.s3 : 'transparent',
                color: active ? C.fg1 : C.fg3,
                border: 'none', borderLeft: i === 0 ? 'none' : `1px solid ${C.border2}`,
                fontFamily:'Roboto Mono', fontSize:9, fontWeight:500, letterSpacing:'0.10em',
                cursor:'pointer', textTransform:'uppercase',
              }}
            >
              {lbl}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ── Gantt time axis (CORE--gantt-major-tick-marker spec) ─────
// Hour-of-day ticks across the 24h viewed-day window (0000Z → 2400Z).
// Tick label format is "HHMM" without a day prefix. CORE spec:
//   • Tick label  → Roboto Mono Regular 12/16px / 0.4px tracking / fg2
//   • Chevron     → 8×8 chevron-down under each label
//   • Now-marker  → time-pill at top + dashed accent vertical line
// (Refs: figma node 33040:10007 CORE--time-marker, 33040:10008
// CORE--gantt-major-tick-marker.)
function TfcGanttAxis({ dayStart, daySpan, nowSec, viewedDay, currentDay }) {
  const tickStepSec = 2 * 3600;  // 2h ticks → 13 ticks (0000, 0200, …, 2400)
  const ticks = [];
  for (let t = 0; t <= TFC_DAY_SEC; t += tickStepSec) {
    ticks.push(t);  // seconds-into-day, not scenario seconds
  }
  const fmtHour = (secIntoDay) => {
    const hh = Math.floor(secIntoDay / 3600);
    const mm = Math.floor((secIntoDay % 3600) / 60);
    return String(hh).padStart(2,'0') + String(mm).padStart(2,'0');
  };

  // Now-pin position only valid when viewing the day the clock sits in.
  const showNowPin = viewedDay === currentDay && nowSec >= dayStart && nowSec <= dayStart + daySpan;
  const nowPct = showNowPin ? ((nowSec - dayStart) / daySpan) * 100 : null;
  // Now-pin pill text — CORE uses HH:MM (e.g. "22:42")
  const fmtNowHHMM = (sec) => {
    // sec is scenario-seconds; convert to minute-of-day on the viewed day
    const minOfDay = Math.floor(((sec - dayStart) % TFC_DAY_SEC) / 60);
    const hh = Math.floor(minOfDay / 60);
    const mm = minOfDay % 60;
    return String(hh).padStart(2,'0') + ':' + String(mm).padStart(2,'0');
  };

  return (
    <div style={{
      display:'flex', alignItems:'flex-start', height:36,
      borderBottom:`1px solid ${C.border}`, flexShrink:0,
      position:'relative',
    }}>
      {/* Label gutter spacer */}
      <div style={{ width:TFC_GANTT_LABEL_W, flexShrink:0,
        borderRight:`1px solid ${C.border}`, height:'100%' }}/>
      {/* Tick band */}
      <div style={{ flex:1, position:'relative', height:'100%' }}>
        {ticks.map((secIntoDay, i) => {
          const pct = (secIntoDay / TFC_DAY_SEC) * 100;
          const isFirst = i === 0;
          const isLast  = i === ticks.length - 1;
          const labelTransform = isFirst ? 'translateX(0)'
                                : isLast  ? 'translateX(-100%)'
                                : 'translateX(-50%)';
          return (
            <React.Fragment key={secIntoDay}>
              {/* Tick line — runs full axis height behind the label */}
              <div style={{
                position:'absolute', left: pct + '%', top:0, bottom:0,
                borderLeft:`1px solid ${C.border}`,
              }}/>
              {/* Label + chevron stack — CORE--gantt-major-tick-marker */}
              <div style={{
                position:'absolute', left: pct + '%', top:4,
                transform: labelTransform,
                display:'flex', flexDirection:'column', alignItems:'center', gap:1,
                pointerEvents:'none',
              }}>
                <span style={{
                  fontFamily:'Roboto Mono', fontWeight:400,
                  fontSize:12, lineHeight:'16px', letterSpacing:'0.4px',
                  color:C.fg2, whiteSpace:'nowrap',
                  padding: isFirst ? '0 0 0 4px' : isLast ? '0 4px 0 0' : '0 4px',
                }}>
                  {fmtHour(secIntoDay)}
                </span>
                {/* Chevron-down 8×8 — matches CORE--icon-chevron-down */}
                <svg width="8" height="8" viewBox="0 0 8 8" style={{ display:'block' }}>
                  <path d="M1.5 3 L4 5.5 L6.5 3" fill="none"
                    stroke={C.fg2} strokeWidth="1.2"
                    strokeLinecap="round" strokeLinejoin="round"/>
                </svg>
              </div>
            </React.Fragment>
          );
        })}
        {/* Scenario-now indicator — CORE--time-marker spec.
            Pill at the very top of the axis shows the current HH:MM,
            a dashed vertical line drops down through the rows below.
            The dashed line is rendered here in the axis only as the
            visible "head" of the line; per-row dashed lines continue
            in each TfcGanttRow's bar lane. */}
        {showNowPin && (
          <div style={{
            position:'absolute',
            left: nowPct + '%',
            top:0, bottom:0,
            transform:'translateX(-50%)',
            display:'flex', flexDirection:'column', alignItems:'center',
            zIndex:3, pointerEvents:'none',
          }}>
            {/* Time pill */}
            <div style={{
              background: C.s3,
              border: `1px solid ${C.accent}`,
              borderRadius: 3,
              padding: '3px 4px',
              fontFamily:'Roboto Mono', fontWeight:500,
              fontSize:10, lineHeight:'12px', letterSpacing:'0.5px',
              color: C.fg1, whiteSpace:'nowrap',
            }}>
              {fmtNowHHMM(nowSec)}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ── BY MISSION view — type-banded sections, one row per mission ─
function TfcGanttByMission({ missions, dayStart, daySpan, nowSec,
                            viewedDay, currentDay,
                            selectedMissionId, onSelectMission,
                            compact = false }) {
  const groups = TFC_GANTT_TYPES.map(type => ({
    type,
    items: missions.filter(m => m.type === type),
  })).filter(g => g.items.length > 0);
  // In compact mode with a single group, drop the type-band header —
  // the workspace's title strip already names what's in this Gantt.
  const hideTypeHeader = compact && groups.length <= 1;

  return (
    <>
      {groups.map(g => {
        const typeLong  = TFC_TYPE_LONG[g.type] || g.type;
        return (
        <div key={g.type}>
          {/* Type header band — long-form caption is the section title.
              Acronym dropped (it's also visible on each row's tooltip and
              in the data underneath). Suppressed when compact + single
              group (the workspace header carries the same info). */}
          {!hideTypeHeader && (
            <div style={{
              display:'flex', alignItems:'center', justifyContent:'center',
              padding:'12px 14px',
              background:C.s2,
              borderTop:`1px solid ${C.border}`,
              borderBottom:`1px solid ${C.border}`,
              textAlign:'center',
            }}>
              <div data-typo="label" style={{
                fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3,
                letterSpacing:'0.10em', textTransform:'uppercase',
                lineHeight:1,
              }}>
                {typeLong} · {g.items.length} MISSION{g.items.length === 1 ? '' : 'S'}
              </div>
            </div>
          )}
          {/* Rows — zebra stripes (odd rows get a translucent tint).
              Wrapped in a container with vertical padding so the first
              and last rows breathe against the section dividers. */}
          <div style={{ paddingTop:TFC_SECTION_VPAD, paddingBottom:TFC_SECTION_VPAD }}>
            {g.items.map((m, i) => (
              <TfcGanttRow
                key={m.id}
                mission={m}
                dayStart={dayStart} daySpan={daySpan} nowSec={nowSec}
                viewedDay={viewedDay} currentDay={currentDay}
                selected={m.id === selectedMissionId}
                onSelect={() => onSelectMission(m.id)}
                zebra={i % 2 === 1}
              />
            ))}
          </div>
        </div>
        );
      })}
    </>
  );
}

// ── Single Gantt row ──────────────────────────────────────────
// Left side: row label (callsign + variant). Right side: NET/NLT
// bar positioned within the viewed-day window. Bars that extend past
// 2400Z render `→` on the right edge (continues into tomorrow); bars
// that start before 0000Z render `←` on the left edge (started
// yesterday). If the entire mission window falls outside the viewed
// day, no bar is rendered.
function TfcGanttRow({ mission: m, dayStart, daySpan, nowSec, viewedDay, currentDay, selected, onSelect, zebra = false }) {
  const netSec = m.netScenario;
  const nltSec = m.nltScenario;
  const dayEnd = dayStart + daySpan;

  // Does any portion of [netSec, nltSec] overlap [dayStart, dayEnd]?
  const overlaps = nltSec > dayStart && netSec < dayEnd;
  // Marker flags (only meaningful when the bar is visible)
  const wrapsLeft  = overlaps && netSec < dayStart;  // started yesterday
  const wrapsRight = overlaps && nltSec > dayEnd;    // continues tomorrow
  const visibleStartSec = Math.max(netSec, dayStart);
  const visibleEndSec   = Math.min(nltSec, dayEnd);
  const clampedStart = ((visibleStartSec - dayStart) / daySpan) * 100;
  const clampedEnd   = ((visibleEndSec   - dayStart) / daySpan) * 100;
  const clampedWidth = Math.max(0, clampedEnd - clampedStart);

  const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
  const hasCritical = openFlags.some(f => f.severity === 'critical');
  const hasAnyFlag = openFlags.length > 0;

  // Past/future state. A bar is "past" when its NLT is before the
  // scenario clock AND the viewed day is the current day (or earlier).
  // Future days never dim. A bar is "mid-window" when now sits inside
  // the bar's visible range — in that case it splits at the now-pin
  // into a past ghost segment and a live solid segment.
  const clockLive      = viewedDay <= currentDay;  // we even care about now this day
  const isPastWindow   = nltSec < nowSec && clockLive;
  const isFutureWindow = netSec > nowSec && viewedDay >= currentDay;
  const isMidWindow    = !isPastWindow && !isFutureWindow
                         && clockLive
                         && nowSec > visibleStartSec
                         && nowSec < visibleEndSec;

  // Bar color / status — uniform accent for "normal," overrides for
  // outcomes and impact severity.
  const isServiced = m.outcome === 'serviced';
  const isScrubbed = m.outcome === 'scrubbed';
  const isRetasked = m.outcome === 'retasked';
  const isLatePast = m.windowState === 'past-window-late';
  const isInWindow = m.windowState === 'in-window' || m.windowState === 'step-taxi-push';

  let barColor = C.accent;
  if (isServiced) barColor = C.normal;
  else if (isScrubbed) barColor = C.fg4;
  else if (isRetasked) barColor = C.advisory;
  else if (hasCritical) barColor = C.critical;
  else if (hasAnyFlag) ; // keep accent fill, advisory border (handled below)
  // PR 24 — Demo baseline drops the AMA-34 clock-label color treatments.
  // Previously bars went red on `past-window-late` (PR 23 cleared) and
  // grey on `!isInWindow` (pre-window / past-window). Both were
  // confusing because the pipeline clock (T+0 = 281430Z) doesn't match
  // the Gantt view clock (TFC_DEMO_NOW_SEC = 0730Z). Bars now color
  // purely off operator-visible state (outcome, impact flags). Past-vs-
  // future is conveyed by the ghost/solid segment treatment, not fill.

  // Flagged-in-window bars keep accent fill but switch border to amber.
  const liveBorderColor = (hasAnyFlag && !hasCritical
                           && !isServiced && !isScrubbed && !isRetasked)
                          ? C.advisory : barColor;
  // Past-segment color recipe (ghost: transparent fill, faint outline).
  const pastFill   = `${barColor}00`;
  const pastBorder = `${barColor}55`;
  // Live-segment color recipe (33% fill, solid outline).
  const liveFill   = `${barColor}55`;
  const liveBorder = liveBorderColor;

  // Where (in viewed-day percent space) does the now-pin sit?
  const nowPct = ((nowSec - dayStart) / daySpan) * 100;
  // Split point in percent — only meaningful when isMidWindow.
  const splitPct = Math.max(clampedStart, Math.min(clampedEnd, nowPct));

  // PR 16 — tooltip. Composed of mission identity, window bounds,
  // current state, and tactical context. Native `title=` keeps it free
  // of layout cost and respects OS conventions.
  const fmtBarZulu = (sec) => {
    // Convert scenario-seconds to a DDHHMMZ string using the day-anchor
    // helper logic. T+0 = 281430Z so base minute count = 28*1440+870.
    const totalMin = 28*1440 + 14*60 + 30 + Math.floor(sec / 60);
    const day = Math.floor(totalMin / 1440);
    const minOfDay = ((totalMin % 1440) + 1440) % 1440;
    const hh = Math.floor(minOfDay / 60);
    const mm = minOfDay % 60;
    return String(day).padStart(2,'0') + String(hh).padStart(2,'0') + String(mm).padStart(2,'0') + 'Z';
  };
  const fmtDuration = (sec) => {
    if (sec == null) return '';
    const s = Math.abs(sec);
    const h = Math.floor(s / 3600);
    const mn = Math.floor((s % 3600) / 60);
    return (h > 0 ? `${h}h ` : '') + `${mn}m`;
  };
  const windowLen = nltSec - netSec;
  // Build tooltip lines (joined by \n — native title supports newlines).
  const ttLines = [];
  ttLines.push(`${m.callsign} · ${m.type} · ${m.variant || ''}`.trim().replace(/\s+·\s+$/, ''));
  if (m.acftType) ttLines.push(`${m.acftType}${m.qty ? ' × ' + m.qty : ''}`);
  if (m.taskunit) ttLines.push(`Tasked: ${m.taskunit}`);
  ttLines.push(`Window: ${fmtBarZulu(netSec)} → ${fmtBarZulu(nltSec)}  (${fmtDuration(windowLen)})`);
  if (isPastWindow) {
    ttLines.push(`Past · ended ${fmtDuration(nowSec - nltSec)} ago`);
  } else if (isFutureWindow) {
    ttLines.push(`Future · steps in ${fmtDuration(netSec - nowSec)}`);
  } else {
    ttLines.push(`In window · ${fmtDuration(nltSec - nowSec)} remaining`);
  }
  if (m.outcome) ttLines.push(`Outcome: ${m.outcome}`);
  if (m.target && m.target.name) {
    ttLines.push(`Target: ${m.target.name}${m.target.detail ? ' (' + m.target.detail + ')' : ''}`);
  }
  if (m.capOrbit && m.capOrbit.stationName) ttLines.push(`Orbit: ${m.capOrbit.stationName}`);
  if (m.station) ttLines.push(`Station: ${m.station}`);
  if (m.depIcao || m.arrIcao) ttLines.push(`Routing: ${m.depIcao || '???'} → ${m.arrIcao || '???'}`);
  if (m.scl) ttLines.push(`SCL: ${m.scl}`);
  if (openFlags.length > 0) {
    ttLines.push(`⚠ ${openFlags.length} open impact flag${openFlags.length === 1 ? '' : 's'}`);
    openFlags.slice(0, 3).forEach(f => {
      const sev = (f.severity || '').toUpperCase();
      const label = f.label || f.kind || 'impact';
      ttLines.push(`  • [${sev}] ${label}`);
    });
    if (openFlags.length > 3) ttLines.push(`  • +${openFlags.length - 3} more…`);
  }
  if (wrapsLeft)  ttLines.push('← began yesterday');
  if (wrapsRight) ttLines.push('→ continues tomorrow');
  const tooltip = ttLines.join('\n');

  // Zebra striping replaces row borders. Selected wins over zebra;
  // hover bumps to s3 on top of the base. Zebra uses a translucent
  // white tint because C.s2 is aliased to C.s1 (data.js line 12) and
  // wouldn't show.
  const baseBg = selected ? C.accentM : (zebra ? TFC_ZEBRA_BG : 'transparent');

  // Label gutter colors — past rows use fg3/fg4 directly instead of
  // applying parent `opacity` (which muddied the zebra stripes).
  const labelMainColor = isPastWindow ? C.fg3 : (isScrubbed ? C.fg4 : C.fg1);
  const labelSubColor  = isPastWindow ? C.fg4 : C.fg3;

  return (
    <button
      onClick={onSelect}
      title={tooltip}
      data-tour={`tfc-gantt-row-${(m.id || '').replace(/^mission-/i, '').toLowerCase()}`}
      style={{
        display:'flex', alignItems:'center', width:'100%', height:28,
        background: baseBg,
        border:'none',
        borderLeft: selected ? `3px solid ${C.accent}` : '3px solid transparent',
        cursor:'pointer', textAlign:'left', padding:0,
        transition:'background 0.12s',
      }}
      onMouseEnter={e => { if (!selected) e.currentTarget.style.background = C.s3; }}
      onMouseLeave={e => { if (!selected) e.currentTarget.style.background = baseBg; }}
    >
      {/* Label gutter — no right border; zebra stripe alone separates
          the label column from the bar lane. Layout: platform icon on
          the left, callsign+variant stacked to its right. */}
      <div style={{
        width: TFC_GANTT_LABEL_W - 3,   // minus the borderLeft we stole
        flexShrink:0, padding:'0 10px',
        height:'100%',
        display:'flex', flexDirection:'row', alignItems:'center', gap:8, overflow:'hidden',
      }}>
        <TfcPlatformIcon acftType={m.acftType} dim={isPastWindow}/>
        <div style={{ flex:1, minWidth:0,
          display:'flex', flexDirection:'column', justifyContent:'center', gap:1, overflow:'hidden' }}>
          <div style={{ display:'flex', alignItems:'baseline', gap:6, minWidth:0 }}>
            <span style={{
              fontFamily:'Roboto', fontSize:11, fontWeight:600,
              color: labelMainColor,
              overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
            }}>{m.callsign}</span>
            {hasAnyFlag && (
              <div style={{
                width:6, height:6, borderRadius:'50%',
                background: hasCritical ? C.critical : C.advisory,
                animation: hasCritical ? 'pulseFade 0.8s infinite' : 'none',
                flexShrink:0,
              }}/>
            )}
          </div>
          <span data-typo="label" style={{
            fontFamily:'Roboto Mono', fontSize:8, color: labelSubColor,
            letterSpacing:'0.08em',
          }}>{m.variant}</span>
        </div>
      </div>
      {/* Bar area */}
      <div style={{ flex:1, position:'relative', height:'100%' }}>
        {/* Segmented mission timeline (PR 20).
            Four phases run sequentially: brief → step → on-station → debrief.
            Each phase is independently clipped to the viewed-day window
            and may split at the now-pin into past / live halves. The
            on-station phase is the primary visual; brief/step/debrief
            are quieter support segments. */}
        {(() => {
          const ms = m.milestones;
          // Phase definitions. Each has a stable color + visual recipe.
          // Phase fills/borders compose with past/live state below.
          const phases = [];
          if (ms && ms.brief) {
            phases.push({
              key:'brief', label:'Brief',
              startSec: ms.brief.sec, endSec: (ms.step ? ms.step.sec : netSec),
              fillAlpha: '24',    // ~14% — faint
              borderStyle: 'dashed',
            });
          }
          if (ms && ms.step) {
            phases.push({
              key:'step', label:'Step / taxi / push',
              startSec: ms.step.sec, endSec: netSec,
              fillAlpha: '40',    // ~25% — denser (committed)
              borderStyle: 'solid',
              hatch: true,        // cross-hatch overlay
            });
          }
          // On-station — the main bar. Always present.
          phases.push({
            key:'onstation', label:'On-station',
            startSec: netSec, endSec: nltSec,
            fillAlpha: '55',     // ~33% — primary visual
            borderStyle: 'solid',
            primary: true,
          });
          if (ms && ms.debrief) {
            phases.push({
              key:'debrief', label:'Debrief',
              startSec: nltSec, endSec: ms.debrief.sec,
              fillAlpha: '24',
              borderStyle: 'dashed',
            });
          }

          // Helper: format a tooltip for a phase
          const fmtPhaseTip = (ph, ssec, esec) => {
            const fmtZ = (s) => fmtBarZulu(s);
            const dur = fmtDuration(esec - ssec);
            const lines = [
              `${ph.label} · ${m.callsign}`,
              `${fmtZ(ssec)} → ${fmtZ(esec)}  (${dur})`,
            ];
            // Relative-to-now hint
            if (esec < nowSec)      lines.push(`Ended ${fmtDuration(nowSec - esec)} ago`);
            else if (ssec > nowSec) lines.push(`Starts in ${fmtDuration(ssec - nowSec)}`);
            else                    lines.push(`Active · ${fmtDuration(esec - nowSec)} remaining`);
            return lines.join('\n');
          };

          // Build render segments for each phase. Each phase, after
          // viewed-day clipping, may split at the now-pin into a past
          // half + live half.
          const out = [];
          phases.forEach((ph, phIdx) => {
            // Clip to viewed-day window
            const cStart = Math.max(ph.startSec, dayStart);
            const cEnd   = Math.min(ph.endSec,   dayStart + daySpan);
            if (cEnd <= cStart) return;  // out of frame

            // Track which side of this phase is the *bar's* outer edge.
            // First phase (brief, or onstation if brief is null) owns
            // the bar's left cap iff its start falls within the viewed
            // day. Last phase (debrief, or onstation if debrief null)
            // owns the right cap iff its end falls within the day.
            const isFirstPhase = phIdx === 0;
            const isLastPhase  = phIdx === phases.length - 1;
            const ownsBarLeft  = isFirstPhase && ph.startSec >= dayStart;
            const ownsBarRight = isLastPhase  && ph.endSec   <= dayStart + daySpan;

            // Phase split at now-pin → up to 2 visible segments
            const splitsHere = clockLive && nowSec > cStart && nowSec < cEnd;
            const segments = splitsHere
              ? [
                  { ss: cStart, se: nowSec, past: true,  ownsLeft: true,  ownsRight: false },
                  { ss: nowSec, se: cEnd,   past: false, ownsLeft: false, ownsRight: true  },
                ]
              : [
                  { ss: cStart, se: cEnd,
                    past: clockLive && cEnd <= nowSec,
                    ownsLeft: true, ownsRight: true },
                ];

            segments.forEach((seg, segIdx) => {
              const leftPct  = ((seg.ss - dayStart) / daySpan) * 100;
              const rightPct = ((seg.se - dayStart) / daySpan) * 100;
              const widthPct = rightPct - leftPct;
              if (widthPct <= 0) return;
              // Color recipe — past dims to ghost (transparent fill,
              // low-alpha border); live uses the phase fill alpha.
              const fill   = seg.past ? `${barColor}00` : `${barColor}${ph.fillAlpha}`;
              const border = seg.past ? `${barColor}55` : (ph.primary ? liveBorder : barColor);
              const bStyle = ph.borderStyle;
              // Outer corners — only on segments that own the bar's outer edges
              const capL = seg.ownsLeft  && ownsBarLeft  && !wrapsLeft;
              const capR = seg.ownsRight && ownsBarRight && !wrapsRight;
              const radTL = capL ? 2 : 0;
              const radBL = capL ? 2 : 0;
              const radTR = capR ? 2 : 0;
              const radBR = capR ? 2 : 0;
              // Wrap markers: only if this segment owns the bar's outer edge
              // AND the bar wraps on that side.
              const showWrapLeft  = seg.ownsLeft  && ownsBarLeft  && wrapsLeft;
              const showWrapRight = seg.ownsRight && ownsBarRight && wrapsRight;
              // Outcome glyph — only on the absolute right-cap of the bar
              const showOutcome = capR;
              out.push(
                <div
                  key={`${ph.key}-${segIdx}`}
                  title={fmtPhaseTip(ph, seg.ss, seg.se)}
                  style={{
                    position:'absolute',
                    left:  leftPct + '%',
                    width: widthPct + '%',
                    top:7, height:14,
                    background: fill,
                    borderTop:    `1px ${bStyle} ${border}`,
                    borderBottom: `1px ${bStyle} ${border}`,
                    borderLeft:   seg.ownsLeft  ? `1px ${ (capL && wrapsLeft) ? 'dashed' : bStyle} ${border}` : 'none',
                    borderRight:  seg.ownsRight ? `1px ${ (capR && wrapsRight) ? 'dashed' : bStyle} ${border}` : 'none',
                    borderRadius: `${radTL}px ${radTR}px ${radBR}px ${radBL}px`,
                    display:'flex', alignItems:'center',
                    justifyContent: 'space-between',
                    overflow:'hidden', padding: '0 3px',
                    pointerEvents:'auto',
                    // Cross-hatch overlay for the step/taxi/push phase
                    backgroundImage: ph.hatch && !seg.past
                      ? `repeating-linear-gradient(45deg, ${barColor}22 0 3px, transparent 3px 6px)`
                      : 'none',
                  }}
                >
                  {/* Left wrap marker */}
                  {showWrapLeft ? (
                    <span style={{
                      color: seg.past ? C.fg4 : C.fg2,
                      fontSize:10, fontWeight:700, lineHeight:1, flexShrink:0,
                    }}>←</span>
                  ) : <span/>}
                  {/* Outcome glyph — only at the bar's right cap */}
                  <span style={{ flex:1, textAlign:'center' }}>
                    {showOutcome && isServiced && (
                      <span style={{ color: seg.past ? `${C.normal}88` : C.normal, fontSize:11, fontWeight:700, lineHeight:1 }}>✓</span>
                    )}
                    {showOutcome && isScrubbed && (
                      <span style={{ color: seg.past ? C.fg4 : C.fg2, fontSize:11, fontWeight:700, lineHeight:1 }}>×</span>
                    )}
                    {showOutcome && isRetasked && (
                      <span style={{ color: seg.past ? `${C.advisory}88` : C.advisory, fontSize:11, fontWeight:700, lineHeight:1 }}>→</span>
                    )}
                  </span>
                  {/* Right wrap marker */}
                  {showWrapRight ? (
                    <span style={{
                      color: seg.past ? C.fg4 : C.fg2,
                      fontSize:10, fontWeight:700, lineHeight:1, flexShrink:0,
                    }}>→</span>
                  ) : <span/>}
                </div>
              );
            });
          });
          return out;
        })()}
        {/* TOT mark — CORE-system target reticle inside the on-station
            segment. The only milestone that doesn't already land on a
            segment edge. Red, since this is bombs-on-target time. */}
        {m.milestones && m.milestones.tot
          && m.milestones.tot.sec >= dayStart
          && m.milestones.tot.sec <= dayStart + daySpan && (() => {
            const t = m.milestones.tot;
            const pct = ((t.sec - dayStart) / daySpan) * 100;
            const inPast = clockLive && t.sec < nowSec;
            const ago = nowSec - t.sec;
            let rel;
            if (ago > 60)       rel = `${Math.floor(ago/3600)}h ${Math.floor((ago%3600)/60)}m ago`;
            else if (ago < -60) rel = `in ${Math.floor((-ago)/3600)}h ${Math.floor(((-ago)%3600)/60)}m`;
            else                rel = 'now';
            const Crosshair = (window.MODE_ICONS || {}).crosshair;
            return (
              <div title={`TOT · ${t.zulu}\n${rel}`} style={{
                position:'absolute', left:`calc(${pct}% - 7px)`, top:'50%',
                marginTop:-7,
                width:14, height:14,
                color: inPast ? '#ffffff88' : C.fg1,
                display:'flex', alignItems:'center', justifyContent:'center',
                zIndex:2,
                pointerEvents:'auto',
              }}>
                {Crosshair ? <Crosshair size={14}/> : null}
              </div>
            );
          })()}
        {/* Scenario-now vertical line within this row — dashed accent,
            matches CORE--time-marker spec (figma 33040:10007). */}
        {viewedDay === currentDay && nowSec >= dayStart && nowSec <= dayStart + daySpan && (
          <div style={{
            position:'absolute',
            left: (((nowSec - dayStart) / daySpan) * 100) + '%',
            top:0, bottom:0,
            borderLeft:`1px dashed ${C.accent}`,
            pointerEvents:'none',
          }}/>
        )}
      </div>
    </button>
  );
}

// ── Alert posture section — non-Gantt rows ────────────────────
function TfcAlertPostureSection({ missions, selectedMissionId, onSelectMission }) {
  return (
    <div>
      {/* Section header — caption-only, matches the type-band treatment. */}
      <div style={{
        display:'flex', alignItems:'center', justifyContent:'center',
        padding:'12px 14px',
        background:C.s2,
        borderTop:`1px solid ${C.border}`,
        borderBottom:`1px solid ${C.border}`,
        textAlign:'center',
      }}>
        <div data-typo="label" style={{
          fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3,
          letterSpacing:'0.10em', textTransform:'uppercase', lineHeight:1,
        }}>
          Alert posture · strip-alert · {missions.length} MISSION{missions.length === 1 ? '' : 'S'}
        </div>
      </div>
      <div style={{ paddingTop:TFC_SECTION_VPAD, paddingBottom:TFC_SECTION_VPAD }}>
      {missions.map((m, i) => {
        const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
        const hasCritical = openFlags.some(f => f.severity === 'critical');
        const hasAnyFlag = openFlags.length > 0;
        const selected = m.id === selectedMissionId;
        const zebra = i % 2 === 1;
        const baseBg = selected ? C.accentM : (zebra ? TFC_ZEBRA_BG : 'transparent');
        // PR 16 — tooltip for alert rows
        const altTip = [
          `${m.callsign} · ${m.type} · ${m.variant || ''}`.trim().replace(/\s+·\s+$/, ''),
          m.acftType ? `${m.acftType}${m.qty ? ' × ' + m.qty : ''}` : null,
          m.taskunit ? `Tasked: ${m.taskunit}` : null,
          'Status: STRIP-ALERT (no scheduled window — fires on trigger)',
          openFlags.length > 0 ? `⚠ ${openFlags.length} open impact flag${openFlags.length === 1 ? '' : 's'}` : null,
        ].filter(Boolean).join('\n');
        return (
          <button
            key={m.id}
            onClick={() => onSelectMission(m.id)}
            title={altTip}
            style={{
              display:'flex', alignItems:'center', width:'100%', height:28,
              background: baseBg,
              border:'none',
              borderLeft: selected ? `3px solid ${C.accent}` : '3px solid transparent',
              cursor:'pointer', textAlign:'left', padding:0,
              transition:'background 0.12s',
            }}
            onMouseEnter={e => { if (!selected) e.currentTarget.style.background = C.s3; }}
            onMouseLeave={e => { if (!selected) e.currentTarget.style.background = baseBg; }}
          >
            <div style={{
              width: TFC_GANTT_LABEL_W - 3, flexShrink:0, padding:'0 10px',
              height:'100%',
              display:'flex', flexDirection:'row', alignItems:'center', gap:8, overflow:'hidden',
            }}>
              <TfcPlatformIcon acftType={m.acftType}/>
              <div style={{ flex:1, minWidth:0,
                display:'flex', flexDirection:'column', justifyContent:'center', gap:1, overflow:'hidden' }}>
                <div style={{ display:'flex', alignItems:'baseline', gap:6, minWidth:0 }}>
                  <span style={{
                    fontFamily:'Roboto', fontSize:11, fontWeight:600, color:C.fg1,
                    overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
                  }}>{m.callsign}</span>
                  {hasAnyFlag && (
                    <div style={{
                      width:6, height:6, borderRadius:'50%',
                      background: hasCritical ? C.critical : C.advisory,
                      animation: hasCritical ? 'pulseFade 0.8s infinite' : 'none',
                      flexShrink:0,
                    }}/>
                  )}
                </div>
                <span data-typo="label" style={{
                  fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
                  letterSpacing:'0.08em',
                }}>{m.variant}</span>
              </div>
            </div>
            <div style={{ flex:1, padding:'0 14px', display:'flex', alignItems:'center' }}>
              <span data-typo="label" style={{
                fontFamily:'Roboto Mono', fontSize:8, fontWeight:600,
                color: C.advisory, letterSpacing:'0.10em',
                padding:'2px 7px', background:`${C.advisory}22`, borderRadius:2,
              }}>ALERT</span>
            </div>
          </button>
        );
      })}
      </div>
    </div>
  );
}

// ── Package view placeholder (PR 12 stub) ─────────────────────
function TfcGanttPackagePlaceholder() {
  return (
    <div style={{ padding:'40px 32px', textAlign:'center', color:C.fg3, fontFamily:'Roboto', fontSize:13 }}>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3,
        letterSpacing:'0.16em', marginBottom:8 }}>BY PACKAGE</div>
      <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg2, lineHeight:1.5, marginBottom:6 }}>
        Package view coming with the PACKAGES&nbsp;[] data layer.
      </div>
      <div style={{ fontSize:11, color:C.fg3, lineHeight:1.5 }}>
        For now, package groupings will be hand-curated as the&nbsp;ATO&nbsp;evolves.
      </div>
    </div>
  );
}

function TfcCycleBoardGroup({ type, items, selectedMissionId, onSelectMission }) {
  // PR 11 — impacted count uses only unacted-on flags so dismissed
  // / resolved decisions stop appearing as warnings.
  const impactedCount = items.filter(m =>
    (m.impactFlags || []).some(f => !f.actedOn)
  ).length;
  const airborneCount = items.filter(m => m.airborneAt).length;
  return (
    <div style={{ borderBottom:`1px solid ${C.border}` }}>
      <div style={{ padding:'7px 14px', background:C.s2, display:'flex', alignItems:'center', gap:8 }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
          color:C.fg2, letterSpacing:'0.10em' }}>{type}</span>
        <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3 }}>
          {items.length} mission{items.length === 1 ? '' : 's'}
        </span>
        <div style={{ flex:1 }}/>
        {airborneCount > 0 && (
          <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.normal }}>
            {airborneCount}● airborne
          </span>
        )}
        {impactedCount > 0 && (
          <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.critical }}>
            {impactedCount}⚠ impacted
          </span>
        )}
      </div>
      {items.map(m => (
        <TfcCycleBoardRow
          key={m.id}
          mission={m}
          selected={m.id === selectedMissionId}
          onSelect={() => onSelectMission(m.id)}
        />
      ))}
    </div>
  );
}

function TfcCycleBoardRow({ mission: m, selected, onSelect }) {
  // PR 11 — only unacted flags count toward the per-row warning.
  const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
  const impactCount = openFlags.length;
  const hasCritical = openFlags.some(f => f.severity === 'critical');
  const stateColor =
      m.outcome === 'serviced' ? C.normal
    : m.outcome === 'scrubbed' ? C.fg4
    : m.outcome === 'retasked' ? C.advisory
    : hasCritical ? C.critical
    : m.airborneAt ? C.accent
    : m.windowState === 'pre-window' ? C.fg3
    : C.fg2;
  const winLabel =
      m.outcome ? m.outcome.toUpperCase()
    : m.status === 'alert' ? 'ALERT'
    : m.windowState === 'pre-window' ? 'PRE'
    : m.windowState === 'step-taxi-push' ? 'STEP'
    : m.windowState === 'in-window' ? 'WINDOW'
    : m.windowState === 'past-window' ? 'PAST'
    : m.windowState === 'past-window-late' ? 'LATE'
    : '—';
  return (
    <button
      onClick={onSelect}
      title={`${m.callsign} (${m.variant}) — ${winLabel}${impactCount > 0 ? ' · ' + impactCount + ' impact flag' + (impactCount === 1 ? '' : 's') : ''}. Click to open mission detail.`}
      style={{
        display:'flex', alignItems:'center', gap:8,
        width:'100%', padding:'7px 14px',
        background: selected ? C.accentM : 'transparent',
        border:'none', borderLeft: selected ? `3px solid ${C.accent}` : '3px solid transparent',
        cursor:'pointer', textAlign:'left',
        transition:'background 0.12s',
      }}
      onMouseEnter={e => { if (!selected) e.currentTarget.style.background = C.s3; }}
      onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
    >
      <div style={{ width:8, height:8, borderRadius:'50%', background:stateColor, flexShrink:0,
        animation: hasCritical ? 'pulseFade 0.7s infinite' : 'none' }}/>
      <div style={{ flex:1, minWidth:0 }}>
        <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
          <span style={{ fontFamily:'Roboto', fontSize:11.5, fontWeight:600, color:C.fg1 }}>
            {m.callsign}
          </span>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.08em' }}>
            {m.variant}
          </span>
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3, marginTop:1 }}>
          {m.netZulu ? `${m.netZulu} → ${m.nltZulu || '—'}` : 'no window'}
          {' · '}
          {m.qty}× {m.acftType || '(ground)'}
        </div>
      </div>
      <div style={{ display:'flex', flexDirection:'column', alignItems:'flex-end', gap:2 }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, fontWeight:600,
          color:stateColor, letterSpacing:'0.10em' }}>
          {winLabel}
        </span>
        {impactCount > 0 && (
          <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:hasCritical?C.critical:C.advisory }}>
            ⚠ {impactCount}
          </span>
        )}
      </div>
    </button>
  );
}

// ── Cycle Summary (top of center column) ─────────────────────
function TfcCycleSummary({ runtime }) {
  const missions = runtime.missions || [];
  const tally = {
    total:    missions.length,
    airborne: missions.filter(m => m.airborneAt).length,
    inWindow: missions.filter(m => m.windowState === 'in-window').length,
    impacted: missions.filter(m => (m.impactFlags || []).length > 0).length,
    serviced: missions.filter(m => m.outcome === 'serviced').length,
    scrubbed: missions.filter(m => m.outcome === 'scrubbed').length,
    retasked: missions.filter(m => m.outcome === 'retasked').length,
  };
  const heldNlt = (runtime.heldNltBreaches || []).length;
  const heldCoord = (runtime.heldCoordAlerts || []).length;
  const heldBriefs = (runtime.heldBriefs || []).filter(b => !b.released).length;
  // PR 8: relocated into the left column. Tighter padding + smaller
  // tally-card minWidth so the 4-up grid fits within 340px.
  return (
    <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:12 }}>
      <div>
        <div title="CYCLE STATUS — At-a-glance tallies of mission states across this ATO cycle." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:4 }}>
          CYCLE STATUS
        </div>
        <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
          <TfcTallyCard label="MISSIONS"  value={tally.total}    color={C.fg1}                                       tip="MISSIONS — Total scheduled missions in this ATO cycle." />
          <TfcTallyCard label="AIRBORNE"  value={tally.airborne} color={C.accent}                                    tip="AIRBORNE — Missions currently in the air (post takeoff, pre debrief)." />
          <TfcTallyCard label="IN WINDOW" value={tally.inWindow} color={C.normal}                                    tip="IN WINDOW — Missions inside their assigned tasking window (between NET and NLT)." />
          <TfcTallyCard label="IMPACTED"  value={tally.impacted} color={tally.impacted>0?C.critical:C.fg3}           tip="IMPACTED — Missions with one or more open impact flags (weather, asset loss, coord gap, etc.)." />
          <TfcTallyCard label="SERVICED"  value={tally.serviced} color={C.normal}                                    tip="SERVICED — Missions completed successfully against tasking." />
          <TfcTallyCard label="SCRUBBED"  value={tally.scrubbed} color={C.fg4}                                       tip="SCRUBBED — Missions cancelled before completion." />
          <TfcTallyCard label="RETASKED"  value={tally.retasked} color={C.advisory}                                  tip="RETASKED — Missions diverted off original tasking to a new target or role." />
        </div>
      </div>
      <div>
        <div title="OPERATOR HOLDS — Items currently held for MC decision. Counts shown drive the red 'DECISIONS PENDING' chip above." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:4 }}>
          OPERATOR HOLDS
        </div>
        <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
          <TfcHoldRow label="Held crew briefs (JTBD-11)"   count={heldBriefs} color={C.advisory}/>
          <TfcHoldRow label="Coord-gap alerts (JTBD-12)"   count={heldCoord}  color={heldCoord>0?C.critical:C.fg3}/>
          <TfcHoldRow label="NLT breach prompts (JTBD-16)" count={heldNlt}    color={heldNlt>0?C.critical:C.fg3}/>
        </div>
      </div>
      <div>
        <div title="IMPACTED MISSIONS — Missions with one or more open impact flags. Click a row to open its detail drawer." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:4 }}>
          IMPACTED MISSIONS
        </div>
        <TfcImpactList runtime={runtime}/>
      </div>
    </div>
  );
}

function TfcTallyCard({ label, value, color, tip }) {
  // PR 8: tighter min-width so 3 cards fit per row in the 340px
  // left column without overflowing.
  return (
    <div title={tip} style={{ minWidth:62, flex:'1 1 62px', padding:'6px 8px', background:C.s2,
      border:`1px solid ${C.border}`, borderRadius:3 }}>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5, color:C.fg3, letterSpacing:'0.10em' }}>{label}</div>
      <div style={{ fontFamily:'Roboto Mono', fontSize:16, fontWeight:600, color, marginTop:2 }}>{value}</div>
    </div>
  );
}

// ── MissionListPanel ─────────────────────────────────────────
// Vertical list of every mission in the cycle, grouped by type-band
// (same groupings the TFC Gantt uses, sans the time bars). Lives in
// the COP right rail alongside the TFC blocks so the operator can
// scan the whole ATO without leaving the COP. Click any row to
// select the mission — same `onSelectMission` contract the Cycle
// Board rows use, so selection state stays in sync with TFC.
function MissionListPanel({ runtime, selectedMissionId, onSelectMission, searchQuery = '', viewMode = 'missions', onOpenTab = () => {} }) {
  const missions = (runtime && runtime.missions) || [];
  // Filter missions by search query (searches callsign, type, variant)
  const filteredMissions = searchQuery.trim()
    ? missions.filter(m => {
        const q = searchQuery.toLowerCase();
        return (
          (m.callsign || '').toLowerCase().includes(q) ||
          (m.type || '').toLowerCase().includes(q) ||
          (m.variant || '').toLowerCase().includes(q)
        );
      })
    : missions;

  // Callsigns view: compact cards sorted by alert level (red > yellow > gray), then alphabetically
  if (viewMode === 'callsigns') {
    const getSeverity = (m) => {
      const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
      const hasCritical = openFlags.some(f => f.severity === 'critical');
      const hasAnyFlag = openFlags.length > 0;
      if (hasCritical) return 0; // red - highest priority
      if (hasAnyFlag) return 1; // yellow
      return 2; // gray - lowest priority
    };
    const sortedMissions = [...filteredMissions].sort((a, b) => {
      const severityDiff = getSeverity(a) - getSeverity(b);
      if (severityDiff !== 0) return severityDiff;
      return (a.callsign || '').localeCompare(b.callsign || '');
    });
    return (
      <div style={{ display:'flex', flexDirection:'column', padding:'8px 0' }}>
        {sortedMissions.map(m => {
          const selected = m.id === selectedMissionId;
          const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
          const hasCritical = openFlags.some(f => f.severity === 'critical');
          const hasAnyFlag = openFlags.length > 0;

          let cardBg = C.s2, borderColor = C.border, leftBorderColor = C.border, hoverBg = C.s3;
          if (hasCritical) {
            cardBg = 'rgba(220, 38, 38, 0.15)'; // red tint
            borderColor = 'rgba(220, 38, 38, 0.4)';
            leftBorderColor = 'rgba(220, 38, 38, 0.8)';
            hoverBg = 'rgba(220, 38, 38, 0.22)';
          } else if (hasAnyFlag) {
            cardBg = 'rgba(245, 158, 11, 0.15)'; // yellow tint
            borderColor = 'rgba(245, 158, 11, 0.4)';
            leftBorderColor = 'rgba(245, 158, 11, 0.8)';
            hoverBg = 'rgba(245, 158, 11, 0.22)';
          }

          if (selected) {
            cardBg = C.accentM;
            borderColor = C.accent;
            leftBorderColor = C.accent;
          }

          const severity = hasCritical ? 'critical' : (hasAnyFlag ? 'advisory' : null);

          return (
            <button
              key={m.id}
              onClick={() => {
                onOpenTab(m.id, m.callsign, 'callsign', severity);
                onSelectMission && onSelectMission(m.id);
              }}
              style={{
                display:'flex', alignItems:'center', width:'calc(100% - 24px)',
                gap:10, padding:'8px 12px',
                background: cardBg,
                border: `1px solid ${borderColor}`,
                borderLeft: `4px solid ${leftBorderColor}`,
                clipPath: 'polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%)',
                cursor:'pointer', textAlign:'left',
                transition:'all 0.12s',
                margin:'4px 12px',
                position:'relative',
                boxShadow: selected ? `0 0 0 1px ${C.accent}` : '0 1px 3px rgba(0,0,0,0.2)',
              }}
              onMouseEnter={e => {
                if (!selected) {
                  e.currentTarget.style.background = hoverBg;
                  e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
                }
              }}
              onMouseLeave={e => {
                if (!selected) {
                  e.currentTarget.style.background = cardBg;
                  e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.2)';
                }
              }}
            >
              {/* Border line for angled corner */}
              <svg style={{ position:'absolute', top:-1, right:-1, width:10, height:10, pointerEvents:'none' }} viewBox="0 0 10 10">
                <line x1="0" y1="0" x2="10" y2="10" stroke={selected ? C.accent : C.border} strokeWidth="1.5"/>
              </svg>
              <TfcPlatformIcon acftType={m.acftType} size={16}/>
              <span style={{ flex:1, fontFamily:'Roboto', fontSize:12, fontWeight:600, color:C.fg1 }}>
                {m.callsign}
              </span>
              {hasAnyFlag && (
                <div style={{
                  width:6, height:6, borderRadius:'50%',
                  background: hasCritical ? C.critical : C.advisory,
                  animation: hasCritical ? 'pulseFade 0.8s infinite' : 'none',
                  flexShrink:0,
                }}/>
              )}
              {m.variant && (
                <span data-typo="label" style={{
                  fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
                  letterSpacing:'0.08em',
                }}>
                  {m.variant}
                </span>
              )}
            </button>
          );
        })}
      </div>
    );
  }

  // Missions view: cards represent mission packages/groups containing callsigns
  // Group missions by type (each type represents a mission package)
  const windowed = filteredMissions.filter(m =>
    !(m.status === 'alert' || m.netScenario == null || m.nltScenario == null)
  );
  const alerts = filteredMissions.filter(m =>
    m.status === 'alert' || m.netScenario == null || m.nltScenario == null
  );

  // Helper to get severity level of a mission group (0=red, 1=yellow, 2=gray)
  const getGroupSeverity = (missions) => {
    const hasCritical = missions.some(m => {
      const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
      return openFlags.some(f => f.severity === 'critical');
    });
    const hasImpact = missions.some(m => {
      const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
      return openFlags.length > 0;
    });
    if (hasCritical) return 0; // red
    if (hasImpact) return 1; // yellow
    return 2; // gray
  };

  const groups = TFC_GANTT_TYPES.map(type => ({
    type, items: windowed.filter(m => m.type === type),
  })).filter(g => g.items.length > 0);

  // Sort groups by alert level (red > yellow > gray)
  const sortedGroups = [...groups].sort((a, b) =>
    getGroupSeverity(a.items) - getGroupSeverity(b.items)
  );

  // Create alerts group
  const alertsGroup = alerts.length > 0 ? {
    type: 'ALERT POSTURE',
    items: alerts,
    alertSection: true,
    severity: getGroupSeverity(alerts)
  } : null;

  // Combine and sort all groups by severity
  const allGroups = alertsGroup
    ? [alertsGroup, ...sortedGroups].sort((a, b) => getGroupSeverity(a.items) - getGroupSeverity(b.items))
    : sortedGroups;

  return (
    <div style={{ display:'flex', flexDirection:'column', padding:'8px 0' }}>
      {allGroups.map(g => (
        <MissionGroupCard
          key={g.type}
          type={g.type}
          missions={g.items}
          selectedMissionId={selectedMissionId}
          onSelectMission={onSelectMission}
          onOpenTab={onOpenTab}
          alertSection={g.alertSection}
        />
      ))}
    </div>
  );
}

// Mission name mapping - cool operation names
const MISSION_NAMES = {
  'DUKE': 'IRON ANVIL',
  'BRONCO': 'RED DAGGER',
  'PETER11': 'SILENT THUNDER',
  'DUDE11': 'PHANTOM REACH',
  'SHOGUN': 'STEEL CURTAIN',
  'WOLF': 'CRIMSON HAWK',
  'PANTON': 'VIPER STRIKE',
  'GRIFFIN': 'STORM BREAKER',
  'BANDIT11': 'NIGHT FALCON',
  'BANDIT21': 'EAGLE TALON',
  'FALCON': 'THUNDER GUARD',
  'PROWLER41': 'IRON FURY',
  'ICEMAN': 'FROZEN DAGGER',
};

// Mission Group Card - represents a mission package containing multiple callsigns
function MissionGroupCard({ type, missions, selectedMissionId, onSelectMission, onOpenTab = () => {}, alertSection }) {
  const typeLong = (TFC_TYPE_LONG[type] || type);

  // Get mission details from first mission (representative of the group)
  const firstMission = missions[0];

  // Generate mission name from mission ID or use cool operation names
  const missionName = (() => {
    if (!firstMission?.id) return `PACKAGE ${type}`;
    const missionKey = firstMission.id.replace('mission-', '').toUpperCase();
    return MISSION_NAMES[missionKey] || `PKG ${missionKey}`;
  })();

  const netTime = firstMission?.netScenario ? `NET ${firstMission.netScenario}` : null;
  const nltTime = firstMission?.nltScenario ? `NLT ${firstMission.nltScenario}` : null;
  const timeWindow = alertSection
    ? 'Strip Alert · No Scheduled Window'
    : [netTime, nltTime].filter(Boolean).join(' / ') || 'Time TBD';
  const missionVariant = firstMission?.variant || null;

  const hasSelected = missions.some(m => m.id === selectedMissionId);
  const hasCritical = missions.some(m => {
    const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
    return openFlags.some(f => f.severity === 'critical');
  });
  const hasImpact = missions.some(m => {
    const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
    return openFlags.length > 0;
  });

  let statusLabel = null, statusColor = C.fg3, cardBg = C.s2, borderColor = C.border, leftBorderColor = C.border;
  if (hasCritical) {
    statusLabel = 'CRITICAL';
    statusColor = C.critical;
    cardBg = 'rgba(220, 38, 38, 0.15)'; // red tint
    borderColor = hasSelected ? C.accent : 'rgba(220, 38, 38, 0.4)';
    leftBorderColor = 'rgba(220, 38, 38, 0.8)';
  } else if (hasImpact) {
    statusLabel = 'IMPACT';
    statusColor = C.advisory;
    cardBg = 'rgba(245, 158, 11, 0.15)'; // yellow tint
    borderColor = hasSelected ? C.accent : 'rgba(245, 158, 11, 0.4)';
    leftBorderColor = 'rgba(245, 158, 11, 0.8)';
  } else if (alertSection) {
    statusLabel = 'ALERT';
    statusColor = C.advisory;
    cardBg = 'rgba(245, 158, 11, 0.15)'; // yellow tint
    borderColor = hasSelected ? C.accent : 'rgba(245, 158, 11, 0.4)';
    leftBorderColor = 'rgba(245, 158, 11, 0.8)';
  }

  if (hasSelected) {
    cardBg = C.accentM;
    leftBorderColor = C.accent;
  }

  const severity = hasCritical ? 'critical' : (hasImpact || alertSection ? 'advisory' : null);

  return (
    <div
      onClick={() => {
        // Click on card itself opens mission tab
        const missionGroupId = `mission-${type}`;
        onOpenTab(missionGroupId, missionName, 'mission', severity);
      }}
      style={{
        display:'flex', flexDirection:'column', width:'calc(100% - 24px)',
        gap:8, padding:'12px',
        background: cardBg,
        border: `1px solid ${hasSelected ? C.accent : borderColor}`,
        borderLeft: `4px solid ${leftBorderColor}`,
        clipPath: 'polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%)',
        textAlign:'left',
        margin:'8px 12px',
        position:'relative',
        cursor:'pointer',
        boxShadow: hasSelected ? `0 0 0 1px ${C.accent}` : '0 1px 3px rgba(0,0,0,0.2)',
      }}>
      {/* Border line for angled corner */}
      <svg style={{ position:'absolute', top:-1, right:-1, width:14, height:14, pointerEvents:'none' }} viewBox="0 0 14 14">
        <line x1="0" y1="0" x2="14" y2="14" stroke={hasSelected ? C.accent : C.border} strokeWidth="1.5"/>
      </svg>

      {/* Card header: Mission title and status */}
      <div style={{ display:'flex', alignItems:'center', gap:10, justifyContent:'space-between', marginBottom:8 }}>
        <div style={{ flex:1 }}>
          <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.12em', textTransform:'uppercase', marginBottom:2 }}>
            MISSION TITLE
          </div>
          <span style={{ fontFamily:'Roboto', fontSize:13, fontWeight:700, color:C.fg1 }}>
            {missionName}
          </span>
        </div>
        {statusLabel && (
          <span data-typo="label" style={{
            fontFamily:'Roboto Mono', fontSize:8, fontWeight:600,
            color: statusColor, letterSpacing:'0.10em',
            padding:'3px 7px', background:`${statusColor}22`, borderRadius:3,
            flexShrink:0,
          }}>{statusLabel}</span>
        )}
      </div>

      {/* Mission details */}
      <div style={{ display:'flex', flexDirection:'column', gap:4, paddingLeft:0, paddingBottom:6 }}>
        <div style={{ display:'flex', alignItems:'center', gap:6 }}>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em', textTransform:'uppercase', minWidth:60 }}>
            MISSION TYPE
          </span>
          <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
            {type}
          </span>
        </div>
        <div style={{ display:'flex', alignItems:'center', gap:6 }}>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em', textTransform:'uppercase', minWidth:60 }}>
            TIME
          </span>
          <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
            {timeWindow}
          </span>
        </div>
        {missionVariant && (
          <div style={{ display:'flex', alignItems:'center', gap:6 }}>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em', textTransform:'uppercase', minWidth:60 }}>
              VARIANT
            </span>
            <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
              {missionVariant}
            </span>
          </div>
        )}
        <div style={{ display:'flex', alignItems:'center', gap:6 }}>
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em', textTransform:'uppercase', minWidth:60 }}>
            ASSETS
          </span>
          <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
            {missions.length} Callsign{missions.length !== 1 ? 's' : ''}
          </span>
        </div>
      </div>

      {/* Callsigns list */}
      <div style={{ display:'flex', flexDirection:'column', gap:2, paddingLeft:0 }}>
        {missions.map(m => {
          const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
          const hasCriticalFlag = openFlags.some(f => f.severity === 'critical');
          const hasAnyFlag = openFlags.length > 0;
          const callsignSeverity = hasCriticalFlag ? 'critical' : (hasAnyFlag ? 'advisory' : null);

          return (
            <button
              key={m.id}
              onClick={() => {
                onOpenTab(m.id, m.callsign, 'callsign', callsignSeverity);
                onSelectMission && onSelectMission(m.id);
              }}
            style={{
              display:'flex', alignItems:'center', gap:8,
              padding:'6px 8px',
              background: m.id === selectedMissionId ? C.accentM : 'transparent',
              border:`1px solid ${m.id === selectedMissionId ? C.accent : 'transparent'}`,
              borderRadius:2,
              cursor:'pointer', textAlign:'left',
              transition:'all 0.12s',
            }}
            onMouseEnter={e => { if (m.id !== selectedMissionId) e.currentTarget.style.background = C.s3; }}
            onMouseLeave={e => { if (m.id !== selectedMissionId) e.currentTarget.style.background = 'transparent'; }}
          >
            <TfcPlatformIcon acftType={m.acftType} size={14}/>
            <span style={{ fontFamily:'Roboto', fontSize:11, fontWeight:600, color:C.fg1 }}>
              {m.callsign}
            </span>
            {m.variant && (
              <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.08em' }}>
                {m.variant}
              </span>
            )}
          </button>
          );
        })}
      </div>
    </div>
  );
}

function MissionListGroup({ type, missions, selectedMissionId, onSelectMission, alertSection }) {
  const typeLong = alertSection
    ? 'Strip-alert · no scheduled window'
    : (TFC_TYPE_LONG[type] || type);
  return (
    <div>
      {/* Type-band header — quieter than the Gantt's centered band since
          this is a denser list view. */}
      <div style={{
        padding:'8px 12px 6px',
        background:C.s2,
        borderTop:`1px solid ${C.border}`,
        borderBottom:`1px solid ${C.border}`,
      }}>
        <div data-typo="label" style={{
          fontFamily:'Roboto Mono', fontSize:9, color:C.fg3,
          letterSpacing:'0.10em', textTransform:'uppercase',
          lineHeight:1,
        }}>
          {typeLong} · {missions.length}
        </div>
      </div>
      {missions.map((m, i) => (
        <MissionListRow
          key={m.id}
          mission={m}
          selected={m.id === selectedMissionId}
          onSelect={() => onSelectMission && onSelectMission(m.id)}
          zebra={i % 2 === 1}
          alertRow={alertSection}
        />
      ))}
    </div>
  );
}

function MissionListRow({ mission: m, selected, onSelect, zebra, alertRow }) {
  const openFlags = (m.impactFlags || []).filter(f => !f.actedOn);
  const hasCritical = openFlags.some(f => f.severity === 'critical');
  const hasAnyFlag = openFlags.length > 0;
  const isServiced = m.outcome === 'serviced';
  const isScrubbed = m.outcome === 'scrubbed';
  const isRetasked = m.outcome === 'retasked';
  const callsignColor = isScrubbed ? C.fg4 : C.fg1;
  // Status pill at the right edge — concise summary of where the
  // mission stands. Outcome > impact > alert > schedule.
  let statusLabel = null, statusColor = C.fg3;
  if (isServiced)         { statusLabel = 'SERVICED'; statusColor = C.normal; }
  else if (isScrubbed)    { statusLabel = 'SCRUBBED'; statusColor = C.fg4; }
  else if (isRetasked)    { statusLabel = 'RETASKED'; statusColor = C.advisory; }
  else if (hasCritical)   { statusLabel = 'CRITICAL'; statusColor = C.critical; }
  else if (hasAnyFlag)    { statusLabel = 'IMPACT';   statusColor = C.advisory; }
  else if (alertRow)      { statusLabel = 'ALERT';    statusColor = C.advisory; }
  return (
    <button
      onClick={onSelect}
      title={`${m.callsign} · ${m.type} · ${m.variant || ''}`.trim().replace(/\s+·\s+$/, '')}
      style={{
        display:'flex', flexDirection:'column', width:'calc(100% - 24px)',
        gap:8, padding:'12px',
        background: selected ? C.accentM : C.s2,
        border: `1px solid ${selected ? C.accent : C.border}`,
        clipPath: 'polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%)',
        cursor:'pointer', textAlign:'left',
        transition:'all 0.12s',
        margin:'8px 12px',
        position:'relative',
        boxShadow: selected ? `0 0 0 1px ${C.accent}` : '0 1px 3px rgba(0,0,0,0.2)',
      }}
      onMouseEnter={e => {
        if (!selected) {
          e.currentTarget.style.background = C.s3;
          e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
        }
      }}
      onMouseLeave={e => {
        if (!selected) {
          e.currentTarget.style.background = C.s2;
          e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.2)';
        }
      }}
    >
      {/* Border line for angled corner */}
      <svg style={{ position:'absolute', top:-1, right:-1, width:14, height:14, pointerEvents:'none' }} viewBox="0 0 14 14">
        <line x1="0" y1="0" x2="14" y2="14" stroke={selected ? C.accent : C.border} strokeWidth="1.5"/>
      </svg>
      {/* Card header: icon, callsign, status */}
      <div style={{ display:'flex', alignItems:'center', gap:10 }}>
        <TfcPlatformIcon acftType={m.acftType} size={20}/>
        <div style={{ flex:1, minWidth:0 }}>
          <div style={{ display:'flex', alignItems:'center', gap:6 }}>
            <span style={{
              fontFamily:'Roboto', fontSize:13, fontWeight:700,
              color: callsignColor,
              overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
            }}>{m.callsign}</span>
            {hasAnyFlag && (
              <div style={{
                width:7, height:7, borderRadius:'50%',
                background: hasCritical ? C.critical : C.advisory,
                animation: hasCritical ? 'pulseFade 0.8s infinite' : 'none',
                flexShrink:0,
              }}/>
            )}
          </div>
        </div>
        {statusLabel && (
          <span data-typo="label" style={{
            fontFamily:'Roboto Mono', fontSize:8, fontWeight:600,
            color: statusColor, letterSpacing:'0.10em',
            padding:'3px 7px', background:`${statusColor}22`, borderRadius:3,
            flexShrink:0,
          }}>{statusLabel}</span>
        )}
      </div>
      {/* Card body: mission details */}
      <div style={{ display:'flex', flexDirection:'column', gap:4, paddingLeft:30 }}>
        <div style={{ display:'flex', alignItems:'center', gap:8 }}>
          <span data-typo="label" style={{
            fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
            letterSpacing:'0.08em', textTransform:'uppercase',
          }}>Type</span>
          <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
            {m.type}
          </span>
        </div>
        {m.variant && (
          <div style={{ display:'flex', alignItems:'center', gap:8 }}>
            <span data-typo="label" style={{
              fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
              letterSpacing:'0.08em', textTransform:'uppercase',
            }}>Variant</span>
            <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>
              {m.variant}
            </span>
          </div>
        )}
      </div>
    </button>
  );
}

function TfcHoldRow({ label, count, color }) {
  // SME-REVIEW: tooltip needs verification
  const tipMap = {
    'Held crew briefs (JTBD-11)': 'Held crew briefs (JTBD-11) — Briefs paused awaiting MC release. Common when intel or coord is still being resolved.',
    'Coord-gap alerts (JTBD-12)': 'Coord-gap alerts (JTBD-12) — Missions missing a required coordination item (controller, tanker, datalink, etc.).',
    'NLT breach prompts (JTBD-16)': 'NLT breach prompts (JTBD-16) — Missions trending toward Not-Later-Than time slip; MC decision required.',
  };
  return (
    <div title={tipMap[label] || label} style={{ display:'flex', alignItems:'center', gap:10, padding:'6px 10px',
      background:C.s2, border:`1px solid ${C.border}`, borderRadius:3 }}>
      <span style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>{label}</span>
      <div style={{ flex:1 }}/>
      <span style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color }}>{count}</span>
    </div>
  );
}

function TfcImpactList({ runtime }) {
  const impacted = (runtime.missions || []).filter(m => (m.impactFlags || []).length > 0);
  if (impacted.length === 0) {
    return (
      <div style={{ padding:'10px 12px', background:C.s2, border:`1px solid ${C.border}`,
        borderRadius:3, fontFamily:'Roboto', fontSize:11, color:C.fg3 }}>
        No active impact flags. Cycle nominal.
      </div>
    );
  }
  return (
    <div style={{ display:'flex', flexDirection:'column', gap:4 }}>
      {impacted.slice(0, 8).map(m => (
        <div key={m.id} title={`${m.callsign} — ${m.type}. ${m.impactFlags.length} impact flag${m.impactFlags.length === 1 ? '' : 's'}. Click in the Cycle Board or Gantt to open mission detail.`} style={{ padding:'7px 10px', background:C.s2, border:`1px solid ${C.border}`,
          borderLeft:`3px solid ${(m.impactFlags || []).some(f => f.severity==='critical') ? C.critical : C.advisory}`,
          borderRadius:3 }}>
          <div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
            <span style={{ fontFamily:'Roboto', fontSize:11, fontWeight:600, color:C.fg1 }}>{m.callsign}</span>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em' }}>{m.type}</span>
          </div>
          {m.impactFlags.slice(0, 2).map((f, i) => (
            <div key={i} style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2, lineHeight:1.4, marginTop:2 }}>
              {f.detail}
            </div>
          ))}
        </div>
      ))}
      {impacted.length > 8 && (
        <div style={{ padding:'6px 10px', fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>
          + {impacted.length - 8} more
        </div>
      )}
    </div>
  );
}

// ── PR 13: Mission Detail drawer ────────────────────────────
// Wraps the existing TfcMissionDetail in an overlay shell that slides
// in from the right edge of the TFC workspace. Renders nothing when
// no mission is selected; slides out + cleans up when the operator
// closes (× / ESC / click-outside the drawer / select null).
//
// The drawer is anchored to the TFC workspace area (the absolute-
// positioned overlay inside the App layout sandwich), so it slides
// from inside the workspace's right edge — not the viewport's.
// Backdrop dim is 40% black; clicking it closes the drawer.
function TfcMissionDetailDrawer({ mission, runtime, onSelectMission, onActionFired, onClose }) {
  const isOpen = !!mission;

  // ESC closes. Listener installed only when open so we don't fight
  // for the keydown event in other states.
  React.useEffect(() => {
    if (!isOpen) return;
    const handler = (e) => {
      if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [isOpen, onClose]);

  // Render dimmed backdrop + slide-in panel. We keep the panel
  // mounted (with a `visibility:hidden` when closed) so React can
  // animate transitions cleanly without unmount/remount churn.
  return (
    <>
      {/* Backdrop — covers the workspace area, NOT the viewport, so
          the left rail and contingency tray stay interactive. Fades
          in/out via opacity. */}
      <div
        onClick={onClose}
        style={{
          position:'absolute', inset:0, zIndex:50,
          background:'rgba(0,0,0,0.40)',
          opacity: isOpen ? 1 : 0,
          pointerEvents: isOpen ? 'auto' : 'none',
          transition:'opacity 0.18s ease',
        }}
      />
      {/* Slide-in panel — anchored to the right edge of the TFC
          workspace area. Width matches the previous right rail
          (360px). */}
      <div
        role="dialog"
        aria-label={mission ? `Mission Detail: ${mission.callsign}` : 'Mission Detail'}
        data-tour="tfc-mission-detail-drawer"
        style={{
          position:'absolute', top:0, bottom:0, right:0,
          width:360, zIndex:60,
          background:C.s1, borderLeft:`1px solid ${C.border2}`,
          display:'flex', flexDirection:'column',
          boxShadow: isOpen ? '-12px 0 32px rgba(0,0,0,0.5)' : 'none',
          transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
          transition:'transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1), box-shadow 0.18s ease',
          pointerEvents: isOpen ? 'auto' : 'none',
        }}
      >
        {mission && (
          <TfcMissionDetail
            mission={mission}
            runtime={runtime}
            onSelectMission={onSelectMission}
            onActionFired={onActionFired}
            onClose={onClose}
          />
        )}
      </div>
    </>
  );
}

// ── Mission Detail (drawer body) ─────────────────────────────
function TfcMissionDetail({ mission: m, runtime, onSelectMission, onActionFired, onClose }) {
  const [selectedTab, setSelectedTab] = React.useState('details');
  console.log('TfcMissionDetail rendering, selectedTab:', selectedTab);

  if (!m) {
    return (
      <div style={{ padding:24, fontFamily:'Roboto', fontSize:12, color:C.fg3 }}>
        <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
          letterSpacing:'0.16em', marginBottom:6 }}>MISSION DETAIL</div>
        Select a mission from the Cycle Board to view its full record.
      </div>
    );
  }
  return (
    <>
      <div style={{ padding:'14px 16px', borderBottom:`1px solid ${C.border}`,
        display:'flex', alignItems:'flex-start', gap:8 }}>
        <div style={{ flex:1, minWidth:0 }}>
          <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>
            MISSION DETAIL · {m.fragId}
          </div>
          <div style={{ fontFamily:'Roboto', fontSize:16, fontWeight:700, color:C.fg1, marginTop:2 }}>
            {m.callsign}
          </div>
          <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2, marginTop:2 }}>
            {m.taskunit} · {m.type} · {m.variant} · {m.package}
          </div>
        </div>
        {onClose && (
          <button
            onClick={onClose}
            title="Close (ESC)"
            style={{
              flexShrink:0, width:26, height:26, borderRadius:3,
              background:'transparent', border:`1px solid ${C.border2}`,
              color:C.fg3, cursor:'pointer', padding:0,
              display:'flex', alignItems:'center', justifyContent:'center',
              fontSize:14, lineHeight:1,
              transition:'background 0.12s, color 0.12s, border-color 0.12s',
            }}
            onMouseEnter={e => {
              e.currentTarget.style.background = C.s3;
              e.currentTarget.style.color = C.fg1;
              e.currentTarget.style.borderColor = C.fg3;
            }}
            onMouseLeave={e => {
              e.currentTarget.style.background = 'transparent';
              e.currentTarget.style.color = C.fg3;
              e.currentTarget.style.borderColor = C.border2;
            }}
          >
            ✕
          </button>
        )}
      </div>

      {/* Tabs */}
      <div style={{ display:'flex', borderBottom:`1px solid ${C.border}`, padding:'0 16px' }}>
        <button
          onClick={() => setSelectedTab('details')}
          style={{
            padding:'10px 16px',
            background: 'transparent',
            border:'none',
            borderBottom: selectedTab === 'details' ? `2px solid ${C.accent}` : '2px solid transparent',
            color: selectedTab === 'details' ? C.fg1 : C.fg3,
            fontFamily:'Roboto Mono',
            fontSize:9,
            fontWeight:600,
            letterSpacing:'0.10em',
            textTransform:'uppercase',
            cursor:'pointer',
            transition:'all 0.15s ease',
            marginBottom:'-1px',
          }}
          onMouseEnter={(e) => {
            if (selectedTab !== 'details') e.currentTarget.style.color = C.fg2;
          }}
          onMouseLeave={(e) => {
            if (selectedTab !== 'details') e.currentTarget.style.color = C.fg3;
          }}
        >
          Details
        </button>
        <button
          onClick={() => setSelectedTab('timeline')}
          style={{
            padding:'10px 16px',
            background: 'transparent',
            border:'none',
            borderBottom: selectedTab === 'timeline' ? `2px solid ${C.accent}` : '2px solid transparent',
            color: selectedTab === 'timeline' ? C.fg1 : C.fg3,
            fontFamily:'Roboto Mono',
            fontSize:9,
            fontWeight:600,
            letterSpacing:'0.10em',
            textTransform:'uppercase',
            cursor:'pointer',
            transition:'all 0.15s ease',
            marginBottom:'-1px',
          }}
          onMouseEnter={(e) => {
            if (selectedTab !== 'timeline') e.currentTarget.style.color = C.fg2;
          }}
          onMouseLeave={(e) => {
            if (selectedTab !== 'timeline') e.currentTarget.style.color = C.fg3;
          }}
        >
          Timeline
        </button>
      </div>

      {selectedTab === 'details' && (
        <div style={{ flex:1, overflowY:'auto', padding:'14px 16px', display:'flex', flexDirection:'column', gap:14 }}>
        {/* PR 11 — DECISION REQUIRED block. Renders only when the
            mission has outstanding (un-acted-on) impact flags or
            held alerts that require MC/SL judgement. */}
        <TfcDecisionBlock mission={m} runtime={runtime} onActionFired={onActionFired}/>
        {/* Window */}
        <TfcDetailBlock label="WINDOW">
          <TfcDetailRow k="NET" v={m.netZulu || '—'}/>
          <TfcDetailRow k="NLT" v={m.nltZulu || '—'}/>
          <TfcDetailRow k="STATE" v={(m.outcome || m.windowState || '').toUpperCase()}/>
          {m.outcome && (
            <TfcDetailRow k="OUTCOME" v={m.outcome.toUpperCase()}/>
          )}
        </TfcDetailBlock>

        {/* Aircraft + load */}
        <TfcDetailBlock label={m.isFlight ? 'FLIGHT' : 'AIRCRAFT'}>
          <TfcDetailRow k="QTY"  v={`${m.qty}× ${m.acftType || '—'}`}/>
          <TfcDetailRow k="DEP"  v={m.depIcao || '—'}/>
          <TfcDetailRow k="ARR"  v={m.arrIcao || '—'}/>
          <TfcDetailRow k="SCL"  v={m.scl || '—'}/>
        </TfcDetailBlock>

        {m.airframeRoster && m.airframeRoster.length > 0 && (
          <TfcDetailBlock label={m.isFlight ? 'FLIGHT ROSTER' : 'AIRFRAMES'}>
            {m.airframeRoster.map((a, i) => {
              const isLost = a.status === 'lost';
              const isLead = a.role === 'lead';
              // Icon color encodes status: red for lost, accent for lead,
              // fg2 otherwise. Replaces the prior status dot — keeps the
              // visual cue while making the platform type instantly
              // recognizable.
              const iconColor = isLost ? C.critical : (isLead ? C.accent : C.fg2);
              return (
                <div key={a.id} style={{ display:'flex', alignItems:'center', gap:10,
                  padding:'4px 0', borderTop: i > 0 ? `1px solid ${C.border}` : 'none' }}>
                  <TfcPlatformIcon acftType={a.type} color={iconColor} size={20}/>
                  <div style={{ flex:1, minWidth:0 }}>
                    <div style={{ display:'flex', gap:6, alignItems:'baseline' }}>
                      <span title="Individual airframe callsign within this flight." style={{ fontFamily:'Roboto', fontSize:11, fontWeight:600,
                        color: isLost ? C.fg4 : C.fg1,
                        textDecoration: isLost ? 'line-through' : 'none' }}>
                        {a.callsign}
                      </span>
                      <span title={a.role === 'lead' ? 'LEAD — Flight lead. Briefs comms, calls fights on/off, owns mission decisions.' : a.role === 'wingman' ? 'WINGMAN — Wingman position. Mutual support, weapons employment per ROE.' : 'Crew role within the flight.'} data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
                        color: isLead ? C.accent : C.fg3, letterSpacing:'0.10em' }}>
                        {(a.role || 'crew').toUpperCase()}
                      </span>
                    </div>
                    <div title="Airframe type and tail number." style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, marginTop:1 }}>
                      {a.type} · {a.tailNum}
                    </div>
                  </div>
                  {isLost && (
                    <span title="LOST — Airframe is no longer mission-capable (combat loss, mechanical, etc.)." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
                      fontWeight:600, color:C.critical, letterSpacing:'0.10em' }}>LOST</span>
                  )}
                </div>
              );
            })}
          </TfcDetailBlock>
        )}

        {m.loadTable && m.loadTable.length > 0 && (
          <TfcDetailBlock label="LOAD">
            {m.loadTable.map((w, i) => (
              <TfcDetailRow key={i} k={w.code} v={`${w.qty}× ${w.name}`}
                title={`${w.code} — ${w.name}. Quantity loaded: ${w.qty}.`}/>
            ))}
          </TfcDetailBlock>
        )}

        {m.target && (
          <TfcDetailBlock label="TARGET">
            <TfcDetailRow k="NAME"   v={m.target.name}/>
            {m.target.id     && <TfcDetailRow k="ID"     v={m.target.id}/>}
            {m.target.detail && <TfcDetailRow k="DETAIL" v={m.target.detail}/>}
            {m.target.geo    && (
              <TfcDetailRow k="DMPI"   v={`${m.target.geo.lat.toFixed(4)}°N ${m.target.geo.lng.toFixed(4)}°E`}/>
            )}
            {m.target.elevFt != null && <TfcDetailRow k="ELEV" v={`${m.target.elevFt} ft MSL`}/>}
          </TfcDetailBlock>
        )}

        {m.capOrbitGeo && (
          <TfcDetailBlock label="CAP / ORBIT">
            <TfcDetailRow k="CENTER" v={`${m.capOrbitGeo.centerLat.toFixed(3)}°N ${m.capOrbitGeo.centerLng.toFixed(3)}°E`}/>
            <TfcDetailRow k="RADIUS" v={`${m.capOrbitGeo.radiusNm} NM`}/>
            <TfcDetailRow k="ALT"    v={`${m.capOrbitGeo.altMinFt}–${m.capOrbitGeo.altMaxFt} ft`}/>
            {m.capOrbitGeo.anchorTrack && <TfcDetailRow k="ANCHOR" v={m.capOrbitGeo.anchorTrack}/>}
          </TfcDetailBlock>
        )}

        {m.controllerCard && (
          <TfcDetailBlock label="CONTROLLER">
            <TfcDetailRow k="UNIT"   v={m.controllerCard.controllingUnit || '—'}/>
            <TfcDetailRow k="CS"     v={m.controllerCard.controllerCallsign || '—'}/>
            <TfcDetailRow k="P FREQ" v={m.controllerCard.primaryFreq || '—'}/>
            <TfcDetailRow k="S FREQ" v={m.controllerCard.secondaryFreq || '—'}/>
            {m.controllerCard.poc && (
              <TfcDetailRow k="POC" v={`${m.controllerCard.poc.rank || ''} ${m.controllerCard.poc.name || ''}`.trim()}/>
            )}
          </TfcDetailBlock>
        )}

        {m.commsBinding && (
          <TfcDetailBlock label="COMMS (SPINS)">
            <TfcDetailRow k="NET"   v={m.commsBinding.net || '—'}/>
            <TfcDetailRow k="FREQ"  v={m.commsBinding.freq || '—'}/>
            {m.commsBinding.rescueRef && <TfcDetailRow k="RESCUE" v={m.commsBinding.rescueRef}/>}
          </TfcDetailBlock>
        )}

        {m.roeTags && m.roeTags.length > 0 && (
          <TfcDetailBlock label="ROE">
            {m.roeTags.map((tag, i) => (
              <div key={i} title="ROE tag — Rules of Engagement constraint applicable to this mission (engagement authority, weapon restrictions, target sets, etc.)." style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg2,
                padding:'2px 0' }}>{tag}</div>
            ))}
          </TfcDetailBlock>
        )}

        {m.impactFlags && m.impactFlags.length > 0 && (
          <TfcDetailBlock label="IMPACT FLAGS" accent={C.critical}>
            {m.impactFlags.map((f, i) => {
              const flagColor = f.severity === 'critical' ? C.critical
                              : f.severity === 'warn'     ? C.advisory
                              :                              C.fg3;
              const acted = !!f.actedOn;
              return (
                <div key={i} style={{
                  padding:'5px 0', marginTop:i>0?6:0,
                  borderTop: i>0 ? `1px solid ${C.border}` : 'none',
                  fontFamily:'Roboto', fontSize:10.5, color:acted?C.fg3:C.fg1, lineHeight:1.4,
                  opacity:acted?0.65:1,
                }}>
                  <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
                    color:flagColor, letterSpacing:'0.10em', marginBottom:2 }}>
                    {f.source} · {f.kind}
                    {acted && <span style={{ color:C.fg4, marginLeft:6 }}>· {f.actedOnBy || 'resolved'}</span>}
                  </div>
                  {f.detail}
                </div>
              );
            })}
          </TfcDetailBlock>
        )}

        {m.retaskCandidates && m.retaskCandidates.length > 0 && (
          <TfcDetailBlock label="RETASK CANDIDATES (ATO/AMA-21)" accent={C.advisory}>
            {m.retaskCandidates.slice(0, 5).map((c, i) => (
              <button key={c.missionId} onClick={() => onSelectMission(c.missionId)}
                style={{ display:'flex', alignItems:'center', gap:8, width:'100%',
                  padding:'6px 8px', marginTop:i>0?4:0, background:C.s2,
                  border:`1px solid ${C.border}`, borderRadius:3, cursor:'pointer', textAlign:'left' }}>
                <span style={{ fontFamily:'Roboto', fontSize:11, fontWeight:500, color:C.fg1 }}>{c.callsign}</span>
                <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.10em' }}>
                  {c.variant}
                </span>
                <div style={{ flex:1 }}/>
                <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2 }}>score {c.score}</span>
              </button>
            ))}
          </TfcDetailBlock>
        )}

        {m.dumpTarget && (
          <TfcDetailBlock label={`DUMP TARGET · ${m.dumpTarget.active ? 'ACTIVE' : 'INACTIVE'}`}
            accent={m.dumpTarget.active ? C.critical : C.fg3}>
            <TfcDetailRow k="NAME" v={m.dumpTarget.name}/>
            {m.dumpTarget.geo && (
              <TfcDetailRow k="DMPI" v={`${m.dumpTarget.geo.lat.toFixed(4)}°N ${m.dumpTarget.geo.lng.toFixed(4)}°E`}/>
            )}
          </TfcDetailBlock>
        )}
      </div>
      )}

      {selectedTab === 'timeline' && (
        <div style={{ flex:1, overflowY:'auto', padding:'14px 16px' }}>
          <div style={{ fontFamily:'Roboto Mono', fontSize:11, color:C.fg3, textAlign:'center', paddingTop:40 }}>
            Timeline view coming soon
          </div>
        </div>
      )}
    </>
  );
}

// ── PR 11: DECISIONS block ───────────────────────────────────
// Top-of-Mission-Detail surface. Two sections, mirroring the CSAR
// Case Action Queue pattern:
//   • Active — open decisions with action buttons
//   • Completed (collapsible) — historical record of what's been
//     acted on or dismissed, with relative timestamps
//
// Compute completed entries directly from the mission's impactFlags +
// runtime's held alerts (filtering by actedOn). The proposeDecisions
// engine only handles open decisions; completed history is a UI-only
// computation.

// Compute a stable "completed" list for the selected mission. Returns
// an array of { source, kind, detail, severity, actedOnAt, actedOnBy }
// ordered most-recent-first.
function _tfcCompletedDecisions(mission, runtime) {
  if (!mission) return [];
  const out = [];
  for (const f of (mission.impactFlags || [])) {
    if (f.actedOn) {
      out.push({
        source: f.source, kind: f.kind, detail: f.detail,
        severity: f.severity || 'warn',
        actedOnAt: f.actedOnAt, actedOnBy: f.actedOnBy || 'resolved',
      });
    }
  }
  for (const a of (runtime.heldCoordAlerts || [])) {
    if (a.actedOn && a.missionId === mission.id) {
      out.push({
        source: 'ATO/AMA-14', kind: 'coord-gap',
        detail: 'Crew brief not yet released — pre-NET coordination gap',
        severity: 'warn',
        actedOnAt: a.actedOnAt, actedOnBy: a.actedOnBy || 'resolved',
      });
    }
  }
  for (const a of (runtime.heldNltBreaches || [])) {
    if (a.actedOn && a.missionId === mission.id) {
      out.push({
        source: 'ATO/AMA-13', kind: 'nlt-breach',
        detail: `Mission past NLT ${a.nltZulu} — scrub or re-task required`,
        severity: 'critical',
        actedOnAt: a.actedOnAt, actedOnBy: a.actedOnBy || 'resolved',
      });
    }
  }
  out.sort((a, b) => (b.actedOnAt || 0) - (a.actedOnAt || 0));
  return out;
}

// Compact "12m ago" / "2h ago" relative time. Used by completed
// decision cards. `actedOnAt` is scenario-clock seconds; runtime
// holds the current tick at `lastTickT`.
function _tfcRelativeTime(actedOnAt, runtime) {
  const now = (runtime && runtime.lastTickT) || 0;
  const elapsed = Math.max(0, now - (actedOnAt || 0));
  if (elapsed < 60)  return Math.floor(elapsed) + 's ago';
  if (elapsed < 3600) return Math.floor(elapsed / 60) + 'm ago';
  return Math.floor(elapsed / 3600) + 'h ' + Math.floor((elapsed % 3600) / 60) + 'm ago';
}

function TfcDecisionBlock({ mission, runtime, onActionFired }) {
  const [completedOpen, setCompletedOpen] = React.useState(false);
  if (!mission || !runtime || !window.ATO || !window.ATO.proposeDecisions) return null;
  const decisions = window.ATO.proposeDecisions(mission, runtime);
  const completed = _tfcCompletedDecisions(mission, runtime);
  if ((!decisions || decisions.length === 0) && completed.length === 0) return null;

  const hasCritical = decisions.some(d => d.severity === 'critical');
  const accentColor = hasCritical ? C.critical : C.advisory;
  const headerLabel = hasCritical ? 'DECISION REQUIRED' : 'REVIEW';
  const headerCountLabel = decisions.length === 1 ? '1 IMPACT' : decisions.length + ' IMPACTS';

  return (
    <div style={{
      display:'flex', flexDirection:'column', gap:6,
    }}>
      {decisions.length > 0 && (
        <>
          <div style={{ display:'flex', alignItems:'center', gap:6 }}>
            {hasCritical && (
              <div style={{ width:7, height:7, borderRadius:'50%', background:C.critical,
                animation:'pulseFade 0.8s infinite', flexShrink:0 }}/>
            )}
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
              color:accentColor, letterSpacing:'0.12em' }}>{headerLabel}</span>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
              letterSpacing:'0.10em' }}>· {headerCountLabel}</span>
          </div>
          {decisions.map((d, i) => (
            <TfcDecisionCard key={i} decision={d} mission={mission}
              onActionFired={onActionFired}/>
          ))}
        </>
      )}
      {completed.length > 0 && (
        <div style={{
          marginTop: decisions.length > 0 ? 6 : 0,
          paddingTop: decisions.length > 0 ? 6 : 0,
          borderTop: decisions.length > 0 ? `1px solid ${C.border}` : 'none',
        }}>
          <button
            onClick={() => setCompletedOpen(v => !v)}
            style={{
              display:'flex', alignItems:'center', gap:6, width:'100%',
              padding:'2px 0', background:'transparent', border:'none',
              cursor:'pointer', textAlign:'left',
            }}
          >
            <span style={{ color:C.fg3, fontSize:9, lineHeight:1 }}>
              {completedOpen ? '▾' : '▸'}
            </span>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
              color:C.fg3, letterSpacing:'0.12em' }}>COMPLETED</span>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4,
              letterSpacing:'0.10em' }}>· {completed.length}</span>
          </button>
          {completedOpen && (
            <div style={{ display:'flex', flexDirection:'column', gap:4, marginTop:6 }}>
              {completed.map((c, i) => (
                <TfcCompletedDecisionCard key={i} entry={c} runtime={runtime}/>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// Compact "completed" entry. Dimmed surface, source/kind chip, brief
// detail, action-taken tag + relative timestamp.
function TfcCompletedDecisionCard({ entry, runtime }) {
  const sev = entry.severity || 'warn';
  const sevColor = sev === 'critical' ? C.critical
                 : sev === 'warn'     ? C.advisory
                 :                       C.fg3;
  const actedLabel = ({
    'dismiss': 'dismissed',
    'release-brief': 'brief released',
    'serviced': 'serviced',
    'scrubbed': 'scrubbed',
    'retasked': 'retasked',
    'provider-retasked': 'provider retasked',
    'act': 'acked',
  }[entry.actedOnBy] || entry.actedOnBy || 'resolved');
  const when = _tfcRelativeTime(entry.actedOnAt, runtime);
  return (
    <div style={{
      padding:'5px 8px', opacity:0.75,
      background:C.s2, border:`1px solid ${C.border}`, borderRadius:3,
      fontFamily:'Roboto', fontSize:10.5, color:C.fg3, lineHeight:1.4,
    }}>
      <div style={{ display:'flex', alignItems:'baseline', gap:6, marginBottom:2 }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          color:sevColor, letterSpacing:'0.10em', opacity:0.85 }}>
          {entry.source} · {entry.kind}
        </span>
        <div style={{ flex:1 }}/>
        <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4 }}>{when}</span>
      </div>
      <div>{entry.detail}</div>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
        color:C.normal, letterSpacing:'0.10em', marginTop:3 }}>
        ✓ {actedLabel.toUpperCase()}
      </div>
    </div>
  );
}

function TfcDecisionCard({ decision, mission, onActionFired }) {
  const sev = decision.severity || 'warn';
  const sevColor = sev === 'critical' ? C.critical
                 : sev === 'warn'     ? C.advisory
                 :                       C.fg3;
  return (
    <div style={{ background:C.s2, border:`1px solid ${C.border}`,
      borderLeft:`2px solid ${sevColor}`, borderRadius:3, padding:'8px 10px' }}>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
        color:sevColor, letterSpacing:'0.10em', marginBottom:3 }}>
        {decision.source} · {decision.kind}
      </div>
      <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg1, lineHeight:1.4, marginBottom:8 }}>
        {decision.detail}
      </div>
      {decision.primary && (
        <TfcDecisionAction
          action={decision.primary}
          variant="primary"
          onActionFired={onActionFired}
        />
      )}
      {decision.alternatives && decision.alternatives.length > 0 && (
        <div style={{ display:'flex', flexDirection:'column', gap:4, marginTop:6 }}>
          {decision.alternatives.map((alt, i) => (
            <TfcDecisionAction
              key={i}
              action={alt}
              variant="secondary"
              onActionFired={onActionFired}
            />
          ))}
        </div>
      )}
    </div>
  );
}

function TfcDecisionAction({ action, variant, onActionFired }) {
  const fire = () => {
    if (!window.ATO || !window.ATO.actions) return;
    const fn = window.ATO.actions[action.actionId];
    if (typeof fn !== 'function') {
      // eslint-disable-next-line no-console
      console.warn('[ATO] unknown action:', action.actionId);
      return;
    }
    fn(...action.args);
    if (onActionFired) onActionFired();
  };

  const isPrimary = variant === 'primary';
  const isDismiss = action.kind === 'dismiss';
  const isScrub   = action.kind === 'scrub';
  const isContinue = action.kind === 'continue';

  const accent = isScrub    ? C.critical
              : isContinue  ? C.normal
              : isDismiss   ? C.fg3
              :               C.accent;

  return (
    <button
      onClick={fire}
      title={action.hint || action.label}
      style={{
        width:'100%', padding:isPrimary ? '7px 10px' : '5px 9px',
        borderRadius:3,
        background: isPrimary ? `${accent}33` : 'transparent',
        border: isPrimary ? `1px solid ${accent}88` : `1px solid ${C.border}`,
        color: isPrimary ? accent : C.fg2,
        fontFamily:'Roboto', fontSize: isPrimary ? 11 : 10,
        fontWeight: isPrimary ? 600 : 500,
        cursor:'pointer', textAlign:'left',
        transition:'background 0.12s, border-color 0.12s',
      }}
      onMouseEnter={e => {
        if (isPrimary) {
          e.currentTarget.style.background = `${accent}55`;
        } else {
          e.currentTarget.style.background = C.s3;
          e.currentTarget.style.borderColor = C.border2;
        }
      }}
      onMouseLeave={e => {
        if (isPrimary) {
          e.currentTarget.style.background = `${accent}33`;
        } else {
          e.currentTarget.style.background = 'transparent';
          e.currentTarget.style.borderColor = C.border;
        }
      }}
    >
      {action.label}
      {action.hint && (
        <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:isPrimary?accent:C.fg3,
          marginLeft:8, letterSpacing:'0.06em', opacity:0.8 }}>
          {action.hint}
        </span>
      )}
    </button>
  );
}

function TfcDetailBlock({ label, accent, children, title }) {
  return (
    <div>
      <div title={title || _tfcBlockTooltip(label)} data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
        color:accent || C.fg3, letterSpacing:'0.16em', marginBottom:4 }}>{label}</div>
      <div style={{ background:C.s2, border:`1px solid ${C.border}`,
        borderLeft: accent ? `2px solid ${accent}` : undefined,
        borderRadius:3, padding:'8px 10px' }}>{children}</div>
    </div>
  );
}

// Centralized tooltip strings for Mission Detail field keys (TfcDetailRow `k`).
// Keep concise — one sentence ideally — and spell out acronyms on first use.
const _TFC_DETAIL_ROW_TIPS = {
  // Window
  NET: 'NET — No Earlier Than. Earliest time this mission may begin its tasking window.',
  NLT: 'NLT — Not Later Than. The mission\'s window-close time; missing it triggers a coordination decision.',
  STATE: 'STATE — Current window state: planned, in-window, complete, or impacted.',
  OUTCOME: 'OUTCOME — Final disposition: serviced, scrubbed, retasked, etc.',
  // Aircraft
  QTY: 'QTY — Number of airframes and type (e.g. 2× F-15E).',
  DEP: 'DEP — Departure ICAO airfield code.',
  ARR: 'ARR — Recovery ICAO airfield code.',
  SCL: 'SCL — Standard Combat Load. The munitions / pod fit assigned for this sortie.',
  // Target
  NAME: 'Target name — short reference label for this aimpoint or objective.',
  ID: 'Target ID — joint targeting catalog identifier (e.g. BE number).',
  DETAIL: 'Target detail — descriptive note from the JPTL or strike folder.',
  DMPI: 'DMPI — Desired Mean Point of Impact. Geo coordinates the weapon is fused to hit.',
  ELEV: 'ELEV — Target elevation above mean sea level (feet MSL). Used for weapon ballistics.',
  // CAP/Orbit
  CENTER: 'CENTER — Geo center of the CAP/orbit circle.',
  RADIUS: 'RADIUS — Orbit radius in nautical miles.',
  ALT: 'ALT — Assigned altitude block (feet MSL) for this orbit.',
  ANCHOR: 'ANCHOR — Reference track or fix the orbit is anchored to.',
  // Controller card
  UNIT: 'UNIT — Controlling unit responsible for this mission (CRC, ASOC, AWACS, etc.).',
  CS: 'CS — Controller callsign.',
  'P FREQ': 'P FREQ — Primary radio frequency for controller-to-mission comms.',
  'S FREQ': 'S FREQ — Secondary / backup frequency.',
  POC: 'POC — Point of Contact at the controlling unit (rank + name).',
  // Comms (SPINS)
  NET_COMMS: 'NET — SPINS net designator for this mission\'s comms package.',
  FREQ: 'FREQ — Assigned operating frequency from the SPINS comms card.',
  RESCUE: 'RESCUE — Linked rescue / CSAR reference for this mission\'s comms package.',
};
function _tfcDetailRowTip(k) {
  if (!k) return undefined;
  return _TFC_DETAIL_ROW_TIPS[k] || undefined;
}

// SME-REVIEW: tooltip needs verification
const _TFC_BLOCK_TIPS = {
  WINDOW: 'WINDOW — Mission tasking window (NET → NLT) and current state.',
  AIRCRAFT: 'AIRCRAFT — Type, quantity, and recovery base for this sortie.',
  FLIGHT: 'FLIGHT — Composition and recovery base for this flight.',
  'FLIGHT ROSTER': 'FLIGHT ROSTER — Per-airframe roster with role (LEAD/WINGMAN) and status (active/lost).',
  AIRFRAMES: 'AIRFRAMES — Per-airframe roster with role and status.',
  LOAD: 'LOAD — Munitions / stores loadout. Codes follow joint weaponeering shorthand.',
  TARGET: 'TARGET — Aimpoint or objective for this mission, pulled from the JPTL.',
  'CAP / ORBIT': 'CAP / ORBIT — Combat Air Patrol orbit geometry (center, radius, altitude block).',
  CONTROLLER: 'CONTROLLER — Controlling agency and comms card for this mission.',
  'COMMS (SPINS)': 'COMMS (SPINS) — Comms binding from the Special Instructions document.',
  ROE: 'ROE — Rules of Engagement tags applicable to this mission.',
  'IMPACT FLAGS': 'IMPACT FLAGS — Events affecting this mission that need MC attention.',
  'RETASK CANDIDATES (ATO/AMA-21)': 'RETASK CANDIDATES — Other ATO missions evaluated as alternates for this tasking, scored by AMA-21.',
};
function _tfcBlockTooltip(label) {
  if (!label) return undefined;
  if (_TFC_BLOCK_TIPS[label]) return _TFC_BLOCK_TIPS[label];
  if (label.startsWith('DUMP TARGET')) return 'DUMP TARGET — Pre-coordinated location for ordnance jettison if mission aborts with stores.';
  return undefined;
}

function TfcDetailRow({ k, v, title }) {
  return (
    <div title={title || _tfcDetailRowTip(k)} style={{ display:'flex', gap:10, padding:'2px 0' }}>
      <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
        letterSpacing:'0.12em', width:60, flexShrink:0 }}>{k}</span>
      <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg1 }}>{v}</span>
    </div>
  );
}

// ── Contingency Console (top of center column) ───────────────
// Demo-only operator action triggers. These call window.ATO.actions.*
// to fire the cascade. PR 4-B replaces this with real, doctrinally
// scoped operator UI (NLT breach prompts, retask selection, etc.).
// ── Contingency Tray — hover-revealed cascade triggers ──────
// Lives at the bottom-center of the TFC viewport. Hidden by default.
// Hovering an invisible 200×24 hot zone pinned to the bottom edge
// raises the tray; moving the cursor away (with a 300ms grace
// delay) lowers it. Hovering the tray itself also keeps it up so
// the operator can travel from the trigger strip to the buttons.
//
// TFC-only — this is rendered inside TfcWorkspace and lives in the
// fixed-position layer. Other master modes never see it.
//
// All button actions are the same cascade triggers from PR 4-A's
// in-layout Contingency Console. The tray's visual housing changes;
// the actions don't.
function TfcContingencyTray({ runtime }) {
  const [open, setOpen] = React.useState(false);
  const [feedback, setFeedback] = React.useState(null);
  const closeTimerRef = React.useRef(null);

  // 300ms grace delay on hover-out so the cursor can travel from
  // the trigger strip up to the buttons without the tray collapsing
  // mid-motion. Any hover-in during the grace cancels the close.
  const openTray = () => {
    if (closeTimerRef.current) {
      clearTimeout(closeTimerRef.current);
      closeTimerRef.current = null;
    }
    setOpen(true);
  };
  const closeTray = () => {
    if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
    closeTimerRef.current = setTimeout(() => {
      setOpen(false);
      closeTimerRef.current = null;
    }, 300);
  };
  React.useEffect(() => () => {
    if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
  }, []);

  const tryAction = (label, fn) => {
    try {
      const r = fn();
      setFeedback({ label, result: r, t: Date.now() });
    } catch (e) {
      setFeedback({ label, result: { ok:false, error: String(e) }, t: Date.now() });
    }
  };

  // Auto-dismiss feedback chip after 4s.
  React.useEffect(() => {
    if (!feedback) return;
    const id = setTimeout(() => setFeedback(null), 4000);
    return () => clearTimeout(id);
  }, [feedback]);

  return (
    <>
      {/* Invisible hot zone — bottom-center, 200×24 fixed. No visible
          hint per design call. Cursor lands here, tray slides up. */}
      <div
        onMouseEnter={openTray}
        onMouseLeave={closeTray}
        style={{
          position:'fixed', bottom:0, left:'50%', transform:'translateX(-50%)',
          width:200, height:24, zIndex:140,
          // Transparent — invisible to the operator until they hit it.
          pointerEvents:'auto',
        }}
      />

      {/* Floating tray — slides up from below the viewport edge. */}
      <div
        onMouseEnter={openTray}
        onMouseLeave={closeTray}
        style={{
          position:'fixed', bottom:0, left:'50%',
          transform: open ? 'translate(-50%, 0)' : 'translate(-50%, calc(100% + 4px))',
          zIndex:150,
          background:C.s2, border:`1px solid ${C.border2}`,
          borderBottom:'none', borderRadius:'6px 6px 0 0',
          boxShadow:'0 -8px 24px rgba(0,0,0,0.5)',
          transition:'transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1)',
          padding:'10px 14px',
          maxWidth:'min(1100px, 92vw)',
        }}
      >
        <div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:8 }}>
          <div title="CONTINGENCY CONSOLE — Demo triggers that simulate disruption events (airframe loss, pop-up threats, dump switches) and run the ATO cascade." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
            letterSpacing:'0.16em' }}>CONTINGENCY CONSOLE</div>
          <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5, color:C.fg4,
            letterSpacing:'0.10em' }}>DEMO TRIGGERS — fires window.ATO.actions.*</div>
        </div>
        <div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
          <TfcActionButton
            label="Lose KING 11 (CSAR tanker)"
            color={C.critical}
            title="Simulate loss of the CSAR tanker (KING 11). Triggers AMA-35 cascade — affects all missions dependent on that air refueling track."
            onClick={() => tryAction(
              'AMA-35 (airframe loss · single-ACFT tanker)',
              () => window.ATO.actions.markAirframeLost('af-king11', 'engine failure post-T/O')
            )}
          />
          <TfcActionButton
            label="Lose DUKE 32 (wingman)"
            color={C.advisory}
            title="Simulate loss of DUKE 32 (wingman). Flight degrades; lead continues solo unless ROE requires abort."
            onClick={() => tryAction(
              'AMA-35 (wingman loss · flight degraded)',
              () => window.ATO.actions.markAirframeLost('af-duke32', 'engine compressor stall')
            )}
          />
          <TfcActionButton
            label="Lose DUKE 31 (lead)"
            color={C.critical}
            title="Simulate loss of DUKE 31 (flight lead). Wingman promotes to Air Mission Commander (AMC)."
            onClick={() => tryAction(
              'AMA-35 (lead loss · wingman promotes to AMC)',
              () => window.ATO.actions.markAirframeLost('af-duke31', 'shot down')
            )}
          />
          <TfcActionButton
            label="Pop-up HQ-9 SAM"
            color={C.critical}
            title="Inject a new pop-up HQ-9 SAM threat. Triggers AMA-36 — affects every mission whose route or orbit falls inside the threat ring."
            onClick={() => tryAction(
              'AMA-36 (new threat)',
              () => window.ATO.actions.recordNewThreat({
                id: 't-demo-' + Date.now(), type:'SAM', label:'HQ-9 BTRY',
                lat:24.30, lng:121.22, radiusKm:80,
              })
            )}
          />
          <TfcActionButton
            label="Activate DUKE dump"
            color={C.advisory}
            title="Activate the pre-coordinated dump target for DUKE flight. Forces stores jettison routing if mission aborts with weapons."
            onClick={() => tryAction(
              'AMA-37 (dump switch)',
              () => window.ATO.actions.activateDumpTarget('mission-DUKE')
            )}
          />
          <TfcActionButton
            label="Mark DUKE serviced"
            color={C.normal}
            title="Mark DUKE mission as serviced (target destroyed). Closes the tasking window successfully."
            onClick={() => tryAction(
              'JTBD-14 (serviced)',
              () => window.ATO.actions.markMissionServiced('mission-DUKE', { result:'destroyed' })
            )}
          />
          <TfcActionButton
            label="Reconcile cycle (force)"
            color={C.accent}
            title="Force the ATO cycle clock past 24h and run the reconcile pass. Closes residual impact flags and rolls cycle metrics."
            onClick={() => tryAction(
              'AMA-41 (reconcile)',
              () => {
                window.ATO.runtime.cycle.elapsedSec = 24*3600 + 1;
                return window.ATO.actions.reconcileCycle();
              }
            )}
          />
        </div>
      </div>

      {/* Feedback toast — floats above the tray, auto-dismisses in 4s.
          Independent of tray open state so the operator sees the result
          even after they move the cursor away. */}
      {feedback && (
        <div style={{
          position:'fixed', bottom: open ? 100 : 28, left:'50%', transform:'translateX(-50%)',
          zIndex:160,
          padding:'7px 12px', background:C.s3, border:`1px solid ${C.border2}`,
          borderRadius:3, fontFamily:'Roboto Mono', fontSize:10, color:C.fg2,
          boxShadow:'0 4px 16px rgba(0,0,0,0.5)',
          transition:'bottom 0.18s ease',
          maxWidth:'min(800px, 92vw)',
        }}>
          <span data-typo="label" style={{ color:C.accent, letterSpacing:'0.10em' }}>{feedback.label}</span>
          {' → '}
          {feedback.result.ok
            ? (feedback.result.flagsAdded != null
                ? `${feedback.result.flagsAdded} impact flags added · ${(feedback.result.affectedMissionIds || []).length} mission(s) affected`
                : 'OK')
            : `failed: ${feedback.result.reason || feedback.result.error || 'unknown'}`}
        </div>
      )}
    </>
  );
}

function TfcActionButton({ label, color, onClick, disabled, title }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      title={title}
      style={{
        padding:'6px 12px', borderRadius:3,
        background: disabled ? C.s3 : `${color}22`,
        border:`1px solid ${disabled ? C.border : color}66`,
        color: disabled ? C.fg4 : color,
        fontFamily:'Roboto', fontSize:10.5, fontWeight:500,
        cursor: disabled ? 'not-allowed' : 'pointer',
        transition:'background 0.12s',
      }}
      onMouseEnter={e => { if (!disabled) e.currentTarget.style.background = `${color}33`; }}
      onMouseLeave={e => { if (!disabled) e.currentTarget.style.background = `${color}22`; }}
    >
      {label}
    </button>
  );
}

function CsarIdleWorkspace({ onOpenDrawer }) {
  const Icon = (window.MODE_ICONS || {})['parachute'];
  return (
    <div style={{
      flex:1, display:'flex', alignItems:'center', justifyContent:'center',
      background:C.s1, padding:24,
    }}>
      <div style={{ background:C.s2, border:`1px solid ${C.border2}`, borderRadius:6, padding:32,
        width:520, maxWidth:'92vw', textAlign:'center', boxShadow:'0 24px 64px rgba(0,0,0,0.6)' }}>
        <div style={{ width:48, height:48, margin:'0 auto 16px', display:'flex', alignItems:'center',
          justifyContent:'center', background:C.accentM, borderRadius:6, color:C.accent }}>
          {Icon ? <Icon size={22}/> : null}
        </div>
        <div style={{ fontFamily:'Roboto', fontWeight:600, fontSize:18, color:C.fg1, marginBottom:6 }}>
          Personnel Recovery (CSAR)
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.normal, letterSpacing:'0.12em', marginBottom:18 }}>
          NO ACTIVE CASE
        </div>
        <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg2, lineHeight:1.55, marginBottom:18 }}>
          The CSAR workspace activates when a J3.1 (downed aircraft / isolated
          personnel) alert fires. Until then, there is nothing to action here.
        </div>
        <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg3, lineHeight:1.5, marginBottom:24,
          padding:'0 8px' }}>
          Standing by. While in this state, return to the COP for situational
          awareness or use Mission Admin to QC pre-mission documents.
        </div>
        <div style={{ display:'flex', justifyContent:'center', gap:10 }}>
          <DTButton onClick={onOpenDrawer} color="mono-muted" variant="outline" size="medium">
            Open mode menu
          </DTButton>
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════
// MISSION ADMIN WORKSPACE
// ═══════════════════════════════════════════════════════════════
// Document library dashboard for the mission commander. Source-of-
// truth for ATOs, ACOs, OPTASKLINKs, JPTLs, and reference documents
// scoped per AOR. Headline answer to "am I ready?" must surface in
// the readiness header within 2 seconds of landing.

// ── Readiness chip — one per required / nice-to-have doc type ─
// Slim readiness chip — matches the Figma "Hawkeye" Mission Readiness
// header treatment (40px tall pill, 3px colored left rail, dot + label
// stack + state caption stack, no separate UPLOAD button — clicking
// the whole chip opens the upload modal when there's a gap).
//
// Layout per chip:
//   [3px L rail] · [8px dot] · [LABEL · optional "nice to have"]
//                                 [STATE]
// Required types render a single line label ("ATO") + state line
// ("CURRENT"). JPTL is the only non-required ("nice to have"); its
// label row carries the dim sublabel inline next to the type label,
// state line follows below. Same layout, just two-tier typography.
function ReadinessChip({ typeId, state, required, aor, onOpenUpload }) {
  const meta = (window.DOC_TYPES || []).find(t => t.id === typeId);
  if (!meta) return null;
  const isCovered  = state === 'covered';
  const isExpiring = state === 'expiring';
  const stateColor = isCovered ? C.normal
                   : isExpiring ? C.advisory
                   : (required ? C.critical : C.advisory);
  const stateLabel = isCovered ? 'CURRENT' : (isExpiring ? 'EXPIRING' : 'MISSING');
  const clickable = !isCovered;
  const handleClick = () => { if (clickable) onOpenUpload({ typeId, aor }); };
  const longName = meta.long ? `${meta.label} — ${meta.long}.` : meta.label;
  const stateLine = isCovered ? 'A current copy is loaded.'
                  : isExpiring ? 'Loaded copy is expiring soon — upload a fresh version.'
                  : (required ? 'No current copy loaded for the primary AOR — required for mission planning.' : 'Not loaded — nice-to-have, not blocking.');
  const tip = `${longName}\n${stateLine}${clickable ? '\nClick to upload.' : ''}`;
  return (
    <div onClick={handleClick}
      title={tip}
      style={{
        display:'flex', alignItems:'center', gap:10,
        height:40, padding:'0 13px 0 11px',
        background:C.s1,
        border:`1px solid ${stateColor}`,
        borderLeftWidth:3,
        borderRadius:3,
        cursor: clickable ? 'pointer' : 'default',
        flexShrink:0, minWidth:140,
        transition:'background 0.12s',
      }}
      onMouseEnter={(e)=>{ if (clickable) e.currentTarget.style.background = C.s2; }}
      onMouseLeave={(e)=>{ if (clickable) e.currentTarget.style.background = C.s1; }}>
      <div style={{
        width:8, height:8, borderRadius:'50%', background:stateColor, flexShrink:0,
        animation: !isCovered ? 'pulseFade 1.4s infinite' : 'none',
      }}/>
      <div style={{ flex:1, minWidth:0, display:'flex', flexDirection:'column', justifyContent:'center', gap:2 }}>
        <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2,
          letterSpacing:'0.10em', lineHeight:1, display:'flex', alignItems:'baseline', gap:8,
          overflow:'hidden', whiteSpace:'nowrap' }}>
          <span>{meta.label}</span>
          {!required && (
            <span style={{ color:C.fg4, fontSize:9, letterSpacing:'0.10em' }}>nice to have</span>
          )}
        </div>
        <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, color:stateColor,
          letterSpacing:'0.08em', lineHeight:1 }}>{stateLabel}</div>
      </div>
    </div>
  );
}

// Mission Readiness header — single-row layout matching Figma:
//   ┌──────────────────────────────────────────────────────────────┐
//   │ MISSION READINESS                              [chips ─→]    │
//   │ ALL CURRENT / GAPS DETECTED                                  │
//   │ PRIMARY AOR · USINDOPACOM                                    │
//   └──────────────────────────────────────────────────────────────┘
// The title block sits flush-left; the chip strip flush-right via
// `marginLeft:auto` on the chip container. Chips wrap to a second row
// on narrow viewports — they keep their fixed height + min width.
function ReadinessHeader({ docs, primaryAor, onOpenUpload }) {
  const primaryDocs = docs.filter(d => !d.isArchived && d.aor === primaryAor);
  const checkType = (typeId) => {
    const tDocs = primaryDocs.filter(d => d.type === typeId);
    if (tDocs.length === 0) return 'missing';
    const states = tDocs.map(d => window.classifyDocState(d));
    if (states.some(s => s === 'current'))  return 'covered';
    if (states.some(s => s === 'expiring')) return 'expiring';
    return 'missing';
  };
  const requiredCoverage = (window.REQUIRED_DOC_TYPES || []).map(typeId => ({
    typeId, state: checkType(typeId),
  }));
  const niceCoverage = (window.NICE_TO_HAVE_DOC_TYPES || []).map(typeId => ({
    typeId, state: checkType(typeId),
  }));
  const hasGap = [...requiredCoverage, ...niceCoverage].some(c => c.state !== 'covered');
  const aorMeta = (window.AORS || []).find(a => a.id === primaryAor);
  const headlineColor = hasGap
    ? (requiredCoverage.some(c => c.state !== 'covered') ? C.critical : C.advisory)
    : C.normal;
  return (
    <div data-tour="admin-readiness" style={{
      padding:'18px 24px 18px', borderBottom:`1px solid ${C.border}`,
      // Faint wash matches the Figma — green when nominal, color-coded
      // when there's a gap. Same hex baseline as the prior version,
      // just at a lower alpha to read more like a status tint than a
      // panel fill.
      background: hasGap ? `${headlineColor}08` : `${C.normal}08`,
    }}>
      <div style={{ display:'flex', alignItems:'center', gap:18, flexWrap:'wrap' }}>
        {/* Title stack — flush-left */}
        <div style={{ flexShrink:0 }}>
          <div title="MISSION READINESS — Real-time coverage check of required doctrinal documents (ATO, ACO, OPTASKLINK) for the primary AOR." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>
            MISSION READINESS
          </div>
          <div title={hasGap ? 'GAPS DETECTED — One or more required documents are missing or expiring.' : 'ALL CURRENT — Every required document is loaded and within its validity window.'} style={{ fontFamily:'Roboto', fontSize:18, fontWeight:600, color:headlineColor,
            letterSpacing:'0.02em', marginTop:2 }}>
            {hasGap ? 'GAPS DETECTED' : 'ALL CURRENT'}
          </div>
          <div title="PRIMARY AOR — The geographic combatant command this dashboard is anchored to. Drives readiness check + default upload scope." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3, letterSpacing:'0.08em', marginTop:4 }}>
            PRIMARY AOR · {aorMeta ? aorMeta.label : ''}
          </div>
        </div>
        {/* Chip strip — pushed flush-right via marginLeft:auto. Wraps to
            a second row on narrow viewports; chip min-width is enough
            to keep label + state legible side-by-side. */}
        <div style={{ display:'flex', gap:10, marginLeft:'auto', flexWrap:'wrap', justifyContent:'flex-end' }}>
          {requiredCoverage.map(c => (
            <ReadinessChip key={c.typeId} typeId={c.typeId} state={c.state} required aor={primaryAor} onOpenUpload={onOpenUpload}/>
          ))}
          {niceCoverage.map(c => (
            <ReadinessChip key={c.typeId} typeId={c.typeId} state={c.state} required={false} aor={primaryAor} onOpenUpload={onOpenUpload}/>
          ))}
        </div>
      </div>
    </div>
  );
}

// ── AOR selector ─────────────────────────────────────────────
// Primary AOR is now driven by a native-select dropdown styled as a
// form element (matches the upload modal's type/AOR pickers), tinted
// accent so it still reads as the "primary" anchor in the row. Picking
// a new AOR from the dropdown:
//   1. Calls onPrimaryAorChange — updates adminPrimaryAor in App.
//   2. Adds the new primary into the active AOR set (so its content
//      doesn't disappear from the library on swap) — and crucially
//      KEEPS the previous primary in active AORs so it gracefully
//      becomes an "adjacent" rather than dropping off the screen.
// The adjacent toggle row to the right re-derives from `activeAors`
// minus the new primary; React rerenders and the buttons swap order.
function AORSelector({ primaryAor, activeAors, onPrimaryAorChange, onActiveAorsChange }) {
  const aors = window.AORS || [];
  const primaryMeta = aors.find(a => a.id === primaryAor);
  const adjacents = aors.filter(a => a.id !== primaryAor);
  const toggleAor = (id) => {
    const next = new Set(activeAors);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    next.add(primaryAor);  // primary can never be deselected
    onActiveAorsChange(next);
  };
  // Promote a new primary. Bring the chosen AOR into the active set,
  // and DROP the outgoing primary from active — picking a new primary
  // means the operator wants to focus on that AOR's docs, not pile
  // the old one's docs on top. The outgoing AOR returns to the
  // adjacent strip in its inactive state; one click re-activates it
  // if the operator actually wants to compare both.
  const handlePrimaryChange = (e) => {
    const next = e.target.value;
    if (!next || next === primaryAor) return;
    onPrimaryAorChange(next);
    const nextActive = new Set(activeAors);
    nextActive.delete(primaryAor); // old primary drops to inactive adjacent
    nextActive.add(next);          // new primary always active
    onActiveAorsChange(nextActive);
  };
  return (
    <div data-tour="admin-aor-selector" style={{ padding:'10px 24px',
      display:'flex', alignItems:'center', gap:14, flexWrap:'wrap' }}>
      <div title="AORS — Areas of Responsibility. Pick the primary AOR (driver of readiness check); toggle adjacents to compare libraries side-by-side." data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em' }}>AORS</div>
      {/* Primary AOR dropdown — native <select> styled as a form
          element. Accent-tinted background + border + text so it
          still reads as the "primary" anchor. PRIMARY caption sits
          inside the same field on the right edge for parity with the
          old pill (operator scans left-to-right: "INDOPACOM PRIMARY"
          — same words, just selectable now). */}
      <div style={{ position:'relative', display:'flex', alignItems:'center' }}>
        <div style={{ position:'absolute', left:11, top:'50%', transform:'translateY(-50%)',
          width:6, height:6, borderRadius:'50%', background:C.accent, pointerEvents:'none' }}/>
        <select value={primaryAor} onChange={handlePrimaryChange}
          aria-label="Primary AOR"
          title="Primary AOR — Selecting a new AOR anchors readiness checks and the default upload scope to that command."
          style={{
            appearance:'none', WebkitAppearance:'none', MozAppearance:'none',
            // Right padding reserves room for: PRIMARY caption (~58px
            // with 0.10em tracking) + 6px gap + chevron (~10px) +
            // 9px right inset. Widest AOR short label (USINDOPACOM
            // would be 88px in Roboto 11/600 but we use the .short
            // form 'INDOPACOM' which is ~76px). Total field width
            // ends up around 200px — comfortable read with no
            // collision between the value and the PRIMARY caption.
            padding:'6px 90px 6px 23px',
            borderRadius:3, background:C.accentM, border:`1px solid ${C.accent}55`,
            color:C.accent, fontFamily:'Roboto', fontSize:11, fontWeight:600, letterSpacing:'0.05em',
            cursor:'pointer', minWidth:200,
            // Hide native chevron — we draw our own to the right of
            // the PRIMARY caption so the selected value reads cleanly.
            backgroundImage:'none',
            // Truncate cleanly if a longer label sneaks in later
            // rather than letting the value run into the right pad.
            textOverflow:'ellipsis', overflow:'hidden', whiteSpace:'nowrap',
          }}>
          {aors.map(a => (
            <option key={a.id} value={a.id} style={{ background:C.s2, color:C.fg1, fontFamily:'Roboto', fontSize:11 }}>
              {a.short}
            </option>
          ))}
        </select>
        {/* PRIMARY caption + chevron — overlaid inside the dropdown's
            right padding so they read as part of the selected value.
            pointerEvents:none so clicks fall through to the select. */}
        <span data-typo="label" style={{ position:'absolute', right:26, top:'50%', transform:'translateY(-50%)',
          fontFamily:'Roboto Mono', fontSize:8, color:C.accent, letterSpacing:'0.10em',
          pointerEvents:'none', lineHeight:1 }}>PRIMARY</span>
        <span style={{ position:'absolute', right:9, top:'50%', transform:'translateY(-50%)',
          fontFamily:'Roboto Mono', fontSize:9, color:C.accent, pointerEvents:'none', lineHeight:1 }}>▾</span>
      </div>
      <div style={{ width:1, height:18, background:C.border }}/>
      <div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
        {adjacents.map(a => {
          const active = activeAors.has(a.id);
          return (
            <button key={a.id} onClick={() => toggleAor(a.id)} title={`${a.label}${active ? ' (selected)' : ''}`} style={{
              padding:'5px 11px', borderRadius:3,
              background: active ? C.s3 : 'transparent',
              border:`1px solid ${active ? C.fg3 : C.border}`,
              color: active ? C.fg1 : C.fg3,
              fontFamily:'Roboto Mono', fontSize:9.5, fontWeight:500, letterSpacing:'0.08em',
              cursor:'pointer', transition:'all 0.12s',
            }}>
              {a.short}
            </button>
          );
        })}
      </div>
      <div style={{ flex:1 }}/>
      {activeAors.size > 1 && (
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.06em' }}>
          {activeAors.size} AORS · COLUMN VISIBLE
        </span>
      )}
    </div>
  );
}

// ── Library tabs (Active / Archive) + view-mode toggle ───────
// Tabs occupy the left. Right edge of the row carries a small
// segmented icon group for the dashboard view mode (cards vs gantt).
// Both sit on the same baseline so the row reads as one strip.
function LibraryTabs({ tab, onChange, viewMode, onViewModeChange }) {
  const cells = [
    { id:'active',  label:'ACTIVE LIBRARY' },
    { id:'archive', label:'ARCHIVE' },
  ];
  // Compact icon glyphs for the view toggle. `cards` = three-column
  // bars (matches the existing doc grid). `gantt` = stacked horizontal
  // bars on a baseline (timeline read).
  const ViewIcon = ({ kind, on }) => {
    const stroke = on ? C.fg1 : C.fg3;
    if (kind === 'cards') {
      return (
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
          <rect x="1.5" y="1.5" width="3" height="11" rx="0.5" stroke={stroke} strokeWidth="1.2"/>
          <rect x="5.5" y="1.5" width="3" height="11" rx="0.5" stroke={stroke} strokeWidth="1.2"/>
          <rect x="9.5" y="1.5" width="3" height="11" rx="0.5" stroke={stroke} strokeWidth="1.2"/>
        </svg>
      );
    }
    return (
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
        <rect x="1" y="2" width="7" height="2.5" rx="0.5" fill={stroke}/>
        <rect x="3" y="6" width="9" height="2.5" rx="0.5" fill={stroke}/>
        <rect x="2" y="10" width="6" height="2.5" rx="0.5" fill={stroke}/>
      </svg>
    );
  };
  return (
    <div style={{ padding:'8px 24px 0', display:'flex', gap:0, alignItems:'center' }}>
      {cells.map(c => {
        const active = c.id === tab;
        return (
          <button data-typo="label" key={c.id} onClick={()=>onChange(c.id)}
            title={c.id === 'active' ? 'ACTIVE LIBRARY — Docs currently in effect or due to take effect.' : 'ARCHIVE — Previously valid docs and superseded versions, kept for audit trail.'}
            style={{
            padding:'8px 16px 10px', background:'transparent', border:'none',
            borderBottom:`2px solid ${active ? C.accent : 'transparent'}`,
            color:active ? C.fg1 : C.fg3, cursor:'pointer',
            fontFamily:'Roboto Mono', fontSize:10, fontWeight:500, letterSpacing:'0.10em',
            transition:'color 0.12s',
          }}>
            {c.label}
          </button>
        );
      })}
      <div style={{ flex:1 }}/>
      {/* View-mode toggle — only meaningful in the active library
          (archive renders the same card grid, no gantt needed there). */}
      {viewMode && onViewModeChange && tab === 'active' && (
        <div style={{ display:'inline-flex', gap:0, marginBottom:6,
          border:`1px solid ${C.border2}`, borderRadius:3, overflow:'hidden' }}>
          {['cards','gantt'].map(k => {
            const on = viewMode === k;
            return (
              <button key={k} onClick={() => onViewModeChange(k)}
                title={k === 'cards' ? 'Card view — grid of doc cards grouped by type and AOR.' : 'Gantt view — timeline showing each doc\'s validity window, ordered by AOR.'}
                style={{
                  width:30, height:24, padding:0,
                  background: on ? C.s3 : 'transparent', border:'none',
                  display:'flex', alignItems:'center', justifyContent:'center',
                  cursor: on ? 'default' : 'pointer',
                  borderRight: k === 'cards' ? `1px solid ${C.border2}` : 'none',
                  transition:'background 0.12s',
                }}>
                <ViewIcon kind={k} on={on}/>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ── Doc-type group + rows ────────────────────────────────────
function fmtDocDateRange(start, end) {
  const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
  const fmt = (d) => `${d.getUTCDate().toString().padStart(2,'0')}${months[d.getUTCMonth()]} ${d.getUTCHours().toString().padStart(2,'0')}${d.getUTCMinutes().toString().padStart(2,'0')}Z`;
  const s = new Date(start);
  if (!end) return `${fmt(s)} (evergreen)`;
  const e = new Date(end);
  return `${fmt(s)} → ${fmt(e)}`;
}

// "12h ago" / "3d ago" relative time. Only used for the uploaded-at
// footer; absolute timestamps are kept in the EFFECTIVE field.
function fmtRelativeAgo(iso) {
  if (!iso) return '';
  const now = new Date(Date.UTC(2026, 3, 28, 14, 30, 0));  // scenario clock
  const t = new Date(iso);
  let mins = Math.floor((now.getTime() - t.getTime()) / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const h = Math.floor(mins / 60);
  if (h < 24) return `${h}h ago`;
  const d = Math.floor(h / 24);
  if (d < 30) return `${d}d ago`;
  const mo = Math.floor(d / 30);
  return `${mo}mo ago`;
}

// ── Document card ────────────────────────────────────────────
// Three rows, and the right-hand edge owns state on every one of them:
// row 1 right is always temporal (IN EFFECT / STARTS / EXPIRES / EXPIRED),
// row 2 right carries exceptions. Nothing about a card's state ever
// appears on the left, so the eye tracks one column.
//
// The type is not repeated here — the column it sits in already says it,
// and repeating it cost a coloured pill on every card. Colour means
// status, never category.
function DocCard({ doc, type, showAorBadge, allDocs, onOpenDoc, isArchive, isLive, secondary, stepControls, deckPos }) {
  const [hover, setHover] = React.useState(false);
  // Revision arrows are revealed by the date row itself, not the whole card.
  const [dateHover, setDateHover] = React.useState(false);
  const now   = window.getAdminNow ? window.getAdminNow().getTime() : Date.now();
  const start = doc.start ? new Date(doc.start).getTime() : null;
  const end   = doc.end   ? new Date(doc.end).getTime()   : null;
  const state = window.classifyDocState(doc);

  // Row 1, right: one temporal fact, phrased for where the doc sits in time.
  let mark = { text:'', tone:C.fg4, dot:false };
  if (!end)                    mark = { text:'EVERGREEN',                          tone:C.fg4 };
  else if (end <= now)         mark = { text:'EXPIRED',                            tone:C.fg4 };
  else if (start && start > now)
    mark = { text:`STARTS ${window.formatTimeRemaining({ end: doc.start })}`,       tone:C.fg3 };
  else if (state === 'expiring')
    mark = { text:`EXPIRES ${window.formatTimeRemaining(doc)}`,                     tone:C.advisory };
  else
    // Live reads as a filled dot plus time remaining. No green: on a healthy
    // desk every card would be green, which tells the eye nothing.
    mark = { text:`${window.formatTimeRemaining(doc)} LEFT`,                        tone:C.fg2, dot:true };

  const expired = !!(end && end <= now);
  const warnCount = doc.parseStatus === 'warnings'
    ? ((doc.parseDetails || []).length || 1) : 0;
  // Only types with a parser can meaningfully be "not parsed". OPTASKLINK,
  // SPINS and references have no parser at all, so flagging them was
  // reporting a non-event as a failure.
  const parseable = !!(type && type.parsedInto);
  const failedParse = parseable && doc.parseStatus === 'preview';
  const aorMeta = showAorBadge ? (window.AORS || []).find(a => a.id === doc.aor) : null;

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={() => onOpenDoc && onOpenDoc(doc.id)}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDoc && onOpenDoc(doc.id); } }}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      title={`${doc.name} — ${doc.format} · uploaded by ${doc.uploader}`}
      style={{
        // The live document is a quiet surface lift, not an outlined box.
        background: isLive ? A.liveBg : (hover ? A.raised : (secondary ? 'transparent' : A.card)),
        // A failed parse on a parseable type is the one thing that makes a
        // document unusable, so it is the only card that gets a red edge.
        border:`1px solid ${failedParse ? C.critical + '73' : (hover ? A.bd2 : A.bd)}`,
        borderRadius:5,
        padding: secondary ? '9px 12px' : '10px 12px',
        marginLeft: secondary ? 10 : 0,
        opacity: (secondary && !hover) ? 0.62 : (expired && !hover ? 0.55 : 1),
        cursor:'pointer', minWidth:0,
        transition:'background 0.12s, border-color 0.12s, opacity 0.12s',
      }}>
      {/* Row 1 — name, then the temporal mark. */}
      <div style={{ display:'flex', alignItems:'center', gap:9 }}>
        {!secondary && <FileGlyph format={doc.format} dim={expired}/>}
        <span style={{ flex:1, minWidth:0, fontSize: secondary ? 11 : 12,
          fontWeight: secondary ? 500 : 600,
          color: expired ? C.fg3 : (secondary ? C.fg2 : C.fg1),
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
          {doc.name}
        </span>
        <span data-typo="label" style={{ display:'flex', alignItems:'center', gap:5,
          fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.1em',
          color:mark.tone, whiteSpace:'nowrap' }}>
          {mark.dot && <span style={{ width:5, height:5, borderRadius:'50%',
            background:'currentColor', flexShrink:0 }}/>}
          {mark.text}
        </span>
      </div>

      {/* Row 2 — effective window, then exceptions. */}
      <div onMouseEnter={() => setDateHover(true)} onMouseLeave={() => setDateHover(false)}
        style={{ display:'flex', alignItems:'center', gap:10, marginTop:7,
        position:'relative' }}>
        <span style={{ flexShrink:1, minWidth:0, fontFamily:'Roboto Mono',
          fontSize: secondary ? 9 : 10,
          color: expired ? C.fg4 : (secondary ? C.fg3 : C.fg2),
          whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
          {fmtWindow(doc.start, doc.end)}
        </span>
        {aorMeta && (
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
            letterSpacing:'0.1em', color:C.fg4, whiteSpace:'nowrap' }}>{aorMeta.short}</span>
        )}
        {deckPos && !stepControls && (
          <span data-typo="label" title="Position in this document series."
            style={{ fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.08em',
              color:C.fg4, whiteSpace:'nowrap' }}>{deckPos}</span>
        )}
        {/* Sit against the end of the date text, not the card edge. Reserving
            the width keeps the row from shifting as they appear. */}
        {stepControls && (
          <span style={{ display:'flex', gap:4, alignItems:'center', flexShrink:0,
            visibility: dateHover ? 'visible' : 'hidden' }}>
            {stepControls}
          </span>
        )}
        <span style={{ flex:1, minWidth:0 }}/>
        {warnCount > 0 && (
          <span data-typo="label" style={{ display:'flex', alignItems:'center', gap:4,
            fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.08em',
            color:C.advisory, background:A.warnBg, border:`1px solid ${C.advisory}59`,
            borderRadius:3, padding:'2.5px 6px', whiteSpace:'nowrap' }}>
            <WarnIcon size={9}/>{warnCount} {warnCount === 1 ? 'WARNING' : 'WARNINGS'}
          </span>
        )}
        {warnCount === 0 && failedParse && !secondary && (
          <span data-typo="label" title={`${type.label} should parse into ${type.parsedInto}. It has not — nothing downstream can use this document.`}
            style={{ display:'flex', alignItems:'center', gap:4, fontFamily:'Roboto Mono',
              fontSize:9, letterSpacing:'0.08em', color:C.critical,
              background:`${C.critical}1f`, border:`1px solid ${C.critical}80`,
              borderRadius:3, padding:'2.5px 6px', whiteSpace:'nowrap' }}>
            <WarnIcon size={9}/>NOT PARSED
          </span>
        )}
      </div>

      {/* Row 3 — who put it there. Dropped on stacked history to keep it thin. */}
      {!secondary && (
        <div style={{ display:'flex', alignItems:'center', gap:7, marginTop:9 }}>
          <Avatar name={doc.uploader} size={17}/>
          <span style={{ fontFamily:'Roboto Mono', fontSize:10,
            color: expired ? '#4a4b4e' : C.fg4,
            whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
            {doc.uploader} · {_ago(doc.uploadedAt)}
          </span>
        </div>
      )}
    </div>
  );
}

function EmptyTypeState({ type, primaryAor, libraryTab, onOpenUpload }) {
  if (libraryTab === 'archive') {
    // Archive empty state: just a quiet single-line note inside the
    // group, no card slot. Keeps the archive view from looking
    // sparse / over-indexed on emptiness.
    return (
      <div style={{ padding:'14px 12px', fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.06em' }}>
        No archived {type.label} documents.
      </div>
    );
  }
  // Active empty state: card-shaped slot inside the grid so the layout
  // doesn't reflow when the slot fills with a real card later.
  return (
    <div style={{
      padding:'14px 16px',
      background:C.s2,
      border:`1px dashed ${type.color}55`,
      borderLeft:`4px dashed ${type.color}`,
      borderRadius:4,
      display:'flex', flexDirection:'column', gap:12, justifyContent:'space-between',
      minHeight:140,
    }}>
      <div>
        <div style={{ fontFamily:'Roboto', fontSize:13, fontWeight:600, color:C.fg2 }}>
          No {type.label} document
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3, letterSpacing:'0.10em', marginTop:3 }}>
          NOTHING UPLOADED FOR SELECTED AOR(S)
        </div>
      </div>
      <button onClick={() => onOpenUpload({ typeId: type.id, aor: primaryAor })}
        title={`Upload a ${type.label}${type.long ? ' (' + type.long + ')' : ''} for ${primaryAor.toUpperCase()}. Opens the upload modal with this slot pre-filled.`}
        style={{
        padding:'7px 12px', borderRadius:3, fontFamily:'Roboto Mono', fontSize:9.5, fontWeight:600,
        letterSpacing:'0.10em', cursor:'pointer', border:'none',
        background:type.color, color:'#000', alignSelf:'flex-start',
      }}>
        UPLOAD {type.label}
      </button>
    </div>
  );
}

// Single doc-type column (kanban-style). Header on top, cards stacking
// vertically below. Used inside DocLibrary's 5-column grid — every type
// is always visible at the same horizontal position so the operator
// can scan "do I have docs in every type?" in one sweep.
//
// Doc-type colours used to surface in the column header + card left rail
// for at-a-glance type ID. Pulled out — colour was reading as a status
// signal even though it isn't, and zebra-striped column backgrounds
// (driven by columnIndex) carry the delineation now without the
// status-language baggage.
// ── Runway strip ─────────────────────────────────────────────
// A runway, not a timebox. The left edge is always NOW and the horizon
// is fixed at 48h, so the fill answers "how much road is ahead" without
// asserting whose watch it is or when it started.
const RUNWAY_HORIZON_MS = 48 * 3600 * 1000;

function RunwayStrip({ rw, tone }) {
  const pct = !rw.validNow ? 0
    : !isFinite(rw.runwayMs) ? 1
    : Math.min(1, rw.runwayMs / RUNWAY_HORIZON_MS);
  // When nothing is in effect, show where the next document picks up.
  const gapPct = (!rw.validNow && rw.nextStart)
    ? Math.min(1, (rw.nextStart.getTime() - (window.getAdminNow ? window.getAdminNow().getTime() : Date.now())) / RUNWAY_HORIZON_MS)
    : 0;
  return (
    <div title={rw.validNow
        ? `Covered for the next ${Math.round((rw.runwayMs / 3600000) * 10) / 10}h of a 48h horizon.`
        : 'Nothing in effect right now.'}
      style={{ display:'flex', height:4, borderRadius:2, background:A.bd,
        marginTop:5, overflow:'hidden' }}>
      {gapPct > 0 && <div style={{ width:`${gapPct * 100}%`, background:'transparent' }}/>}
      <div style={{ width:`${Math.max(pct * 100, rw.validNow ? 2 : 0)}%`,
        background:tone, borderRadius:2, opacity: tone === C.fg4 ? 0.55 : 1 }}/>
    </div>
  );
}

// ── Bucket status ────────────────────────────────────────────
// Collapses coverage + parse health into the one line the commander
// actually reads. Order matters: a gap in the shift outranks a parse
// warning, because a missing document is worse than a messy one.
function bucketStatus(docs, required) {
  if (!docs.length) {
    return required
      ? { label:'MISSING', tone:C.advisory, dot:C.advisory }
      : { label:'NONE LOADED', tone:C.fg4, dot:C.fg4 };
  }
  const rw = window.deskRunway(docs);
  if (!rw.validNow) {
    return {
      label: required ? 'NOT VALID NOW' : 'NONE IN EFFECT',
      tone: required ? C.advisory : C.fg4,
      dot:  required ? C.advisory : C.fg4,
      rw,
    };
  }
  const live = window.activeDocFor(docs);
  if (live && live.parseStatus === 'warnings') {
    return { label:'VALID · NEEDS REVIEW', tone:C.advisory, dot:C.advisory, rw };
  }
  // Under six hours of road left is worth flagging — you should have the
  // next document loaded before you hand over.
  if (isFinite(rw.runwayMs) && rw.runwayMs < 6 * 3600000) {
    return { label:'VALID · EXPIRES SOON', tone:C.advisory, dot:C.advisory, rw };
  }
  // Healthy is deliberately colourless. If six buckets all glow green the
  // eye has nowhere to land and amber stops meaning anything.
  return { label:'VALID NOW', tone:C.fg3, dot:C.fg4, rw };
}

// ── Document lineage ─────────────────────────────────────────
// A lineage is one document and its earlier revisions: "ATO 28-APR" with
// "ATO 28-APR CHG 002" behind it. Not every document has one — most are
// single cards — so only the few with prior work show a stack.
//
// Deliberately narrower than type+AOR. Grouping a whole type into one
// deck hid documents that need to be seen side by side; grouping by
// revision only affects the handful that genuinely supersede each other.
const _LINEAGE_SUFFIX = /\s+(CHG\s*\d+|REV\s*[A-Z0-9]+|V\s*\d+|v\d+)$/;
const lineageKey = (d) => `${d.type}|${d.aor}|${(d.name || '').replace(_LINEAGE_SUFFIX, '').trim()}`;

// Two hairlines peeking below the card. Enough to read as "there is more
// underneath" without drawing competing outlines.
function StackEdges({ count }) {
  if (!count) return null;
  return (
    <React.Fragment>
      {Array.from({ length: Math.min(2, count) }).map((_, k) => (
        <div key={k} aria-hidden style={{
          position:'absolute', left:6 + k * 6, right:6 + k * 6,
          bottom:-(3 + k * 3), height:3 + k * 3,
          background:C.s2, borderBottom:`1px solid ${A.bd}`,
          borderLeft:`1px solid ${A.bd}`, borderRight:`1px solid ${A.bd}`,
          borderRadius:'0 0 5px 5px', opacity:0.75 - k * 0.25, zIndex:0,
        }}/>
      ))}
    </React.Fragment>
  );
}

// One card, plus its revision history behind it if it has any.
function DocLineage({ docs, type, showAorBadge, onOpenDoc, isArchive }) {
  // Newest revision on top.
  const ordered = [...docs].sort((a, b) =>
    new Date(b.uploadedAt || b.start || 0) - new Date(a.uploadedAt || a.start || 0));
  const [idx, setIdx] = React.useState(0);
  const [dir, setDir] = React.useState(null);
  const [hover, setHover] = React.useState(false);
  const now = window.getAdminNow ? window.getAdminNow().getTime() : Date.now();
  const i = Math.min(idx, ordered.length - 1);
  const doc = ordered[i];
  if (!doc) return null;

  const older = ordered[i + 1];
  const newer = ordered[i - 1];
  const isLive = (() => {
    const st = doc.start ? new Date(doc.start).getTime() : 0;
    const en = doc.end ? new Date(doc.end).getTime() : Infinity;
    return st <= now && en > now;
  })();
  const revLabel = (d) => {
    const m = (d.name || '').match(_LINEAGE_SUFFIX);
    return m ? m[1].replace(/\s+/g, ' ').toUpperCase() : 'ORIGINAL';
  };
  const step = (delta, d) => (e) => { e.stopPropagation(); setDir(d); setIdx(v => v + delta); };
  // Arrows only. The revision label lives in the tooltip — the control
  // sits inside the date row and should not crowd it.
  const Step = ({ onClick, text, back }) => (
    <button onClick={onClick}
      title={back ? `Earlier revision — ${text}` : `Later revision — ${text}`}
      style={{ width:16, height:16, display:'flex', alignItems:'center',
        justifyContent:'center', borderRadius:3, background:A.hi,
        border:`1px solid ${A.bd2}`, color:C.fg2, cursor:'pointer', padding:0,
        fontFamily:'Roboto Mono', fontSize:10, lineHeight:1, flexShrink:0 }}>
      {back ? '‹' : '›'}
    </button>
  );

  return (
    <div style={{ position:'relative', marginBottom: ordered.length > 1 ? 7 : 0 }}>
      <StackEdges count={ordered.length - 1}/>
      <div key={doc.id} style={{ position:'relative', zIndex:1,
        animation: dir ? `${dir === 'back' ? 'deckBack' : 'deckFwd'} 150ms ease-out` : undefined }}>
        <DocCard doc={doc} type={type} showAorBadge={showAorBadge}
          allDocs={docs} onOpenDoc={onOpenDoc} isArchive={isArchive} isLive={isLive}
          stepControls={ordered.length > 1 ? (
            <React.Fragment>
              {older && <Step back onClick={step(1, 'back')} text={revLabel(older)}/>}
              {newer && <Step onClick={step(-1, 'fwd')} text={revLabel(newer)}/>}
            </React.Fragment>
          ) : null}
          deckPos={ordered.length > 1 ? `${i + 1}/${ordered.length}` : null}/>
      </div>
    </div>
  );
}

// ── Bucket (document-type workspace) ─────────────────────────
// Not a kanban lane: nothing moves between these columns. Each is a
// standing workspace for one document type.
function DocTypeColumn({ type, docs, primaryAor, showAorColumn, libraryTab,
  onOpenUpload, onOpenDoc, columnIndex = 0 }) {
  const isArchive = libraryTab === 'archive';
  const now = window.getAdminNow ? window.getAdminNow().getTime() : Date.now();
  const inEffect = (d) => {
    const st = d.start ? new Date(d.start).getTime() : 0;
    const en = d.end ? new Date(d.end).getTime() : Infinity;
    return st <= now && en > now;
  };

  // Group into lineages, then lay the lineages out flat — same ordering
  // the plain list had, so most of the column reads exactly as before.
  const groups = docs.reduce((acc, d) => {
    (acc[lineageKey(d)] = acc[lineageKey(d)] || []).push(d); return acc;
  }, {});
  const lineages = Object.values(groups).sort((a, b) => {
    const rank = (g) => g.some(inEffect) ? 0
      : g.some(d => d.start && new Date(d.start).getTime() > now) ? 1 : 2;
    const r = rank(a) - rank(b);
    if (r) return r;
    return new Date(b[0].start || 0) - new Date(a[0].start || 0);
  });

  const isStriped = columnIndex % 2 === 1;
  const current = !isArchive && docs.some(inEffect);

  return (
    <div style={{
      display:'flex', flexDirection:'column', minWidth:0, height:'100%',
      background: isStriped ? 'rgba(255,255,255,0.018)' : 'transparent',
      padding:'0 10px 14px',
    }}>
      <div style={{ display:'flex', alignItems:'center', gap:8,
        padding:'10px 0 9px', borderBottom:`1px solid ${A.bd}`, marginBottom:11 }}>
        <div style={{ width:3, height:13, background:C.fg4, borderRadius:1, flexShrink:0 }}/>
        <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:600,
          color:C.fg1, letterSpacing:'0.06em' }}>{type.label}</div>
        {type.required && (
          <span data-typo="label" title="Required for the desk to be usable."
            style={{ fontFamily:'Roboto Mono', fontSize:9,
              letterSpacing:'0.14em', color:C.fg4 }}>REQ</span>
        )}
        <div style={{ flex:1 }}/>
        {/* Status sits on the right, next to the count. Blue rather than
            green: green was reading as "good" on a row where nothing is
            wrong by default, and blue is the system's informational tone. */}
        {current && (
          <span title="Current — a document of this type is in effect right now"
            style={{ display:'flex', alignItems:'center', gap:5, flexShrink:0 }}>
            <span style={{ display:'flex', alignItems:'center', justifyContent:'center',
              width:13, height:13, borderRadius:'50%',
              background:`${C.accent}24`, color:C.accent,
              fontSize:9, fontWeight:700, lineHeight:1 }}>✓</span>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
              letterSpacing:'0.1em', color:C.accent }}>CURRENT</span>
          </span>
        )}
        {type.required && !current && !isArchive && (
          <span data-typo="label" title={`No ${type.label} is in effect right now.`}
            style={{ fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.1em',
              color:C.advisory, flexShrink:0 }}>NOT CURRENT</span>
        )}
        <span data-typo="label" title={`${docs.length} document${docs.length === 1 ? '' : 's'} in this bucket.`}
          style={{ fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.06em',
            color:C.fg4, flexShrink:0 }}>
          {docs.length} {docs.length === 1 ? 'DOC' : 'DOCS'}
        </span>
      </div>

      <div style={{ display:'flex', flexDirection:'column', gap:9, minWidth:0 }}>
        {docs.length === 0 ? (
          <EmptyTypeState type={type} primaryAor={primaryAor}
            libraryTab={libraryTab} onOpenUpload={onOpenUpload}/>
        ) : lineages.map(g => (
          <DocLineage key={lineageKey(g[0])} docs={g} type={type}
            showAorBadge={showAorColumn} onOpenDoc={onOpenDoc} isArchive={isArchive}/>
        ))}
      </div>
    </div>
  );
}

function DocLibrary({ docs, activeAors, primaryAor, libraryTab, onOpenUpload, onOpenDoc }) {
  const showAorColumn = activeAors.size > 1;
  const types = window.DOC_TYPES || [];
  return (
    // Kanban-style grid — one column per doc type. All 5 columns always
    // visible at the same vertical position so coverage reads in one
    // horizontal sweep across column tops. Zebra striping inside each
    // DocTypeColumn delineates the column boundaries; the grid's gap
    // is zero so the stripes butt cleanly against each other.
    <div data-tour="admin-library" style={{
      paddingTop:6, paddingBottom:18, minHeight:'100%',
      display:'grid',
      gridTemplateColumns: `repeat(${types.length}, minmax(0, 1fr))`,
      gap:0,
      alignItems:'stretch',
    }}>
      {types.map((t, i) => {
        const typeDocs = docs.filter(d => d.type === t.id);
        return (
          <DocTypeColumn
            key={t.id}
            type={t}
            docs={typeDocs}
            primaryAor={primaryAor}
            showAorColumn={showAorColumn}
            libraryTab={libraryTab}
            onOpenUpload={onOpenUpload}
            onOpenDoc={onOpenDoc}
            columnIndex={i}
          />
        );
      })}
    </div>
  );
}

// ── Gantt timeline view (DTK-styled, custom-built) ──────────
// Alternate dashboard view to the doc-card grid. Shows a fixed window
// (~3 days) centered on the scenario clock, with one section per
// active AOR and one row per doc type within each AOR. Each doc
// instance renders as a horizontal block spanning its validity
// window. The "now" cursor (vertical dashed line + flag) marks the
// scenario clock.
//
// Visual + structural model is a token-port of accelint's Design
// Toolkit Gantt component (storybook: components/gantt). We can't
// install the package in this babel-standalone prototype, so we
// reproduce its vocabulary + visual idiom locally:
//
//   GanttPanelContainer   → left "label" rail (flex column of rows)
//   GanttPanelRow         → one row's label cell
//   GanttContentContainer → right "content" rail
//   GanttContentRow       → one row's bar track (absolute-positioned)
//   GanttBlock            → a solid filled bar over a time range
//
// We keep the lane-stacking behavior here — DTK's Gantt is one
// timeline per row, but our docs need overlap-stacking so two
// active ATO cycles render side-by-side cleanly. When a future
// real-DTK install lands, the swap is mechanical: replace these
// internals with imports + thread state per their API.
//
// Color tokens are semantic, matching DTK's `accent | serious |
// critical` convention (so a future swap stays declarative):
//   current   → accent   (normal, on-cycle)
//   expiring  → serious  (within 6h of expiry)
//   expired   → critical (past expiry)
//   evergreen → accent
//
// Layout knobs:
//   GANTT_PANEL_W   — width of the left-rail label column (px)
//   GANTT_AXIS_HOURS_BEFORE / _AFTER — fixed window relative to now
//   GANTT_TIMESCALE — string like "6h" / "2h" / "30m" — interval
//                     between time-axis tick labels (DTK prop name).
//   GANTT_ROW_H     — base vertical height per doc-type row (px)
const GANTT_PANEL_W           = 124;
const GANTT_AXIS_HOURS_BEFORE = 24;
const GANTT_AXIS_HOURS_AFTER  = 48;
const GANTT_TIMESCALE         = '6h';
const GANTT_ROW_H             = 40;   // matches DTK's rowHeightPx default

// Parse a DTK-style timescale string (e.g. "6h", "2h", "30m") into ms.
// Falls back to 6h if the string is malformed — the tick generator
// can't divide-by-zero so a sane default keeps the gantt rendering.
function _parseGanttTimescale(s) {
  const m = String(s || '').trim().match(/^(\d+)\s*([hm])$/i);
  if (!m) return 6 * 3600 * 1000;
  const n = parseInt(m[1], 10);
  const unit = m[2].toLowerCase();
  return unit === 'h' ? n * 3600 * 1000 : n * 60 * 1000;
}

function _ganttMs(d) { return new Date(d).getTime(); }
function _ganttFmtTickLabel(date) {
  const dd = String(date.getUTCDate()).padStart(2,'0');
  const hh = String(date.getUTCHours()).padStart(2,'0');
  const mm = String(date.getUTCMinutes()).padStart(2,'0');
  return `${dd} ${hh}${mm}Z`;
}

// Map our doc-state classifier output → a DTK semantic color name.
// Internals of our Gantt resolve this back to a hex via _ganttColorHex.
function _ganttStateToColor(state) {
  if (state === 'expired')  return 'critical';
  if (state === 'expiring') return 'serious';
  return 'accent';   // 'current' | 'evergreen' | unknown
}
function _ganttColorHex(token) {
  if (token === 'critical') return C.critical;
  if (token === 'serious')  return C.advisory;
  return C.normal;   // 'accent'
}

// Interval-pack a list of docs into the minimum number of horizontal
// lanes such that no two docs in the same lane overlap in time.
// Greedy first-fit: sort by start, place each doc in the first lane
// whose most-recently-placed doc has already ended; otherwise spawn
// a new lane. Returns { laneByDocId: Map<id,laneIdx>, laneCount }.
//
// Two docs are considered overlapping when their visibility windows
// (clipped to the gantt axis) share any time. We use the clipped
// window for lane assignment so docs that are entirely off-axis don't
// reserve a lane they'd never visually occupy.
//
// DTK's own Gantt doesn't lane-pack — its model is one timeline per
// row, sequence of blocks/spacers in source order. We retain this
// behavior locally because the doc-validity overlap case (ATO
// transition, ACO renewal during overlap window) demands it.
function _assignGanttLanes(docs, axis) {
  const { startMs, endMs } = axis;
  const sorted = docs
    .map(d => {
      const dStart = Math.max(_ganttMs(d.start), startMs);
      const dEnd   = Math.min(d.end ? _ganttMs(d.end) : endMs + 1, endMs);
      return { doc:d, vStart:dStart, vEnd:dEnd };
    })
    .filter(o => o.vEnd > o.vStart)
    // Stable sort: by visible start asc, then by visible end desc so
    // the longer of two same-start docs lands in the lower lane (reads
    // more naturally — primary visible bar on top).
    .sort((a,b) => (a.vStart - b.vStart) || (b.vEnd - a.vEnd));
  const laneEnds = []; // lane index → most-recent vEnd assigned to it
  const laneByDocId = new Map();
  sorted.forEach(({ doc, vStart, vEnd }) => {
    let placed = -1;
    for (let i = 0; i < laneEnds.length; i++) {
      if (laneEnds[i] <= vStart) { placed = i; break; }
    }
    if (placed === -1) { placed = laneEnds.length; laneEnds.push(vEnd); }
    else { laneEnds[placed] = vEnd; }
    laneByDocId.set(doc.id, placed);
  });
  return { laneByDocId, laneCount: Math.max(1, laneEnds.length) };
}

// One block inside a content row. Reverted to the original subtle-
// wash visual: state-tinted background fill at low alpha + matching
// border at slightly higher alpha + light-grey body text + inset
// state-colored status pill on the right. Hover brightens the wash.
//
// Computes its left/width from the doc's validity window against
// the axis; lane index + lane count determine the vertical slot
// inside its row. Click → preview drawer.
function GanttBlock({ doc, axis, onOpen, lane=0, laneCount=1 }) {
  const { startMs, endMs, totalMs } = axis;
  const docStartMs = _ganttMs(doc.start);
  // Evergreen docs have no end — visually treat as "extends past the
  // right edge" (clip to axis end). State-color tinting still uses
  // classifyDocState which returns 'evergreen' for these.
  const docEndMs = doc.end ? _ganttMs(doc.end) : endMs + 1;
  // Clip to axis bounds. If a doc is fully outside the visible window
  // we don't render anything for it.
  const visStart = Math.max(docStartMs, startMs);
  const visEnd   = Math.min(docEndMs,   endMs);
  if (visEnd <= visStart) return null;
  const leftPct  = ((visStart - startMs) / totalMs) * 100;
  const widthPct = ((visEnd - visStart) / totalMs) * 100;
  // Lane → percent slot. 5px outer padding, 2px gap between stacked
  // lanes when laneCount > 1. With laneCount=1 we keep the original
  // top:5/bottom:5 read for backwards-compat.
  const lanePadPx = 5;
  const laneGapPx = 2;
  const slotStyle = laneCount === 1
    ? { top: lanePadPx, bottom: lanePadPx }
    : (() => {
        // Compute via calc() so percentage of the row is split evenly
        // and the px gap rolls into the math without measuring height.
        const slotPct = 100 / laneCount;
        const topPct  = slotPct * lane;
        const botPct  = slotPct * (laneCount - lane - 1);
        return {
          top:    `calc(${topPct}% + ${lanePadPx}px)`,
          bottom: `calc(${botPct}% + ${lanePadPx}px)`,
          // Half-gap on each side (top of bars in lane>0 + bottom of
          // bars in lane<last) so adjacent bars get a clean breath.
          marginTop:    lane > 0 ? laneGapPx / 2 : 0,
          marginBottom: lane < laneCount - 1 ? laneGapPx / 2 : 0,
        };
      })();
  const state = (window.classifyDocState && window.classifyDocState(doc)) || 'current';
  const stateColor = state === 'expired'  ? C.critical
                   : state === 'expiring' ? C.advisory
                   : state === 'evergreen'? C.normal
                   :                        C.normal;
  const stateLabel = state === 'expired'  ? 'EXPIRED'
                   : state === 'expiring' ? (window.formatTimeRemaining ? window.formatTimeRemaining(doc).toUpperCase() + ' LEFT' : 'EXPIRING')
                   :                        'CURRENT';
  // Subtle outer wash (state-tinted background) + 1px state-tinted
  // border. Hovering brightens the wash; clicking opens preview.
  return (
    <button onClick={() => onOpen && onOpen(doc.id)}
      title={`${doc.name} · ${stateLabel.toLowerCase()}`}
      style={{
        position:'absolute',
        ...slotStyle,
        left: `${leftPct}%`, width: `${widthPct}%`,
        minWidth:80,
        background: `${stateColor}1a`,
        border:`1px solid ${stateColor}55`,
        borderRadius:3,
        cursor:'pointer', textAlign:'left',
        padding:'0 9px',
        display:'flex', alignItems:'center', gap:8,
        overflow:'hidden', whiteSpace:'nowrap',
        transition:'background 0.12s',
      }}
      onMouseEnter={(e)=>{ e.currentTarget.style.background = `${stateColor}33`; }}
      onMouseLeave={(e)=>{ e.currentTarget.style.background = `${stateColor}1a`; }}>
      <span style={{ flex:1, minWidth:0,
        fontFamily:'Roboto Mono', fontSize:9.5, fontWeight:500, color:C.fg1,
        letterSpacing:'0.04em', overflow:'hidden', textOverflow:'ellipsis' }}>
        {doc.name}
      </span>
      <span style={{ flexShrink:0, display:'inline-flex', alignItems:'center', gap:5,
        padding:'2px 7px', borderRadius:2,
        background: `${stateColor}22`, color: stateColor,
        fontFamily:'Roboto Mono', fontSize:8, fontWeight:600, letterSpacing:'0.10em' }}>
        <span style={{ width:5, height:5, borderRadius:'50%', background:stateColor }}/>
        {stateLabel}
      </span>
    </button>
  );
}

// Empty-required-slot warning for required types with no doc on file
// in this AOR. Mirrors the "NO X ON FILE" treatment from the Figma —
// dashed critical-tinted band that fills the row.
function GanttContentRowEmpty({ type }) {
  return (
    <div style={{
      position:'absolute', top:4, bottom:4, left:4, right:4,
      background:'transparent', border:`1px dashed ${C.critical}66`, borderRadius:3,
      display:'flex', alignItems:'center', gap:7, padding:'0 11px',
      fontFamily:'Roboto Mono', fontSize:9, color:C.critical, letterSpacing:'0.10em',
    }}>
      <span style={{ fontSize:10 }}>⚠</span>
      <span>NO {type.label.toUpperCase()} ON FILE</span>
    </div>
  );
}

// One panel-row + one content-row, paired positionally. Extracts
// row-height from lane-pack output so panel + content stay aligned
// on rows that grew vertically.
function GanttDocRow({ aorId, type, docs, axis, onOpen }) {
  const aorDocs = docs.filter(d => d.aor === aorId && d.type === type.id);
  const required = !!type.required;
  const isEmpty = aorDocs.length === 0;
  // Lane-pack the docs so overlapping validity windows stack
  // vertically rather than painting on top of each other. laneCount=1
  // means no overlaps → row stays its base height. >1 lanes grow the
  // row proportionally so each stacked block keeps its readable height.
  const { laneByDocId, laneCount } = _assignGanttLanes(aorDocs, axis);
  const rowHeight = GANTT_ROW_H + (laneCount - 1) * 28;
  return (
    <div style={{ display:'flex', alignItems:'stretch', height:rowHeight,
      borderBottom:`1px solid ${C.border}` }}>
      {/* GanttPanelRow — type icon glyph + caps label + ·req chip */}
      <div style={{ width:GANTT_PANEL_W, flexShrink:0, padding:'0 12px',
        borderRight:`1px solid ${C.border}`, display:'flex', alignItems:'center', gap:8,
        background:C.s1 }}>
        <div style={{ width:14, height:14, display:'flex', alignItems:'center',
          justifyContent:'center', color:type.color || C.fg2, flexShrink:0 }}>
          {type.id === 'ato'  && <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor"><path d="M6.5 1L8 5.5L12.5 6.5L8 7.5L6.5 12L5 7.5L0.5 6.5L5 5.5z"/></svg>}
          {type.id === 'aco'  && <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M2 4l4.5-2.5L11 4l-4.5 2.5L2 4z"/><path d="M2 7l4.5 2.5L11 7"/><path d="M2 10l4.5 2.5L11 10"/></svg>}
          {type.id === 'otl'  && <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M5.5 7.5L2.5 10.5a2 2 0 1 1-2-2L3.5 5.5"/><path d="M7.5 5.5l3-3a2 2 0 1 1 2 2L9.5 7.5"/></svg>}
          {type.id === 'jptl' && <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.2"><circle cx="6.5" cy="6.5" r="5"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="6.5" cy="6.5" r="0.8" fill="currentColor"/></svg>}
          {type.id === 'ref'  && <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M2.5 2v9h7.5v-9zM4.5 4h4M4.5 6h4M4.5 8h2.5"/></svg>}
        </div>
        <span style={{ flex:1, fontFamily:'Roboto Mono', fontSize:10, fontWeight:500,
          color:C.fg1, letterSpacing:'0.10em', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
          {type.label}
        </span>
        {required && (
          <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, letterSpacing:'0.10em' }}>
            ·req
          </span>
        )}
      </div>
      {/* GanttContentRow — blocks positioned absolutely against this
          container's width. Overlapping blocks lane-stack vertically
          via _assignGanttLanes (custom extension over DTK's flat-row
          model). */}
      <div style={{ flex:1, position:'relative' }}>
        {isEmpty && required ? <GanttContentRowEmpty type={type}/> :
          aorDocs.map(d => (
            <GanttBlock key={d.id} doc={d} axis={axis} onOpen={onOpen}
              lane={laneByDocId.get(d.id) || 0}
              laneCount={laneCount}
            />
          ))}
      </div>
    </div>
  );
}

function GanttAorSection({ aor, docs, axis, onOpen }) {
  const types = window.DOC_TYPES || [];
  // Friendly geographic sub-label per AOR — shown next to the AOR
  // chip in the section header. Mirrors what the Figma showed.
  const aorSubtitle = ({
    centcom:'Middle East / Central Asia',
    indopacom:'Pacific / S.E. Asia',
    eucom:'Europe / Eastern Flank',
    africom:'Africa / Sahel',
    northcom:'North America',
    southcom:'South America / Caribbean',
  })[aor.id] || '';
  return (
    <div>
      {/* AOR section header — accent dot + AOR short + geographic subtitle */}
      <div style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px',
        background:C.s2, borderTop:`1px solid ${C.border}`, borderBottom:`1px solid ${C.border}` }}>
        <span style={{ display:'inline-flex', alignItems:'center', gap:6,
          padding:'3px 9px', borderRadius:3,
          background:aor.isPrimary ? C.accentM : C.s3,
          border:`1px solid ${aor.isPrimary ? C.accent + '55' : C.border}` }}>
          <span style={{ width:6, height:6, borderRadius:'50%',
            background: aor.isPrimary ? C.accent : C.fg3 }}/>
          <span style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
            color: aor.isPrimary ? C.accent : C.fg2, letterSpacing:'0.10em' }}>
            {aor.short}
          </span>
        </span>
        {aorSubtitle && (
          <span style={{ fontFamily:'Roboto', fontSize:11, color:C.fg3, letterSpacing:'0.04em' }}>
            {aorSubtitle}
          </span>
        )}
      </div>
      {/* Rows */}
      {types.map(t => (
        <GanttDocRow key={t.id} aorId={aor.id} type={t} docs={docs} axis={axis} onOpen={onOpen}/>
      ))}
    </div>
  );
}

function GanttView({ docs, activeAors, primaryAor, onOpen }) {
  const allAors = window.AORS || [];
  // Active AORs only (per locked decision). Sort with primary first
  // so the operator's main AOR sits at the top.
  const aorSet = activeAors instanceof Set ? activeAors : new Set();
  const visibleAors = allAors.filter(a => aorSet.has(a.id));
  visibleAors.sort((a,b) => {
    if (a.id === primaryAor) return -1;
    if (b.id === primaryAor) return 1;
    return 0;
  });

  // Build the time axis. Fixed window relative to scenario now.
  const now = (window.getAdminNow && window.getAdminNow()) || new Date();
  const startMs = now.getTime() - GANTT_AXIS_HOURS_BEFORE * 3600 * 1000;
  const endMs   = now.getTime() + GANTT_AXIS_HOURS_AFTER  * 3600 * 1000;
  const totalMs = endMs - startMs;

  // Tick generation. Aligned to the GANTT_TIMESCALE interval starting
  // from the first tick at-or-after startMs that's a clean multiple
  // (so labels don't skew across runs with different now-anchors).
  const tickIntervalMs = _parseGanttTimescale(GANTT_TIMESCALE);
  const firstTickMs = Math.ceil(startMs / tickIntervalMs) * tickIntervalMs;
  const ticks = [];
  for (let t = firstTickMs; t <= endMs; t += tickIntervalMs) {
    ticks.push(t);
  }

  const nowLeftPct = ((now.getTime() - startMs) / totalMs) * 100;
  const axis = { startMs, endMs, totalMs };

  return (
    <div style={{ padding:'12px 0 24px' }}>
      {/* Time axis header. Panel-column gutter on left mirrors the
          GanttPanelContainer width below so axis ticks align with
          content rows. */}
      <div style={{ display:'flex', alignItems:'stretch', position:'relative',
        borderBottom:`1px solid ${C.border}` }}>
        <div style={{ width:GANTT_PANEL_W, flexShrink:0, padding:'8px 12px',
          borderRight:`1px solid ${C.border}`,
          fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em',
          background:C.s1 }}>
          AOR / DOC
        </div>
        <div style={{ flex:1, position:'relative', height:32 }}>
          {ticks.map(t => {
            const leftPct = ((t - startMs) / totalMs) * 100;
            return (
              <div key={t} style={{
                position:'absolute', top:0, bottom:0,
                left:`${leftPct}%`,
                fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3,
                letterSpacing:'0.06em',
                paddingLeft:4, paddingTop:6,
                whiteSpace:'nowrap',
              }}>
                {_ganttFmtTickLabel(new Date(t))}
                <div style={{ position:'absolute', left:0, top:22, bottom:0, width:1, background:C.border }}/>
              </div>
            );
          })}
          {/* Now cursor flag — DTK uses a colored chip at the top of
              the axis with a vertical line going down. Same here. */}
          <div style={{ position:'absolute', top:0, left:`${nowLeftPct}%`, transform:'translateX(-50%)',
            fontFamily:'Roboto Mono', fontSize:8, fontWeight:700, letterSpacing:'0.10em',
            color:'#000', background:C.accent, padding:'2px 6px', borderRadius:2,
            zIndex:2 }}>
            NOW
          </div>
        </div>
      </div>
      {/* Body: each AOR section + its rows. The 'now' line continues
          all the way down through the body via a sibling overlay. */}
      <div style={{ position:'relative' }}>
        {visibleAors.length === 0 ? (
          <div style={{ padding:'30px 24px', textAlign:'center',
            fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.10em' }}>
            NO AORS ACTIVE — toggle one in the AOR selector
          </div>
        ) : (
          visibleAors.map(a => (
            <GanttAorSection key={a.id} aor={a} docs={docs} axis={axis} onOpen={onOpen}/>
          ))
        )}
        {/* Now line — stretches the full height of the body. Positioned
            relative to the body container; left% computed against the
            same axis but accounting for the panel gutter on the left. */}
        {visibleAors.length > 0 && (
          <div style={{
            position:'absolute', top:0, bottom:0,
            left:`calc(${GANTT_PANEL_W}px + (100% - ${GANTT_PANEL_W}px) * ${nowLeftPct/100})`,
            width:1, pointerEvents:'none',
            borderLeft:`1px dashed ${C.accent}99`,
            zIndex:1,
          }}/>
        )}
      </div>
    </div>
  );
}

// ── Persistent upload zone (bottom of workspace) ────────────
function PersistentUploadZone({ onOpenUpload }) {
  const [hover, setHover] = React.useState(false);
  return (
    <div style={{ padding:'14px 24px', borderTop:`1px solid ${C.border}`, background:C.s2, flexShrink:0 }}>
      <div
        onClick={() => onOpenUpload(null)}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        title="Upload a new document. Opens the upload modal where you pick doc type + AOR."
        style={{
          padding:'14px 20px',
          border:`1px dashed ${hover ? C.fg3 : C.border2}`,
          background: hover ? C.s3 : 'transparent',
          borderRadius:3, textAlign:'center', cursor:'pointer',
          transition:'all 0.12s',
        }}>
        <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:hover ? C.fg1 : C.fg2, letterSpacing:'0.10em' }}>
          DROP A FILE HERE OR CLICK TO UPLOAD
        </div>
        <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3, letterSpacing:'0.08em', marginTop:3 }}>
          XML · PDF · DOCX · XLSX · CSV · TXT
        </div>
      </div>
    </div>
  );
}

// ── Mission Admin upload modal ──────────────────────────────
function ScopeField({ label, value, locked, children }) {
  const labelTip = label === 'DOC TYPE'
    ? 'DOC TYPE — Kind of document being uploaded (ATO, ACO, OPTASKLINK, JPTL, or reference).'
    : label === 'AOR'
    ? 'AOR — Geographic Area of Responsibility this document applies to.'
    : label;
  return (
    <div style={{ flex:1 }}>
      <div title={labelTip} style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:5 }}>
        {label}
      </div>
      {locked ? (
        <div title="LOCKED — This field was pre-set by the surface that opened this dialog (e.g., a Readiness chip clicked for a specific AOR)." style={{ padding:'7px 11px', borderRadius:3, background:C.s3, border:`1px solid ${C.border}`,
          fontFamily:'Roboto', fontSize:11, color:C.fg1, display:'flex', alignItems:'center', justifyContent:'space-between' }}>
          <span>{value}</span>
          <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.08em' }}>LOCKED</span>
        </div>
      ) : children}
    </div>
  );
}

function MissionAdminUploadModal({ scope, onClose, onUpload }) {
  const [typeId, setTypeId] = React.useState(scope?.typeId || null);
  const [aor,    setAor]    = React.useState(scope?.aor    || null);
  const [phase,  setPhase]  = React.useState('idle');
  const [pct,    setPct]    = React.useState(0);
  React.useEffect(() => {
    if (!scope) return;
    setTypeId(scope.typeId || null);
    setAor(scope.aor || null);
    setPhase('idle');
    setPct(0);
  }, [scope]);
  if (!scope) return null;
  const typeMeta = typeId ? (window.DOC_TYPES || []).find(t => t.id === typeId) : null;
  const aorMeta  = aor    ? (window.AORS || []).find(a => a.id === aor)         : null;
  const canStart = typeId && aor && phase === 'idle';
  const isReplacing = !!scope.replacingId;
  const startUpload = () => {
    if (!canStart) return;
    setPhase('uploading'); setPct(0);
    const upStart = performance.now();
    const tickUp = () => {
      const dt = performance.now() - upStart;
      const p = Math.min(1, dt / 1500);
      setPct(p);
      if (p < 1) requestAnimationFrame(tickUp);
      else {
        setPhase('parsing'); setPct(0);
        const psStart = performance.now();
        const tickPs = () => {
          const dt2 = performance.now() - psStart;
          const p2 = Math.min(1, dt2 / 2000);
          setPct(p2);
          if (p2 < 1) requestAnimationFrame(tickPs);
          else {
            setPhase('done');
            onUpload(typeId, aor).then(() => {
              setTimeout(() => onClose(), 600);
            });
          }
        };
        requestAnimationFrame(tickPs);
      }
    };
    requestAnimationFrame(tickUp);
  };
  const phaseLabel = phase === 'uploading' ? 'UPLOADING'
                   : phase === 'parsing'   ? 'PARSING'
                   : phase === 'done'      ? 'COMPLETE'
                   :                          'READY';
  const progressColor = phase === 'parsing' ? C.accent
                      : phase === 'done'    ? C.normal
                      :                        C.fg2;
  return (
    <div style={{ position:'fixed', inset:0, zIndex:250, background:'rgba(0,0,0,0.78)',
      backdropFilter:'blur(4px)', display:'flex', alignItems:'center', justifyContent:'center' }}>
      <div style={{ background:C.s2, border:`1px solid ${C.border2}`, borderRadius:6, width:520,
        maxWidth:'92vw', display:'flex', flexDirection:'column', boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex', alignItems:'center', padding:'12px 14px',
          borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto', fontWeight:600, fontSize:13, color:C.fg1 }}>
              {isReplacing ? 'Replace document' : 'Upload document'}
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.08em', marginTop:2 }}>
              {isReplacing ? 'OLD VERSION MOVES TO ARCHIVE' : 'NEW DOCUMENT ENTERS THE ACTIVE LIBRARY'}
            </div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ padding:'18px 18px 14px' }}>
          <div style={{ display:'flex', gap:14, marginBottom:18 }}>
            <ScopeField label="DOC TYPE" value={typeMeta ? `${typeMeta.label} · ${typeMeta.long}` : ''} locked={!!scope.typeId}>
              {!scope.typeId && (
                <select value={typeId || ''} onChange={(e)=>setTypeId(e.target.value)} style={{
                  width:'100%', padding:'7px 10px', borderRadius:3, background:C.s3,
                  border:`1px solid ${C.border}`, color:C.fg1, fontFamily:'Roboto', fontSize:11,
                }}>
                  <option value="">Select type</option>
                  {(window.DOC_TYPES || []).map(t => <option key={t.id} value={t.id}>{t.label} · {t.long}</option>)}
                </select>
              )}
            </ScopeField>
            <ScopeField label="AOR" value={aorMeta ? aorMeta.short : ''} locked={!!scope.aor}>
              {!scope.aor && (
                <select value={aor || ''} onChange={(e)=>setAor(e.target.value)} style={{
                  width:'100%', padding:'7px 10px', borderRadius:3, background:C.s3,
                  border:`1px solid ${C.border}`, color:C.fg1, fontFamily:'Roboto', fontSize:11,
                }}>
                  <option value="">Select AOR</option>
                  {(window.AORS || []).map(a => <option key={a.id} value={a.id}>{a.short}</option>)}
                </select>
              )}
            </ScopeField>
          </div>
          {phase === 'idle' && (
            <button disabled={!canStart} onClick={startUpload} style={{
              width:'100%', padding:'24px 20px',
              border:`1px dashed ${canStart ? C.accent : C.border2}`,
              borderRadius:3,
              background:canStart ? `${C.accent}0a` : 'transparent',
              color:canStart ? C.accent : C.fg3,
              cursor:canStart ? 'pointer' : 'not-allowed',
              fontFamily:'Roboto Mono', fontSize:11, fontWeight:500, letterSpacing:'0.10em',
            }}>
              {canStart ? 'CLICK TO UPLOAD' : 'SELECT TYPE AND AOR'}
            </button>
          )}
          {phase !== 'idle' && (
            <div style={{ padding:'8px 4px 4px' }}>
              <div style={{ display:'flex', justifyContent:'space-between', marginBottom:8,
                fontFamily:'Roboto Mono', fontSize:9.5, letterSpacing:'0.10em' }}>
                <span style={{ color:progressColor }}>{phaseLabel}</span>
                <span style={{ color:C.fg3 }}>{Math.round(pct*100)}%</span>
              </div>
              <div style={{ width:'100%', height:4, background:C.s3, borderRadius:2, overflow:'hidden' }}>
                <div style={{ width:`${pct*100}%`, height:'100%', background:progressColor, transition:'width 0.06s linear' }}/>
              </div>
              {phase === 'parsing' && typeMeta && typeMeta.parsedInto && (
                <div style={{ marginTop:8, fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.05em' }}>
                  Parsing into {typeMeta.parsedInto}…
                </div>
              )}
              {phase === 'done' && (
                <div style={{ marginTop:10, fontFamily:'Roboto Mono', fontSize:10, color:C.normal, letterSpacing:'0.08em' }}>
                  ✓ Document materialized in library.
                </div>
              )}
            </div>
          )}
        </div>
        <div style={{ display:'flex', justifyContent:'flex-end', gap:8, padding:'12px 14px',
          borderTop:`1px solid ${C.border}` }}>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="medium">
            {phase === 'done' ? 'Close' : 'Cancel'}
          </DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Doc preview renderers ───────────────────────────────────
// Three renderers, one per preview kind returned from getDocPreview:
//   text → monospace <pre> block, looks like an opened .txt file
//   csv  → simple table with mono headers + row dividers
//   pdf  → fake PDF viewer chrome with page-style content underneath

// ── Formatted (parsed) document view ─────────────────────────
// The raw file is the source of truth, but it is not what an operator
// reads day to day. Where a document has been parsed we render the
// structured result and let them flip to the original to verify.
// Types with no parser fall straight through to raw.
function parsedRowsFor(doc) {
  if (!doc) return null;
  if (doc.type === 'ato') {
    const units = (window.ATO_TASKUNITS || []).filter(t => t.atoId === doc.id);
    if (!units.length) return null;
    return {
      label:'TASK UNITS',
      cols:['CALL SIGN', 'PLATFORM', 'MISSION', 'DEP → ARR', 'ARMAMENT'],
      widths:'96px 84px 96px 104px 1fr',
      rows: units.map(u => [u.cs, u.type, u.mission || u.role || '—',
        `${u.dep || '—'} → ${u.arr || '—'}`, u.armament || '—']),
    };
  }
  if (doc.type === 'aco') {
    const acms = (window.ACO_ACMS || []).filter(a => a.acoId === doc.id);
    if (!acms.length) return null;
    return {
      label:'AIRSPACE CONTROL MEASURES',
      cols:['DESIGNATOR', 'TYPE', 'FLOOR', 'CEILING', 'CONTROLLING'],
      widths:'1.4fr 88px 78px 78px 1fr',
      rows: acms.map(a => [a.designator, a.type, a.altLow || '—',
        a.altHigh || '—', a.controlling || '—']),
    };
  }
  return null;
}

function FormattedPreview({ doc }) {
  const parsed = parsedRowsFor(doc);
  if (!parsed) {
    return (
      <div style={{ padding:'22px 16px', textAlign:'center', background:C.s2,
        border:`1px solid ${C.border}`, borderRadius:3 }}>
        <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3,
          letterSpacing:'0.08em' }}>NO PARSED REPRESENTATION</div>
        <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg4, marginTop:7 }}>
          This document type is stored as-is. Switch to Raw to read the original file.
        </div>
      </div>
    );
  }
  return (
    <div style={{ background:C.s2, border:`1px solid ${C.border}`, borderRadius:3,
      overflow:'hidden' }}>
      <div style={{ display:'flex', alignItems:'center', gap:8, padding:'9px 12px',
        borderBottom:`1px solid ${C.border}` }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          letterSpacing:'0.16em', color:C.fg3 }}>{parsed.label}</span>
        <div style={{ flex:1 }}/>
        <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg4 }}>
          {parsed.rows.length} rows
        </span>
      </div>
      <div style={{ display:'grid', gridTemplateColumns:parsed.widths, gap:10,
        padding:'8px 12px', borderBottom:`1px solid ${C.border}`, background:C.s1 }}>
        {parsed.cols.map(c => (
          <span key={c} data-typo="label" style={{ fontFamily:'Roboto Mono',
            fontSize:7.5, letterSpacing:'0.14em', color:C.fg4 }}>{c}</span>
        ))}
      </div>
      <div style={{ maxHeight:420, overflowY:'auto' }}>
        {parsed.rows.map((r, i) => (
          <div key={i} style={{ display:'grid', gridTemplateColumns:parsed.widths, gap:10,
            padding:'8px 12px', borderBottom:`1px solid ${C.s3}`, whiteSpace:'nowrap' }}>
            {r.map((cell, j) => (
              <span key={j} style={{ fontFamily:'Roboto Mono', fontSize:10,
                color: j === 0 ? C.fg1 : C.fg3, overflow:'hidden',
                textOverflow:'ellipsis' }}>{cell}</span>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

function ViewToggle({ value, onChange, hasParsed }) {
  const Opt = ({ id, label, disabled }) => {
    const on = value === id;
    return (
      <button onClick={() => !disabled && onChange(id)} disabled={disabled}
        title={disabled ? 'This document type has no parsed representation.' : `Show the ${label.toLowerCase()} view`}
        style={{ padding:'5px 12px', border:'none', cursor: disabled ? 'default' : 'pointer',
          background: on ? A.hi : 'transparent',
          color: disabled ? C.fg4 : (on ? C.fg1 : C.fg3),
          fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.1em',
          fontWeight: on ? 700 : 400, opacity: disabled ? 0.5 : 1 }}>{label}</button>
    );
  };
  return (
    <div style={{ display:'flex', border:`1px solid ${A.bd2}`, borderRadius:4,
      overflow:'hidden', flexShrink:0 }}>
      <Opt id="formatted" label="FORMATTED" disabled={!hasParsed}/>
      <Opt id="raw" label="RAW"/>
    </div>
  );
}

function TextPreview({ preview }) {
  return (
    <div style={{
      background:'#0e0f10',
      border:`1px solid ${C.border}`,
      borderRadius:3,
      padding:'14px 16px',
      fontFamily:'Roboto Mono, monospace',
      fontSize:10.5, lineHeight:1.55, color:C.fg2,
      whiteSpace:'pre-wrap', wordBreak:'break-word',
      letterSpacing:'0.01em',
    }}>
      {preview.body}
    </div>
  );
}

function CsvPreview({ preview }) {
  return (
    <div style={{
      background:'#0e0f10',
      border:`1px solid ${C.border}`,
      borderRadius:3,
      overflow:'hidden',
    }}>
      <table style={{ width:'100%', borderCollapse:'collapse', fontFamily:'Roboto Mono, monospace', fontSize:10 }}>
        <thead>
          <tr style={{ background:C.s3 }}>
            {preview.headers.map(h => (
              <th key={h} style={{
                padding:'7px 10px', textAlign:'left', fontWeight:500, letterSpacing:'0.10em',
                color:C.fg3, borderBottom:`1px solid ${C.border}`,
              }}>{h}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {preview.rows.map((r, i) => (
            <tr key={i} style={{ borderBottom:`1px solid ${C.border}`,
              background: i % 2 === 1 ? 'rgba(255,255,255,0.012)' : 'transparent' }}>
              {r.map((cell, j) => (
                <td key={j} style={{ padding:'5px 10px', color:C.fg2 }}>{cell}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function PdfPreview({ preview }) {
  // Fake PDF viewer. Toolbar mimics typical PDF chrome (zoom, page nav,
  // search, download). Content underneath is one stylised "page" with
  // the body text. Page nav buttons advance the indicator but the page
  // content stays the same — pure visual placeholder.
  const [page, setPage] = React.useState(1);
  const [zoom, setZoom] = React.useState(100);
  const total = preview.pages || 12;
  const PdfBtn = ({ onClick, disabled, title, children }) => (
    <button onClick={onClick} disabled={disabled} title={title} style={{
      padding:'4px 8px', borderRadius:2,
      background:disabled ? 'transparent' : C.s3,
      border:`1px solid ${disabled ? C.border : C.border2}`,
      color:disabled ? C.fg4 : C.fg2,
      fontFamily:'Roboto Mono', fontSize:9, fontWeight:500, letterSpacing:'0.06em',
      cursor:disabled ? 'not-allowed' : 'pointer',
      display:'flex', alignItems:'center', justifyContent:'center', minWidth:24, minHeight:22,
    }}>{children}</button>
  );
  return (
    <div style={{
      background:'#0e0f10',
      border:`1px solid ${C.border}`,
      borderRadius:3,
      overflow:'hidden',
    }}>
      {/* Toolbar */}
      <div style={{
        display:'flex', alignItems:'center', gap:8, padding:'8px 10px',
        background:C.s2, borderBottom:`1px solid ${C.border}`,
      }}>
        <PdfBtn onClick={()=>setZoom(z=>Math.max(50, z-25))} title="Zoom out">−</PdfBtn>
        <span style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg2, minWidth:42, textAlign:'center' }}>{zoom}%</span>
        <PdfBtn onClick={()=>setZoom(z=>Math.min(200, z+25))} title="Zoom in">+</PdfBtn>
        <div style={{ width:1, height:14, background:C.border, margin:'0 4px' }}/>
        <PdfBtn onClick={()=>setPage(p=>Math.max(1, p-1))} disabled={page<=1} title="Previous page">‹</PdfBtn>
        <span style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg2, minWidth:64, textAlign:'center' }}>
          PAGE {page} / {total}
        </span>
        <PdfBtn onClick={()=>setPage(p=>Math.min(total, p+1))} disabled={page>=total} title="Next page">›</PdfBtn>
        <div style={{ flex:1 }}/>
        <input
          placeholder="search"
          style={{
            background:C.s3, border:`1px solid ${C.border2}`, borderRadius:2,
            padding:'3px 8px', color:C.fg2, fontFamily:'Roboto Mono', fontSize:9.5,
            letterSpacing:'0.04em', width:120, outline:'none',
          }}
          onChange={()=>{ /* mocked — non-functional */ }}
        />
        <PdfBtn title="Download (mock)">↓</PdfBtn>
      </div>
      {/* Page content */}
      <div style={{ padding:'24px 18px', display:'flex', justifyContent:'center', background:'#0a0b0c' }}>
        <div style={{
          width:'100%', maxWidth:540,
          background:'#f7f5ee',  // off-white "page"
          color:'#1a1a1a',
          border:`1px solid #444`, borderRadius:2,
          padding:'34px 38px',
          fontFamily:'Roboto, serif', fontSize:11, lineHeight:1.55,
          minHeight:560,
          whiteSpace:'pre-wrap',
          boxSizing:'border-box',
          transform:`scale(${zoom/100})`,
          transformOrigin:'top center',
          // Center: when scaled down/up, keep visual stability
        }}>
          {preview.body}
        </div>
      </div>
    </div>
  );
}

function DocPreviewBody({ preview }) {
  if (!preview) return null;
  if (preview.kind === 'text') return <TextPreview preview={preview}/>;
  if (preview.kind === 'csv')  return <CsvPreview preview={preview}/>;
  if (preview.kind === 'pdf')  return <PdfPreview preview={preview}/>;
  return null;
}

// ── Doc preview drawer ─────────────────────────────────────
// Slides in from the right at 480 px when an active doc id is set.
// Backdrop scrim covers the workspace beneath at z=200; drawer at
// z=210. Click on backdrop → close. Esc → close (handled in App).
// AOR/tab/mode change closes via App effect. Replace button anchors
// the footer; opens the upload modal pre-scoped + locked.
function DocPreviewDrawer({ doc, allDocs, onClose, onReplace, onArchive, onOpenSubview }) {
  const open = !!doc;
  // Reset internal scroll when doc swaps. The drawer body is its own
  // scrollable region; on doc change we scroll it to top so each doc
  // opens at its header, not wherever the previous doc was scrolled to.
  const bodyRef = React.useRef(null);
  React.useEffect(() => {
    if (bodyRef.current) bodyRef.current.scrollTop = 0;
  }, [doc && doc.id]);
  const preview = doc ? window.getDocPreview(doc) : null;
  const hasParsed = !!parsedRowsFor(doc);
  const [docView, setDocView] = React.useState('formatted');
  // Reset to the best available view whenever the drawer swaps documents.
  React.useEffect(() => {
    setDocView(parsedRowsFor(doc) ? 'formatted' : 'raw');
  }, [doc && doc.id]);
  const typeMeta = doc ? (window.DOC_TYPES || []).find(t => t.id === doc.type) : null;
  const aorMeta = doc ? (window.AORS || []).find(a => a.id === doc.aor) : null;
  const replacedByDoc = doc && doc.replacedBy ? allDocs.find(d => d.id === doc.replacedBy) : null;
  const isArchive = doc && doc.isArchived;
  const state = doc ? window.classifyDocState(doc) : null;
  const remainingColor = state === 'expired'  ? C.critical
                       : state === 'expiring' ? C.advisory
                       : state === 'evergreen' ? C.fg3
                       :                          C.fg2;
  // Clean is the default state and earns no colour; only warnings speak up.
  const parseColor = doc && doc.parseStatus === 'warnings' ? C.advisory : C.fg3;
  const parseLabel = doc && doc.parseStatus === 'clean'    ? 'CLEAN'
                   : doc && doc.parseStatus === 'warnings' ? 'WARNINGS'
                   :                                          'PREVIEW';
  return (
    <>
      {/* Backdrop scrim — non-interactive workspace beneath. */}
      {open && (
        <div
          onClick={onClose}
          style={{
            position:'fixed', left:0, right:0, bottom:0, top:CLASS_BAR_H, zIndex:200,
            background:'rgba(0,0,0,0.55)', animation:'fadeIn 0.18s ease-out',
          }}
        />
      )}
      {/* Drawer panel — always mounted so the slide-in transition runs
          cleanly. pointerEvents:none when closed so it doesn't capture
          clicks from the workspace beneath. */}
      <div style={{
        position:'fixed', right:0, top:CLASS_BAR_H, bottom:0, width:600, zIndex:210,
        background:C.s1, borderLeft:`1px solid ${C.border}`,
        display:'flex', flexDirection:'column',
        transform: open ? 'translateX(0)' : 'translateX(600px)',
        transition: 'transform 180ms ease-out',
        pointerEvents: open ? 'auto' : 'none',
        boxShadow: open ? '-6px 0 24px rgba(0,0,0,0.5)' : 'none',
      }}>
        {/* Header */}
        <div style={{
          padding:'14px 16px 12px', borderBottom:`1px solid ${C.border}`,
          display:'flex', alignItems:'flex-start', gap:10, flexShrink:0,
          background:C.s2,
        }}>
          <div style={{ flex:1, minWidth:0 }}>
            <div style={{ fontFamily:'Roboto', fontSize:16, fontWeight:600, color:C.fg1,
              letterSpacing:'0.01em', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
              {doc ? doc.name : ''}
            </div>
            <div style={{ display:'flex', alignItems:'center', gap:8, marginTop:5,
              fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em' }}>
              {typeMeta && <span style={{ color:C.fg2 }}>{typeMeta.label}</span>}
              {aorMeta && (
                <>
                  <span style={{ color:C.fg4 }}>·</span>
                  <span style={{ color:C.fg2 }}>{aorMeta.short}</span>
                </>
              )}
              {doc && (
                <>
                  <span style={{ color:C.fg4 }}>·</span>
                  <span>{(doc.format || '').toUpperCase()}</span>
                </>
              )}
              {preview && (
                <>
                  <span style={{ color:C.fg4 }}>·</span>
                  <span style={{ color:C.fg3, fontStyle:'italic' }}>{preview.filename}</span>
                </>
              )}
            </div>
          </div>
          <button onClick={onClose} title="Close (Esc)" aria-label="Close" style={{
            width:24, height:24, borderRadius:3, border:`1px solid ${C.border2}`, background:'transparent',
            color:C.fg2, cursor:'pointer', flexShrink:0, fontFamily:'Roboto Mono', fontSize:13,
            display:'flex', alignItems:'center', justifyContent:'center', padding:0,
            transition:'all 0.12s',
          }}
          onMouseEnter={(e)=>{ e.currentTarget.style.color = C.fg1; e.currentTarget.style.borderColor = C.fg3; }}
          onMouseLeave={(e)=>{ e.currentTarget.style.color = C.fg2; e.currentTarget.style.borderColor = C.border2; }}>
            ×
          </button>
        </div>

        {/* Scrollable body */}
        <div ref={bodyRef} style={{ flex:1, overflowY:'auto', padding:'14px 16px 18px', minHeight:0 }}>
          {/* Metadata block */}
          {doc && (
            <div style={{
              display:'grid', gridTemplateColumns:'1fr 1fr', gap:'12px 16px',
              padding:'12px 14px', marginBottom:14,
              background:C.s2, border:`1px solid ${C.border}`, borderRadius:3,
            }}>
              <div>
                <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>EFFECTIVE</div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:10.5, color:C.fg2, letterSpacing:'0.04em' }}>
                  {fmtWindow(doc.start, doc.end)}
                </div>
              </div>
              <div>
                <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>REMAINING</div>
                <span style={{
                  display:'inline-block',
                  padding: state === 'expired' || state === 'expiring' ? '2px 8px' : '0',
                  borderRadius:2,
                  background: state === 'expired' ? `${C.critical}22`
                            : state === 'expiring' ? `${C.advisory}22`
                            : 'transparent',
                  color:remainingColor, fontFamily:'Roboto Mono', fontSize:10.5,
                  fontWeight:500, letterSpacing:'0.04em',
                }}>
                  {window.formatTimeRemaining(doc)}
                </span>
              </div>
              <div>
                <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>UPLOADED</div>
                <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>
                  {fmtRelativeAgo(doc.uploadedAt)} <span style={{ color:C.fg3 }}>by</span> {doc.uploader}
                </div>
              </div>
              <div>
                <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:3 }}>PARSE</div>
                <span data-typo="label" style={{
                  display:'inline-block',
                  padding:'2px 8px', borderRadius:2, fontFamily:'Roboto Mono', fontSize:9,
                  fontWeight:500, letterSpacing:'0.10em', background:`${parseColor}22`, color:parseColor,
                }}>
                  {parseLabel}
                </span>
              </div>
            </div>
          )}

          {/* Parsed-content band — only for ATO docs. Three buttons jump
              into the Platforms / Objectives / Assets sub-pages with the
              source-ATO chip pre-applied. Sits visually parallel to the
              supersede band that follows it. */}
          {doc && doc.type === 'ato' && onOpenSubview && (
            <div style={{
              padding:'10px 12px 12px', marginBottom:14,
              background:C.s2, border:`1px solid ${C.border}`, borderRadius:3,
            }}>
              <div data-typo="label" style={{
                fontFamily:'Roboto Mono', fontSize:8, color:C.fg3,
                letterSpacing:'0.16em', marginBottom:8,
              }}>PARSED CONTENT</div>
              <div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
                <button onClick={() => onOpenSubview('platforms', doc.id)} style={{
                  flex:1, minWidth:120, padding:'8px 10px', borderRadius:3,
                  background:'transparent', border:`1px solid ${C.border2}`, color:C.fg2,
                  cursor:'pointer', fontFamily:'Roboto', fontSize:11, fontWeight:500,
                  letterSpacing:'0.02em',
                }}
                onMouseEnter={(e) => { e.currentTarget.style.background = C.s3; }}
                onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
                  View Platforms
                </button>
                <button onClick={() => onOpenSubview('objectives', doc.id)} style={{
                  flex:1, minWidth:120, padding:'8px 10px', borderRadius:3,
                  background:'transparent', border:`1px solid ${C.border2}`, color:C.fg2,
                  cursor:'pointer', fontFamily:'Roboto', fontSize:11, fontWeight:500,
                  letterSpacing:'0.02em',
                }}
                onMouseEnter={(e) => { e.currentTarget.style.background = C.s3; }}
                onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
                  View Objectives
                </button>
                <button onClick={() => onOpenSubview('assets', doc.id)} style={{
                  flex:1, minWidth:120, padding:'8px 10px', borderRadius:3,
                  background:'transparent', border:`1px solid ${C.border2}`, color:C.fg2,
                  cursor:'pointer', fontFamily:'Roboto', fontSize:11, fontWeight:500,
                  letterSpacing:'0.02em',
                }}
                onMouseEnter={(e) => { e.currentTarget.style.background = C.s3; }}
                onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
                  View Assets
                </button>
              </div>
            </div>
          )}

          {/* Supersede band when archived */}
          {replacedByDoc && (
            <div style={{
              padding:'10px 12px', marginBottom:14,
              background:C.s3, border:`1px solid ${C.border}`, borderRadius:3,
              fontFamily:'Roboto Mono', fontSize:10, color:C.fg2, letterSpacing:'0.06em',
            }}>
              <span data-typo="label" style={{ color:C.fg3 }}>SUPERSEDED BY</span> {replacedByDoc.name} <span style={{ color:C.fg3 }}>→</span>
            </div>
          )}

          {/* Parse warnings panel — only when parseStatus === 'warnings' */}
          {doc && doc.parseStatus === 'warnings' && doc.parseDetails && doc.parseDetails.length > 0 && (
            <div style={{
              padding:'12px 14px', marginBottom:14,
              background:`${C.advisory}10`, border:`1px solid ${C.advisory}55`,
              borderLeft:`3px solid ${C.advisory}`, borderRadius:3,
            }}>
              <div style={{
                fontFamily:'Roboto Mono', fontSize:9, color:C.advisory,
                letterSpacing:'0.10em', marginBottom:6, fontWeight:600,
              }}>PARSE WARNINGS</div>
              <ul style={{ margin:0, paddingLeft:18, color:C.fg2,
                fontFamily:'Roboto', fontSize:11, lineHeight:1.5 }}>
                {doc.parseDetails.map((w, i) => <li key={i}>{w}</li>)}
              </ul>
            </div>
          )}

          {/* Document body — parsed by default where one exists, with the
              original a click away for verification. */}
          <div style={{ display:'flex', alignItems:'center', gap:9, marginBottom:9 }}>
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
              letterSpacing:'0.16em', color:C.fg3 }}>DOCUMENT</span>
            <div style={{ flex:1 }}/>
            {docView === 'raw' && preview && (
              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg4 }}>
                {preview.filename}
              </span>
            )}
            <ViewToggle value={docView} onChange={setDocView} hasParsed={hasParsed}/>
          </div>
          {docView === 'formatted'
            ? <FormattedPreview doc={doc}/>
            : <DocPreviewBody preview={preview}/>}
        </div>

        {/* Footer — actions split into two clusters:
              • Left: destructive / move actions (Archive). Distanced
                from the right cluster so it doesn't read as the
                "default" action.
              • Right: primary action (Replace) + dismiss (Close).
            Archive only renders when the doc is currently in the
            active library; archived docs already show "Restore" /
            "View replacement" affordances upstream so a redundant
            Archive button there would be a no-op. */}
        <div style={{
          padding:'10px 14px', borderTop:`1px solid ${C.border}`,
          display:'flex', alignItems:'center', gap:8, flexShrink:0,
          background:C.s2,
        }}>
          {!isArchive && onArchive && (
            <DTButton
              onClick={() => {
                // Lightweight inline confirm — archiving sends the doc
                // to the Archive tab without uploading a replacement,
                // so we want a beat to make sure it's intentional.
                if (window.confirm(`Move ${doc.name} to the archive? It can be restored from the Archive tab.`)) {
                  onArchive(doc.id);
                }
              }}
              title="Archive — Move this doc to the Archive tab without uploading a replacement. Reversible from the Archive tab."
              color="mono-muted" variant="outline" size="medium">
              Archive
            </DTButton>
          )}
          <div style={{ flex:1 }}/>
          {!isArchive && (
            <DTButton onClick={() => onReplace(doc.id)} title="Replace — Upload a new version of this document. The current copy is moved to the Archive tab." color="accent" size="medium">
              Replace
            </DTButton>
          )}
          <DTButton onClick={onClose} title="Close this preview drawer (ESC)." color="mono-muted" variant="outline" size="medium">
            Close
          </DTButton>
        </div>
      </div>
    </>
  );
}

// ── Mission Admin workspace dispatcher ─────────────────────
// The Mission Admin master mode has four views the operator can land
// on. Three are table sub-pages reachable from the doc-preview
// drawer's "View …" buttons; the default is the dashboard with the
// readiness header, AOR selector, library tabs, and grouped doc
// table. The dispatcher owns no state of its own — all sub-page
// state lives in App so it persists across master-mode toggles.
function MissionAdminWorkspace(props) {
  const view = props.subview || 'dashboard';
  // Each sub-page reads its own sort slot from the App-level
  // `sortBySubview` map. The dispatcher slices it down so the page
  // gets a flat `sort` prop and doesn't need to know the slot key.
  const sortByView = props.sortBySubview || {};
  if (view === 'platforms')  return <PlatformsSubPage  {...props} sort={sortByView.platforms  || null}/>;
  if (view === 'objectives') return <ObjectivesSubPage {...props} sort={sortByView.objectives || null}/>;
  if (view === 'assets')     return <AssetsSubPage     {...props} sort={sortByView.assets     || null}/>;
  if (view === 'missions')   return <MissionDetailsSubPage {...props}/>;
  return <MissionAdminDashboard {...props}/>;
}

// ═══════════════════════════════════════════════════════════════
// ── Mission Admin dashboard — multi-view ───────────────────────
// ═══════════════════════════════════════════════════════════════
// Four peer views over one document library, chosen from an icon
// switcher in the page header:
//   board    — columns by DAY (1 back, today, N forward)  ← default
//   kanban   — columns by DOC TYPE (the original board)
//   table    — one sortable list
//   timeline — GanttView, where spanning docs read properly
//
// The old readiness banner is gone: its job moved into the day
// column headers, which can say *what* is missing and *when*.
// Colour is deliberately restrained — cards are neutral surfaces
// and colour lives only in type pills and status badges. Amber is
// reserved for gaps and parse warnings.

// Local surface ramp. These sit between the existing C tokens;
// naming them here keeps a later design-foundation swap mechanical.
// Surfaces come straight from the design-foundation semantic layer.
// These are var() rather than resolved hex on purpose — semantic tokens
// are light-dark() pairs, so keeping the reference live means the app
// follows color-scheme instead of freezing one theme in place. (index.html
// declares color-scheme: dark; without it these resolve to the light
// values and the whole workspace goes white.)
//
// Unlike C, nothing here is string-concatenated or handed to MapLibre,
// so a var() reference is safe.
const A = {
  card:   'var(--bg-surface-raised)',      // document cards, controls
  raised: 'var(--bg-surface-raised)',      // hover / active fill
  hi:     'var(--bg-interactive-muted)',   // pressed / selected fill
  bd:     'var(--outline-static)',         // dividers
  bd2:    'var(--outline-interactive)',    // control borders
  liveBg: 'var(--bg-surface-muted)',       // the in-effect document (neutral lift)
  warnBg: 'var(--bg-serious-muted)',       // warning containers
};

const DAY_TYPES  = ['ato', 'aco', 'jptl'];  // change day to day
const CONT_TYPES = ['otl', 'ref'];          // run for days, or evergreen

const _MON = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const _DOW = ['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY'];

// A doc occupies a day when its effective window overlaps that UTC day.
function _overlapsDay(doc, dayStart) {
  const dayEnd = new Date(dayStart.getTime() + 86400000);
  const s = doc.start ? new Date(doc.start) : null;
  const e = doc.end   ? new Date(doc.end)   : null;
  if (!s && !e) return true;                       // evergreen
  if (s && s.getTime() >= dayEnd.getTime())   return false;
  if (e && e.getTime() <= dayStart.getTime()) return false;
  return true;
}

// Relative upload age against the scenario clock.
const _ago = (iso) => {
  if (!iso) return '—';
  const now = window.getAdminNow ? window.getAdminNow() : new Date();
  const h = Math.round((now.getTime() - new Date(iso).getTime()) / 3600000);
  if (h < 1)  return 'just now';
  if (h < 24) return `${h}h ago`;
  const d = Math.round(h / 24);
  return d < 30 ? `${d}d ago` : `${Math.round(d / 30)}mo ago`;
};

const _zulu = (iso) => {
  const d = new Date(iso);
  return `${String(d.getUTCHours()).padStart(2,'0')}${String(d.getUTCMinutes()).padStart(2,'0')}Z`;
};

// Compact duration for runway readouts — 3h, 21h, 1d 21h.
function fmtDur(ms) {
  if (!isFinite(ms)) return 'indefinitely';
  const h = Math.floor(ms / 3600000);
  if (h < 1) return `${Math.max(1, Math.round(ms / 60000))}m`;
  if (h < 48) return h < 24 ? `${h}h` : `1d ${h - 24}h`;
  return `${Math.floor(h / 24)}d ${h % 24}h`;
}

// ── Time formatting ──────────────────────────────────────────
// `28 APR 11:30 Z`. Day and month separated, colon in the time, Z spaced
// off — the compressed DTG form (281130Z) is hard to scan at card size.
const _MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const _pad = (n) => String(n).padStart(2, '0');

function fmtStamp(iso) {
  const d = new Date(iso);
  return `${_pad(d.getUTCDate())} ${_MONTHS[d.getUTCMonth()]} ${_pad(d.getUTCHours())}:${_pad(d.getUTCMinutes())} Z`;
}

// A window. When both ends fall on the same UTC day the date is stated
// once — `28 APR 08:30 → 17:30 Z` rather than repeating "28 APR".
function fmtWindow(startIso, endIso) {
  if (!startIso) return '—';
  if (!endIso) return `${fmtStamp(startIso)} → evergreen`;
  const a = new Date(startIso), b = new Date(endIso);
  const sameDay = a.getUTCFullYear() === b.getUTCFullYear()
    && a.getUTCMonth() === b.getUTCMonth() && a.getUTCDate() === b.getUTCDate();
  if (sameDay) {
    return `${_pad(a.getUTCDate())} ${_MONTHS[a.getUTCMonth()]} `
         + `${_pad(a.getUTCHours())}:${_pad(a.getUTCMinutes())} → `
         + `${_pad(b.getUTCHours())}:${_pad(b.getUTCMinutes())} Z`;
  }
  return `${fmtStamp(startIso)} → ${fmtStamp(endIso)}`;
}

// ── Avatar (DTK pattern) ─────────────────────────────────────
// Design-toolkit's Avatar takes an image and falls back to initials.
// We have no licensed photography yet, so every avatar renders the
// fallback; drop a src on the person record and the image path lights
// up without touching call sites. Images must be bundled, never
// hot-linked — this has to work disconnected.
// Placeholder portraits, downscaled and committed under public/Assets so
// the app still renders them disconnected. Sourced from randomuser.me —
// replace with licensed imagery before this goes anywhere real.
const _PEOPLE = {
  'Capt Park':     { src:'Assets/avatars/capt-park.jpg' },
  'Lt Col Hayes':  { src:'Assets/avatars/lt-col-hayes.jpg' },
  'Maj Ellery':    { src:'Assets/avatars/maj-ellery.jpg' },
  'Maj Vargas':    { src:'Assets/avatars/maj-vargas.jpg' },
};

function Avatar({ name, size = 18, src }) {
  const initials = (name || '?').split(/\s+/).filter(Boolean)
    .map(w => w[0]).slice(-2).join('').toUpperCase();
  const img = src || (_PEOPLE[name] || {}).src;
  return (
    <span title={name} style={{
      width:size, height:size, borderRadius:'50%', flexShrink:0,
      background:A.hi, color:C.fg2, overflow:'hidden',
      display:'flex', alignItems:'center', justifyContent:'center',
      fontFamily:'Roboto Mono', fontSize:Math.round(size * 0.4), fontWeight:700,
      letterSpacing:'0.02em',
    }}>
      {img
        ? <img src={img} alt="" style={{ width:'100%', height:'100%', objectFit:'cover' }}/>
        : initials}
    </span>
  );
}

// ── File-type glyph ──────────────────────────────────────────
// A page outline with the extension set into it. Restores the visual
// anchor the old card had, and the silhouette is scannable without
// reading the letters.
function FileGlyph({ format, dim }) {
  const ext = (format || '').toUpperCase().slice(0, 4);
  return (
    <span title={ext} style={{
      width:16, height:19, flexShrink:0, borderRadius:2,
      border:`1.2px solid ${dim ? C.fg4 : '#3d4045'}`,
      display:'flex', alignItems:'flex-end', justifyContent:'center',
      paddingBottom:1.5, fontFamily:'Roboto Mono', fontSize:5.2,
      letterSpacing:'0.02em', color: dim ? C.fg4 : C.fg3,
    }}>{ext}</span>
  );
}

// ── Warning glyph ────────────────────────────────────────────
// Drawn rather than the U+26A0 character, which renders as text and sits
// badly against the mono labels.
function WarnIcon({ size = 11 }) {
  const line = { fill:'none', stroke:'currentColor', strokeWidth:1.5,
    strokeLinecap:'round', strokeLinejoin:'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" style={{ display:'block', flexShrink:0 }}>
      <path {...line} d="M8 2.9 14.4 13.4H1.6z"/>
      <path {...line} d="M8 6.6v3.1"/>
      <circle cx="8" cy="11.7" r="0.75" fill="currentColor" stroke="none"/>
    </svg>
  );
}

// ── Shift context + readiness ────────────────────────────────
// The old readiness banner said one thing twice and ate a whole row.
// This is the compact replacement: which shift we are judging against,
// and how many required buckets clear it. Detail lives in the buckets.
function DeskReadiness({ docs }) {
  const types = window.DOC_TYPES || [];
  const required = types.filter(t => t.required);
  const results = required.map(t => {
    const ds = docs.filter(d => d.type === t.id);
    const rw = ds.length ? window.deskRunway(ds) : { validNow:false, runwayMs:0 };
    return { t, ...rw };
  });
  const short = results.filter(r => !r.validNow);
  const soon  = results.filter(r => r.validNow && isFinite(r.runwayMs) && r.runwayMs < 6 * 3600000);
  const allGood = short.length === 0;
  const review = types.filter(t => {
    const live = window.activeDocFor(docs.filter(d => d.type === t.id));
    return live && live.parseStatus === 'warnings';
  }).length;

  // The shortest runway among required buckets is the real headline: it is
  // when the desk stops being trustworthy if nobody does anything.
  const weakest = results.filter(r => r.validNow && isFinite(r.runwayMs))
    .sort((a, b) => a.runwayMs - b.runwayMs)[0];

  // Colour only when something wants attention. A good desk is grey.
  const tone = allGood ? (soon.length ? C.advisory : C.fg3) : C.advisory;
  const headline = !allGood
    ? `${short.length} NOT VALID · ${short.map(r => r.t.label).join(' ')}`
    : soon.length
      ? `EXPIRES SOON · ${soon.map(r => r.t.label).join(' ')}`
      : 'DESK VALID';

  return (
    <div style={{ display:'flex', alignItems:'center', gap:9, whiteSpace:'nowrap' }}>
      <span title={allGood
          ? 'Every required document type is in effect at this moment.'
          : `Nothing in effect for: ${short.map(r => r.t.label).join(', ')}`}
        style={{ display:'flex', alignItems:'center', gap:7, height:28, padding:'0 11px',
          borderRadius:4, border:`1px solid ${tone}59`, background:`${tone}0f` }}>
        <span style={{ width:6, height:6, borderRadius:'50%', background:tone }}/>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
          letterSpacing:'0.1em', color:tone }}>{headline}</span>
      </span>
      {allGood && weakest && (
        <span data-typo="label" title={`${weakest.t.label} is the first required document to lapse.`}
          style={{ fontFamily:'Roboto Mono', fontSize:8.5, letterSpacing:'0.08em', color:C.fg4 }}>
          {weakest.t.label} RUNS OUT IN {fmtDur(weakest.runwayMs)}
        </span>
      )}
      {review > 0 && (
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8.5,
          letterSpacing:'0.08em', color:C.fg3 }}>{review} TO REVIEW</span>
      )}
    </div>
  );
}

// ── Secondary AOR picker ─────────────────────────────────────
// The primary AOR is the desk. Adding a neighbour extends what is
// visible for cross-AOR missions without moving the desk itself, so the
// primary is listed but cannot be removed here.
function AdminAorExtras({ primaryAor, activeAors, onActiveAorsChange, counts }) {
  const [open, setOpen] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const aors = window.AORS || [];
  const extras = Array.from(activeAors).filter(a => a !== primaryAor);
  const toggle = (id) => {
    if (id === primaryAor) return;
    const next = new Set(activeAors);
    if (next.has(id)) next.delete(id); else next.add(id);
    next.add(primaryAor);
    onActiveAorsChange(next);
  };
  const label = extras.length === 0 ? 'None'
    : extras.length === 1 ? (aors.find(a => a.id === extras[0]) || {}).short
    : `${extras.length} added`;
  return (
    <div style={{ position:'relative', flexShrink:0 }}>
      <button onClick={() => setOpen(o => !o)}
        title="Bring a neighbouring AOR's documents into view for cross-AOR missions."
        onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
        style={{ display:'flex', alignItems:'center', gap:8, height:28, padding:'0 10px',
          borderRadius:4, cursor:'pointer', whiteSpace:'nowrap',
          background: extras.length ? A.raised : A.card,
          border:`1px solid ${open || hover || extras.length ? C.fg4 : A.bd2}`,
          color: open || hover || extras.length ? C.fg1 : C.fg2,
          fontFamily:'Roboto', fontSize:11.5 }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          letterSpacing:'0.16em', color:C.fg4 }}>+AOR</span>
        <span>{label}</span>
        <DropChevron open={open}/>
      </button>
      {open && (
        <React.Fragment>
          <div onClick={() => setOpen(false)} style={{ position:'fixed', inset:0, zIndex:190 }}/>
          <DropPanel align="left">
            {aors.map(a => (
              <DropItem key={a.id}
                label={a.id === primaryAor ? `${a.short} — primary` : a.short}
                hint={String(counts[a.id] || 0)}
                selected={activeAors.has(a.id)}
                onClick={() => toggle(a.id)}/>
            ))}
          </DropPanel>
        </React.Fragment>
      )}
    </div>
  );
}

// ── Upload glyph ─────────────────────────────────────────────
// accelint CORE has no upload icon (closest is attach-file, a
// paperclip, which reads as "attach to a message" rather than "load
// into the library"). Drawn here as the conventional arrow-out-of-tray
// so it stays legible at 13px; swap for the design-system equivalent
// once one exists.
function UploadIcon({ size = 13 }) {
  const line = { fill:'none', stroke:'currentColor', strokeWidth:1.5,
    strokeLinecap:'round', strokeLinejoin:'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" style={{ display:'block', flexShrink:0 }}>
      <path {...line} d="M8 10.5V2.6M4.6 6L8 2.6 11.4 6"/>
      <path {...line} d="M2.6 10.8v1.8c0 .6.4 1 1 1h8.8c.6 0 1-.4 1-1v-1.8"/>
    </svg>
  );
}

// ── Dropdown primitives ──────────────────────────────────────
// The native <select> clipped its own label and inherited none of the
// app's styling, so both dropdowns are built here instead. The trigger
// hugs its content — no fixed width, nothing to cut off.
function DropChevron({ open }) {
  return (
    <span style={{ width:5.5, height:5.5, flexShrink:0, marginTop:open ? 2 : -2,
      borderRight:'1.4px solid currentColor', borderBottom:'1.4px solid currentColor',
      transform: open ? 'rotate(-135deg)' : 'rotate(45deg)', transition:'transform 0.12s' }}/>
  );
}

function DropPanel({ children, align }) {
  return (
    <div style={{ position:'absolute', top:'calc(100% + 6px)',
      [align === 'right' ? 'right' : 'left']:0, minWidth:'100%', zIndex:200,
      background:A.card, border:'1px solid #3c3e42', borderRadius:6, padding:5,
      boxShadow:'0 18px 44px rgba(0,0,0,0.8)', whiteSpace:'nowrap' }}>
      {children}
    </div>
  );
}

function DropItem({ label, hint, selected, onClick }) {
  const [h, setH] = React.useState(false);
  return (
    <div onClick={onClick} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
      style={{ display:'flex', alignItems:'center', gap:10, height:30, padding:'0 10px',
        borderRadius:4, cursor:'pointer', background: h ? A.raised : 'transparent' }}>
      <span style={{ width:10, textAlign:'center', color:C.fg2, fontSize:10 }}>
        {selected ? '✓' : ''}
      </span>
      <span style={{ flex:1, fontSize:11.5, color: selected ? C.fg1 : C.fg2,
        fontWeight: selected ? 600 : 400 }}>{label}</span>
      {hint && <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg4 }}>{hint}</span>}
    </div>
  );
}

// Single-choice dropdown. `prefix` is a quiet label baked into the trigger.
function AdminSelect({ prefix, value, options, onChange, align, title }) {
  const [open, setOpen] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const sel = options.find(o => o.id === value);
  return (
    <div style={{ position:'relative', flexShrink:0 }}>
      <button onClick={() => setOpen(o => !o)} title={title}
        onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
        style={{ display:'flex', alignItems:'center', gap:8, height:28, padding:'0 10px',
          borderRadius:4, background:A.card, cursor:'pointer', whiteSpace:'nowrap',
          border:`1px solid ${open || hover ? C.fg4 : A.bd2}`,
          color: open || hover ? C.fg1 : C.fg2, fontFamily:'Roboto', fontSize:11.5 }}>
        {prefix && (
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
            letterSpacing:'0.16em', color:C.fg4 }}>{prefix}</span>
        )}
        <span>{sel ? sel.label : ''}</span>
        <DropChevron open={open}/>
      </button>
      {open && (
        <React.Fragment>
          <div onClick={() => setOpen(false)} style={{ position:'fixed', inset:0, zIndex:190 }}/>
          <DropPanel align={align}>
            {options.map(o => (
              <DropItem key={o.id} label={o.label} hint={o.hint} selected={o.id === value}
                onClick={() => { onChange(o.id); setOpen(false); }}/>
            ))}
          </DropPanel>
        </React.Fragment>
      )}
    </div>
  );
}

// Multi-choice type filter. Empty set means "everything".
function AdminTypeFilter({ value, onChange, counts }) {
  const [open, setOpen] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const types = window.DOC_TYPES || [];
  const active = value.size > 0;
  const label = !active ? 'All types'
    : value.size === 1 ? (types.find(t => value.has(t.id)) || {}).label
    : `${value.size} types`;
  const toggle = (id) => {
    const next = new Set(value);
    if (next.has(id)) next.delete(id); else next.add(id);
    onChange(next);
  };
  return (
    <div style={{ position:'relative', flexShrink:0 }}>
      <button onClick={() => setOpen(o => !o)} title="Filter the library by document type."
        onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
        style={{ display:'flex', alignItems:'center', gap:8, height:28, padding:'0 10px',
          borderRadius:4, cursor:'pointer', whiteSpace:'nowrap',
          background: active ? A.raised : A.card,
          border:`1px solid ${open || hover || active ? C.fg4 : A.bd2}`,
          color: open || hover || active ? C.fg1 : C.fg2, fontFamily:'Roboto', fontSize:11.5 }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
          letterSpacing:'0.16em', color:C.fg4 }}>FILTER</span>
        <span>{label}</span>
        <DropChevron open={open}/>
      </button>
      {open && (
        <React.Fragment>
          <div onClick={() => setOpen(false)} style={{ position:'fixed', inset:0, zIndex:190 }}/>
          <DropPanel align="left">
            <DropItem label="All types" selected={!active} onClick={() => onChange(new Set())}/>
            <div style={{ height:1, background:A.bd, margin:'4px 3px' }}/>
            {types.map(t => (
              <DropItem key={t.id} label={t.long || t.label} hint={String(counts[t.id] || 0)}
                selected={value.has(t.id)} onClick={() => toggle(t.id)}/>
            ))}
          </DropPanel>
        </React.Fragment>
      )}
    </div>
  );
}

// ── View switcher — icons with hover labels ───────────────────
function AdminViewSwitcher({ value, onChange }) {
  const [hover, setHover] = React.useState(null);
  const views = [
    { id:'board',    label:'DATE BOARD' },
    { id:'kanban',   label:'KANBAN' },
    { id:'table',    label:'TABLE' },
    { id:'timeline', label:'TIMELINE' },
  ];
  const Glyph = ({ kind }) => {
    const bar = { background:'currentColor', borderRadius:1, position:'absolute', left:-0.5, right:-0.5, height:1.3 };
    if (kind === 'board') return (
      <div style={{ width:14, height:14, border:'1.3px solid currentColor', borderRadius:2, position:'relative' }}>
        <div style={{ ...bar, top:2 }}/>
      </div>
    );
    if (kind === 'kanban') return (
      <div style={{ width:14, height:14, display:'flex', gap:2 }}>
        {[0, 4, 1.5].map((mb, i) => (
          <div key={i} style={{ flex:1, border:'1.3px solid currentColor', borderRadius:1, marginBottom:mb }}/>
        ))}
      </div>
    );
    if (kind === 'table') return (
      <div style={{ width:14, height:14, border:'1.3px solid currentColor', borderRadius:2, position:'relative' }}>
        <div style={{ ...bar, top:2.6 }}/>
        <div style={{ ...bar, top:6.8 }}/>
      </div>
    );
    return (
      <div style={{ width:14, height:14, display:'flex', flexDirection:'column', justifyContent:'center', gap:2.6 }}>
        <div style={{ background:'currentColor', borderRadius:1, height:1.7, width:'62%' }}/>
        <div style={{ background:'currentColor', borderRadius:1, height:1.7, width:'80%', alignSelf:'flex-end' }}/>
        <div style={{ background:'currentColor', borderRadius:1, height:1.7, width:'44%' }}/>
      </div>
    );
  };
  return (
    <div style={{ display:'flex', gap:2, padding:2, background:A.card,
      border:`1px solid ${A.bd2}`, borderRadius:5, flexShrink:0 }}>
      {views.map(v => {
        const on = value === v.id;
        return (
          <div key={v.id} style={{ position:'relative' }}
            onMouseEnter={() => setHover(v.id)} onMouseLeave={() => setHover(null)}>
            <button onClick={() => onChange(v.id)} aria-label={v.label} style={{
              width:30, height:26, display:'flex', alignItems:'center', justifyContent:'center',
              border:'none', borderRadius:3, cursor:'pointer', padding:0,
              background: on ? A.hi : (hover === v.id ? A.raised : 'transparent'),
              color: (on || hover === v.id) ? C.fg1 : C.fg3,
              transition:'background 0.12s, color 0.12s',
            }}><Glyph kind={v.id}/></button>
            {hover === v.id && (
              <div style={{ position:'absolute', top:'calc(100% + 9px)', left:'50%',
                transform:'translateX(-50%)', background:A.hi, border:`1px solid ${A.bd2}`,
                color:C.fg1, borderRadius:4, fontFamily:'Roboto Mono', fontSize:9,
                letterSpacing:'0.1em', padding:'5px 9px', whiteSpace:'nowrap', zIndex:60,
                boxShadow:'0 6px 18px rgba(0,0,0,0.55)', pointerEvents:'none' }}>
                {v.label}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

// ── Shared card chrome ───────────────────────────────────────
function TypePill({ typeId, large }) {
  const t = (window.DOC_TYPES || []).find(x => x.id === typeId);
  if (!t) return null;
  const c = t.color || C.fg3;
  return (
    <span data-typo="label" style={{
      fontFamily:'Roboto Mono', fontSize:large ? 8.5 : 7, letterSpacing:'0.16em',
      fontWeight:700, padding:large ? '3.5px 8px' : '2.5px 6px', borderRadius:3,
      border:`1px solid ${c}61`, color:c, whiteSpace:'nowrap',
    }}>{t.label}</span>
  );
}

function StatusBadge({ label, tone }) {
  const c = tone || C.fg3;
  return (
    <span data-typo="label" style={{
      fontFamily:'Roboto Mono', fontSize:7, letterSpacing:'0.1em', padding:'2.5px 6px',
      borderRadius:3, border:`1px solid ${tone ? c + '73' : A.bd2}`, color:c, whiteSpace:'nowrap',
    }}>{label}</span>
  );
}

function docBadge(doc) {
  const state = window.classifyDocState(doc);
  if (doc.parseStatus === 'warnings') {
    const n = (doc.parseDetails || []).length || 1;
    return { label:`${n} WARN`, tone:C.advisory };
  }
  if (state === 'expiring') return { label:'EXPIRING', tone:C.advisory };
  if (state === 'expired')  return { label:'EXPIRED',  tone:null };
  return { label:(doc.parseStatus || 'clean').toUpperCase(), tone:null };
}

// One document card. Neutral surface; the type pill carries identity.
function AdminDocCard({ doc, allDocs, onOpenDoc, continuing, showDate }) {
  const [hover, setHover] = React.useState(false);
  const badge = docBadge(doc);
  const remaining = doc.end ? window.formatTimeRemaining(doc) : 'evergreen';
  const supersedes = (allDocs || []).filter(d => d.replacedBy === doc.id).length;
  return (
    <div>
      <div onClick={() => onOpenDoc && onOpenDoc(doc.id)}
        onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
        title={`${doc.name} — ${doc.format} · uploaded by ${doc.uploader}`}
        style={{
          background: continuing ? (hover ? A.card : 'transparent') : (hover ? A.raised : A.card),
          border:`1px solid ${continuing ? '#202024' : (hover ? A.bd2 : A.bd)}`,
          borderRadius:5, padding: continuing ? '7px 10px' : '9px 10px', cursor:'pointer',
          opacity: continuing && !hover ? 0.6 : 1, transition:'all 0.12s', minWidth:0,
        }}>
        <div style={{ display:'flex', alignItems:'center', gap:6, whiteSpace:'nowrap' }}>
          <TypePill typeId={doc.type}/>
          {showDate && (
            <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5,
              letterSpacing:'0.14em', color:C.fg4 }}>{showDate}</span>
          )}
          <div style={{ flex:1 }}/>
          {!continuing && <StatusBadge label={badge.label} tone={badge.tone}/>}
        </div>
        <div style={{ fontSize: continuing ? 10.5 : 11.5, fontWeight: continuing ? 500 : 600,
          color: continuing ? C.fg2 : C.fg1, marginTop: continuing ? 5 : 7,
          overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
          {doc.name}
        </div>
        {!continuing && (
          <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3, marginTop:4 }}>
            {doc.end ? `${_zulu(doc.start)} → ${_zulu(doc.end)} · ${remaining}` : 'evergreen'}
          </div>
        )}
      </div>
      {supersedes > 0 && (
        <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5, color:C.fg4,
          padding:'3px 0 1px 13px' }}>
          ↳ {supersedes} earlier version{supersedes > 1 ? 's' : ''}
        </div>
      )}
    </div>
  );
}

// A missing required doc for a given day — click to upload it.
function GapCard({ typeId, aor, dayStart, onOpenUpload }) {
  const [hover, setHover] = React.useState(false);
  const t = (window.DOC_TYPES || []).find(x => x.id === typeId);
  return (
    <div onClick={() => onOpenUpload && onOpenUpload({ typeId, aor })}
      onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      title={`No ${t ? t.label : typeId} loaded for this day — click to upload one.`}
      style={{ border:`1px dashed ${hover ? C.advisory : '#43412f'}`, borderRadius:5,
        padding:'9px 10px', textAlign:'center', cursor:'pointer', minHeight:74,
        display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center',
        background: hover ? `${C.advisory}12` : `${C.advisory}07`, transition:'all 0.12s' }}>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
        letterSpacing:'0.1em', color:C.advisory }}>
        ⚠ NO {t ? t.label : typeId.toUpperCase()}
      </div>
      <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5,
        color:C.fg4, marginTop:4, letterSpacing:'0.06em' }}>CLICK TO UPLOAD</div>
    </div>
  );
}

// Evergreen references collapse into one card per column.
function RefStackCard({ docs, onOpenDoc }) {
  const [hover, setHover] = React.useState(false);
  return (
    <div onClick={() => onOpenDoc && onOpenDoc(docs[0].id)}
      onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      title={docs.map(d => d.name).join(' · ')}
      style={{ background: hover ? A.card : 'transparent',
        border:'1px solid #202024', borderRadius:5, padding:'7px 10px',
        cursor:'pointer', opacity: hover ? 1 : 0.6, transition:'all 0.12s', minWidth:0 }}>
      <div style={{ display:'flex', alignItems:'center', gap:6, whiteSpace:'nowrap' }}>
        <TypePill typeId="ref"/>
      </div>
      <div style={{ fontSize:10.5, fontWeight:500, color:C.fg2, marginTop:5,
        overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
        {docs.length} evergreen
      </div>
    </div>
  );
}

// ── Date board — one column per day ──────────────────────────
function DateBoard({ docs, allDocs, primaryAor, daysBack, daysFwd, showContinuing, sort, onOpenUpload, onOpenDoc }) {
  const now = window.getAdminNow ? window.getAdminNow() : new Date();
  const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
  const cols = [];
  for (let i = -daysBack; i <= daysFwd; i++) {
    const dayStart = new Date(todayStart.getTime() + i * 86400000);
    const dayDocs  = docs.filter(d => _overlapsDay(d, dayStart));
    // Order within a column follows the toolbar sort. Type order groups
    // ATO → ACO → JPTL, which is how the kill chain reads.
    const scoped   = dayDocs.filter(d => DAY_TYPES.includes(d.type)).sort((a, b) => {
      if (sort === 'uploaded') return new Date(b.uploadedAt || 0) - new Date(a.uploadedAt || 0);
      if (sort === 'effective') return new Date(b.start || 0) - new Date(a.start || 0);
      return (DAY_TYPES.indexOf(a.type) - DAY_TYPES.indexOf(b.type)) ||
             (new Date(b.start || 0) - new Date(a.start || 0));
    });
    // Long-running docs repeat in every column they cover. References
    // collapse to a single card — three evergreen cards per column, on
    // every column, would bury the documents that actually change.
    const cont     = dayDocs.filter(d => d.type === 'otl');
    const refs     = dayDocs.filter(d => d.type === 'ref');
    // Only nag about missing documents from today forward.
    const gaps = i < 0 ? [] : DAY_TYPES.filter(t => !scoped.some(d => d.type === t));
    cols.push({ i, dayStart, scoped, cont, refs, gaps });
  }
  return (
    <div data-tour="admin-board" style={{ flex:1, display:'grid', minHeight:0, gap:10, padding:'12px 16px 16px',
      gridTemplateColumns:`${daysBack > 0 ? '0.62fr ' : ''}repeat(${daysFwd + 1}, 1fr)` }}>
      {cols.map(col => {
        const past  = col.i < 0;
        const today = col.i === 0;
        const status = past
          ? { text:'WAS COVERED', tone:C.fg3 }
          : col.gaps.length === DAY_TYPES.length
            ? { text:'⚠ NOTHING LOADED', tone:C.advisory }
            : col.gaps.length
              ? { text:`⚠ NO ${col.gaps.map(g => (window.DOC_TYPES.find(t => t.id === g) || {}).label).join(', ')}`, tone:C.advisory }
              : { text:`COVERED · ${col.scoped.length} DOC${col.scoped.length === 1 ? '' : 'S'}`, tone:C.fg3 };
        return (
          <div key={col.i} style={{ display:'flex', flexDirection:'column', gap:7, minWidth:0,
            opacity: past ? 0.4 : 1, overflowY:'auto' }}>
            <div style={{ padding:'9px 10px', borderRadius:5, flexShrink:0,
              background: today ? A.raised : A.card,
              border:`1px solid ${today ? A.bd2 : A.bd}` }}>
              <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
                <span style={{ fontFamily:'Roboto Mono', fontSize:13, fontWeight:700,
                  color: today ? C.fg1 : C.fg2 }}>
                  {String(col.dayStart.getUTCDate()).padStart(2,'0')}
                </span>
                <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
                  letterSpacing:'0.12em', color:C.fg4 }}>{_MON[col.dayStart.getUTCMonth()]}</span>
              </div>
              <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7.5,
                letterSpacing:'0.18em', color: today ? C.fg2 : C.fg4, marginTop:3 }}>
                {today ? `TODAY · ${_zulu(now.toISOString())}` : past ? 'YESTERDAY' : _DOW[col.dayStart.getUTCDay()]}
              </div>
              <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8,
                letterSpacing:'0.1em', color:status.tone, marginTop:8 }}>{status.text}</div>
            </div>
            {col.scoped.map(d => (
              <AdminDocCard key={d.id} doc={d} allDocs={allDocs} onOpenDoc={onOpenDoc}/>
            ))}
            {col.gaps.map(g => (
              <GapCard key={g} typeId={g} aor={primaryAor} dayStart={col.dayStart} onOpenUpload={onOpenUpload}/>
            ))}
            {showContinuing && (col.cont.length > 0 || col.refs.length > 0) && (
              <React.Fragment>
                <div style={{ display:'flex', alignItems:'center', gap:8, padding:'9px 0 3px' }}>
                  <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:6.5,
                    letterSpacing:'0.2em', color:C.fg4 }}>CONTINUING</span>
                  <div style={{ flex:1, height:1, background:A.bd }}/>
                </div>
                {col.cont.map(d => (
                  <AdminDocCard key={d.id} doc={d} allDocs={allDocs} onOpenDoc={onOpenDoc} continuing/>
                ))}
                {col.refs.length > 0 && (
                  <RefStackCard docs={col.refs} onOpenDoc={onOpenDoc}/>
                )}
              </React.Fragment>
            )}
          </div>
        );
      })}
    </div>
  );
}

// ── Table view ───────────────────────────────────────────────
function DocTable({ docs, allDocs, onOpenDoc, sort, onSortChange }) {
  const setSort = onSortChange || (() => {});
  const cols = [
    { id:'type',      label:'TYPE',      w:'92px' },
    { id:'name',      label:'DOCUMENT',  w:'minmax(210px,0.95fr)' },
    { id:'aor',       label:'AOR',       w:'minmax(110px,0.5fr)' },
    { id:'effective', label:'EFFECTIVE', w:'minmax(180px,0.7fr)' },
    { id:'left',      label:'LEFT',      w:'minmax(74px,0.35fr)' },
    { id:'format',    label:'FORMAT',    w:'minmax(74px,0.35fr)' },
    { id:'parse',     label:'PARSE',     w:'minmax(100px,0.4fr)' },
    { id:'uploaded',  label:'UPLOADED',  w:'minmax(170px,0.75fr)' },
  ];
  const grid = cols.map(c => c.w).join(' ');
  const sorted = [...docs].sort((a, b) => {
    if (sort === 'name')     return a.name.localeCompare(b.name);
    if (sort === 'type')     return a.type.localeCompare(b.type);
    if (sort === 'aor')      return a.aor.localeCompare(b.aor) || a.name.localeCompare(b.name);
    if (sort === 'format')   return (a.format || '').localeCompare(b.format || '') || a.name.localeCompare(b.name);
    if (sort === 'parse')    return (a.parseStatus || '').localeCompare(b.parseStatus || '');
    if (sort === 'left')     return new Date(a.end || 0) - new Date(b.end || 0);
    if (sort === 'uploaded') return new Date(b.uploadedAt || 0) - new Date(a.uploadedAt || 0);
    return new Date(b.start || 0) - new Date(a.start || 0);
  });
  return (
    <div style={{ flex:1, display:'flex', flexDirection:'column', minHeight:0 }}>
      <div style={{ display:'grid', gridTemplateColumns:grid, gap:12, alignItems:'center',
        padding:'0 16px', height:34, background:'#161618', flexShrink:0,
        borderBottom:`1px solid ${A.bd}`, whiteSpace:'nowrap' }}>
        {cols.map(c => (
          <span key={c.id} data-typo="label" onClick={() => setSort(c.id)}
            title={`Sort by ${c.label.toLowerCase()}`}
            style={{ fontFamily:'Roboto Mono', fontSize:7.5, letterSpacing:'0.18em',
              color: sort === c.id ? C.fg2 : C.fg4, cursor:'pointer' }}>
            {c.label}{sort === c.id ? ' ▼' : ''}
          </span>
        ))}
      </div>
      <div style={{ flex:1, minHeight:0, overflowY:'auto' }}>
        {sorted.map(d => {
          const badge = docBadge(d);
          return (
            <div key={d.id} onClick={() => onOpenDoc && onOpenDoc(d.id)}
              style={{ display:'grid', gridTemplateColumns:grid, gap:12, alignItems:'center',
                padding:'0 16px', height:46, borderBottom:'1px solid #1e1e21',
                whiteSpace:'nowrap', cursor:'pointer' }}>
              <span><TypePill typeId={d.type}/></span>
              <span style={{ fontSize:12, fontWeight:600, color:C.fg1, overflow:'hidden',
                textOverflow:'ellipsis' }}>{d.name}</span>
              <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3 }}>
                {((window.AORS || []).find(a => a.id === d.aor) || {}).short || '—'}
              </span>
              <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3 }}>
                {d.end ? `${_zulu(d.start)} → ${_zulu(d.end)}` : 'evergreen'}
              </span>
              <span style={{ fontFamily:'Roboto Mono', fontSize:10,
                color: window.classifyDocState(d) === 'expiring' ? C.advisory : C.fg3 }}>
                {d.end ? window.formatTimeRemaining(d) : '—'}
              </span>
              <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3 }}>{d.format}</span>
              <span><StatusBadge label={badge.label} tone={badge.tone}/></span>
              <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, overflow:'hidden',
                textOverflow:'ellipsis' }}>{d.uploader} · {_ago(d.uploadedAt)}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── Overflow menu — archive + board settings ─────────────────
function AdminOverflowMenu({ onClose, libraryTab, onLibraryTabChange, archiveCount,
  daysBack, onDaysBackChange, daysFwd, onDaysFwdChange,
  showContinuing, onShowContinuingChange }) {
  const Row = ({ label, children, onClick, strong }) => {
    const [h, setH] = React.useState(false);
    return (
      <div onClick={onClick} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
        style={{ display:'flex', alignItems:'center', gap:11, height:34, padding:'0 11px',
          borderRadius:5, cursor: onClick ? 'pointer' : 'default',
          background: h && onClick ? A.raised : 'transparent', whiteSpace:'nowrap' }}>
        <span style={{ flex:1, fontSize:12, color: strong ? C.fg1 : C.fg2,
          fontWeight: strong ? 600 : 400 }}>{label}</span>
        {children}
      </div>
    );
  };
  const Seg = ({ options, value, onChange }) => (
    <div style={{ display:'flex', border:`1px solid ${A.bd2}`, borderRadius:4, overflow:'hidden' }}>
      {options.map((o, i) => (
        <span key={o} onClick={() => onChange(o)} style={{
          fontFamily:'Roboto Mono', fontSize:8.5, padding:'3.5px 9px', cursor:'pointer',
          borderRight: i < options.length - 1 ? `1px solid ${A.bd}` : 'none',
          background: value === o ? A.hi : 'transparent',
          color: value === o ? C.fg1 : C.fg3, fontWeight: value === o ? 700 : 400,
        }}>{o}</span>
      ))}
    </div>
  );
  const Count = ({ n }) => (
    <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8.5,
      letterSpacing:'0.08em', color:C.fg3, border:`1px solid ${A.bd2}`,
      borderRadius:3, padding:'2.5px 7px' }}>{n}</span>
  );
  const Head = ({ children }) => (
    <div data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:7,
      letterSpacing:'0.2em', color:C.fg4, padding:'10px 11px 5px' }}>{children}</div>
  );
  return (
    <React.Fragment>
      <div onClick={onClose} style={{ position:'fixed', inset:0, zIndex:190 }}/>
      <div style={{ position:'absolute', top:'calc(100% + 6px)', right:0, width:288, zIndex:200,
        background:A.card, border:'1px solid #3c3e42', borderRadius:7, padding:6,
        boxShadow:'0 22px 52px rgba(0,0,0,0.82)' }}>
        <Head>LIBRARY</Head>
        <Row strong label={libraryTab === 'archive' ? 'Back to active library' : 'View archive'}
          onClick={() => { onLibraryTabChange(libraryTab === 'archive' ? 'active' : 'archive'); onClose(); }}>
          {libraryTab === 'archive' ? null : <Count n={archiveCount}/>}
        </Row>
        <div style={{ height:1, background:A.bd, margin:'6px 3px' }}/>
        <Head>DATE BOARD</Head>
        <Row label="Days back">
          <Seg options={[0,1,2]} value={daysBack} onChange={onDaysBackChange}/>
        </Row>
        <Row label="Days forward">
          <Seg options={[2,3,5]} value={daysFwd} onChange={onDaysFwdChange}/>
        </Row>
        <Row label="Show continuing documents">
          <Seg options={['ON','OFF']} value={showContinuing ? 'ON' : 'OFF'}
            onChange={(v) => onShowContinuingChange(v === 'ON')}/>
        </Row>
      </div>
    </React.Fragment>
  );
}

function MissionAdminDashboard({
  docs, primaryAor, activeAors,
  onPrimaryAorChange, onActiveAorsChange,
  libraryTab, onLibraryTabChange,
  viewMode, onViewModeChange,
  onOpenUpload, onOpenDoc,
}) {
  const [menuOpen, setMenuOpen]   = React.useState(false);
  const [daysBack, setDaysBack]   = React.useState(1);
  const [daysFwd,  setDaysFwd]    = React.useState(3);
  const [showCont, setShowCont]   = React.useState(true);
  const [uploadHover, setUploadHover] = React.useState(false);
  const [typeFilter, setTypeFilter]   = React.useState(() => new Set());
  const [sort, setSort]               = React.useState('type');


  const scoped = libraryTab === 'active'
    ? docs.filter(d => !d.isArchived && activeAors.has(d.aor))
    : docs.filter(d =>  d.isArchived && activeAors.has(d.aor));
  // Counts are taken before the type filter so the dropdown always shows
  // how much is behind each option, not how much survived the filter.
  const typeCounts = scoped.reduce((acc, d) => {
    acc[d.type] = (acc[d.type] || 0) + 1; return acc;
  }, {});
  const aorCounts = docs.reduce((acc, d) => {
    if (!d.isArchived) acc[d.aor] = (acc[d.aor] || 0) + 1;
    return acc;
  }, {});
  const inScope = typeFilter.size === 0 ? scoped : scoped.filter(d => typeFilter.has(d.type));
  const archiveCount = docs.filter(d => d.isArchived && activeAors.has(d.aor)).length;
  // Archive is a flat list regardless of the chosen view — a day board
  // of expired and superseded documents is noise.
  const view = libraryTab === 'archive' ? 'table' : (viewMode || 'kanban');
  const aorMeta = (window.AORS || []).find(a => a.id === primaryAor);

  return (
    <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden',
      minHeight:0, background:C.s1 }}>
      {/* Row 1 — identity and the primary action, nothing else. */}
      <div style={{ display:'flex', alignItems:'center', gap:12, padding:'0 16px', height:52,
        borderBottom:`1px solid ${A.bd}`, whiteSpace:'nowrap', flexShrink:0 }}>
        <span style={{ fontSize:15, fontWeight:600, letterSpacing:'-0.01em', color:C.fg1 }}>
          Mission Admin
        </span>
        {libraryTab === 'archive' && (
          <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:8.5,
            letterSpacing:'0.12em', color:C.fg4 }}>ARCHIVE · {archiveCount} DOCUMENTS</span>
        )}
        <div style={{ flex:1 }}/>
        <button onClick={() => onOpenUpload(null)} title="Upload a document into the active library."
          style={{ display:'flex', alignItems:'center', gap:7, height:30, padding:'0 15px',
            borderRadius:4, background:A.hi, border:'1px solid #4a4c50', color:C.fg1,
            cursor:'pointer', fontFamily:'Roboto Mono', fontSize:9.5,
            letterSpacing:'0.1em', fontWeight:700 }}>
          <UploadIcon/>UPLOAD
        </button>
      </div>

      {/* Row 2 — scope on the left, ordering and display on the right.
          Filters narrow *which* documents you see, sort only reorders
          what survived, so the pair reads broad → narrow left to right.
          The view switcher joins sort and the kebab because all three
          change how the library is displayed, not what is in it. */}
      <div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 16px', height:44,
        borderBottom:`1px solid ${A.bd}`, whiteSpace:'nowrap', flexShrink:0 }}>
        <AdminSelect prefix="AOR" value={primaryAor} onChange={onPrimaryAorChange}
          title="Primary AOR — drives the library scope and the default COP focus."
          options={(window.AORS || []).map(a => ({ id:a.id, label:a.short }))}/>
        <AdminAorExtras primaryAor={primaryAor} activeAors={activeAors}
          onActiveAorsChange={onActiveAorsChange} counts={aorCounts}/>
        <AdminTypeFilter value={typeFilter} onChange={setTypeFilter} counts={typeCounts}/>
        {typeFilter.size > 0 && (
          <button onClick={() => setTypeFilter(new Set())} title="Clear the type filter."
            style={{ height:28, padding:'0 10px', borderRadius:4, background:'transparent',
              border:`1px solid ${A.bd}`, color:C.fg3, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.1em' }}>CLEAR</button>
        )}
        <span data-typo="label" style={{ fontFamily:'Roboto Mono', fontSize:9,
          letterSpacing:'0.1em', color:C.fg4, marginLeft:2 }}>
          {inScope.length} OF {scoped.length}
        </span>
        <div style={{ flex:1 }}/>
        <AdminSelect prefix="SORT" value={sort} onChange={setSort} align="right"
          title="Ordering — applies within each day column, and to the table."
          options={[
            { id:'type',      label:'Document type' },
            { id:'effective', label:'Effective time' },
            { id:'uploaded',  label:'Recently uploaded' },
            { id:'name',      label:'Name' },
          ]}/>
        {libraryTab === 'active' && (
          <span data-tour="admin-views" style={{ display:'flex' }}>
            <AdminViewSwitcher value={view} onChange={onViewModeChange}/>
          </span>
        )}
        <div style={{ position:'relative' }}>
          <button onClick={() => setMenuOpen(o => !o)} title="More — archive and board settings"
            style={{ width:28, height:28, display:'flex', alignItems:'center',
              justifyContent:'center', gap:2.6, flexDirection:'column', borderRadius:4,
              background:A.card, border:`1px solid ${A.bd2}`, color:C.fg3, cursor:'pointer' }}>
            {[0,1,2].map(i => (
              <span key={i} style={{ width:2.6, height:2.6, borderRadius:'50%', background:'currentColor' }}/>
            ))}
          </button>
          {menuOpen && (
            <AdminOverflowMenu onClose={() => setMenuOpen(false)}
              libraryTab={libraryTab} onLibraryTabChange={onLibraryTabChange}
              archiveCount={archiveCount}
              daysBack={daysBack} onDaysBackChange={setDaysBack}
              daysFwd={daysFwd}  onDaysFwdChange={setDaysFwd}
              showContinuing={showCont} onShowContinuingChange={setShowCont}/>
          )}
        </div>
      </div>

      {/* Body */}
      {view === 'board' ? (
        <DateBoard docs={inScope} allDocs={docs} primaryAor={primaryAor}
          daysBack={daysBack} daysFwd={daysFwd} showContinuing={showCont} sort={sort}
          onOpenUpload={onOpenUpload} onOpenDoc={onOpenDoc}/>
      ) : view === 'table' ? (
        <DocTable docs={inScope} allDocs={docs} onOpenDoc={onOpenDoc}
          sort={sort} onSortChange={setSort}/>
      ) : (
        <div style={{ flex:1, overflowY:'auto', padding:'0 14px', minHeight:0 }}>
          {view === 'timeline' ? (
            <GanttView docs={inScope} activeAors={activeAors}
              primaryAor={primaryAor} onOpen={onOpenDoc}/>
          ) : (
            <DocLibrary docs={inScope} activeAors={activeAors} primaryAor={primaryAor}
              libraryTab={libraryTab} onOpenUpload={onOpenUpload} onOpenDoc={onOpenDoc}/>
          )}
        </div>
      )}

      {/* Persistent dropzone */}
      <div onClick={() => onOpenUpload(null)}
        onMouseEnter={() => setUploadHover(true)} onMouseLeave={() => setUploadHover(false)}
        title="Upload a new document."
        style={{ padding:'13px 16px', borderTop:`1px dashed ${A.bd2}`, textAlign:'center',
          cursor:'pointer', flexShrink:0, background: uploadHover ? A.card : 'transparent',
          fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.14em',
          color: uploadHover ? C.fg1 : C.fg3, transition:'all 0.12s' }}>
        DROP A FILE HERE OR CLICK TO UPLOAD · XML · PDF · DOCX · XLSX · CSV
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════
// ── Mission Admin sub-pages ────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
// Reachable from the doc-preview drawer's three "View …" buttons
// when the open doc is type=ATO. Each sub-page is a table view of a
// parsed-content type (Platforms / Objectives / Assets) with a
// shared chrome (breadcrumb, title+subtitle, source-ATO filter chip
// row, action row) followed by the table-specific component.

function AdminBreadcrumb({ pageLabel, onHome }) {
  const Crumb = ({ label, terminal, onClick }) => (
    <button data-typo="label" onClick={onClick} disabled={terminal} style={{
      background:'transparent', border:'none', padding:0,
      cursor:terminal?'default':'pointer',
      color:terminal?C.fg1:C.fg3,
      fontFamily:'Roboto Mono', fontSize:9.5, letterSpacing:'0.10em',
      textTransform:'uppercase',
    }}>{label}</button>
  );
  return (
    <div style={{ display:'flex', alignItems:'center', gap:8,
      padding:'10px 24px 6px',
      fontFamily:'Roboto Mono', fontSize:9.5 }}>
      <Crumb label="Mission Admin" onClick={onHome}/>
      <span style={{ color:C.fg4 }}>›</span>
      <Crumb label="Manage ATOs" onClick={onHome}/>
      <span style={{ color:C.fg4 }}>›</span>
      <Crumb label={pageLabel} terminal/>
    </div>
  );
}

function AdminSubviewTitle({ title, subtitle }) {
  return (
    <div style={{ padding:'14px 24px 12px' }}>
      <div style={{ fontFamily:'Roboto', fontSize:22, fontWeight:600, color:C.fg1, letterSpacing:'-0.005em' }}>
        {title}
      </div>
      {subtitle && (
        <div style={{ marginTop:4, fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.06em' }}>
          {subtitle}
        </div>
      )}
    </div>
  );
}

// Filter chip row — one chip per source-ATO in the active filter set,
// plus a trailing "+ Add ATO" picker. Removing the last chip puts the
// filter into "showing all" mode.
function AtoFilterChipRow({ filter, onFilterChange, allDocs }) {
  const [pickerOpen, setPickerOpen] = React.useState(false);
  const ids = Array.from(filter.atoIds || []);
  const docsById = new Map((allDocs || []).map(d => [d.id, d]));
  const aorMetaById = (id) => {
    const d = docsById.get(id);
    if (!d) return null;
    return (window.AORS || []).find(a => a.id === d.aor) || null;
  };
  const removeId = (id) => {
    const next = new Set(filter.atoIds);
    next.delete(id);
    onFilterChange({ atoIds: next });
  };
  const addId = (id) => {
    const next = new Set(filter.atoIds);
    next.add(id);
    onFilterChange({ atoIds: next });
    setPickerOpen(false);
  };
  const clearAll = () => {
    onFilterChange({ atoIds: new Set() });
    setPickerOpen(false);
  };
  // Pick options = active, non-archived ATO docs not currently in the chip set.
  const pickerOptions = (allDocs || [])
    .filter(d => d.type === 'ato' && !d.isArchived && !filter.atoIds.has(d.id));
  return (
    <div style={{ padding:'10px 24px',
      display:'flex', alignItems:'center', gap:8, flexWrap:'wrap', position:'relative' }}>
      <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginRight:4 }}>
        FILTER · ATOs
      </span>
      {ids.length === 0 && (
        <span style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg3, letterSpacing:'0.06em' }}>
          showing all ATOs
        </span>
      )}
      {ids.map(id => {
        const d = docsById.get(id);
        const meta = aorMetaById(id);
        const label = d ? `${d.name}${meta ? ' · ' + meta.short : ''}` : id;
        return (
          <span key={id} style={{
            display:'inline-flex', alignItems:'center', gap:6, padding:'4px 6px 4px 10px',
            borderRadius:3, background:C.accentM, border:`1px solid ${C.accent}55`,
            fontFamily:'Roboto Mono', fontSize:10, color:C.accent, letterSpacing:'0.04em',
          }}>
            {label}
            <button onClick={() => removeId(id)} aria-label="Remove filter" style={{
              background:'transparent', border:'none', color:C.accent, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:11, padding:'0 2px', lineHeight:1,
            }}>×</button>
          </span>
        );
      })}
      {pickerOptions.length > 0 && (
        <div style={{ position:'relative' }}>
          <button onClick={() => setPickerOpen(o => !o)} style={{
            padding:'4px 10px', borderRadius:3, background:'transparent',
            border:`1px dashed ${C.border2}`, color:C.fg2, cursor:'pointer',
            fontFamily:'Roboto Mono', fontSize:9.5, letterSpacing:'0.04em',
          }}>+ Add ATO</button>
          {pickerOpen && (
            <div style={{ position:'absolute', top:'calc(100% + 4px)', left:0, zIndex:240,
              background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
              boxShadow:'0 8px 22px rgba(0,0,0,0.6)', minWidth:220, padding:'4px 0' }}>
              {pickerOptions.map(d => {
                const meta = (window.AORS || []).find(a => a.id === d.aor);
                return (
                  <button key={d.id} onClick={() => addId(d.id)} style={{
                    display:'block', width:'100%', textAlign:'left',
                    padding:'7px 12px', background:'transparent', border:'none',
                    color:C.fg2, cursor:'pointer', fontFamily:'Roboto', fontSize:11,
                  }}
                  onMouseEnter={(e) => { e.currentTarget.style.background = C.s3; }}
                  onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
                    <span style={{ color:C.fg1 }}>{d.name}</span>
                    {meta && <span style={{ color:C.fg3, marginLeft:8 }}>· {meta.short}</span>}
                  </button>
                );
              })}
            </div>
          )}
        </div>
      )}
      {ids.length > 0 && (
        <button onClick={clearAll} style={{
          marginLeft:'auto', padding:'4px 10px', borderRadius:3, background:'transparent',
          border:'none', color:C.fg3, cursor:'pointer',
          fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.10em',
        }}>CLEAR</button>
      )}
    </div>
  );
}

// Action row — `+ Add` button (visual stub, wired to a no-op for now)
// + free-text search input. Centered search matches the figma layout.
function AdminSearchAddRow({ addLabel, search, onSearchChange, searchPlaceholder }) {
  return (
    <div style={{ padding:'12px 24px',
      display:'flex', alignItems:'center', gap:14 }}>
      <button onClick={() => {}} data-stub="true" style={{
        padding:'7px 14px', borderRadius:3, background:C.s3, border:`1px solid ${C.border2}`,
        color:C.fg1, cursor:'pointer', fontFamily:'Roboto', fontSize:11, fontWeight:500,
        letterSpacing:'0.02em',
      }}>+ {addLabel}</button>
      <div style={{ flex:1, display:'flex', justifyContent:'center' }}>
        <input
          type="text"
          value={search}
          onChange={(e) => onSearchChange(e.target.value)}
          placeholder={searchPlaceholder || 'Search…'}
          style={{
            width:'100%', maxWidth:520, padding:'8px 14px', borderRadius:18,
            background:C.s2, border:`1px solid ${C.border2}`, color:C.fg1,
            fontFamily:'Roboto Mono', fontSize:10.5, letterSpacing:'0.04em',
            outline:'none', textAlign:'center',
          }}
        />
      </div>
      <div style={{ width:80 }}/>
    </div>
  );
}

// Common chrome wrapper used by all three sub-pages.
function AdminSubPageShell({
  pageLabel, title, subtitle, addLabel, searchPlaceholder,
  filter, onFilterChange, search, onSearchChange, allDocs,
  onHome, children,
}) {
  return (
    <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:C.s1 }}>
      <AdminBreadcrumb pageLabel={pageLabel} onHome={onHome}/>
      <AdminSubviewTitle title={title} subtitle={subtitle}/>
      <AtoFilterChipRow filter={filter} onFilterChange={onFilterChange} allDocs={allDocs}/>
      <AdminSearchAddRow
        addLabel={addLabel}
        search={search}
        onSearchChange={onSearchChange}
        searchPlaceholder={searchPlaceholder}
      />
      <div style={{ flex:1, overflowY:'auto', minHeight:0 }}>
        {children}
      </div>
    </div>
  );
}

// ── Editable popover cells ─────────────────────────────────
// Closed state shows a label + chevron. Click toggles a popover with
// option list. Click outside or pick an option to close. Used by the
// Platforms table for Mission and Status columns.
function EditablePopoverCell({ value, label, options, onChange, color, width }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    window.addEventListener('mousedown', handler);
    return () => window.removeEventListener('mousedown', handler);
  }, [open]);
  return (
    <div ref={ref} style={{ position:'relative', display:'inline-block' }}>
      <button onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }} style={{
        display:'flex', alignItems:'center', gap:6,
        padding:'4px 8px', borderRadius:2, background:'transparent',
        border:`1px solid ${C.border2}`, color: color || C.fg1,
        cursor:'pointer', fontFamily:'Roboto Mono', fontSize:10,
        letterSpacing:'0.04em', minWidth:width || 110, textAlign:'left',
      }}
      onMouseEnter={(e) => { e.currentTarget.style.borderColor = C.fg3; }}
      onMouseLeave={(e) => { e.currentTarget.style.borderColor = C.border2; }}>
        <span style={{ flex:1 }}>{label}</span>
        <span style={{ color:C.fg3 }}>▾</span>
      </button>
      {open && (
        <div style={{
          position:'absolute', top:'calc(100% + 3px)', left:0, zIndex:240,
          background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
          boxShadow:'0 8px 22px rgba(0,0,0,0.6)', minWidth:200, maxHeight:300, overflowY:'auto',
          padding:'4px 0',
        }}>
          {options.map((opt, i) => {
            // option may be a string, {value,label,group} or a section header
            if (opt && opt.kind === 'section') {
              return (
                <div data-typo="label" key={'sec-'+i} style={{
                  padding:'6px 10px 3px', fontFamily:'Roboto Mono', fontSize:8,
                  color:C.fg4, letterSpacing:'0.16em', textTransform:'uppercase',
                }}>{opt.label}</div>
              );
            }
            const ov = (typeof opt === 'string') ? opt : opt.value;
            const ol = (typeof opt === 'string') ? opt : opt.label;
            const oc = (typeof opt === 'string') ? null : opt.color;
            const active = ov === value;
            return (
              <button key={ov} onClick={(e) => { e.stopPropagation(); onChange(ov); setOpen(false); }} style={{
                display:'block', width:'100%', textAlign:'left',
                padding:'5px 12px', background:active?C.s3:'transparent', border:'none',
                color: oc || C.fg1, cursor:'pointer',
                fontFamily:'Roboto Mono', fontSize:10, letterSpacing:'0.04em',
              }}
              onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = C.s3; }}
              onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
                {ol}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

function PriorityPill({ priority }) {
  const c = priority === 'High'   ? C.critical
          : priority === 'Medium' ? C.advisory
          : priority === 'Low'    ? C.normal
          :                          C.fg3;
  return (
    <span style={{
      padding:'2px 9px', borderRadius:10, background:`${c}22`, color:c,
      fontFamily:'Roboto Mono', fontSize:11, fontWeight:500, letterSpacing:'0.04em',
      border:`1px solid ${c}55`,
    }}>{priority}</span>
  );
}

// ── Shared table primitives — Hawkeye DT-aligned styling ───────
// The figma reference (Hawkeye file 12024-179628) sets a specific
// visual contract for these tables: 44 px row height, 12 px × 8 px
// cell padding, 12 px Roboto Mono body text, 12 px Roboto Medium
// muted-grey headers, zebra striping with surface-raised on every
// other row, accent-blue for navigable IDs, and a status cell that's
// a colored dot + label + dropdown chevron. The primitives below
// codify those rules so all three sub-page tables share one
// source of truth.

// Column widths used across sub-page tables. Tweaking the constant
// updates every table at once.
const TABLE_CHECKBOX_W = 40;     // leading checkbox column
const TABLE_KEBAB_W    = 40;     // trailing kebab/actions column
const TABLE_ROW_H      = 44;     // figma spec — 44px container row…
const TABLE_ROW_INNER_H = 40;    // …with a 40px zebra inset (4px gutter)
const TABLE_HEADER_H   = 40;     // figma spec
const TABLE_CELL_PAD   = '8px 12px';

// ── DS Checkbox ────────────────────────────────────────────────
// Custom 16×16 checkbox matching the design-toolkit spec:
//   border 1px solid var(--outline/interactive, #414245)
//   radius/small (2px)
//   accent fill on checked (uses C.accent)
// Replaces the native `<input type="checkbox">` so the visual
// matches across browsers (Safari/Chrome render the native control
// inconsistently and the accent-color pseudo-element on Safari is
// flaky).
function DSCheckbox({ checked, onChange, ariaLabel }) {
  const handleClick = (e) => {
    e.stopPropagation();
    if (onChange) onChange(!checked);
  };
  const handleKey = (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      handleClick(e);
    }
  };
  return (
    <div role="checkbox" aria-checked={!!checked} aria-label={ariaLabel || 'Select row'}
      tabIndex={0}
      onClick={handleClick}
      onKeyDown={handleKey}
      style={{
        width:16, height:16,
        borderRadius:2,
        border:`1px solid ${C.border2}`,
        background: checked ? C.accent : 'transparent',
        display:'flex', alignItems:'center', justifyContent:'center',
        cursor:'pointer',
        transition:'background 0.12s, border-color 0.12s',
      }}
      onMouseEnter={(e) => { if (!checked) e.currentTarget.style.borderColor = C.fg3; }}
      onMouseLeave={(e) => { if (!checked) e.currentTarget.style.borderColor = C.border2; }}>
      {checked && (
        <svg width="11" height="11" viewBox="0 0 16 16" style={{ display:'block' }}>
          <path d="M3.5 8.5 L7 12 L13 5"
            fill="none" stroke="#000" strokeWidth="2.2"
            strokeLinecap="round" strokeLinejoin="round"/>
        </svg>
      )}
    </div>
  );
}

// Header row — receives an array of column descriptors. Each column
// is `{ id, label, width?, minWidth?, align?, sortable? }`. When
// `sortable` is true and the column has `id`, clicking the header
// emits `onSort(id)` and the cell shows an arrow indicator when
// it's the active sort column.
//
// 3-state cycle is owned upstream (the App-level sort state cycles
// asc → desc → null); this component just renders state and emits
// click events.
function TableHeader({ columns, sort, onSort }) {
  return (
    <div style={{
      display:'flex', alignItems:'center',
      height:TABLE_HEADER_H, minHeight:TABLE_HEADER_H,
      borderBottom:`1px solid ${C.border}`,
      background:'transparent',
    }}>
      {columns.map((col, i) => {
        const isActive  = col.sortable && col.id && sort && sort.columnId === col.id;
        const dir       = isActive ? sort.dir : null;
        const clickable = col.sortable && col.id && onSort;
        const sty = {
          padding:TABLE_CELL_PAD,
          fontFamily:'Roboto', fontSize:12, fontWeight:500,
          letterSpacing:'0.033em',
          color: isActive ? C.fg2 : '#898989',
          flex: col.width ? `0 0 ${col.width}px` : '1 1 0',
          minWidth: col.minWidth ? `${col.minWidth}px` : (col.width ? `${col.width}px` : '96px'),
          textAlign: col.align || 'left',
          cursor: clickable ? 'pointer' : 'default',
          userSelect: 'none',
          display: 'flex', alignItems: 'center', gap: 4,
          justifyContent: col.align === 'right' ? 'flex-end' : 'flex-start',
        };
        const onClick = clickable ? () => onSort(col.id) : undefined;
        const onMouseEnter = clickable ? (e) => { e.currentTarget.style.color = C.fg1; } : undefined;
        const onMouseLeave = clickable ? (e) => { e.currentTarget.style.color = isActive ? C.fg2 : '#898989'; } : undefined;
        return (
          <div key={i} data-typo="label" style={sty} onClick={onClick}
            onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
            <span>{col.label}</span>
            {col.sortable && col.id && (
              <span style={{
                fontSize:9, lineHeight:1, marginLeft:2,
                color: isActive ? C.accent : C.fg4,
                opacity: isActive ? 1 : 0.55,
                transition: 'opacity 0.12s, color 0.12s',
              }}>
                {dir === 'asc' ? '▲' : dir === 'desc' ? '▼' : '↕'}
              </span>
            )}
          </div>
        );
      })}
    </div>
  );
}

// Body row — zebra-striped by index. Children should be cell-shaped
// flex children. The outer container is 44px (TABLE_ROW_H) for
// inter-row breathing room; the visible zebra background sits in a
// 40px (TABLE_ROW_INNER_H) inner div so striping has a 2px gutter
// above and below — matches the figma's zebra-stripe overlay layer.
function TableRow({ index, children, onClick }) {
  const zebra = (index % 2) === 0;
  return (
    <div onClick={onClick} style={{
      display:'flex', alignItems:'center',
      height:TABLE_ROW_H, minHeight:TABLE_ROW_H,
      cursor: onClick ? 'pointer' : 'default',
    }}>
      <div style={{
        display:'flex', alignItems:'center',
        height:TABLE_ROW_INNER_H, width:'100%',
        background: zebra ? C.s3 : 'transparent',
        borderRadius:4,
      }}>
        {children}
      </div>
    </div>
  );
}

// Default body cell — 12px Roboto Mono Regular, white. Layout flex
// inherits from TableRow. Pass {width} for fixed columns or leave
// blank for flow.
function TableCell({ width, minWidth=96, align='left', muted=false, children, style }) {
  return (
    <div style={{
      flex: width ? `0 0 ${width}px` : '1 1 0',
      minWidth: width ? `${width}px` : `${minWidth}px`,
      padding:TABLE_CELL_PAD,
      fontFamily:'Roboto Mono', fontSize:12, fontWeight:400,
      letterSpacing:'0.033em',
      color: muted ? C.fg4 : C.fg1,
      textAlign:align,
      whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
      ...(style || {}),
    }}>
      {children}
    </div>
  );
}

// Navigable cell — accent-bold callsign / id that hover-pulses to a
// brighter shade. Click handler is required.
function TableNavCell({ width, minWidth=96, onClick, children }) {
  return (
    <div style={{
      flex: width ? `0 0 ${width}px` : '1 1 0',
      minWidth: width ? `${width}px` : `${minWidth}px`,
      padding:TABLE_CELL_PAD,
      whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
    }}>
      <button onClick={(e) => { e.stopPropagation(); if (onClick) onClick(); }} style={{
        background:'transparent', border:'none', padding:0, cursor:'pointer',
        fontFamily:'Roboto Mono', fontSize:12, fontWeight:400,
        letterSpacing:'0.033em',
        color:C.accent,
      }}
      onMouseEnter={(e) => { e.currentTarget.style.color = '#7fd1ff'; }}
      onMouseLeave={(e) => { e.currentTarget.style.color = C.accent; }}>
        {children}
      </button>
    </div>
  );
}

// Leading checkbox cell. 40 px square, custom DSCheckbox at the
// center matching the design-toolkit's interactive 16×16 spec.
function TableCheckboxCell({ checked, onChange }) {
  return (
    <div style={{
      flex:`0 0 ${TABLE_CHECKBOX_W}px`,
      width:TABLE_CHECKBOX_W,
      display:'flex', alignItems:'center', justifyContent:'center',
    }}>
      <DSCheckbox checked={checked} onChange={onChange}/>
    </div>
  );
}

// Trailing kebab cell — 3-dot icon button. v1 stubbed (popover still
// to design); click opens a menu later. For now it's a hover affordance.
function TableKebabCell({ onClick }) {
  return (
    <div style={{
      flex:`0 0 ${TABLE_KEBAB_W}px`,
      width:TABLE_KEBAB_W,
      display:'flex', alignItems:'center', justifyContent:'center',
    }}>
      <button onClick={(e) => { e.stopPropagation(); if (onClick) onClick(); }}
        title="Row actions"
        data-stub="true"
        style={{
          background:'transparent', border:'none', padding:'4px 6px',
          color:C.fg3, cursor:'pointer', borderRadius:3, lineHeight:0,
        }}
        onMouseEnter={(e) => { e.currentTarget.style.background = C.s4; e.currentTarget.style.color = C.fg1; }}
        onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = C.fg3; }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="6" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="18" r="1.6"/></svg>
        </button>
    </div>
  );
}

// Status cell — colored dot + label + chevron, with a popover dropdown
// that swaps Active / Scheduled / RTB / Complete. Mirrors the figma
// status cell exactly.
function TableStatusCell({ value, onChange, options }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    window.addEventListener('mousedown', handler);
    return () => window.removeEventListener('mousedown', handler);
  }, [open]);
  const opts = options || _STATUS_OPTIONS;
  const opt = opts.find(o => o.value === value) || opts[0];
  return (
    <div ref={ref} style={{
      flex:'1 1 0', minWidth:96, padding:TABLE_CELL_PAD,
      position:'relative',
    }}>
      <button onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }} style={{
        display:'flex', alignItems:'center', gap:6,
        padding:'2px 4px 2px 0', background:'transparent', border:'none',
        cursor:'pointer',
        fontFamily:'Roboto Mono', fontSize:12, fontWeight:400,
        letterSpacing:'0.033em',
        color: opt.color || C.fg1,
      }}>
        <span style={{
          width:8, height:8, borderRadius:'50%',
          background: opt.color || C.fg2, flexShrink:0,
        }}/>
        <span>{opt.label}</span>
        <span style={{ color:C.fg3, fontSize:10 }}>▾</span>
      </button>
      {open && (
        <div style={{
          position:'absolute', top:'calc(100% - 4px)', left:8, zIndex:240,
          background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
          boxShadow:'0 8px 22px rgba(0,0,0,0.6)', minWidth:160,
          padding:'4px 0',
        }}>
          {opts.map(o => (
            <button key={o.value} onClick={(e) => { e.stopPropagation(); onChange(o.value); setOpen(false); }} style={{
              display:'flex', alignItems:'center', gap:8, width:'100%', textAlign:'left',
              padding:'6px 12px', background: o.value === value ? C.s3 : 'transparent', border:'none',
              color: o.color || C.fg1, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:12, fontWeight:400, letterSpacing:'0.033em',
            }}
            onMouseEnter={(e) => { if (o.value !== value) e.currentTarget.style.background = C.s3; }}
            onMouseLeave={(e) => { if (o.value !== value) e.currentTarget.style.background = 'transparent'; }}>
              <span style={{ width:8, height:8, borderRadius:'50%', background: o.color || C.fg2, flexShrink:0 }}/>
              {o.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// Mission cell — the editable mission-type popover specifically for
// Platforms. Uses the existing EditablePopoverCell shell but wraps it
// in a TableCell-shaped container so columns line up.
function TableMissionCell({ value, label, options, onChange, width, minWidth=120 }) {
  return (
    <div style={{
      flex: width ? `0 0 ${width}px` : '1 1 0',
      minWidth: width ? `${width}px` : `${minWidth}px`,
      padding:TABLE_CELL_PAD,
    }}>
      <EditablePopoverCell
        value={value}
        label={label}
        options={options}
        onChange={onChange}
        width={(width || 140) - 24}
      />
    </div>
  );
}

// Asset-tag chip used in tag/assignment cells. Bordered pill, info-tone
// by default. Click optional (e.g. Objectives uses it as a callsign cross-
// link).
function TableTagChip({ label, onClick, tone='info' }) {
  // tone palette: info=primary-muted, accent=accent, normal=normal, etc.
  const isAccent = tone === 'accent';
  const fg = isAccent ? C.accent : C.fg2;
  const bd = isAccent ? `${C.accent}55` : C.border2;
  const bg = isAccent ? `${C.accent}1a` : 'transparent';
  const hoverBg = isAccent ? `${C.accent}33` : C.s4;
  const Tag = onClick ? 'button' : 'span';
  return (
    <Tag onClick={onClick ? ((e) => { e.stopPropagation(); onClick(); }) : undefined}
      style={{
        display:'inline-flex', alignItems:'center', gap:4,
        padding:'2px 8px', borderRadius:3,
        background:bg, border:`1px solid ${bd}`,
        color:fg, cursor: onClick ? 'pointer' : 'default',
        fontFamily:'Roboto Mono', fontSize:11, fontWeight:400,
        letterSpacing:'0.033em', whiteSpace:'nowrap',
      }}
      onMouseEnter={onClick ? ((e) => { e.currentTarget.style.background = hoverBg; }) : undefined}
      onMouseLeave={onClick ? ((e) => { e.currentTarget.style.background = bg; }) : undefined}>
      {label}
    </Tag>
  );
}

// Tag cell — flex row of TableTagChips.
function TableTagsCell({ tags, onTagClick, width, minWidth=160 }) {
  return (
    <div style={{
      flex: width ? `0 0 ${width}px` : '1 1 0',
      minWidth: width ? `${width}px` : `${minWidth}px`,
      padding:TABLE_CELL_PAD,
    }}>
      {(!tags || tags.length === 0) ? (
        <span style={{ color:C.fg4, fontFamily:'Roboto Mono', fontSize:12 }}>—</span>
      ) : (
        <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
          {tags.map((t, i) => (
            <TableTagChip key={i} label={t}
              tone={onTagClick ? 'accent' : 'info'}
              onClick={onTagClick ? () => onTagClick(t) : undefined}/>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Helpers shared by the sub-pages ────────────────────────

// Filter platforms (ATO_TASKUNITS records) by the active source-ATO
// chip set + operator's free-text search. Empty chip set = all ATOs.
function _filterPlatforms(platforms, filter, search) {
  let rows = platforms.filter(t => t && t.atoId);
  if (filter && filter.atoIds && filter.atoIds.size > 0) {
    rows = rows.filter(t => filter.atoIds.has(t.atoId));
  }
  const q = (search || '').trim().toLowerCase();
  if (q) {
    rows = rows.filter(t => {
      const fields = [t.cs, t.msnno, t.type, t.unit, t.mission, t.role,
                      window.generateTailNumber ? window.generateTailNumber(t.type, t.msnno) : ''].join(' ').toLowerCase();
      return fields.indexOf(q) !== -1;
    });
  }
  return rows;
}

// Synthesize plausible takeoff/landing window per platform, deterministic.
// Anchored to the source ATO's effective `start` so they match the doc.
function _synthesizeFlightWindow(track, doc) {
  if (!doc || !doc.start) return { takeoff:'—', landing:'—' };
  const sortieHours = (() => {
    const m = (track.mission || track.role || '').toUpperCase();
    if (m.indexOf('TANKER') !== -1) return 6;
    if (m.indexOf('AWACS') !== -1)  return 8;
    if (m.indexOf('HALE') !== -1 || m.indexOf('SIGINT') !== -1) return 12;
    if (m.indexOf('CSAR') !== -1)   return 2.5;
    if (m.indexOf('SARCAP') !== -1) return 3;
    if (m.indexOf('CAP') !== -1)    return 4;
    if (m.indexOf('STRIKE') !== -1) return 3;
    if (m.indexOf('CAS') !== -1)    return 3;
    if (m.indexOf('AMC') !== -1)    return 4;
    if (m.indexOf('SOF') !== -1)    return 5;
    return 3.5;
  })();
  // Hash msnno → 0–6h offset into the ATO window so platforms stagger.
  const h = (() => {
    const s = String(track.msnno || track.id || '');
    let x = 0x811c9dc5;
    for (let i = 0; i < s.length; i++) {
      x ^= s.charCodeAt(i);
      x = (x * 0x01000193) >>> 0;
    }
    return x;
  })();
  const offsetHours = (h % 600) / 100; // 0..6h
  const startMs = new Date(doc.start).getTime() + offsetHours * 3600 * 1000;
  const endMs   = startMs + sortieHours * 3600 * 1000;
  const fmt = (ms) => {
    const d = new Date(ms);
    const dd = String(d.getUTCDate()).padStart(2,'0');
    const hh = String(d.getUTCHours()).padStart(2,'0');
    const mm = String(d.getUTCMinutes()).padStart(2,'0');
    const mon = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'][d.getUTCMonth()];
    const yy = String(d.getUTCFullYear()).slice(-2);
    return `${dd}${hh}${mm}Z${mon}${yy}`;
  };
  return { takeoff: fmt(startMs), landing: fmt(endMs) };
}

// Read mission/status with overlay applied — drop-in for table cells.
function _readPlatform(track, overrides) {
  const o = (overrides && overrides[track.id]) || {};
  return {
    ...track,
    mission: o.mission != null ? o.mission : (track.mission || track.role || ''),
    status:  o.status  != null ? o.status  : 'Active',
  };
}

// Mission picker options (grouped) for EditablePopoverCell.
function _missionOptions() {
  const types = window.MISSION_TYPES || [];
  const out = [];
  let lastGroup = null;
  types.forEach(t => {
    if (t.group !== lastGroup) {
      out.push({ kind:'section', label:t.group });
      lastGroup = t.group;
    }
    out.push({ value:t.code, label:`${t.code} · ${t.label}` });
  });
  return out;
}

// Status picker options.
const _STATUS_OPTIONS = [
  { value:'Active',    label:'Active',    color:C.normal },
  { value:'Scheduled', label:'Scheduled', color:C.fg2 },
  { value:'RTB',       label:'RTB',       color:C.advisory },
  { value:'Complete',  label:'Complete',  color:C.fg3 },
];

// ── Sort helpers ───────────────────────────────────────────
// Comparator factories for the sub-page tables. Each takes a `dir`
// of 'asc' | 'desc' and a value-extractor `getKey(row)` that returns
// the comparable value for that row. The comparator handles null/
// undefined by sinking them to the end regardless of direction so a
// missing field never beats a real one when sorting.
//
// Strings use locale-aware natural sort (DUKE 1 < DUKE 2 < DUKE 10).
// Numbers / dates use straight subtraction. Enum order — for status
// and priority — uses an index map so the order matches operator
// intuition rather than alphabet.

const _STATUS_RANK   = { Active:0, Scheduled:1, RTB:2, Complete:3 };
const _PRIORITY_RANK = { High:0,   Medium:1,    Low:2 };

function _sortRows(rows, sort, getKeyForColumn) {
  if (!sort || !sort.columnId) return rows;
  const dir = sort.dir === 'desc' ? -1 : 1;
  const get = (r) => getKeyForColumn(r, sort.columnId);
  const cmp = (a, b) => {
    const av = get(a);
    const bv = get(b);
    // Null/undefined sink to the end regardless of direction.
    const aNull = av == null || av === '';
    const bNull = bv == null || bv === '';
    if (aNull && bNull) return 0;
    if (aNull) return 1;
    if (bNull) return -1;
    if (typeof av === 'number' && typeof bv === 'number') {
      return (av - bv) * dir;
    }
    return String(av).localeCompare(String(bv), undefined, { numeric:true, sensitivity:'base' }) * dir;
  };
  return [...rows].sort(cmp);
}

// ── PlatformsSubPage ───────────────────────────────────────
function PlatformsSubPage({
  filter, onFilterChange, search, onSearchChange, onSubviewChange, onSubviewNavigate,
  platformOverrides, onApplyPlatformOverride, docs,
  sort, onSortColumn,
}) {
  const allPlatforms = window.ATO_TASKUNITS || [];
  const filtered = _filterPlatforms(allPlatforms, filter, search);
  const docsById = new Map((docs || []).map(d => [d.id, d]));
  const missionOptions = _missionOptions();
  // Sort key extractor — translates a column id to the comparable
  // value pulled off the row. The applied overlay (mission/status)
  // is read-through so an operator-edited mission sorts under the
  // edited value.
  const getKey = (track, columnId) => {
    const view = _readPlatform(track, platformOverrides);
    switch (columnId) {
      case 'cs':       return track.cs || '';
      case 'tail':     return window.generateTailNumber(track.type, track.msnno || track.id);
      case 'type':     return track.type || '';
      case 'mission':  return window.getMissionTypeLabel(view.mission) || '';
      case 'ato':      {
        const d = docsById.get(track.atoId);
        return d ? d.name : '';
      }
      case 'takeoff':
      case 'landing': {
        const d = docsById.get(track.atoId);
        const w = _synthesizeFlightWindow(track, d);
        return w[columnId] || '';
      }
      case 'status':   return _STATUS_RANK[view.status] != null ? _STATUS_RANK[view.status] : 99;
      default:         return '';
    }
  };
  const rows = _sortRows(filtered, sort, getKey);
  const subtitle = (() => {
    const n = filter.atoIds && filter.atoIds.size;
    if (!n) return `${rows.length} platforms across all ATOs`;
    return `${rows.length} platforms from ${n} selected ATO${n === 1 ? '' : 's'}`;
  })();
  const onSort = (columnId) => onSortColumn && onSortColumn('platforms', columnId);
  return (
    <AdminSubPageShell
      pageLabel="ATO Platforms" title="ATO Platforms" subtitle={subtitle}
      addLabel="Add Platform" searchPlaceholder="Search call sign · type · mission · tail"
      filter={filter} onFilterChange={onFilterChange}
      search={search} onSearchChange={onSearchChange}
      allDocs={docs}
      onHome={() => onSubviewChange('dashboard')}>
      <div style={{ padding:'0 24px' }}>
        <TableHeader sort={sort} onSort={onSort} columns={[
          { label:'',           width: TABLE_CHECKBOX_W },
          { label:'',           width: TABLE_KEBAB_W },
          { id:'cs',      label:'Call sign',   minWidth:120, sortable:true },
          { id:'tail',    label:'Tail number', minWidth:110, sortable:true },
          { id:'type',    label:'Platform',    minWidth:96,  sortable:true },
          { id:'mission', label:'Mission',     minWidth:160, sortable:true },
          { id:'ato',     label:'ATO',         minWidth:120, sortable:true },
          { id:'takeoff', label:'Takeoff',     minWidth:130, sortable:true },
          { id:'landing', label:'Landing',     minWidth:130, sortable:true },
          { id:'status',  label:'Status',      minWidth:130, sortable:true },
        ]}/>
        {rows.map((track, i) => {
          const view = _readPlatform(track, platformOverrides);
          const tail = window.generateTailNumber(track.type, track.msnno || track.id);
          const doc = docsById.get(track.atoId);
          const flightWin = _synthesizeFlightWindow(track, doc);
          const missionLabel = window.getMissionTypeLabel(view.mission);
          return (
            <TableRow key={track.id} index={i}>
              <TableCheckboxCell />
              <TableKebabCell />
              <TableNavCell minWidth={120} onClick={() => onSubviewNavigate('assets', { search: track.cs })}>
                {track.cs}
              </TableNavCell>
              <TableCell minWidth={110}>{tail}</TableCell>
              <TableCell minWidth={96}>{track.type}</TableCell>
              <TableMissionCell
                value={view.mission}
                label={missionLabel}
                options={missionOptions}
                onChange={(code) => onApplyPlatformOverride(track.id, { mission: code })}
                minWidth={160}
              />
              <TableCell minWidth={120} muted={!doc}>{doc ? doc.name : '—'}</TableCell>
              <TableCell minWidth={130}>{flightWin.takeoff}</TableCell>
              <TableCell minWidth={130}>{flightWin.landing}</TableCell>
              <TableStatusCell
                value={view.status}
                onChange={(s) => onApplyPlatformOverride(track.id, { status: s })}
              />
            </TableRow>
          );
        })}
        {rows.length === 0 && (
          <div style={{ padding:'40px 24px', textAlign:'center',
            fontFamily:'Roboto Mono', fontSize:12, color:C.fg3 }}>
            No platforms match the current filter.
          </div>
        )}
      </div>
    </AdminSubPageShell>
  );
}

// ── ObjectivesSubPage ──────────────────────────────────────
function ObjectivesSubPage({
  filter, onFilterChange, search, onSearchChange, onSubviewChange, onSubviewNavigate,
  onFlyToObjective, docs,
  sort, onSortColumn,
}) {
  const allObjectives = window.MOCK_OBJECTIVES || [];
  let filtered = allObjectives;
  if (filter && filter.atoIds && filter.atoIds.size > 0) {
    filtered = filtered.filter(o => filter.atoIds.has(o.atoId));
  }
  const q = (search || '').trim().toLowerCase();
  if (q) {
    filtered = filtered.filter(o => {
      const fields = [o.targetId, o.name, o.type, (window.OPERATION_NAMES_BY_ATO || {})[o.atoId] || '',
                      (o.assignedMsnnos || []).join(' '), o.notes || ''].join(' ').toLowerCase();
      return fields.indexOf(q) !== -1;
    });
  }
  const docsById = new Map((docs || []).map(d => [d.id, d]));
  const getKey = (obj, columnId) => {
    switch (columnId) {
      case 'targetId':  return obj.targetId || '';
      case 'name':      return obj.name || '';
      case 'type':      return obj.type || '';
      case 'priority':  return _PRIORITY_RANK[obj.priority] != null ? _PRIORITY_RANK[obj.priority] : 99;
      case 'ato':       {
        const d = docsById.get(obj.atoId);
        return d ? d.name : '';
      }
      case 'operation': return (window.OPERATION_NAMES_BY_ATO || {})[obj.atoId] || '';
      case 'assigned':  return (obj.assignedMsnnos || []).length;
      default:          return '';
    }
  };
  const rows = _sortRows(filtered, sort, getKey);
  const subtitle = (() => {
    const n = filter.atoIds && filter.atoIds.size;
    if (!n) return `${rows.length} objectives across all ATOs`;
    return `${rows.length} objectives from ${n} selected ATO${n === 1 ? '' : 's'}`;
  })();
  const onSort = (columnId) => onSortColumn && onSortColumn('objectives', columnId);
  return (
    <AdminSubPageShell
      pageLabel="Mission Objectives" title="Mission Objectives" subtitle={subtitle}
      addLabel="Add Objective" searchPlaceholder="Search target · type · operation · callsign"
      filter={filter} onFilterChange={onFilterChange}
      search={search} onSearchChange={onSearchChange}
      allDocs={docs}
      onHome={() => onSubviewChange('dashboard')}>
      <div style={{ padding:'0 24px' }}>
        <TableHeader sort={sort} onSort={onSort} columns={[
          { label:'',                       width: TABLE_CHECKBOX_W },
          { label:'',                       width: TABLE_KEBAB_W },
          { id:'targetId',  label:'Target ID',          minWidth:120, sortable:true },
          { id:'name',      label:'Target name',        minWidth:200, sortable:true },
          { id:'type',      label:'Type',               minWidth:140, sortable:true },
          { id:'priority',  label:'Priority',           minWidth:96,  sortable:true },
          { id:'ato',       label:'ATO',                minWidth:120, sortable:true },
          { id:'operation', label:'Operation',          minWidth:160, sortable:true },
          { id:'assigned',  label:'Assigned',           minWidth:200, sortable:true },
          { label:'Coordinates (MGRS)', minWidth:160 },
        ]}/>
        {rows.map((obj, i) => {
          const doc = docsById.get(obj.atoId);
          const op = (window.OPERATION_NAMES_BY_ATO || {})[obj.atoId] || '—';
          const assigned = obj.assignedMsnnos || [];
          return (
            <TableRow key={obj.id} index={i}>
              <TableCheckboxCell />
              <TableKebabCell />
              <TableCell minWidth={120}>{obj.targetId}</TableCell>
              <TableCell minWidth={200} style={{ color:C.fg1 }}>{obj.name}</TableCell>
              <TableCell minWidth={140}>{obj.type}</TableCell>
              <div style={{ flex:'1 1 0', minWidth:96, padding:TABLE_CELL_PAD }}>
                <PriorityPill priority={obj.priority}/>
              </div>
              <TableCell minWidth={120} muted={!doc}>{doc ? doc.name : '—'}</TableCell>
              <TableCell minWidth={160}>{op}</TableCell>
              <TableTagsCell minWidth={200} tags={assigned}
                onTagClick={assigned.length ? (cs) => onSubviewNavigate('platforms', { search: cs }) : null}/>
              <div style={{ flex:'1 1 0', minWidth:160, padding:TABLE_CELL_PAD,
                whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
                <button onClick={() => onFlyToObjective(obj)} title="Jump to map and fly to coordinates"
                  style={{
                    background:'transparent', border:'none', padding:0, cursor:'pointer',
                    color:C.accent, fontFamily:'Roboto Mono', fontSize:12, fontWeight:400,
                    letterSpacing:'0.033em', textDecoration:'underline', textUnderlineOffset:2,
                  }}
                  onMouseEnter={(e) => { e.currentTarget.style.color = '#7fd1ff'; }}
                  onMouseLeave={(e) => { e.currentTarget.style.color = C.accent; }}>
                  {obj.mgrs}
                </button>
              </div>
            </TableRow>
          );
        })}
        {rows.length === 0 && (
          <div style={{ padding:'40px 24px', textAlign:'center',
            fontFamily:'Roboto Mono', fontSize:12, color:C.fg3 }}>
            No objectives match the current filter.
          </div>
        )}
      </div>
    </AdminSubPageShell>
  );
}

// ── AssetsSubPage ──────────────────────────────────────────
function AssetsSubPage({
  filter, onFilterChange, search, onSearchChange, onSubviewChange, onSubviewNavigate, docs,
  sort, onSortColumn,
}) {
  const allPlatforms = window.ATO_TASKUNITS || [];
  // Filter platforms first by the ATO chip set (search is applied to
  // the asset rows themselves so an operator can search across station
  // and weapon, not just platform fields).
  const platformsScoped = allPlatforms.filter(t => {
    if (!t.atoId) return false;
    if (filter && filter.atoIds && filter.atoIds.size > 0 && !filter.atoIds.has(t.atoId)) return false;
    return true;
  });
  // Expand each scoped platform via LOADOUT_TEMPLATES.
  const docsById = new Map((docs || []).map(d => [d.id, d]));
  const templates = window.LOADOUT_TEMPLATES || {};
  const assetRows = [];
  platformsScoped.forEach(track => {
    const tmpl = templates[track.type];
    const doc  = docsById.get(track.atoId);
    if (!tmpl) {
      assetRows.push({
        trackId:track.id, cs:track.cs, type:track.type,
        atoId:track.atoId, atoName: doc ? doc.name : '—',
        station:'—', weapon:`No template for ${track.type}`,
        muted:true,
      });
      return;
    }
    if (tmpl.noKinetic) {
      assetRows.push({
        trackId:track.id, cs:track.cs, type:track.type,
        atoId:track.atoId, atoName: doc ? doc.name : '—',
        station:'—', weapon: tmpl.role || 'No kinetic loadout',
        muted:true,
      });
      return;
    }
    // Per-instance variation: hash the track id, swap one station's weapon
    // to a deterministic alternate. Keeps loadouts realistic but varied.
    const swapHash = (() => {
      const s = String(track.id || track.msnno || '');
      let x = 0x811c9dc5;
      for (let i = 0; i < s.length; i++) {
        x ^= s.charCodeAt(i);
        x = (x * 0x01000193) >>> 0;
      }
      return x;
    })();
    const swapIdx = swapHash % tmpl.length;
    const altWeapon = (() => {
      const w = tmpl[swapIdx].weapon;
      // Simple swap rules — kinetic alternates to a similar-class weapon.
      if (w.indexOf('GBU-38') !== -1) return 'Mk 82 (500lb)';
      if (w.indexOf('GBU-31') !== -1) return 'GBU-32 JDAM';
      if (w.indexOf('GBU-12') !== -1) return 'GBU-49 EP-II';
      if (w.indexOf('AIM-9X') !== -1) return 'AIM-9M Sidewinder';
      if (w.indexOf('AIM-120D') !== -1) return 'AIM-120C-7 AMRAAM';
      if (w.indexOf('AGM-114') !== -1) return 'AGM-176 Griffin';
      if (w.indexOf('AGM-65') !== -1) return 'AGM-65L Maverick';
      if (w.indexOf('JASSM') !== -1) return 'AGM-86C CALCM';
      return w;
    })();
    tmpl.forEach((s, i) => {
      assetRows.push({
        trackId:track.id, cs:track.cs, type:track.type,
        atoId:track.atoId, atoName: doc ? doc.name : '—',
        station:s.station,
        weapon: i === swapIdx ? altWeapon : s.weapon,
      });
    });
  });
  // Search applies to all asset-row fields.
  const q = (search || '').trim().toLowerCase();
  const filteredAssets = q
    ? assetRows.filter(r => [r.cs, r.type, r.atoName, r.station, r.weapon].join(' ').toLowerCase().indexOf(q) !== -1)
    : assetRows;
  const getKey = (r, columnId) => {
    switch (columnId) {
      case 'cs':      return r.cs || '';
      case 'type':    return r.type || '';
      case 'atoName': return r.atoName || '';
      case 'station': return r.station || '';
      case 'weapon':  return r.weapon || '';
      default:        return '';
    }
  };
  const sortedAssets = _sortRows(filteredAssets, sort, getKey);
  const platformsRepresented = new Set(sortedAssets.map(r => r.trackId)).size;
  const subtitle = `Showing ${sortedAssets.length} assets from ${platformsRepresented} platform${platformsRepresented === 1 ? '' : 's'}`;
  const onSort = (columnId) => onSortColumn && onSortColumn('assets', columnId);
  return (
    <AdminSubPageShell
      pageLabel="ATO Assets" title="ATO Assets" subtitle={subtitle}
      addLabel="Add Asset" searchPlaceholder="Search call sign · station · weapon"
      filter={filter} onFilterChange={onFilterChange}
      search={search} onSearchChange={onSearchChange}
      allDocs={docs}
      onHome={() => onSubviewChange('dashboard')}>
      <div style={{ padding:'0 24px' }}>
        <TableHeader sort={sort} onSort={onSort} columns={[
          { label:'',                       width: TABLE_CHECKBOX_W },
          { label:'',                       width: TABLE_KEBAB_W },
          { id:'cs',      label:'Platform call sign', minWidth:140, sortable:true },
          { id:'type',    label:'Platform type',      minWidth:96,  sortable:true },
          { id:'atoName', label:'ATO',                minWidth:120, sortable:true },
          { id:'station', label:'Station',            minWidth:120, sortable:true },
          { id:'weapon',  label:'Weapon',             minWidth:200, sortable:true },
        ]}/>
        {sortedAssets.map((r, i) => (
          <TableRow key={`${r.trackId}-${r.station}-${i}`} index={i}>
            <TableCheckboxCell />
            <TableKebabCell />
            <TableNavCell minWidth={140} onClick={() => onSubviewNavigate('platforms', { search: r.cs })}>
              {r.cs}
            </TableNavCell>
            <TableCell minWidth={96}>{r.type}</TableCell>
            <TableCell minWidth={120} muted={!r.atoName || r.atoName === '—'}>{r.atoName}</TableCell>
            <TableCell minWidth={120} muted={r.muted}>{r.station}</TableCell>
            <TableCell minWidth={200} muted={r.muted}
              style={r.muted ? { fontStyle:'italic' } : null}>
              {r.weapon}
            </TableCell>
          </TableRow>
        ))}
        {sortedAssets.length === 0 && (
          <div style={{ padding:'40px 24px', textAlign:'center',
            fontFamily:'Roboto Mono', fontSize:12, color:C.fg3 }}>
            No assets match the current filter.
          </div>
        )}
      </div>
    </AdminSubPageShell>
  );
}

// ── Mission Details sub-page ───────────────────────────────────
function MissionDetailsSubPage({
  filter, onFilterChange, search, onSearchChange, onSubviewChange, docs,
}) {
  const [selectedTab, setSelectedTab] = React.useState('details'); // 'details' or 'timeline'

  // Mock mission data - will be replaced with real data
  const MISSIONS = [
    {
      id: 'msn-001',
      name: 'IRON ANVIL',
      startLocation: 'Andersen AFB, Guam',
      destination: 'South China Sea AO',
      departureTime: '281430Z',
      arrivalTime: '281645Z',
      callsigns: [
        { cs: 'EAGLE 11', tags: ['PULSE 1', 'EFFORT 1'] },
        { cs: 'EAGLE 12', tags: ['PULSE 1', 'EFFORT 1'] },
        { cs: 'VIPER 21', tags: ['DCA'] },
        { cs: 'VIPER 22', tags: ['DCA'] },
      ],
      callsignGroups: [
        { name: 'EAGLE Flight', callsigns: ['EAGLE 11', 'EAGLE 12'] },
        { name: 'VIPER Flight', callsigns: ['VIPER 21', 'VIPER 22'] },
      ],
      atoId: 'ato-001',
    },
    {
      id: 'msn-002',
      name: 'RED DAGGER',
      startLocation: 'Kadena AB, Japan',
      destination: 'Eastern Theater AO',
      departureTime: '281500Z',
      arrivalTime: '281730Z',
      callsigns: [
        { cs: 'RAPTOR 31', tags: ['EFFORT 1'] },
        { cs: 'RAPTOR 32', tags: ['EFFORT 1'] },
        { cs: 'HAWKEYE 01', tags: ['PULSE 1'] },
      ],
      callsignGroups: [
        { name: 'RAPTOR Package', callsigns: ['RAPTOR 31', 'RAPTOR 32'] },
      ],
      atoId: 'ato-001',
    },
    {
      id: 'msn-003',
      name: 'SILENT THUNDER',
      startLocation: 'Al Dhafra AB, UAE',
      destination: 'Northern Iraq AO',
      departureTime: '281545Z',
      arrivalTime: '281820Z',
      callsigns: [
        { cs: 'BONE 41', tags: ['PULSE 1'] },
        { cs: 'BLADE 15', tags: ['DCA'] },
        { cs: 'BLADE 16', tags: ['DCA'] },
      ],
      callsignGroups: [
        { name: 'BLADE Element', callsigns: ['BLADE 15', 'BLADE 16'] },
      ],
      atoId: 'ato-002',
    },
    {
      id: 'msn-004',
      name: 'PHANTOM REACH',
      startLocation: 'Incirlik AB, Turkey',
      destination: 'Mediterranean AO',
      departureTime: '281630Z',
      arrivalTime: '281905Z',
      callsigns: [
        { cs: 'SENTRY 71', tags: ['PULSE 1', 'EFFORT 1'] },
        { cs: 'COBRA 23', tags: ['EFFORT 1'] },
        { cs: 'COBRA 24', tags: ['EFFORT 1'] },
      ],
      callsignGroups: [
        { name: 'COBRA Strike', callsigns: ['COBRA 23', 'COBRA 24'] },
      ],
      atoId: 'ato-002',
    },
    {
      id: 'msn-005',
      name: 'STEEL CURTAIN',
      startLocation: 'RAF Lakenheath, UK',
      destination: 'Baltic Sea AO',
      departureTime: '281400Z',
      arrivalTime: '281615Z',
      callsigns: [
        { cs: 'DAGGER 51', tags: ['DCA'] },
        { cs: 'DAGGER 52', tags: ['DCA'] },
        { cs: 'DAGGER 53', tags: ['DCA'] },
        { cs: 'DAGGER 54', tags: ['DCA'] },
      ],
      callsignGroups: [
        { name: 'DAGGER Formation', callsigns: ['DAGGER 51', 'DAGGER 52', 'DAGGER 53', 'DAGGER 54'] },
      ],
      atoId: 'ato-003',
    },
  ];

  // Filter by ATO
  const missionsScoped = MISSIONS.filter(m => {
    if (filter && filter.atoIds && filter.atoIds.size > 0 && !filter.atoIds.has(m.atoId)) return false;
    return true;
  });

  // Search across mission fields
  const q = (search || '').trim().toLowerCase();
  const filteredMissions = q
    ? missionsScoped.filter(m =>
        [m.name, m.startLocation, m.destination, ...m.callsigns.map(c => c.cs)].join(' ').toLowerCase().indexOf(q) !== -1
      )
    : missionsScoped;

  const subtitle = `Showing ${filteredMissions.length} mission${filteredMissions.length === 1 ? '' : 's'}`;

  return (
    <AdminSubPageShell
      pageLabel="Mission Details" title="Mission Details" subtitle={subtitle}
      addLabel="Add Mission" searchPlaceholder="Search mission · callsign · location"
      filter={filter} onFilterChange={onFilterChange}
      search={search} onSearchChange={onSearchChange}
      allDocs={docs}
      onHome={() => onSubviewChange('dashboard')}>
      <div style={{ padding:'0 24px 24px' }}>
        {filteredMissions.map((mission, idx) => (
          <div key={mission.id} style={{
            background:C.s2, borderRadius:4, padding:20, marginBottom:16,
            border:`1px solid ${C.border}`
          }}>
            {/* Mission Header */}
            <div style={{ marginBottom:12, paddingBottom:12, borderBottom:`1px solid ${C.border}` }}>
              <div style={{ fontFamily:'Roboto', fontSize:16, fontWeight:600, color:C.fg1, marginBottom:6 }}>
                {mission.name}
              </div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.04em' }}>
                MISSION ID: {mission.id.toUpperCase()}
              </div>
            </div>

            {/* Tabs */}
            <div style={{ marginBottom:16, display:'flex', gap:2, borderBottom:`1px solid ${C.border}` }}>
              <button
                onClick={() => setSelectedTab('details')}
                style={{
                  padding:'8px 16px',
                  background: selectedTab === 'details' ? C.s3 : 'transparent',
                  border:'none',
                  borderBottom: selectedTab === 'details' ? `2px solid ${C.accent}` : '2px solid transparent',
                  color: selectedTab === 'details' ? C.fg1 : C.fg3,
                  fontFamily:'Roboto Mono',
                  fontSize:10,
                  fontWeight:500,
                  letterSpacing:'0.08em',
                  textTransform:'uppercase',
                  cursor:'pointer',
                  transition:'all 0.15s ease',
                }}
                onMouseEnter={(e) => {
                  if (selectedTab !== 'details') {
                    e.currentTarget.style.background = C.s3;
                    e.currentTarget.style.color = C.fg2;
                  }
                }}
                onMouseLeave={(e) => {
                  if (selectedTab !== 'details') {
                    e.currentTarget.style.background = 'transparent';
                    e.currentTarget.style.color = C.fg3;
                  }
                }}
              >
                Details
              </button>
              <button
                onClick={() => setSelectedTab('timeline')}
                style={{
                  padding:'8px 16px',
                  background: selectedTab === 'timeline' ? C.s3 : 'transparent',
                  border:'none',
                  borderBottom: selectedTab === 'timeline' ? `2px solid ${C.accent}` : '2px solid transparent',
                  color: selectedTab === 'timeline' ? C.fg1 : C.fg3,
                  fontFamily:'Roboto Mono',
                  fontSize:10,
                  fontWeight:500,
                  letterSpacing:'0.08em',
                  textTransform:'uppercase',
                  cursor:'pointer',
                  transition:'all 0.15s ease',
                }}
                onMouseEnter={(e) => {
                  if (selectedTab !== 'timeline') {
                    e.currentTarget.style.background = C.s3;
                    e.currentTarget.style.color = C.fg2;
                  }
                }}
                onMouseLeave={(e) => {
                  if (selectedTab !== 'timeline') {
                    e.currentTarget.style.background = 'transparent';
                    e.currentTarget.style.color = C.fg3;
                  }
                }}
              >
                Timeline
              </button>
            </div>

            {/* Tab Content */}
            {selectedTab === 'details' && (
              <div>
                {/* Schedule Section */}
            <div style={{ marginBottom:18 }}>
              <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:8 }}>
                SCHEDULE
              </div>
              <div style={{ display:'grid', gridTemplateColumns:'auto 1fr', gap:'8px 16px',
                fontFamily:'Roboto', fontSize:12 }}>
                <div style={{ color:C.fg3 }}>Departure:</div>
                <div style={{ color:C.fg1 }}>
                  <span style={{ fontWeight:500 }}>{mission.startLocation}</span>
                  <span style={{ color:C.fg3, marginLeft:12, fontFamily:'Roboto Mono', fontSize:11 }}>
                    {mission.departureTime}
                  </span>
                </div>
                <div style={{ color:C.fg3 }}>Arrival:</div>
                <div style={{ color:C.fg1 }}>
                  <span style={{ fontWeight:500 }}>{mission.destination}</span>
                  <span style={{ color:C.fg3, marginLeft:12, fontFamily:'Roboto Mono', fontSize:11 }}>
                    {mission.arrivalTime}
                  </span>
                </div>
              </div>
            </div>

            {/* Callsign Groups */}
            {mission.callsignGroups && mission.callsignGroups.length > 0 && (
              <div style={{ marginBottom:18 }}>
                <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:8 }}>
                  CALLSIGN GROUPS
                </div>
                <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
                  {mission.callsignGroups.map((group, i) => (
                    <div key={i} style={{
                      padding:'6px 12px', borderRadius:3, background:C.s3,
                      border:`1px solid ${C.border2}`,
                      fontFamily:'Roboto Mono', fontSize:10, color:C.fg2
                    }}>
                      {group.name}
                      <span style={{ color:C.fg3, marginLeft:8 }}>
                        ({group.callsigns.join(', ')})
                      </span>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {/* Associated Callsigns */}
            <div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.16em', marginBottom:8 }}>
                ASSOCIATED CALLSIGNS
              </div>
              <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(280px, 1fr))', gap:10 }}>
                {mission.callsigns.map((cs, i) => (
                  <div key={i} style={{
                    padding:'10px 12px', borderRadius:3, background:C.s3,
                    border:`1px solid ${C.border2}`,
                    display:'flex', alignItems:'center', justifyContent:'space-between'
                  }}>
                    <span style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:500, color:C.fg1 }}>
                      {cs.cs}
                    </span>
                    <div style={{ display:'flex', gap:6 }}>
                      {cs.tags.map((tag, j) => (
                        <span key={j} style={{
                          padding:'3px 8px', borderRadius:2,
                          fontSize:9, fontFamily:'Roboto Mono', letterSpacing:'0.04em',
                          ...(tag === 'PULSE 1' ? {
                            background:C.accentM, color:C.accent, border:`1px solid ${C.accent}55`
                          } : tag === 'EFFORT 1' ? {
                            background:`${C.advisory}22`, color:C.advisory, border:`1px solid ${C.advisory}55`
                          } : tag === 'DCA' ? {
                            background:`${C.critical}22`, color:C.critical, border:`1px solid ${C.critical}55`
                          } : {
                            background:C.s1, color:C.fg3, border:`1px solid ${C.border}`
                          })
                        }}>
                          {tag}
                        </span>
                      ))}
                    </div>
                  </div>
                ))}
              </div>
            </div>
              </div>
            )}

            {/* Timeline Tab Content */}
            {selectedTab === 'timeline' && (
              <div style={{ padding:'20px 0' }}>
                <div style={{ fontFamily:'Roboto Mono', fontSize:11, color:C.fg3, textAlign:'center' }}>
                  Timeline view coming soon
                </div>
              </div>
            )}
          </div>
        ))}
        {filteredMissions.length === 0 && (
          <div style={{ padding:'40px 24px', textAlign:'center',
            fontFamily:'Roboto Mono', fontSize:12, color:C.fg3 }}>
            No missions match the current filter.
          </div>
        )}
      </div>
    </AdminSubPageShell>
  );
}

// ── Mode switch confirmation modal ────────────────────────────
// Surfaces when leaving an active CSAR session for another master mode.
// Lists live case status (phase, mission progress, survivors, clock) so
// the operator sees exactly what they're stepping away from. CSAR keeps
// running in the background regardless — the modal is a "you sure?"
// step, not a freeze.
function buildCsarStatusLines(state) {
  const { phase, launched, recoveryDone, missionPhase, missionProgress,
          elapsed, bravoLocated, pickupDone } = state;
  const lines = [];
  lines.push(['CASE',  `${window.CASE.number} / DUKE 31`]);
  const phaseLabel = phase===0?'Immediate Actions'
                  : phase===1?'Locate / Plot'
                  : phase===2?'Airspace / Threat'
                  :           'Tactical Execution';
  lines.push(['PHASE', phaseLabel]);
  if (launched && !recoveryDone) {
    const pct = Math.round(missionProgress * 100);
    const segment = missionPhase==='ingress'  ? `PEDRO 61 ingressing — ${pct}% to IP`
                  : missionPhase==='terminal' ? 'Terminal area handoff in progress'
                  : missionPhase==='pickup'   ? 'Pickup in progress at IP'
                  : missionPhase==='egress'   ? `Egress underway — ${100-pct}% to staging`
                  :                              'Mission complete';
    lines.push(['PROGRESS', segment]);
  }
  const survivors = pickupDone ? 'On board PEDRO 61'
                  : bravoLocated ? 'ALPHA · BRAVO awaiting pickup'
                  :                 'ALPHA awaiting pickup · BRAVO unlocated';
  lines.push(['SURVIVORS', survivors]);
  lines.push(['CLOCK', fmtElapsed(elapsed)]);  // already T+ prefixed
  return lines;
}

function ModeSwitchConfirmModal({ pendingMode, csarStatus, onConfirm, onCancel }) {
  if (!pendingMode) return null;
  const target = (window.MASTER_MODES || []).find(m => m.id === pendingMode);
  if (!target) return null;
  const statusLines = buildCsarStatusLines(csarStatus);
  return (
    <div style={{ position:'fixed', inset:0, zIndex:250, background:'rgba(0,0,0,0.78)',
      backdropFilter:'blur(4px)', display:'flex', alignItems:'center', justifyContent:'center',
      animation:'fadeIn 0.18s ease' }}>
      <div style={{ background:C.s2, border:`1px solid ${C.border2}`, borderRadius:6, width:480, maxWidth:'92vw',
        display:'flex', flexDirection:'column', boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex', alignItems:'center', padding:'12px 14px',
          borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto', fontWeight:600, fontSize:13, color:C.fg1 }}>
              Switch master mode?
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, marginTop:2, letterSpacing:'0.06em' }}>
              CSAR → {target.short.toUpperCase()}
            </div>
          </div>
          <CloseButton onClose={onCancel}/>
        </div>
        <div style={{ padding:'18px 18px 14px' }}>
          <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg2, lineHeight:1.5, marginBottom:14 }}>
            The active CSAR case will keep running in the background.
            Critical alerts will surface in <span style={{ color:C.fg1, fontWeight:500 }}>{target.label}</span>.
          </div>
          <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderRadius:4, padding:'10px 12px' }}>
            {statusLines.map(([label, value]) => (
              <div key={label} style={{ display:'flex', alignItems:'baseline', gap:10, padding:'3px 0',
                fontFamily:'Roboto Mono', fontSize:10.5 }}>
                <span data-typo="label" style={{ width:88, flexShrink:0, color:C.fg3, letterSpacing:'0.08em' }}>{label}</span>
                <span style={{ color:C.fg1 }}>{value}</span>
              </div>
            ))}
          </div>
        </div>
        <div style={{ display:'flex', justifyContent:'flex-end', gap:8, padding:'12px 14px',
          borderTop:`1px solid ${C.border}` }}>
          <DTButton onClick={onCancel} color="mono-muted" variant="outline" size="medium">Cancel</DTButton>
          <DTButton onClick={onConfirm} color="accent" size="medium">Switch to {target.short}</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── J3.1 mini-map ─────────────────────────────────────────────
// Tiny MapLibre instance embedded in the J3.1 overlay so the operator
// sees real satellite imagery for the IP location before they've even
// acknowledged into CSAR master mode. Non-interactive — pure locator.
// Satellite source is baked into the initial style so MapLibre fetches
// tiles as soon as the canvas has dimensions — no separate addSource
// timing dance, which was leaving the modal blank on cold starts.
function J31MiniMap() {
  const ref = React.useRef(null);
  const mapRef = React.useRef(null);
  React.useEffect(() => {
    if (!ref.current || mapRef.current) return;
    if (typeof maplibregl === 'undefined') return;
    const map = new maplibregl.Map({
      container: ref.current,
      style: {
        version: 8,
        sources: {
          'esri-sat': {
            type: 'raster',
            tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
            tileSize: 256,
          },
        },
        layers: [
          { id: 'bg',        type: 'background', paint: { 'background-color': '#001129' } },
          { id: 'satellite', type: 'raster',     source: 'esri-sat', paint: { 'raster-opacity': 1.0 } },
        ],
      },
      center: [(window.IP_COORD.lng + window.STAGING_COORD.lng)/2, (window.IP_COORD.lat + window.STAGING_COORD.lat)/2],
      zoom: 7.4,
      attributionControl: false,
      interactive: false,
      pitchWithRotate: false,
    });
    mapRef.current = map;

    // Make sure the map paints once the container has real layout dimensions
    // — the modal renders the container at zero size for one tick before the
    // flex parent gives it width/height.
    let raf = 0;
    const ensureSize = () => {
      try { map.resize(); } catch (e) {}
      raf = requestAnimationFrame(ensureSize);
    };
    raf = requestAnimationFrame(ensureSize);
    setTimeout(() => { cancelAnimationFrame(raf); }, 1500); // 1.5 s is plenty

    // Staging marker — small accent dot
    const stEl = document.createElement('div');
    stEl.style.cssText = `width:9px;height:9px;border-radius:50%;background:${C.accent};border:1.5px solid #ffffff;box-shadow:0 0 0 1px rgba(0,0,0,0.5);`;
    new maplibregl.Marker({ element: stEl, anchor: 'center' })
      .setLngLat([window.STAGING_COORD.lng, window.STAGING_COORD.lat]).addTo(map);
    // IP marker — pulsing critical ring + dot, with label
    const ipEl = document.createElement('div');
    ipEl.style.cssText = 'position:relative;width:0;height:0;';
    ipEl.innerHTML = `
      <div style="position:absolute;left:-12px;top:-12px;width:24px;height:24px;border-radius:50%;border:1.5px solid ${C.critical};box-shadow:0 0 12px ${C.critical}aa;animation:pulseFade 1.1s infinite;"></div>
      <div style="position:absolute;left:-5px;top:-5px;width:10px;height:10px;border-radius:50%;background:${C.critical};border:1.5px solid #ffffff;"></div>
      <div style="position:absolute;left:14px;top:-9px;font-family:'Roboto Mono',monospace;font-size:9px;font-weight:600;color:${C.critical};text-shadow:0 0 4px rgba(0,0,0,0.85),0 0 4px rgba(0,0,0,0.85);white-space:nowrap;">IP · DUKE 31</div>
    `;
    new maplibregl.Marker({ element: ipEl, anchor: 'center' })
      .setLngLat([window.IP_COORD.lng, window.IP_COORD.lat]).addTo(map);

    return () => {
      cancelAnimationFrame(raf);
      try { map.remove(); } catch (e) {}
      mapRef.current = null;
    };
  }, []);
  return <div ref={ref} style={{ position:'absolute', inset:0 }} />;
}

// ── J3.1 Acknowledge overlay ──────────────────────────────────
function AcknowledgeOverlay({ visible, onAck }) {
  if (!visible) return null;
  return (
    <div style={{ position:'fixed',inset:0,zIndex:300,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center',animation:'fadeIn 0.3s ease' }}>
      <div style={{ background:C.s2,border:`2px solid ${C.critical}`,borderRadius:6,width:920,height:480,maxWidth:'96vw',display:'flex',overflow:'hidden' }}>
        {/* Left — text + acknowledge */}
        <div style={{ flex:1, padding:'28px 28px', textAlign:'center', display:'flex', flexDirection:'column', justifyContent:'center', alignItems:'center' }}>
          <div style={{ display:'flex',alignItems:'center',justifyContent:'center',gap:10,marginBottom:14 }}>
            <div style={{ width:10,height:10,borderRadius:'50%',background:C.critical,animation:'pulseFade 0.5s infinite' }} />
            <div style={{ fontFamily:'Roboto Mono',fontWeight:700,fontSize:11,color:C.critical,letterSpacing:'0.14em' }}>EMERGENCY TRANSMISSION RECEIVED</div>
          </div>
          <div style={{ fontFamily:'Roboto, sans-serif',fontWeight:700,fontSize:26,color:C.critical,letterSpacing:'0.04em',marginBottom:10 }}>J3.1 RECEIVED</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:10,color:C.fg3,marginBottom:6,letterSpacing:'0.06em' }}>CONFIRMED BY BMT ST (JTBD-01) · 281430Z</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:13,color:C.fg1,marginBottom:4 }}>DN ACFT · DUKE 31 · T-2804-01</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:10.5,color:C.fg2,marginBottom:14 }}>ALPHA: 51RTH 8451 1234<br/>BRAVO: ~750M NE (UNCONFIRMED)</div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:10,color:C.normal,marginBottom:20 }}>DUKE 32: TWO GOOD CHUTES<br/>BOTH CREW AMBULATORY · ORBITING IP</div>
          <DTButton onClick={onAck} color="critical" size="large" style={{ borderRadius:4, padding:'12px 32px' }}>
            Acknowledge — enter CSAR master mode
          </DTButton>
          <div style={{ marginTop:12,fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3 }}>CASE PR-2804-001 · AWAITING OPERATOR CONFIRMATION</div>
        </div>
        {/* Right — satellite mini-map locator */}
        <div style={{ flex:1, position:'relative', borderLeft:`1px solid ${C.critical}55`, background:'#001129' }}>
          <J31MiniMap/>
        </div>
      </div>
    </div>
  );
}

// ── OSC Drawer ────────────────────────────────────────────────
function OSCDrawer({ open, onClose, onAppoint, appointedId }) {
  const [sel, setSel] = React.useState(appointedId||'bf6');
  if (!open) return null;
  return (
    <div style={{ position:'fixed',inset:0,zIndex:200,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:680,maxWidth:'93vw',maxHeight:'82vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>OSC Appointment — AMA-16 Candidate Shortlist</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>JTBD-03 · Deadline T+2:00 · AFTTP 4.19.4.2 · DUKE 32 on scene and recommended</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ overflowX:'auto',flex:1 }}>
          <table style={{ width:'100%',borderCollapse:'collapse' }}>
            <thead>
              <tr style={{ background:C.s3 }}>
                <th style={{ width:30 }} />
                {(() => {
                  // SME-REVIEW: tooltip needs verification
                  const tipMap = {
                    'CALL SIGN': 'CALL SIGN — Aircraft callsign.',
                    'TYPE': 'TYPE — Aircraft type/model.',
                    'STATUS': 'STATUS — Availability or current task (ON STATION, RTB, BINGO, etc.).',
                    'PLAYTIME': 'PLAYTIME — Remaining on-station time before bingo / RTB.',
                    'FUEL': 'FUEL — Percent fuel remaining.',
                    'SENSOR': 'SENSOR — Sensor fit (FLIR / EO / SAR / etc.).',
                    'WEAPONS': 'WEAPONS — Weapons currently carried.',
                    'DROGUE': 'DROGUE — Drogue-equipped for air-to-air refueling.',
                    'SANDY': 'SANDY — Sandy-qualified (CSAR escort role).',
                  };
                  return ['CALL SIGN','TYPE','STATUS','PLAYTIME','FUEL','SENSOR','WEAPONS','DROGUE','SANDY'].map(h=>(
                    <th key={h} title={tipMap[h]} style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,fontWeight:500,letterSpacing:'0.1em',padding:'6px 9px',textAlign:'left',borderBottom:`1px solid ${C.border}` }}>{h}</th>
                  ));
                })()}
              </tr>
            </thead>
            <tbody>
              {window.OSC_CANDIDATES.map((a,i)=>{
                const isSel = sel===a.id;
                return (
                  <tr key={a.id} onClick={()=>setSel(a.id)} style={{ background:isSel?C.accentM:i===0?`rgba(110,209,255,0.06)`:'transparent',cursor:'pointer',borderBottom:`1px solid ${C.border}`,transition:'background 0.15s' }}>
                    <td style={{ padding:'8px 9px',textAlign:'center' }}>
                      <div style={{ width:12,height:12,borderRadius:'50%',border:`2px solid ${isSel?C.accent:C.border2}`,background:isSel?C.accent:'transparent',margin:'auto' }} />
                    </td>
                    <td style={{ padding:'8px 9px' }}>
                      <div style={{ display:'flex',alignItems:'center',gap:5 }}>
                        <span style={{ fontFamily:'Roboto Mono',fontWeight:500,fontSize:10.5,color:isSel?C.accent:C.fg1 }}>{a.cs}</span>
                        {i===0 && <span style={{ fontFamily:'Roboto Mono',fontSize:7,color:C.accent2,background:C.accent2M,padding:'1px 4px',borderRadius:2 }}>ON SCENE</span>}
                      </div>
                    </td>
                    {[a.type, a.note||'AVAILABLE', a.play, `${a.fuel}%`, a.sensor, a.weapons, a.drogue?'✓':'—', a.sandy?'✓':'—'].map((v,vi)=>(
                      <td key={vi} style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:v==='✓'?C.normal:v==='—'?C.fg3:C.fg2,padding:'8px 9px' }}>{v}</td>
                    ))}
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>{sel?`Selected: ${window.OSC_CANDIDATES.find(a=>a.id===sel)?.cs}`:'Select a candidate'}</span>
          <DTButton onClick={onClose} title="Cancel without appointing." color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton disabled={!sel} onClick={()=>{onAppoint(sel);onClose();}} title="Appoint the selected aircraft as On-Scene Commander (OSC). Hands them tactical authority for the recovery." color="critical" size="small" style={{ borderRadius:3 }}>Appoint OSC</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Toast (DT critical / complete styling) + ToastStack ──────
// Toasts come in two flavors: `critical` (AMA pre-deadline escalations) and
// `complete` (action-card satisfied confirmations). Critical toasts linger for
// 30s; complete toasts auto-dismiss after 5s — short enough to feel out of
// the way, long enough to read. Hovering pauses the timer; fresh `ttl` starts
// on mouseleave so the operator gets the full window to read once they roll
// out. A `requiresAck:true` flag (used for POPCORN — the survivors-aboard
// brevity code) disables the timer entirely and makes the operator press
// ACKNOWLEDGE to clear; the toast also picks up an explicit ack button.
function Toast({ toast, onDismiss, onAutoDismiss }) {
  // Two dismissal paths now: `onDismiss` (operator hits the ✕ — toast just
  // disappears) and `onAutoDismiss` (TTL elapsed — toast demotes into the
  // Alerts feed so the operator can read it later). Falls back to onDismiss
  // if no auto handler was passed.
  const onDismissRef = React.useRef(onDismiss);
  onDismissRef.current = onDismiss;
  const onAutoRef = React.useRef(onAutoDismiss || onDismiss);
  onAutoRef.current = onAutoDismiss || onDismiss;
  const level = toast.level || 'critical';
  const requiresAck = !!toast.requiresAck;
  // Complete and advisory toasts auto-dismiss in 5 s (positive/notable).
  // Critical toasts linger 30 s — they demand attention.
  const ttl = (level === 'complete' || level === 'advisory') ? 5000 : 30000;
  const [paused, setPaused] = React.useState(false);
  React.useEffect(() => {
    if (requiresAck) return; // ack-required toasts never auto-dismiss
    if (paused) return; // hover-pause: don't arm the timer while pointer is over
    const t = setTimeout(() => onAutoRef.current(toast.id), ttl);
    return () => clearTimeout(t);
  }, [toast.id, ttl, paused, requiresAck]);

  // Three palettes: complete (green confirm), advisory (amber, informational
  // but notable), and critical (red, demand attention). Advisory uses the
  // same auto-dismiss TTL as complete since it's a positive/neutral notice.
  // Toast elevation is a single neutral drop shadow regardless of level —
  // colored glows (per-level red/amber/green halos) were retired in favour
  // of relying on the rail colour + body fill + label chip to convey
  // urgency. Drop shadow stays so toasts lift cleanly off the surface
  // beneath them (alerts panel or top-bar overlay).
  const dropShadow = '0 4px 12px rgba(0,0,0,0.4)';
  const palette = level === 'complete'
    ? { bg:'#10231a', border:'#2a7a44', rail:C.normal,   label:'COMPLETE', labelColor:C.normal,   shadow:dropShadow, titleColor:C.fg1,    bodyColor:C.fg2 }
    : level === 'advisory'
    ? { bg:'#241a08', border:'#7a5410', rail:C.advisory, label:'ADVISORY', labelColor:C.advisory, shadow:dropShadow, titleColor:C.fg1,    bodyColor:C.fg2 }
    : { bg:'#ff2e27', border:'#d40b04', rail:'#ffffff',  label:'CRITICAL', labelColor:'#ffffff',  shadow:dropShadow, titleColor:'#ffffff', bodyColor:'rgba(255,255,255,0.92)' };

  return (
    <div
      role="alert"
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      style={{
      background: palette.bg,
      border: `1px solid ${palette.border}`,
      borderLeft: `4px solid ${palette.rail}`,
      borderRadius: 4,
      padding: '9px 11px',
      boxShadow: palette.shadow,
      // Width is controlled by the parent stack — the toast itself just
      // fills its slot. ToastStack pins the slot to the alerts-panel width
      // (340px) for above/inline modes, and to a centered ~440px slot for
      // the top-bar mode. This way toasts and alerts never have a width
      // mismatch where they sit one above the other.
      width: '100%',
      boxSizing: 'border-box',
      animation: level === 'critical' ? 'pulseFade 0.7s 2' : 'fadeIn 0.2s ease-out',
      display: 'flex', alignItems: 'flex-start', gap: 10,
    }}>
      <div style={{ flex:1, minWidth:0 }}>
        <div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:3 }}>
          <span style={{ fontFamily:'Roboto Mono', fontWeight:700, fontSize:8.5, color:palette.labelColor, letterSpacing:'0.12em' }}>{palette.label}</span>
          {toast.ref && <span style={{ fontFamily:'Roboto Mono', fontSize:8, color: level==='critical'?'rgba(255,255,255,0.75)':C.fg3, letterSpacing:'0.08em' }}>{toast.ref}</span>}
        </div>
        <div style={{ fontFamily:'Roboto', fontWeight:600, fontSize:11.5, color:palette.titleColor, lineHeight:1.3, marginBottom:toast.body?3:0 }}>{toast.title}</div>
        {toast.body && <div style={{ fontFamily:'Roboto', fontSize:10.5, color:palette.bodyColor, lineHeight:1.45 }}>{toast.body}</div>}
      </div>
      {requiresAck ? (
        // Explicit acknowledge button — for POPCORN and any future toast that
        // demands operator acknowledgement before clearing. Styled to match
        // the green palette since the toast itself is already styled by level.
        <button
          onClick={() => onDismiss(toast.id)}
          style={{
            background: C.normal, border:`1px solid ${C.normal}`, color:'#000',
            fontFamily:'Roboto Mono', fontWeight:700, fontSize:9.5, letterSpacing:'0.12em',
            padding:'5px 11px', borderRadius:3, cursor:'pointer', flexShrink:0,
            alignSelf:'center',
          }}>
          ACKNOWLEDGE
        </button>
      ) : (
        <button onClick={() => onDismiss(toast.id)} style={{ background:'transparent', border:'none', color: level==='critical'?'#ffffff':C.fg2, fontSize:14, cursor:'pointer', opacity:0.85, padding:'0 2px', flexShrink:0 }}>✕</button>
      )}
    </div>
  );
}

// Toast positioning has three modes — see comments inline at the call site.
// `mode` selects which one to render in:
//   'top'       — fixed at the top of the viewport (used when the action
//                 queue is expanded and the bottom-left alerts tray is
//                 hidden behind the queue's columns).
//   'above'     — absolute above the FloatingAlerts panel; used when the
//                 alerts panel is COLLAPSED (and queue not expanded).
//   'inline'    — embedded INSIDE the alerts panel via the `headerExtras`
//                 slot. ToastStack returns the children directly (no
//                 wrapping fixed-position container) when called this way.
function ToastStack({ toasts, onDismiss, onAutoDismiss, suppressed=false, mode='above', isCsar=false, onReturn=null }) {
  if (!toasts.length) return null;
  // When a modal is open we slide the toast layer behind the modal overlays
  // (modal range 200–219) instead of hiding the toasts entirely. They stack
  // in the background and resurface on modal close — keeps the operator from
  // missing a comm-complete confirmation while they're working a draft.
  const z = suppressed ? 150 : 500;
  // Wrap each toast in a fixed-width slot so its width matches whatever
  // container we're rendering in. Alerts panel is 340 px wide including
  // its 1 px borders (see FloatingAlerts) — so when toasts render inline
  // inside the panel they need to be 2 px narrower to clear the inner
  // border on each side; otherwise the toast's right edge / box-shadow
  // gets clipped by the panel's `overflow:hidden`. `above` mode keeps
  // the full 340 so it lines up flush with the panel header below it.
  // Top-bar mode gets a slightly wider slot since it has more horizontal
  // real estate to play with.
  const slotWidth = mode === 'global' ? 460
                  : mode === 'top'    ? 440
                  : mode === 'inline' ? 338 : 340;
  // `onReturn` is set whenever the toast is a cross-mode burn-through
  // (operator is OUT of CSAR with a session live). Drives both the
  // "CSAR · BURN-THROUGH" prefix chip AND the click-to-return cursor +
  // handler — regardless of whether the placement is 'global' (top
  // center, when no map is visible) or 'above' (lower-left, when the
  // COP map is visible). Earlier build coupled this to `mode === 'global'`
  // which broke when we split cross-mode placement between map-visible
  // and map-hidden workspaces.
  const isBurnThrough = !!onReturn;
  const items = toasts.map(t => (
    <div key={t.id} style={{ pointerEvents: suppressed ? 'none' : 'auto', width: slotWidth, boxSizing:'border-box',
      cursor: isBurnThrough ? 'pointer' : 'default' }}
      onClick={isBurnThrough ? () => onReturn() : undefined}
      title={isBurnThrough ? 'Click to return to CSAR' : undefined}>
      {/* Burn-through toasts get a "CSAR ·" prefix chip so the operator
          immediately sees these are burn-throughs from another mode, not
          alerts native to whatever they're currently looking at. */}
      {isBurnThrough && (
        <div style={{ marginBottom:2, display:'flex', alignItems:'center', gap:6 }}>
          <span style={{ background:C.criticalM, color:C.critical, padding:'1px 6px', borderRadius:2,
            fontFamily:'Roboto Mono', fontSize:8.5, fontWeight:600, letterSpacing:'0.12em' }}>
            CSAR
          </span>
          <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.fg3, letterSpacing:'0.08em' }}>
            BURN-THROUGH · CLICK TO RETURN
          </span>
        </div>
      )}
      <Toast toast={t} onDismiss={onDismiss} onAutoDismiss={onAutoDismiss} />
    </div>
  ));
  if (mode === 'inline') {
    // Render inside the alerts panel header — no positioning chrome of our
    // own. Stack vertically with a small gap; the panel's own scroll/border
    // handles the rest. Width is set to match the panel exactly so the
    // toast lines up flush, no padding gutter that'd cause the visible
    // mismatch.
    return (
      <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
        {items}
      </div>
    );
  }
  if (mode === 'top') {
    // Centered at the very top of the viewport, slotted into the 46 px
    // top bar between Case Clock and ALL SYS. Used when the action queue
    // is expanded and the bottom-left alerts tray would be obscured.
    return (
      <div style={{
        position:'fixed', top:0, left:0, right:0,
        zIndex:z, display:'flex', flexDirection:'column',
        alignItems:'center', gap:6,
        paddingTop:6, paddingBottom:6,
        pointerEvents:'none',
      }}>
        {items}
      </div>
    );
  }
  if (mode === 'global') {
    // Cross-mode burn-through: critical CSAR toasts surface centered at
    // top of the viewport regardless of which master mode is active. Sits
    // above stub-mode overlays (z=120) but below modals (z=200+). Each
    // toast is clickable — click returns the operator to CSAR.
    return (
      <div style={{
        position:'fixed', top:54, left:0, right:0,
        zIndex:Math.max(z, 180), display:'flex', flexDirection:'column',
        alignItems:'center', gap:8,
        pointerEvents:'none',
      }}>
        {items}
      </div>
    );
  }
  // Default: 'above' — stack just above the FloatingAlerts panel. The
  // airspace toolbar (44 px wide) is now persistent across every master
  // mode, so the alerts panel and its toast stack always clear it
  // (left:56 = 44 toolbar + 12 gutter).
  const leftOffset = 56;
  return (
    <div style={{
      position:'fixed', left:leftOffset, bottom:64,
      zIndex:z, display:'flex', flexDirection:'column-reverse',
      alignItems:'flex-start', gap:6,
      pointerEvents:'none',
    }}>
      {items}
    </div>
  );
}

// ── JPRC Notification Draft Modal ─────────────────────────────
// Split layout: editable draft on the left, read-only ISOPREP (PERS-4421)
// on the right with per-crewmember tabs. Each pane scrolls independently.
function JPRCDraftModal({ open, draft, onChange, onClose, onSend, isoprep }) {
  const [tab, setTab] = React.useState(0);
  if (!open) return null;
  const setField = (i, v) => onChange({ ...draft, body: draft.body.map((r,ix)=>ix===i?{...r,v}:r) });
  const setSubject = v => onChange({ ...draft, subject: v });
  const inputStyle = {
    flex:1, background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
    color:C.fg1, fontFamily:'Roboto Mono', fontSize:10, padding:'4px 7px', outline:'none', minWidth:0,
  };
  const rec = isoprep.records[tab];
  const Row = ({ l, v }) => (
    <div style={{ display:'flex',gap:8,marginBottom:3 }}>
      <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:108,flexShrink:0,letterSpacing:'0.04em' }}>{l}</span>
      <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1,wordBreak:'break-word' }}>{v}</span>
    </div>
  );
  const SectionLabel = ({ children }) => (
    <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>{children}</div>
  );
  return (
    <div style={{ position:'fixed',inset:0,zIndex:210,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:1100,maxWidth:'96vw',height:'88vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        {/* Header */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>JPRC Notification Draft — with attached ISOPREP ({isoprep.ref})</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-15 · JTBD-02 · AFTTP 4.19.4.2 · Edit draft fields if needed, review ISOPREP, then CONFIRM &amp; SEND</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        {/* Split body */}
        <div style={{ flex:1,display:'flex',minHeight:0 }}>
          {/* Left pane — Draft */}
          <div style={{ flex:'1 1 0',minWidth:0,display:'flex',flexDirection:'column',borderRight:`1px solid ${C.border}` }}>
            <div style={{ padding:'7px 14px',borderBottom:`1px solid ${C.border}`,background:C.s3 }}>
              <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8.5,fontWeight:500,color:C.fg2,letterSpacing:'0.11em',textTransform:'uppercase' }}>Notification Draft</span>
            </div>
            <div style={{ overflowY:'auto',flex:1,padding:'12px 14px' }}>
              <div style={{ display:'grid',gridTemplateColumns:'110px 1fr',gap:'5px 10px',padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
                {[
                  ['PRECEDENCE',   draft.precedence],
                  ['CLASSIFICATION',draft.classification],
                  ['FROM',         draft.originator],
                  ['TO',           draft.addressee],
                  ['DTG',          draft.dtg],
                ].map(([l,v])=>(
                  <React.Fragment key={l}>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,letterSpacing:'0.06em' }}>{l}</span>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:l==='CLASSIFICATION'?C.advisory:C.fg1 }}>{v}</span>
                  </React.Fragment>
                ))}
              </div>
              <div style={{ display:'flex',alignItems:'center',gap:10,marginBottom:10 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:100,flexShrink:0,letterSpacing:'0.06em' }}>SUBJECT</span>
                <input value={draft.subject} onChange={e=>setSubject(e.target.value)} style={inputStyle} />
              </div>
              <SectionLabel>BODY</SectionLabel>
              <div style={{ display:'flex',flexDirection:'column',gap:6 }}>
                {draft.body.map((r,i)=>(
                  <div key={r.l} style={{ display:'flex',alignItems:'center',gap:10 }}>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:100,flexShrink:0,letterSpacing:'0.06em' }}>{r.l}</span>
                    <input value={r.v} onChange={e=>setField(i,e.target.value)} style={inputStyle} />
                  </div>
                ))}
              </div>
            </div>
          </div>
          {/* Right pane — ISOPREP */}
          <div style={{ flex:'1 1 0',minWidth:0,display:'flex',flexDirection:'column',background:C.s1 }}>
            <div style={{ padding:'7px 14px',borderBottom:`1px solid ${C.border}`,background:C.s3,display:'flex',alignItems:'center',gap:8 }}>
              <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8.5,fontWeight:500,color:C.fg2,letterSpacing:'0.11em',textTransform:'uppercase',flex:1 }}>ISOPREP · {isoprep.ref}</span>
              <span style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3 }}>AMA-04 · {isoprep.records.length} records · read-only</span>
            </div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,fontWeight:500,color:C.advisory,background:C.advisoryM,textAlign:'center',padding:'3px 0',letterSpacing:'0.12em' }}>
              {isoprep.classification}
            </div>
            <div style={{ display:'flex',borderBottom:`1px solid ${C.border}`,background:C.s2 }}>
              {isoprep.records.map((r,i)=>(
                <button key={r.callSign} onClick={()=>setTab(i)} style={{
                  flex:1,padding:'7px 10px',background:tab===i?C.s1:'transparent',border:'none',
                  borderBottom:`2px solid ${tab===i?C.accent:'transparent'}`,
                  color:tab===i?C.accent:C.fg3,fontFamily:'Roboto Mono',fontSize:10,fontWeight:500,letterSpacing:'0.06em',cursor:'pointer',
                }}>{r.callSign}</button>
              ))}
            </div>
            <div style={{ overflowY:'auto',flex:1,padding:'12px 14px' }}>
              <SectionLabel>IDENTITY</SectionLabel>
              <div style={{ padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
                <Row l="CALL SIGN"  v={rec.callSign} />
                <Row l="POSITION"   v={rec.position} />
                <Row l="NAME"       v={rec.name} />
                <Row l="UNIT"       v={rec.unit} />
                <Row l="SSN"        v={rec.ssn} />
                <Row l="DOB"        v={rec.dob} />
                <Row l="BLOOD TYPE" v={rec.bloodType} />
                <Row l="HT / WT"    v={`${rec.height} / ${rec.weight}`} />
                <Row l="HAIR / EYE" v={rec.hairEye} />
              </div>
              <SectionLabel>AUTHENTICATION</SectionLabel>
              <div style={{ padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
                {rec.authCodes.map(c=><Row key={c.l} l={c.l} v={c.v} />)}
                <div style={{ height:1,background:C.border,margin:'6px 0' }} />
                {rec.authQA.map(q=><Row key={q.q} l={q.q} v={q.a} />)}
              </div>
              <SectionLabel>EQUIPMENT</SectionLabel>
              <div style={{ padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
                {rec.equipment.map(e=>(
                  <div key={e} style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1,padding:'2px 0' }}>· {e}</div>
                ))}
              </div>
              <SectionLabel>MEDICAL</SectionLabel>
              <div style={{ padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:4 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1 }}>{rec.medical}</span>
              </div>
            </div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,fontWeight:500,color:C.advisory,background:C.advisoryM,textAlign:'center',padding:'3px 0',letterSpacing:'0.12em' }}>
              {isoprep.classification}
            </div>
          </div>
        </div>
        {/* Footer */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Transmitting to JPRC · originator {draft.originator} · {isoprep.ref} attached</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSend} color="critical" size="small" style={{ borderRadius:3 }}>Confirm &amp; send</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── ISOP Authentication Modal (AMA-04 · JTBD-05) ─────────────
// Tabs per crewmember. BRAVO tab disabled until comms exist. Operator picks
// a method, sends challenge, scripted RX fires, operator confirms match.
function ISOPAuthModal({ open, onClose, onSubmit, isoprep, methods, bravoContact=false, initialTab=0 }) {
  const [tab, setTab] = React.useState(initialTab);   // 0 = ALPHA, 1 = BRAVO
  const [methodId, setMethodId] = React.useState(null);
  const [challenge, setChallenge] = React.useState('');
  const [response, setResponse] = React.useState(null); // {text, correct}
  const [sending, setSending] = React.useState(false);
  const [hoveredMethod, setHoveredMethod] = React.useState(null);
  React.useEffect(()=>{
    if (!open) return;
    setTab(initialTab); setMethodId(null); setChallenge('');
    setResponse(null); setSending(false); setHoveredMethod(null);
  },[open, initialTab]);
  if (!open) return null;
  const method = methods.find(m=>m.id===methodId);
  const rec = isoprep.records[tab];
  const pickMethod = (id) => {
    const m = methods.find(x=>x.id===id);
    setMethodId(id); setChallenge(m.challenge); setResponse(null); setSending(false);
  };
  const send = () => {
    if (!method) return;
    setSending(true);
    setTimeout(()=>{
      setSending(false);
      setResponse({ text: `${rec.callSign}: ${method.correct}`, correct:true });
    }, 1400);
  };
  const labelStyle = { fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,letterSpacing:'0.06em' };
  return (
    <div style={{ position:'fixed',inset:0,zIndex:215,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:720,maxWidth:'94vw',maxHeight:'86vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        {/* Header */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>Authenticate ISOP</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-04 · JTBD-05 · Challenge / response via ISOPREP {isoprep.ref}</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        {/* Tab strip */}
        <div style={{ display:'flex',borderBottom:`1px solid ${C.border}`,background:C.s3 }}>
          {isoprep.records.map((r,i)=>{
            const disabled = i===1 && !bravoContact;
            return (
              <button key={r.callSign} onClick={()=>!disabled && setTab(i)} disabled={disabled} style={{
                flex:1,padding:'7px 10px',background:tab===i?C.s2:'transparent',border:'none',
                borderBottom:`2px solid ${tab===i?C.accent:'transparent'}`,
                color:disabled?C.fg4:(tab===i?C.accent:C.fg3),fontFamily:'Roboto Mono',fontSize:10,fontWeight:500,letterSpacing:'0.06em',
                cursor:disabled?'not-allowed':'pointer', opacity:disabled?0.7:1,
              }}>
                {r.callSign}{disabled && ' · AWAITING CONTACT'}
              </button>
            );
          })}
        </div>
        {/* Body */}
        <div style={{ overflowY:'auto',flex:1,padding:'12px 14px' }}>
          {/* Subject */}
          <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>SUBJECT</div>
          <div style={{ padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
            <div style={{ display:'flex',gap:8,marginBottom:3 }}><span style={{ ...labelStyle,width:100,flexShrink:0 }}>CALL SIGN</span><span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1 }}>{rec.callSign}</span></div>
            <div style={{ display:'flex',gap:8,marginBottom:3 }}><span style={{ ...labelStyle,width:100,flexShrink:0 }}>NAME</span><span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1 }}>{rec.name}</span></div>
            <div style={{ display:'flex',gap:8,marginBottom:3 }}><span style={{ ...labelStyle,width:100,flexShrink:0 }}>BEACON</span><span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:C.fg1 }}>{rec.equipment[0]}</span></div>
          </div>
          {/* Method picker */}
          <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>Select Method</div>
          <div style={{ display:'grid',gridTemplateColumns:'repeat(4, 1fr)',gap:6,marginBottom:11 }}>
            {methods.map(m=>{
              const sel = methodId===m.id;
              const hov = hoveredMethod===m.id;
              const bg     = sel ? C.accentM : (hov ? C.s4 : C.s3);
              const border = sel ? C.accent  : (hov ? C.fg3 : C.border2);
              const labelColor = sel ? C.accent : (hov ? '#ffffff' : C.fg1);
              return (
                <button key={m.id}
                  onClick={()=>pickMethod(m.id)}
                  onMouseEnter={()=>setHoveredMethod(m.id)}
                  onMouseLeave={()=>setHoveredMethod(null)}
                  style={{
                    textAlign:'left',padding:'6px 9px',background:bg,
                    border:`1px solid ${border}`,borderRadius:3,cursor:'pointer',
                    transition:'background 0.15s, border-color 0.15s, color 0.15s',
                  }}>
                  <div style={{ fontFamily:'Roboto Mono',fontSize:10,fontWeight:500,color:labelColor,letterSpacing:'0.06em' }}>{m.label}</div>
                  <div style={{ fontFamily:'Roboto',fontSize:9.5,color:C.fg3,marginTop:2,lineHeight:1.35 }}>{m.desc}</div>
                </button>
              );
            })}
          </div>
          {/* Challenge + send */}
          <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>CHALLENGE (TX)</div>
          <textarea value={challenge} onChange={e=>setChallenge(e.target.value)} rows={2} style={{
            width:'100%',background:C.s2,border:`1px solid ${C.border2}`,borderRadius:3,
            color:C.fg1,fontFamily:'Roboto Mono',fontSize:10,padding:'6px 8px',outline:'none',resize:'vertical',marginBottom:8,
          }} />
          <div style={{ display:'flex',alignItems:'center',gap:9,marginBottom:12 }}>
            <DTButton onClick={send} disabled={!method||sending||!!response} color="accent" size="small" style={{ borderRadius:3 }}>
              {sending ? 'Transmitting…' : response ? 'Sent' : 'Send challenge'}
            </DTButton>
            {sending && <span style={{ fontFamily:'Roboto Mono',fontSize:9,color:C.accent }}>awaiting response…</span>}
          </div>
          {/* Response */}
          {response && (
            <>
              <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>RESPONSE (RX)</div>
              <div style={{ padding:'7px 10px',background:C.normalM,border:`1px solid ${C.normal}55`,borderRadius:3,marginBottom:10,display:'flex',alignItems:'center',gap:9 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:14,color:C.normal }}>✓</span>
                <div style={{ flex:1 }}>
                  <div style={{ fontFamily:'Roboto Mono',fontSize:10,color:C.fg1,lineHeight:1.4 }}>{response.text}</div>
                  <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.normal,marginTop:2,letterSpacing:'0.08em' }}>IDENTITY MATCH — per ISOPREP record {isoprep.ref}</div>
                </div>
              </div>
            </>
          )}
        </div>
        {/* Footer */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>
            {response ? 'Identity verified · confirm to log authentication' : 'Pick a method and send the challenge'}
          </span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton
            onClick={()=>onSubmit(tab===0?'alpha':'bravo')}
            disabled={!response}
            color="critical" size="small"
            style={{ borderRadius:3 }}
          >Confirm authentication</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── COA Review Modal (AMA-50 · Phase 2 decision support) ─────
// Shows 3 COAs side-by-side with an SVG map preview across the top. Each card
// carries tradeoffs, air parity verdict, cross-role impact, and a Select button.
// SME-REVIEW: folding JTBD-30 air parity into per-COA tradeoffs is a UX call.
function COAReviewModal({ open, coas=[], onClose, onSubmit, breachActive, threats=[], ip, staging }) {
  const [hoverId, setHoverId] = React.useState(null);
  const [showAlts, setShowAlts] = React.useState(false);
  React.useEffect(()=>{ if (open) { setShowAlts(false); setHoverId(null); } },[open]);
  if (!open) return null;
  const top = coas[0];
  const dominant = !!(top && top.dominant);
  const visibleCoas = dominant && !showAlts ? [top] : coas;
  const alts = coas.slice(1);
  const active = hoverId || (visibleCoas[0] && visibleCoas[0].id);

  // Bounding box for preview SVG
  const pts = [
    ...(ip ? [ip] : []),
    ...(staging ? [staging] : []),
    ...threats.filter(t=>t.lat!=null).map(t=>({lat:t.lat, lng:t.lng})),
  ];
  const pad = 0.5;
  let minLat = 32, maxLat = 34, minLng = 47, maxLng = 50;
  if (pts.length) {
    minLat = Math.min(...pts.map(p=>p.lat)) - pad;
    maxLat = Math.max(...pts.map(p=>p.lat)) + pad;
    minLng = Math.min(...pts.map(p=>p.lng)) - pad;
    maxLng = Math.max(...pts.map(p=>p.lng)) + pad;
  }
  // SVG viewBox aspect tuned for the 75/25 layout — map column is ~3:2 tall
  // so threat rings + corridors get vertical room. Was 1040×260 (4:1) when
  // the map sat above the cards full-width; now sits beside them.
  const W = 1040, H = 540;
  // Project geographic → SVG. Flip Y (higher lat = smaller y).
  const proj = (lng, lat) => {
    const x = ((lng - minLng) / (maxLng - minLng)) * W;
    const y = (1 - (lat - minLat) / (maxLat - minLat)) * H;
    return [x, y];
  };
  // Geographic km per degree at the scene centre — for threat ring radii
  const midLatC = (minLat + maxLat) / 2;
  const latKmPerDeg = 111.32;
  const lngKmPerDeg = 111.32 * Math.cos(midLatC * Math.PI / 180);
  const kmToSvgX = km => (km / lngKmPerDeg) / (maxLng - minLng) * W;
  const kmToSvgY = km => (km / latKmPerDeg) / (maxLat - minLat) * H;

  const coaColors = { 'coa-a':'#6ed1ff', 'coa-b':'#fbab16', 'coa-c':'#ff7de0' };

  const polygonToPoints = (coord2d) => coord2d.map(([lng,lat])=>proj(lng,lat).join(',')).join(' ');
  const routeToPoints   = (waypoints) => (waypoints || []).map(p => proj(p.lng, p.lat).join(',')).join(' ');

  return (
    <div style={{ position:'fixed',inset:0,zIndex:212,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:1280,maxWidth:'96vw',maxHeight:'92vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        {/* Breach banner */}
        {breachActive && (
          <div style={{ background:C.criticalM, borderBottom:`1px solid ${C.critical}66`, padding:'6px 14px', display:'flex', alignItems:'center', gap:8 }}>
            <span style={{ width:6,height:6,borderRadius:'50%',background:C.critical,animation:'pulseFade 0.7s infinite' }}/>
            <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.critical, letterSpacing:'0.1em', fontWeight:500 }}>CORRIDOR BREACH — fresh COAs generated against updated threat picture</span>
          </div>
        )}
        {/* Header */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>Courses of Action</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-50 · JTBD-26 / 28 / 30 [SME-REVIEW] · {threats.length} active threats · Staging → IP ingress</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        {/* Body: 75/25 split — map preview left, COA cards stacked right.
            Both columns share a single horizontal padding band so the borders
            line up. The cards column scrolls independently if it overflows. */}
        <div style={{ display:'flex', gap:14, padding:'12px 14px', background:C.s1, flex:1, minHeight:0, overflow:'hidden' }}>
          <div style={{ flex:3, display:'flex', minWidth:0 }}>
            <svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet" style={{ display:'block', background:'#0a0e18', border:`1px solid ${C.border}`, borderRadius:4 }}>
            {/* Threat rings */}
            {threats.filter(t=>t.lat!=null).map(t=>{
              const [cx,cy] = proj(t.lng, t.lat);
              const rx = kmToSvgX(t.radiusKm), ry = kmToSvgY(t.radiusKm);
              return (
                <g key={t.id}>
                  <ellipse cx={cx} cy={cy} rx={rx} ry={ry} fill={C.critical} fillOpacity={0.06} stroke={C.critical} strokeOpacity={0.55} strokeWidth={1} strokeDasharray="4 3"/>
                  <rect x={cx-4} y={cy-4} width={8} height={8} fill={C.critical} transform={`rotate(45 ${cx} ${cy})`}/>
                  <text x={cx+7} y={cy-6} fontSize={9} fill={C.fg3} fontFamily="Roboto Mono">{t.label}</text>
                </g>
              );
            })}
            {/* Spider routes — ingress (solid) + egress (dashed) per COA.
                When both sides are bowed-direct (≤3 vertices = no real
                detour from threats/terrain), collapse to a single line so
                the picture isn't cluttered by two near-identical curves. */}
            {visibleCoas.map(coa=>{
              const color = coaColors[coa.id] || C.accent;
              const isActive = active === coa.id;
              const ingress = coa.ingressRoute;
              const egress  = coa.egressRoute;
              if (!ingress || !egress) return null;
              const opacity = isActive ? 1 : 0.45;
              const ingressW = isActive ? 2.4 : 1.4;
              const egressW  = isActive ? 1.8 : 1.0;
              const isStraight = ingress.length <= 3 && egress.length <= 3;
              if (isStraight) {
                // One direct line start→end — no bow — to represent the COA.
                const direct = [ingress[0], ingress[ingress.length - 1]];
                return (
                  <polyline key={coa.id}
                    points={routeToPoints(direct)}
                    fill="none"
                    stroke={color} strokeOpacity={opacity} strokeWidth={ingressW}
                  />
                );
              }
              return (
                <g key={coa.id}>
                  <polyline
                    points={routeToPoints(ingress)}
                    fill="none"
                    stroke={color} strokeOpacity={opacity} strokeWidth={ingressW}
                  />
                  <polyline
                    points={routeToPoints(egress)}
                    fill="none"
                    stroke={color} strokeOpacity={opacity * 0.85} strokeWidth={egressW}
                    strokeDasharray="4 4"
                  />
                </g>
              );
            })}
            {/* Kill boxes — one per SEAD target. Only COA B (SEAD + DIRECT)
                carries any. Iterate the FeatureCollection so every threat
                being suppressed gets its own visible box. */}
            {visibleCoas.map(coa=>{
              if (!coa.killBox || !coa.killBox.features) return null;
              const color = coaColors[coa.id] || C.advisory;
              const isActive = active === coa.id;
              return (
                <g key={coa.id+'-kb'}>
                  {coa.killBox.features.map((f, i) => (
                    <polygon key={i}
                      points={polygonToPoints(f.geometry.coordinates[0])}
                      fill={color} fillOpacity={isActive ? 0.18 : 0.05}
                      stroke={color} strokeOpacity={isActive ? 0.9 : 0.3} strokeWidth={isActive ? 1.5 : 1}
                    />
                  ))}
                </g>
              );
            })}
            {/* IP + Staging markers */}
            {ip && (()=>{ const [x,y] = proj(ip.lng, ip.lat); return (
              <g>
                <circle cx={x} cy={y} r={6} fill="none" stroke={C.isopPink} strokeWidth={1.5}/>
                <circle cx={x} cy={y} r={3} fill={C.isopPink}/>
                <text x={x+9} y={y+4} fontSize={9} fill={C.isopPink} fontFamily="Roboto Mono">IP</text>
              </g>
            ); })()}
            {staging && (()=>{ const [x,y] = proj(staging.lng, staging.lat); return (
              <g>
                <circle cx={x} cy={y} r={4} fill={C.fg2}/>
                <text x={x+7} y={y+4} fontSize={9} fill={C.fg3} fontFamily="Roboto Mono">STAGING</text>
              </g>
            ); })()}
          </svg>
          </div>
          {/* COA cards column — 25% width, vertical stack with recommended on top */}
          {(()=>{
          const renderCard = (coa)=>{
            // Card chrome is neutral on both COAs — the green RECOMMENDED tag
            // (and a subtle green border accent on the recommended card) is
            // the only differentiator. Per-COA accent (cyan/amber) was hiding
            // the recommendation under colour noise. The map preview lines
            // still use coaColors so the operator can match a card to its
            // route on the SVG.
            const parityColor = coa.risk.airParity === 'GO' ? C.normal
                              : coa.risk.airParity === 'GO W/ CAVEATS' ? C.advisory
                              : C.critical;
            const isRec = !!coa.recommended;
            return (
              <div key={coa.id}
                onMouseEnter={()=>setHoverId(coa.id)}
                onMouseLeave={()=>setHoverId(null)}
                style={{ background:C.s3, border:`1px solid ${isRec ? C.normal+'88' : C.border2}`, borderRadius:4, padding:'10px 11px', display:'flex', flexDirection:'column', gap:7 }}>
                <div style={{ display:'flex', alignItems:'center', gap:6 }}>
                  <div style={{ fontFamily:'Roboto Mono', fontWeight:500, fontSize:11, color:C.fg1, letterSpacing:'0.05em', flex:1 }}>{coa.label}</div>
                  {isRec && (
                    <span style={{ fontFamily:'Roboto Mono', fontSize:7.5, color:C.normal, background:C.normalM, border:`1px solid ${C.normal}55`, padding:'2px 6px', borderRadius:2, letterSpacing:'0.12em', fontWeight:500 }}>RECOMMENDED</span>
                  )}
                </div>
                <div style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2, lineHeight:1.4 }}>{coa.concept}</div>
                {/* Risk chips */}
                <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:3, paddingTop:3 }}>
                  {[
                    { l:'EXPOSURE', v:`${coa.risk.threatExposure}%`, hi: coa.risk.threatExposure >= 25 ? C.critical : coa.risk.threatExposure >= 10 ? C.advisory : C.normal },
                    { l:'INGRESS', v:`${coa.risk.ingressTimeMin} min` },
                    { l:'FUEL',    v:coa.risk.fuelImpact.toUpperCase(), hi: coa.risk.fuelImpact==='bingo'?C.critical:coa.risk.fuelImpact==='tight'?C.advisory:C.fg2 },
                    { l:'DARK',    v:coa.risk.darknessFactor.toUpperCase(), hi: coa.risk.darknessFactor==='degraded'?C.advisory:C.fg2 },
                  ].map(r=>(
                    <div key={r.l} style={{ display:'flex', justifyContent:'space-between', padding:'2px 5px', background:C.s2, borderRadius:2, fontFamily:'Roboto Mono', fontSize:8 }}>
                      <span style={{ color:C.fg3 }}>{r.l}</span>
                      <span style={{ color:r.hi || C.fg1 }}>{r.v}</span>
                    </div>
                  ))}
                </div>
                {/* Air parity */}
                <div style={{ display:'flex', alignItems:'center', gap:6, padding:'4px 6px', background:parityColor+'14', borderRadius:2 }}>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em' }}>AIR PARITY</span>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:parityColor, fontWeight:500, letterSpacing:'0.06em', marginLeft:'auto' }}>{coa.risk.airParity}</span>
                </div>
                {/* Cross-role strip */}
                {coa.requiresSEAD && (
                  <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.advisory, padding:'3px 6px', background:C.advisoryM, borderRadius:2, letterSpacing:'0.06em' }}>
                    ⚠ {coa.risk.crossRole.toUpperCase()} · SEAD: {coa.seadTargets.map(t=>t.threatLabel).join(', ')}
                  </div>
                )}
                {/* Why-this */}
                <div style={{ fontFamily:'Roboto', fontSize:9.5, color:C.fg3, fontStyle:'italic', lineHeight:1.4, paddingTop:2 }}>{coa.whyThis}</div>
                <div style={{ flex:1 }} />
                <DTButton onClick={()=>onSubmit(coa.id)} color="accent" size="small" fullWidth style={{ borderRadius:3 }}>
                  Select {coa.label.split(' — ')[0]}
                </DTButton>
              </div>
            );
          };

          return (
            <div style={{ flex:1, minWidth:280, display:'flex', flexDirection:'column', gap:10, overflowY:'auto', minHeight:0 }}>
              {dominant && !showAlts && (
                <div style={{ display:'flex', alignItems:'flex-start', gap:8, padding:'6px 8px', background:C.normalM, border:`1px solid ${C.normal}33`, borderRadius:3, flexShrink:0 }}>
                  <span style={{ width:5, height:5, borderRadius:'50%', background:C.normal, marginTop:5, flexShrink:0 }}/>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.normal, letterSpacing:'0.08em', lineHeight:1.4 }}>DIRECT CORRIDOR CLEAR — AMA-50 recommends a single COA. Alternatives available if the picture is incomplete.</span>
                </div>
              )}
              {/* Stack cards vertically — recommended is already first in `coas` */}
              {visibleCoas.map(renderCard)}
              {dominant && alts.length > 0 && (
                <button onClick={()=>setShowAlts(v=>!v)} style={{
                  display:'flex', alignItems:'center', justifyContent:'center', gap:6,
                  width:'100%', background:'none', border:`1px dashed ${C.border2}`, borderRadius:3,
                  color:C.fg3, fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.08em',
                  padding:'7px 10px', cursor:'pointer', flexShrink:0,
                }}
                onMouseEnter={e=>{ e.currentTarget.style.color=C.fg2; e.currentTarget.style.borderColor=C.fg3; }}
                onMouseLeave={e=>{ e.currentTarget.style.color=C.fg3; e.currentTarget.style.borderColor=C.border2; }}
                >
                  {showAlts
                    ? '▴ Hide alternatives'
                    : `▾ Show ${alts.length} alternative${alts.length===1?'':'s'}`}
                </button>
              )}
            </div>
          );
        })()}
        {/* close the flex row wrapping the map + cards */}
        </div>
        {/* Footer */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Selecting a COA activates its corridor and sends any lateral coordination (SEAD tasking etc.).</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Phase 3: Launch Readiness Modal (AMA-51 · SME-REVIEW) ──
function LaunchReadinessModal({ open, onClose, onSubmit, readiness }) {
  if (!open || !readiness) return null;
  const { roster = [], corridor, environment, timing, verdict, rationale } = readiness;
  const verdictColor = verdict === 'GO' ? C.normal : verdict === 'GO W/ CAVEATS' ? C.advisory : C.critical;
  const statusChip = s => {
    const color = s === 'ready' ? C.normal : s === 'on-station' ? C.accent : s === 'damage' ? C.critical : C.advisory;
    return <span style={{ fontFamily:'Roboto Mono', fontSize:8, color, background:color+'1f', border:`1px solid ${color}55`, padding:'1px 6px', borderRadius:2, letterSpacing:'0.08em' }}>{s.toUpperCase()}</span>;
  };
  return (
    <div style={{ position:'fixed',inset:0,zIndex:213,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:840,maxWidth:'96vw',maxHeight:'92vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        {/* Header */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>CSARTF Launch Readiness</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-51 · JTBD-31 [SME-REVIEW] · roll-up of element status, corridor, and environment</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        {/* Body */}
        <div style={{ padding:'12px 14px', overflowY:'auto', display:'flex', flexDirection:'column', gap:12 }}>
          {/* CSARTF roster */}
          <div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>CSARTF ROSTER</div>
            <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderRadius:3, overflow:'hidden' }}>
              {roster.map((r,i)=>(
                <div key={r.cs} style={{ display:'grid', gridTemplateColumns:'100px 100px 1fr auto 90px 70px', alignItems:'center', gap:8, padding:'7px 10px', borderTop: i===0?'none':`1px solid ${C.border}` }}>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:10.5, color:C.fg1, fontWeight:500 }}>{r.cs}</span>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>{r.type}</span>
                  <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2 }}>{r.role}</span>
                  <span style={{ fontFamily:'Roboto', fontSize:9.5, color:C.fg3, fontStyle:'italic' }}>{r.note || ''}</span>
                  <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>FUEL {r.fuel}%</span>
                  {statusChip(r.status)}
                </div>
              ))}
            </div>
          </div>
          {/* Corridor + Environment + Timing */}
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:10 }}>
            {corridor && (
              <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderLeft:`4px solid ${C.accent}`, borderRadius:3, padding:'8px 10px' }}>
                <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:4 }}>CORRIDOR</div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.accent, marginBottom:4 }}>{corridor.label}</div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2, lineHeight:1.6 }}>
                  EXPOSURE {corridor.exposure}% · INGRESS {corridor.ingressMin} min · FUEL {corridor.fuelImpact.toUpperCase()}<br/>
                  AIR PARITY <span style={{ color: corridor.airParity==='GO'?C.normal:corridor.airParity==='GO W/ CAVEATS'?C.advisory:C.critical }}>{corridor.airParity}</span><br/>
                  SEAD {corridor.seadStatus.toUpperCase()}
                </div>
              </div>
            )}
            <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderLeft:`4px solid ${C.fg3}`, borderRadius:3, padding:'8px 10px' }}>
              <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:6 }}>ENVIRONMENT</div>
              {/* Sun + moon illumination bar — replaces the old darkness text
                  chip. Lower % is favourable for NOE/snorkel ingress; higher %
                  starts to degrade air parity. The fill colour follows the
                  same threshold the parity calc uses. */}
              <div style={{ marginBottom:6 }}>
                <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, marginBottom:3 }}>
                  <span>ILLUMINATION</span>
                  <span style={{ color: environment.illumination >= 60 ? C.advisory : environment.illumination >= 25 ? C.fg1 : C.normal }}>
                    {environment.illumination}% · {environment.darkness.toUpperCase()}
                  </span>
                </div>
                <div style={{ position:'relative', height:6, background:C.s4, borderRadius:3, overflow:'hidden' }}>
                  <div style={{
                    position:'absolute', left:0, top:0, bottom:0,
                    width:`${environment.illumination}%`,
                    background: environment.illumination >= 60 ? C.advisory
                              : environment.illumination >= 25 ? C.fg2
                              : C.normal,
                    transition:'width 0.4s ease, background 0.4s ease',
                  }}/>
                </div>
                <div style={{ display:'flex', justifyContent:'space-between', fontFamily:'Roboto Mono', fontSize:7, color:C.fg4, marginTop:2 }}>
                  <span>NIGHT · NOE-FRIENDLY</span>
                  <span>DAY · DEGRADED</span>
                </div>
              </div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2, lineHeight:1.6 }}>
                WEATHER {environment.weather}<br/>
                CEILING {environment.ceiling.toUpperCase()}
              </div>
            </div>
            <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderLeft:`4px solid ${C.fg3}`, borderRadius:3, padding:'8px 10px' }}>
              <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:4 }}>TIMING (EST.)</div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2, lineHeight:1.6 }}>
                INGRESS {timing.ingressMin} min<br/>
                ON-TARGET T+{timing.onTargetMin} min<br/>
                RTB T+{timing.rtbMin} min
              </div>
            </div>
          </div>
          {/* Verdict */}
          <div style={{ background:verdictColor+'14', border:`1px solid ${verdictColor}55`, borderRadius:3, padding:'10px 12px', display:'flex', alignItems:'center', gap:10 }}>
            <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.1em' }}>LAUNCH VERDICT</span>
            <span style={{ fontFamily:'Roboto Mono', fontSize:14, fontWeight:500, color:verdictColor, letterSpacing:'0.06em' }}>{verdict}</span>
            <span style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2, flex:1, fontStyle:'italic' }}>{rationale}</span>
          </div>
        </div>
        {/* Footer */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Launch commits CSARTF to the ingress corridor. Mission progress will be tracked on the COP.</span>
          <DTButton onClick={()=>onSubmit('no-go')} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>NO-GO (hold)</DTButton>
          <DTButton onClick={()=>onSubmit('go')} color="critical" size="small" style={{ borderRadius:3 }}>GO — launch CSARTF</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Phase 3: Divert decision modal (AMA-52 · SME-REVIEW) ──
function DivertModal({ open, onClose, onSubmit }) {
  if (!open) return null;
  const opt = (choice, title, body, color)=>(
    <button onClick={()=>onSubmit(choice)} style={{
      textAlign:'left', background:C.s3, border:`1px solid ${color}55`, borderLeft:`4px solid ${color}`, borderRadius:3,
      padding:'10px 12px', cursor:'pointer', color:C.fg1, fontFamily:'Roboto',
    }}
    onMouseEnter={e=>{ e.currentTarget.style.background = color+'12'; }}
    onMouseLeave={e=>{ e.currentTarget.style.background = C.s3; }}>
      <div style={{ fontFamily:'Roboto Mono', fontSize:10.5, color, fontWeight:500, letterSpacing:'0.05em', marginBottom:4 }}>{title}</div>
      <div style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2, lineHeight:1.4 }}>{body}</div>
    </button>
  );
  return (
    <div style={{ position:'fixed',inset:0,zIndex:214,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:540,maxWidth:'94vw',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>Divert Decision · JOLLY 62 Battle Damage</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-52 · JTBD-DIVERT [SME-REVIEW]</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:10 }}>
          <div style={{ background:C.criticalM, border:`1px solid ${C.critical}33`, borderRadius:3, padding:'8px 10px' }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color:C.critical, letterSpacing:'0.08em', marginBottom:3 }}>DAMAGE SUMMARY</div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:9.5, color:C.fg2, lineHeight:1.5 }}>
              JOLLY 62 · Engine #2 caution · Fuel leak ~6%/min · Crew OK · Hoist operable
            </div>
          </div>
          {opt('continue-solo', 'Continue — PEDRO 61 solo',
            'JOLLY 62 peels off corridor and RTB direct. PEDRO 61 continues to IP alone for recovery. Accepts single-vehicle risk.', C.advisory)}
          {opt('abort', 'Abort — both RTB',
            'Both HH-60s reverse course immediately and return to FARP. Case requires re-planning before another attempt.', C.critical)}
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Selection commits immediately — HH-60 tracks update on the COP.</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Phase 3: Terminal handoff modal (AMA-53 · SME-REVIEW) ──
function TerminalHandoffModal({ open, onClose, onSubmit, oscLabel }) {
  if (!open) return null;
  return (
    <div style={{ position:'fixed',inset:0,zIndex:215,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:460,maxWidth:'94vw',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>Terminal Area Handoff</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-53 · JTBD-33 [SME-REVIEW]</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:10 }}>
          <div style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2, lineHeight:1.5 }}>
            CSARTF within 10 km of IP. Delegate close-in control to <span style={{ color:C.accent2, fontFamily:'Roboto Mono' }}>{oscLabel || 'OSC'}</span> so MC/SL can monitor while terminal approach executes.
          </div>
          <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderRadius:3, padding:'8px 10px' }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:5 }}>CLOSE-IN CONTROL BRIEF</div>
            <div style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2, lineHeight:1.6 }}>
              • OSC authenticates survivor via ISOPREP answers<br/>
              • OSC talks PEDRO 61 onto the pickup LZ<br/>
              • OSC calls "clear for pickup" or waves off per threat picture<br/>
              • MC/SL retains launch/abort authority, receives pickup ack
            </div>
          </div>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Delegation is logged to the case file.</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSubmit} color="serious" size="small" style={{ borderRadius:3 }}>Delegate</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── 15-Line modal (parameterized by section) ─────────────────
// One modal renders any of A / B / C / D / CLOSEOUT — same chrome, the
// `section` prop carries the title, AMA tag, footer copy, and the right-
// pane context block specific to that transmission moment.
//
// Section A — PICKUP / ROZ COLLAPSE      (Phase 3, on pickup_ack)
// Section B — INITIAL / CSARTF TASKED    (Phase 3, at launch GO)
// Section C — INGRESS / BORDER CROSSED   (Phase 3, ~25% ingress)
// Section D — TERMINAL AREA              (Phase 3, terminal entry)
// CLOSEOUT  — RECOVERY COMPLETE          (Phase 3, recovery_complete)
//
// SME-REVIEW: section labels + line subset per section is a doctrinally-
// proposed mapping; AFTTP-3 may number/order differently.
function FifteenLineModal({ open, section, draft, onChange, onClose, onSubmit, tabs, activeTab, onTabChange }) {
  if (!open || !draft || !section) return null;
  const setField = (i, v) => onChange({ ...draft, body: draft.body.map((r,ix)=>ix===i?{...r,v}:r) });
  const setSubject = v => onChange({ ...draft, subject: v });
  const inputStyle = {
    flex:1, background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
    color:C.fg1, fontFamily:'Roboto Mono', fontSize:10, padding:'4px 7px', outline:'none', minWidth:0,
  };
  const SectionLabel = ({ children }) => (
    <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>{children}</div>
  );
  // Derive the left-pane "<section> draft — editable" label. Each section
  // can override via `section.draftLabel`; otherwise we extract the
  // "Section X" / "Closeout" portion from the title so the modal correctly
  // reads "Section B Draft" / "Section C Draft" / etc. instead of the
  // hardcoded "Section A Draft" the modal was showing for every section.
  const draftLabel = section.draftLabel || (() => {
    const t = section.title || '';
    // Pull the leading "15-Line Section X" or "15-Line Closeout" from the title.
    const m = t.match(/^15-Line\s+(Section\s+[A-Z]|Closeout)/i);
    return m ? `${m[1]} Draft — editable` : 'Draft — editable';
  })();
  // Locked drafts (already transmitted) can be reviewed but not re-submitted
  // or edited from the modal — surfacing the prior content is a reference,
  // not an action surface. The active tab (the one App opened the modal
  // for) stays editable / submittable.
  const activeTabMeta = tabs && activeTab ? tabs.find(t => t.id === activeTab) : null;
  const isLocked = !!(activeTabMeta && activeTabMeta.sent);
  return (
    <div style={{ position:'fixed',inset:0,zIndex:216,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:1100,maxWidth:'96vw',height:'84vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        {/* Header */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>{section.title}</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>{section.amaRef} · Review, edit if needed, then TRANSMIT</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        {/* Tab strip — one chip per 15-Line section. Active tab is highlighted;
            sent sections show a green ✓; pending sections sit in muted styling.
            Clicking a tab swaps the modal's section + draft via onTabChange. */}
        {tabs && tabs.length > 0 && typeof onTabChange === 'function' && (
          <div style={{ display:'flex', gap:6, padding:'8px 14px', borderBottom:`1px solid ${C.border}`, background:C.s1, overflowX:'auto', flexShrink:0 }}>
            {tabs.map(t => {
              const isActive = t.id === activeTab;
              const sent = !!t.sent;
              const fg = isActive ? C.fg1 : (sent ? C.normal : C.fg3);
              const bg = isActive ? C.s3 : 'transparent';
              const border = isActive
                ? (sent ? C.normal : C.accent)
                : (sent ? C.normal+'55' : C.border);
              return (
                <button key={t.id} onClick={()=>onTabChange(t.id)} style={{
                  display:'flex', alignItems:'center', gap:6,
                  padding:'5px 10px', borderRadius:3, cursor:'pointer',
                  background: bg, border:`1px solid ${border}`,
                  color: fg, fontFamily:'Roboto Mono', fontSize:9, fontWeight: isActive ? 600 : 500, letterSpacing:'0.08em',
                  transition:'background 0.15s, border-color 0.15s, color 0.15s',
                  flexShrink:0,
                }}>
                  <span>{t.label}</span>
                  {sent
                    ? <span style={{ color:C.normal, fontSize:8, fontWeight:700 }}>✓ SENT</span>
                    : isActive
                      ? <span style={{ color:C.accent, fontSize:8, fontWeight:600 }}>· ACTIVE</span>
                      : <span style={{ color:C.fg3, fontSize:8, fontWeight:500 }}>· PENDING</span>}
                </button>
              );
            })}
          </div>
        )}
        {/* Split body */}
        <div style={{ flex:1,display:'flex',minHeight:0 }}>
          {/* Left pane — Draft */}
          <div style={{ flex:'1 1 0',minWidth:0,display:'flex',flexDirection:'column',borderRight:`1px solid ${C.border}` }}>
            <div style={{ padding:'7px 14px',borderBottom:`1px solid ${C.border}`,background:C.s3 }}>
              <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8.5,fontWeight:500,color:C.fg2,letterSpacing:'0.11em',textTransform:'uppercase' }}>{draftLabel}</span>
            </div>
            <div style={{ overflowY:'auto',flex:1,padding:'12px 14px' }}>
              <div style={{ display:'grid',gridTemplateColumns:'110px 1fr',gap:'5px 10px',padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
                {[
                  ['PRECEDENCE',   draft.precedence],
                  ['CLASSIFICATION',draft.classification],
                  ['FROM',         draft.originator],
                  ['TO',           draft.addressee],
                  ['DTG',          draft.dtg],
                ].map(([l,v])=>(
                  <React.Fragment key={l}>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,letterSpacing:'0.06em' }}>{l}</span>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:l==='CLASSIFICATION'?C.advisory:C.fg1 }}>{v}</span>
                  </React.Fragment>
                ))}
              </div>
              <div style={{ display:'flex',alignItems:'center',gap:10,marginBottom:10 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:100,flexShrink:0,letterSpacing:'0.06em' }}>SUBJECT</span>
                <input value={draft.subject} onChange={e=>setSubject(e.target.value)} style={inputStyle} disabled={isLocked} />
              </div>
              <SectionLabel>LINES</SectionLabel>
              <div style={{ display:'flex',flexDirection:'column',gap:6 }}>
                {draft.body.map((r,i)=>(
                  <div key={r.l} style={{ display:'flex',alignItems:'center',gap:10 }}>
                    <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:180,flexShrink:0,letterSpacing:'0.06em' }}>{r.l}</span>
                    <input value={r.v} onChange={e=>setField(i,e.target.value)} style={inputStyle} disabled={isLocked} />
                  </div>
                ))}
              </div>
            </div>
          </div>
          {/* Right pane — section-specific context */}
          <div style={{ flex:'1 1 0',minWidth:0,display:'flex',flexDirection:'column',background:C.s1 }}>
            <div style={{ padding:'7px 14px',borderBottom:`1px solid ${C.border}`,background:C.s3 }}>
              <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8.5,fontWeight:500,color:C.fg2,letterSpacing:'0.11em',textTransform:'uppercase' }}>{section.contextLabel || 'Mission context · read-only'}</span>
            </div>
            <div style={{ overflowY:'auto',flex:1,padding:'12px 14px',display:'flex',flexDirection:'column',gap:11 }}>
              {(section.contextBlocks || []).map((b, i) => (
                <div key={i} style={{
                  background: b.tone==='good' ? C.normalM : C.s3,
                  border:`1px solid ${b.tone==='good' ? C.normal+'33' : C.border}`,
                  borderLeft: b.tone==='advisory' ? `4px solid ${C.advisory}` : undefined,
                  borderRadius:3, padding:'9px 11px',
                }}>
                  <div style={{ fontFamily:'Roboto Mono', fontSize:8.5, color: b.tone==='good'?C.normal:b.tone==='advisory'?C.advisory:C.fg3, letterSpacing:'0.08em', marginBottom:5 }}>{b.label}</div>
                  <div style={{ fontFamily: b.mono?'Roboto Mono':'Roboto', fontSize: b.mono?9.5:10, color:C.fg2, lineHeight: b.mono?1.65:1.55, whiteSpace:'pre-line' }}>{b.body}</div>
                </div>
              ))}
            </div>
          </div>
        </div>
        {/* Footer */}
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:isLocked?C.normal:C.fg3,flex:1 }}>
            {isLocked
              ? `Already transmitted${activeTabMeta && activeTabMeta.sentAt ? ` at ${activeTabMeta.sentAt}` : ''} — review only.`
              : (section.footerCopy || 'Transmit forwards the section to JPRC via AMC.')}
          </span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Close</DTButton>
          {!isLocked && (
            <DTButton onClick={onSubmit} color={section.submitColor || 'critical'} size="small" style={{ borderRadius:3 }}>{section.submitLabel || 'Transmit & Acknowledge'}</DTButton>
          )}
        </div>
      </div>
    </div>
  );
}

// ── PR ROZ Drop modal (JTBD-15) ────────────────────────────
// Phase 1 doctrinal task: declare the PR ROZ around the IP. On transmit
// the ROZ is published on the airspace control order and the ring appears
// on the COP (gated by `rozPublished` state).
function RozDropModal({ open, draft, onChange, onClose, onSubmit }) {
  if (!open || !draft) return null;
  const setField = (i, v) => onChange({ ...draft, body: draft.body.map((r,ix)=>ix===i?{...r,v}:r) });
  const setSubject = v => onChange({ ...draft, subject: v });
  const inputStyle = {
    flex:1, background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
    color:C.fg1, fontFamily:'Roboto Mono', fontSize:10, padding:'4px 7px', outline:'none', minWidth:0,
  };
  return (
    <div style={{ position:'fixed',inset:0,zIndex:216,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:780,maxWidth:'94vw',maxHeight:'88vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>PR ROZ Drop — Publish Airspace Measure</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>JTBD-15 · Personnel Recovery Restricted Operations Zone · 5 NM around DUKE 31 IP</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ overflowY:'auto',flex:1,padding:'14px' }}>
          <div style={{ display:'grid',gridTemplateColumns:'120px 1fr',gap:'5px 10px',padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3,marginBottom:11 }}>
            {[
              ['CLASSIFICATION', draft.classification],
              ['FROM',           draft.originator],
              ['TO',              draft.addressee],
              ['DTG',             draft.dtg],
            ].map(([l,v])=>(
              <React.Fragment key={l}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,letterSpacing:'0.06em' }}>{l}</span>
                <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:l==='CLASSIFICATION'?C.advisory:C.fg1 }}>{v}</span>
              </React.Fragment>
            ))}
          </div>
          <div style={{ display:'flex',alignItems:'center',gap:10,marginBottom:10 }}>
            <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:110,flexShrink:0,letterSpacing:'0.06em' }}>SUBJECT</span>
            <input value={draft.subject} onChange={e=>setSubject(e.target.value)} style={inputStyle} />
          </div>
          <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>ROZ PARAMETERS</div>
          <div style={{ display:'flex',flexDirection:'column',gap:6,marginBottom:11 }}>
            {draft.body.map((r,i)=>(
              <div key={r.l} style={{ display:'flex',alignItems:'center',gap:10 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:110,flexShrink:0,letterSpacing:'0.06em' }}>{r.l}</span>
                <input value={r.v} onChange={e=>setField(i,e.target.value)} style={inputStyle} />
              </div>
            ))}
          </div>
          <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderLeft:`4px solid ${C.advisory}`, borderRadius:3, padding:'9px 11px', marginTop:6 }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.advisory, letterSpacing:'0.1em', marginBottom:5 }}>EFFECT ON TRANSMIT</div>
            <div style={{ fontFamily:'Roboto', fontSize:10, color:C.fg2, lineHeight:1.55 }}>
              • ROZ ring renders on the COP centered at IP, 5 NM (9.26 km) radius, SFC → FL180.<br/>
              • AMC publishes on ATO supplement; ABM updates airspace deconfliction.<br/>
              • Adjacent SARCAP / strike packages are cued to deconflict.<br/>
              • ROZ collapses automatically at survivor pickup (15-Line Section A).
            </div>
          </div>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Transmit publishes the ROZ and notifies AMC/ABM.</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSubmit} color="critical" size="small" style={{ borderRadius:3 }}>Transmit &amp; Publish</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── 9-Line medevac modal ───────────────────────────────────
// PJ-drafted patient assessment. Hands off the medical picture to JPRC via
// AMC so receiving facility / surface transport can prepare for arrival.
// Editable subject + lines, send button transmits.
function Medevac9LineModal({ open, draft, onChange, onClose, onSubmit }) {
  if (!open || !draft) return null;
  const setField = (i, v) => onChange({ ...draft, body: draft.body.map((r,ix)=>ix===i?{...r,v}:r) });
  const setSubject = v => onChange({ ...draft, subject: v });
  const inputStyle = {
    flex:1, background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3,
    color:C.fg1, fontFamily:'Roboto Mono', fontSize:10, padding:'4px 7px', outline:'none', minWidth:0,
  };
  return (
    <div style={{ position:'fixed',inset:0,zIndex:217,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:760,maxWidth:'94vw',maxHeight:'90vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>9-Line Medevac · PJ Assessment</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>JTBD-45 [SME-REVIEW] · drafted by PEDRO 61 PJ — review and authorise relay</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ overflowY:'auto', padding:'12px 14px', display:'flex', flexDirection:'column', gap:10 }}>
          <div style={{ display:'grid',gridTemplateColumns:'110px 1fr',gap:'5px 10px',padding:'8px 10px',background:C.s3,border:`1px solid ${C.border}`,borderRadius:3 }}>
            {[
              ['PRECEDENCE',     draft.precedence],
              ['CLASSIFICATION', draft.classification],
              ['FROM',           draft.originator],
              ['TO',             draft.addressee],
              ['DTG',            draft.dtg],
            ].map(([l,v])=>(
              <React.Fragment key={l}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,letterSpacing:'0.06em' }}>{l}</span>
                <span style={{ fontFamily:'Roboto Mono',fontSize:9.5,color:l==='CLASSIFICATION'?C.advisory:C.fg1 }}>{v}</span>
              </React.Fragment>
            ))}
          </div>
          <div style={{ display:'flex',alignItems:'center',gap:10 }}>
            <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:100,flexShrink:0,letterSpacing:'0.06em' }}>SUBJECT</span>
            <input value={draft.subject} onChange={e=>setSubject(e.target.value)} style={inputStyle} />
          </div>
          <div style={{ display:'flex',flexDirection:'column',gap:6 }}>
            {draft.body.map((r,i)=>(
              <div key={r.l} style={{ display:'flex',alignItems:'flex-start',gap:10 }}>
                <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,width:170,flexShrink:0,letterSpacing:'0.06em',paddingTop:5 }}>{r.l}</span>
                {r.l === 'PJ ASSESSMENT' ? (
                  <textarea value={r.v} onChange={e=>setField(i,e.target.value)} rows={3} style={{ ...inputStyle, padding:'5px 7px', resize:'vertical' }} />
                ) : (
                  <input value={r.v} onChange={e=>setField(i,e.target.value)} style={inputStyle} />
                )}
              </div>
            ))}
          </div>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Relay sends 9-Line + PJ assessment to JPRC via AMC.</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSubmit} color="serious" size="small" style={{ borderRadius:3 }}>Authorize relay</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── J3.5 confirmation modal ────────────────────────────────
// Commits the J3.1 → J3.5 track classification change for one IP. Confirms
// that the required evidence (beacon, position fix, authentication progress)
// supports the conversion before the formal track state flips.
function ConfirmJ35Modal({ open, crew, trigger, onClose, onConfirm, state }) {
  if (!open || !crew) return null;
  const label = crew === 'bravo' ? 'DUKE 31 BRAVO' : 'DUKE 31 ALPHA';
  const trackId = crew === 'bravo' ? 'T-2804-01-B' : 'T-2804-01-A';
  const beaconAcked = crew === 'bravo' ? state?.beaconAckBravo : state?.beaconAckAlpha;
  const authDone    = crew === 'bravo' ? state?.authBravo      : state?.authAlpha;
  const positionOk  = crew === 'alpha' ? true : !!state?.bravoLocated;
  const kind = trigger === 'beacon-ack' ? 'Beacon Acknowledge + J3.5 Conversion' : 'J3.5 Conversion';
  const chip = (ok, text) => (
    <span style={{ fontFamily:'Roboto Mono', fontSize:8.5, color: ok?C.normal:C.fg3, background: ok?C.normalM:C.s3, border:`1px solid ${ok?C.normal+'55':C.border}`, padding:'2px 7px', borderRadius:2, letterSpacing:'0.06em' }}>
      {ok ? '✓' : '·'} {text}
    </span>
  );
  return (
    <div style={{ position:'fixed',inset:0,zIndex:219,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:500,maxWidth:'94vw',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>{kind} — {label}</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>JTBD-09 · AFTTP 4.69 · Track {trackId}</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:10 }}>
          <div style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2, lineHeight:1.5 }}>
            {trigger === 'beacon-ack'
              ? `Acknowledging the CSEL beacon from ${label} will auto-convert the track from J3.1 (DN ACFT) to J3.5 (FRND TROOP CONCENTRATION). Confirm to proceed.`
              : `Convert ${label}'s track from J3.1 (DN ACFT) to J3.5 (FRND TROOP CONCENTRATION). This is a formal track classification change recorded to the JPRC feed.`}
          </div>
          <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderRadius:3, padding:'9px 11px' }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:6 }}>SUPPORTING EVIDENCE</div>
            <div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
              {chip(positionOk, 'POSITION FIX')}
              {chip(trigger === 'beacon-ack' || !!beaconAcked, 'BEACON ACK')}
              {chip(!!authDone, 'ISOPREP AUTH')}
            </div>
            <div style={{ fontFamily:'Roboto', fontSize:9.5, color:C.fg3, fontStyle:'italic', lineHeight:1.5, marginTop:7 }}>
              AUTH is a separate confidence gate — the J3.5 classification itself advances on beacon + position.
            </div>
          </div>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Conversion logged to case file + JPRC feed.</span>
          <DTButton onClick={onClose} title="Cancel — do not convert the track." color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onConfirm} title="J3.5 — Convert the track from J3.1 (downed aircraft) to J3.5 (friendly troop concentration). Formal classification change logged to JPRC feed." color="critical" size="small" style={{ borderRadius:3 }}>
            {trigger === 'beacon-ack' ? 'Acknowledge + Confirm J3.5' : `Confirm J3.5 · ${crew==='bravo'?'BRAVO':'ALPHA'}`}
          </DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Phase 3: Recovery complete modal (AMA-34 · SME-REVIEW) ──
function RecoveryCompleteModal({ open, onClose, onSubmit, completed=[] }) {
  if (!open) return null;
  return (
    <div style={{ position:'fixed',inset:0,zIndex:217,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:480,maxWidth:'94vw',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>Recovery Complete — Close Case</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>AMA-34 · JTBD-44 [SME-REVIEW]</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:10 }}>
          <div style={{ fontFamily:'Roboto', fontSize:10.5, color:C.fg2, lineHeight:1.5 }}>
            CSARTF back at FARP. All survivors recovered and routed to receiving medical. Close this case to finalize the log.
          </div>
          <div style={{ background:C.s3, border:`1px solid ${C.border}`, borderRadius:3, padding:'8px 10px', maxHeight:180, overflowY:'auto' }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg3, letterSpacing:'0.1em', marginBottom:5 }}>CASE TIMELINE</div>
            {completed.map(c=>(
              <div key={c.id} style={{ display:'flex', gap:8, fontFamily:'Roboto Mono', fontSize:9, color:C.fg2, padding:'2px 0' }}>
                <span style={{ color:C.fg3, width:46 }}>{c.at}</span>
                <span>{c.label}</span>
              </div>
            ))}
          </div>
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>This archives the case.</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSubmit} color="mono-bold" size="small" style={{ borderRadius:3 }}>Close case</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Small composer modal (shared chrome for TX limit + UAR tasking) ──
function ComposerModal({ open, onClose, onSubmit, title, subtitle, fields, submitLabel, submitColor='critical' }) {
  const [values, setValues] = React.useState({});
  React.useEffect(()=>{
    if (!open) return;
    const init = {};
    fields.forEach(f=>{ init[f.key] = f.default || ''; });
    setValues(init);
  },[open]);
  if (!open) return null;
  const inputBase = { width:'100%', background:C.s2, border:`1px solid ${C.border2}`, borderRadius:3, color:C.fg1, fontFamily:'Roboto Mono', fontSize:10, padding:'5px 7px', outline:'none' };
  return (
    <div style={{ position:'fixed',inset:0,zIndex:218,background:'rgba(0,0,0,0.88)',backdropFilter:'blur(4px)',display:'flex',alignItems:'center',justifyContent:'center' }} onClick={e=>e.target===e.currentTarget&&onClose()}>
      <div style={{ background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,width:560,maxWidth:'94vw',maxHeight:'84vh',display:'flex',flexDirection:'column',boxShadow:'0 24px 64px rgba(0,0,0,0.7)' }}>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderBottom:`1px solid ${C.border}` }}>
          <div style={{ flex:1 }}>
            <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:13,color:C.fg1 }}>{title}</div>
            <div style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,marginTop:2 }}>{subtitle}</div>
          </div>
          <CloseButton onClose={onClose}/>
        </div>
        <div style={{ overflowY:'auto',flex:1,padding:'12px 14px',display:'flex',flexDirection:'column',gap:10 }}>
          {fields.map(f=>(
            <div key={f.key} style={{ display:'flex',flexDirection:'column',gap:4 }}>
              <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8,color:C.fg3,letterSpacing:'0.1em' }}>{f.label}</span>
              {f.type==='textarea'
                ? <textarea value={values[f.key]||''} onChange={e=>setValues(v=>({...v,[f.key]:e.target.value}))} rows={f.rows||3} disabled={f.readOnly} style={{ ...inputBase, resize:'vertical', opacity:f.readOnly?0.7:1 }} />
                : <input    value={values[f.key]||''} onChange={e=>setValues(v=>({...v,[f.key]:e.target.value}))} disabled={f.readOnly} style={{ ...inputBase, opacity:f.readOnly?0.7:1 }} />
              }
            </div>
          ))}
        </div>
        <div style={{ display:'flex',alignItems:'center',padding:'11px 14px',borderTop:`1px solid ${C.border}`,gap:9 }}>
          <span style={{ fontFamily:'Roboto Mono',fontSize:8.5,color:C.fg3,flex:1 }}>Action will be logged in the completed queue</span>
          <DTButton onClick={onClose} color="mono-muted" variant="outline" size="small" style={{ borderRadius:3 }}>Cancel</DTButton>
          <DTButton onClick={onSubmit} color={submitColor} size="small" style={{ borderRadius:3 }}>{submitLabel}</DTButton>
        </div>
      </div>
    </div>
  );
}

// ── Tweaks ────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"layout":"A"}/*EDITMODE-END*/;

function TweaksPanel({ visible, tweaks, onTweak, onReset }) {
  if (!visible) return null;
  return (
    <div style={{ position:'fixed',bottom:90,right:14,zIndex:400,background:C.s2,border:`1px solid ${C.border2}`,borderRadius:6,padding:14,width:224,boxShadow:'0 8px 32px rgba(0,0,0,0.6)' }}>
      <div style={{ fontFamily:'Roboto',fontWeight:600,fontSize:12,color:C.fg1,marginBottom:12 }}>Tweaks</div>
      <div style={{ marginBottom:12 }}>
        <div style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,letterSpacing:'0.1em',marginBottom:5 }}>LAYOUT</div>
        {[['A','COP Focus (70/30)'],['B','Command Console (22/56/22)'],['C','Situation Room (map top)']].map(([k,l])=>(
          <button key={k} onClick={()=>onTweak('layout',k)} style={{ display:'block',width:'100%',textAlign:'left',background:tweaks.layout===k?C.accentM:'none',border:`1px solid ${tweaks.layout===k?C.accent:C.border}`,borderRadius:3,color:tweaks.layout===k?C.accent:C.fg2,fontFamily:'Roboto Mono',fontSize:9,padding:'4px 8px',cursor:'pointer',marginBottom:3 }}>{k} — {l}</button>
        ))}
      </div>
      <button onClick={onReset} style={{ display:'block',width:'100%',background:'none',border:`1px solid ${C.criticalM}`,borderRadius:3,color:C.critical,fontFamily:'Roboto Mono',fontSize:8.5,padding:'5px',cursor:'pointer',letterSpacing:'0.06em' }}>↺ RESET SIMULATION</button>
    </div>
  );
}

// ── Layout shells ─────────────────────────────────────────────
function LayoutA({ map, left, left2, right, right2, right2Expanded, leftMinimized = false, left2Minimized = false, rightMinimized = false, right2Minimized = false, queueExpanded = false, right2Scroll = false }) {
  // Two left rails, then map, then right rails
  // `left` = Mission Overview (Cycle Board, ATO Details, Cycle Status)
  // `left2` = Missions & Callsigns
  // `right` = case context (CSAR mode only)
  // `right2` = Map Layers (normal ops) or action queue + comms (CSAR)
  // When `leftMinimized` or `left2Minimized` are set, those rails collapse to 28px sliver.
  // When `rightMinimized` is set, right rail collapses to a 28px sliver.
  // When `queueExpanded` is set, Rail 2 takes over all the space the map
  // previously held — the operator gets a full-width 5-column work surface.
  // Left rails keep their existing width (full or sliver). The map element is
  // KEPT MOUNTED in this case (just visually hidden via `display:none`) so
  // MapLibre's internal state — markers, layers, sources, animation refs —
  // doesn't get torn down and rebuilt.
  const leftRailWidth = leftMinimized ? 28 : 308;
  const left2RailWidth = left2Minimized ? 28 : 308;
  const railWidth  = rightMinimized  ? 28 : 308;
  const rail2Width = right2Minimized ? 28 : 308;
  return (
    <div style={{ flex:1,display:'flex',overflow:'hidden',minHeight:0 }}>
      {/* First left rail: Mission Overview */}
      {left && (
        <div style={{ width:leftRailWidth,flexShrink:0,borderRight:`1px solid ${C.border}`,overflowY: leftMinimized ? 'hidden' : 'auto',background:C.s1,display:'flex',flexDirection:'column',transition:'width 0.18s ease' }}>{left}</div>
      )}
      {/* Second left rail: Missions & Callsigns */}
      {left2 && (
        <div style={{ width:left2RailWidth,flexShrink:0,borderRight:`1px solid ${C.border}`,overflowY: left2Minimized ? 'hidden' : 'auto',background:C.s1,display:'flex',flexDirection:'column',transition:'width 0.18s ease' }}>{left2}</div>
      )}
      {/* In queueExpanded mode, right rail sits after left rails */}
      {queueExpanded && right && (
        <div style={{ width:railWidth,flexShrink:0,borderRight:`1px solid ${C.border}`,overflowY: rightMinimized ? 'hidden' : 'auto',background:C.s1,display:'flex',flexDirection:'column',transition:'width 0.18s ease' }}>{right}</div>
      )}
      {/* Map: always mounted. Hidden via display:none when expanded so its
          MapLibre instance + all markers/layers persist across the toggle.
          When visible it claims `flex:1` to fill the gap between left and right rails. */}
      <div style={{
        flex: queueExpanded ? '0 0 0px' : 1,
        display: queueExpanded ? 'none' : 'flex',
        flexDirection:'column', overflow:'hidden', minHeight:0,
      }}>
        {map}
      </div>
      {!queueExpanded && right && (
        <div style={{ width:railWidth,flexShrink:0,borderLeft:`1px solid ${C.border}`,overflowY: rightMinimized ? 'hidden' : 'auto',background:C.s1,display:'flex',flexDirection:'column',transition:'width 0.18s ease' }}>{right}</div>
      )}
      {/* Rail 2: 308 px column normally; absorbs all remaining space when
          queueExpanded is true. */}
      {right2 && !queueExpanded && (
        <div style={{ width:rail2Width,flexShrink:0,borderLeft:`1px solid ${C.border}`,overflowY: right2Minimized ? 'hidden' : (right2Scroll ? 'auto' : 'hidden'), overflowX:'hidden', background:C.s1,display:'flex',flexDirection:'column',minHeight:0,transition:'width 0.18s ease' }}>{right2}</div>
      )}
      {queueExpanded && (right2Expanded || right2) && (
        <div style={{ flex:1,minWidth:0,borderLeft:`1px solid ${C.border}`,overflow:'hidden',background:C.s1,display:'flex',flexDirection:'column',minHeight:0 }}>{right2Expanded || right2}</div>
      )}
    </div>
  );
}
function LayoutB({ map, left, right }) {
  return (
    <div style={{ flex:1,display:'flex',overflow:'hidden',minHeight:0 }}>
      <div style={{ width:282,flexShrink:0,borderRight:`1px solid ${C.border}`,overflowY:'auto',background:C.s1,display:'flex',flexDirection:'column' }}>{left}</div>
      <div style={{ flex:1,display:'flex',flexDirection:'column',overflow:'hidden',minHeight:0 }}>{map}</div>
      <div style={{ width:282,flexShrink:0,borderLeft:`1px solid ${C.border}`,overflowY:'auto',background:C.s1,display:'flex',flexDirection:'column' }}>{right}</div>
    </div>
  );
}
function LayoutC({ map, bottom }) {
  return (
    <div style={{ flex:1,display:'flex',flexDirection:'column',overflow:'hidden',minHeight:0 }}>
      <div style={{ flex:1,overflow:'hidden',minHeight:0 }}>{map}</div>
      <div style={{ height:256,flexShrink:0,borderTop:`1px solid ${C.border}`,display:'flex',overflowX:'auto',background:C.s1 }}>{bottom}</div>
    </div>
  );
}

// ── Floating Alerts overlay (bottom-left over the COP) ───────
const NORMAL_ALERTS = [
  { id:'n1', level:'info',     time:'1420Z', title:'ISR ON STATION', body:'CHECK 41 on station. ISR orbit established.' },
  { id:'n2', level:'info',     time:'1425Z', title:'TANKER ACTIVE',  body:'KING 11 tanker track active. Ready for refueling.' },
  { id:'n3', level:'advisory', time:'1432Z', title:'SA-6 ACTIVE',    body:'SA-6 site ACTIVE — MANPADS threat updated NE sector.' },
  { id:'n4', level:'info',     time:'1435Z', title:'STRIKE INGRESS', body:'DUKE 31/32 ingressing strike corridor ALPHA.' },
];

function FloatingAlerts({ isCsar, alerts, collapsed: collapsedProp, onCollapsedChange, headerExtras }) {
  const [internalCollapsed, setInternalCollapsed] = React.useState(false);
  const isControlled = collapsedProp !== undefined && typeof onCollapsedChange === 'function';
  const collapsed = isControlled ? collapsedProp : internalCollapsed;
  const setCollapsed = (v) => {
    const next = typeof v === 'function' ? v(collapsed) : v;
    if (isControlled) onCollapsedChange(next);
    else setInternalCollapsed(next);
  };
  const items = isCsar ? alerts : NORMAL_ALERTS;
  const hasCritical = items.some(a => a.level === 'critical');
  // FloatingAlerts is positioned absolute inside the workspace area —
  // which now sits to the right of the master-mode strip (the strip
  // is its own 44 px column at App root, NOT inside the workspace).
  // So the offset here is just the visual margin from the workspace's
  // own left edge, mirroring the bottom margin (12 px). Previously
  // this was 56 = 44 (strip) + 12 (gap), which double-counted the
  // strip and pushed the tray ~44 px right of where it belonged.
  const leftOffset = 12;
  // No always-on pulse on the header — the alerts panel is a passive feed,
  // not a "you must act on me" prompt. The count chip + critical wash give
  // enough signal that there's something to read; pulsing was nagging the
  // operator with no satisfiable action.
  return (
    <div style={{
      position:'absolute', left:leftOffset, bottom:12, zIndex:8, width:340,
      background:'rgba(21,21,23,0.92)', backdropFilter:'blur(6px)',
      border:`1px solid ${hasCritical ? C.critical+'55' : C.border2}`, borderRadius:4,
      boxShadow:'0 8px 24px rgba(0,0,0,0.55)', overflow:'hidden',
      transition:'border-color 0.4s',
    }}>
      {/* When the panel is open, fresh toasts dock at the very top of the
          list (above the existing alerts) so the operator's eye lands on
          new traffic first. `headerExtras` is the live-toast tray, rendered
          by App. */}
      {!collapsed && headerExtras}
      <div onClick={()=>setCollapsed(c=>!c)} title="Alerts feed — passive event log for this workspace. Click to collapse/expand." style={{
        display:'flex', alignItems:'center', gap:8, padding:'7px 11px',
        background: hasCritical ? `rgba(255,46,39,0.10)` : C.s2,
        borderBottom: collapsed ? 'none' : `1px solid ${C.border}`,
        borderTop:    !collapsed && headerExtras ? `1px solid ${C.border}` : 'none',
        cursor:'pointer', userSelect:'none', transition:'background 0.4s',
      }}>
        <span data-typo="label" style={{ fontFamily:'Roboto Mono',fontSize:8.5,fontWeight:500,color:hasCritical?C.critical:C.fg2,letterSpacing:'0.11em',textTransform:'uppercase',flex:1 }}>Alerts</span>
        <span style={{ background:items.length>0?(hasCritical?C.criticalM:C.accentM):C.s3, color:items.length>0?(hasCritical?C.critical:C.accent):C.fg3, fontFamily:'Roboto Mono',fontSize:8,fontWeight:500,padding:'1px 6px',borderRadius:10 }}>{items.length}</span>
        <span style={{ color:C.fg3, fontSize:10, fontFamily:'Roboto Mono' }}>{collapsed?'▲':'▼'}</span>
      </div>
      {!collapsed && (
        <div style={{ maxHeight:240, overflowY:'auto' }}>
          {items.map(a=>(
            <div key={a.id} style={{ display:'flex',gap:8,alignItems:'flex-start',padding:'7px 11px',borderBottom:`1px solid ${C.border}` }}>
              <div style={{ width:3,alignSelf:'stretch',background:a.level==='critical'?C.critical:a.level==='advisory'?C.advisory:C.accent,borderRadius:2,flexShrink:0 }} />
              <div style={{ flex:1, minWidth:0 }}>
                <div style={{ display:'flex',gap:6,alignItems:'center',marginBottom:2 }}>
                  <span style={{ fontFamily:'Roboto',fontWeight:500,fontSize:10.5,color:C.fg1 }}>{a.title || a.body}</span>
                  <span style={{ fontFamily:'Roboto Mono',fontSize:7.5,color:C.fg3,marginLeft:'auto',flexShrink:0 }}>{a.time}</span>
                </div>
                {a.title && <span style={{ fontFamily:'Roboto',fontSize:9.5,color:C.fg2,lineHeight:1.4 }}>{a.body}</span>}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Onboarding tour (PR 30) ──────────────────────────────────
// First-load guided demo covering COP → Admin → TFC → OCA. Each step
// has an optional `setup` hook that drives the app to the right state
// (switches MM, opens a drawer, selects a mission) before the spotlight
// renders. Target resolution is via `data-tour="<key>"` attributes on
// the elements in the rendered tree.
//
// Persistence: a localStorage flag `mcs.tour.completed` stops the
// auto-launch once dismissed/completed. Restart from the hamburger
// drawer's "Restart tour" row (added below the role selector).
const TOUR_STORAGE_KEY = 'mcs.tour.completed.v1';

function _tourBuildScript(api) {
  // `api` exposes the state setters the script's setup hooks need:
  //   setActiveMode, setTfcSelectedMissionId, setMmSelectedMissionId,
  //   setOcaDrawerOpen, openDrawer, closeDrawer.
  // The script is built per-mount so the hooks close over current refs.
  return [
    // ── COP / TacSit ─────────────────────────────────────────
    { mode:'home', target:null, placement:'center',
      setup: () => { api.setActiveMode('home'); api.closeDrawer(); api.setRail1Expanded(false); api.setRail2Expanded(false); },
      section:'COP / TacSit', stepLabel:'1 / 4',
      title:'Welcome to the Mission Commander Suite',
      body:`The MSC starts on the TACSIT Map, your default situational view. Live tracks, ACMs, threat rings — all of INDOPACOM in one frame. From there we'll walk through the four implemented workspaces in about a minute.` },
    { mode:'home', target:'master-mode-drawer', placement:'right',
      setup: () => { api.openDrawer(); api.setRail1Expanded(false); api.setRail2Expanded(false); },
      section:'COP / TacSit', stepLabel:'2 / 4',
      title:'Master-mode strip',
      body:`Each icon on this left strip is a master mode workspace. Click to switch. The icon order mirrors the mission-type ordering you'll see in TFC.` },
    { mode:'home', target:'rail-mission-overview', placement:'left',
      setup: () => { api.closeDrawer(); api.setRail1Expanded(true); api.setRail2Expanded(false); },
      section:'COP / TacSit', stepLabel:'3 / 4',
      title:'Mission Overview rail',
      body:`The right rail carries the same Cycle Board, ATO Details, and Mission List you'll see inside TFC. State is shared — any change in TFC reflects here and vice versa.` },
    { mode:'home', target:'rail-map-layers', placement:'left',
      setup: () => { api.closeDrawer(); api.setRail1Expanded(false); api.setRail2Expanded(true); },
      section:'COP / TacSit', stepLabel:'4 / 4',
      title:'Map Layers panel',
      body:`The far-right rail toggles friendlies, hostiles, threats, ACMs, and bases per-AOR. Per-AOR control means you can hide things outside your active operating area without losing them entirely.` },

    // ── Mission Admin ────────────────────────────────────────
    { mode:'admin', target:null, placement:'center',
      setup: () => { api.setActiveMode('admin'); api.closeDrawer(); api.setRail1Expanded(false); api.setRail2Expanded(false); },
      section:'Mission Admin', stepLabel:'1 / 4',
      title:'Mission Admin',
      body:`This is your pre-mission setup workspace. Upload ATOs, ACOs, OPTASKLINK, JPTL, and reference docs. Everything else in the app reads from what's uploaded here — it's the source of truth.` },
    { mode:'admin', target:'admin-board', placement:'bottom',
      section:'Mission Admin', stepLabel:'2 / 4',
      title:'Date board',
      body:`One column per day — yesterday for reference, today, then the days ahead. Each column header carries its own state, so you can see not just that something is missing but exactly which day it's missing from.` },
    { mode:'admin', target:'admin-board', placement:'top',
      section:'Mission Admin', stepLabel:'3 / 4',
      title:'Gaps are the action',
      body:`A day with no ATO or ACO shows a dashed placeholder instead of an empty space. Click one and the upload opens with the document type and AOR already filled in.` },
    { mode:'admin', target:'admin-views', placement:'bottom',
      section:'Mission Admin', stepLabel:'4 / 4',
      title:'Four views, one library',
      body:`The same documents, four ways: date board, the kanban grouped by type, a sortable table, and a timeline where long-running docs like OPTASKLINK read properly. Hover any icon for its name.` },

    // ── TFC — Tactical Fluid Control ─────────────────────────
    { mode:'tfc', target:null, placement:'center',
      setup: () => { api.setActiveMode('tfc'); },
      section:'TFC', stepLabel:'1 / 5',
      title:'Tactical Fluid Control',
      body:`TFC is your battle-management workspace. See every active ATO mission, monitor execution, and act on decisions as they surface.` },
    { mode:'tfc', target:'tfc-cycle-board', placement:'right',
      section:'TFC', stepLabel:'2 / 5',
      title:'Cycle Board',
      body:`Left column: cycle clock, ATO details, real-time mission status counts, operator holds, and any impacted missions. This is the same data the COP right rail surfaces.` },
    { mode:'tfc', target:'tfc-gantt', placement:'left',
      section:'TFC', stepLabel:'3 / 5',
      title:'Mission Gantt',
      body:`Center column: 24-hour calendar-day timeline. Each row is a mission. Bars segment into brief → step → on-station → debrief phases. Red reticles mark TOT — Time on Target.` },
    { mode:'tfc', target:'tfc-day-selector', placement:'bottom',
      section:'TFC', stepLabel:'4 / 5',
      title:'Day selector',
      body:`Walk forward or back through days with the arrows. Bars that span midnight render → / ← continuation markers. The pill at the now-pin shows the current scenario time.` },
    { mode:'tfc', target:'tfc-mission-detail-drawer', placement:'left',
      setup: () => { api.setTfcSelectedMissionId('mission-DUKE'); },
      section:'TFC', stepLabel:'5 / 5',
      title:'Mission Detail drawer',
      body:`Click any Gantt row to open the full mission record — airframes, target, controller card, ROE, impact flags, decision history. Try clicking through a few; selection state survives mode switches.` },

    // ── OCA — Attack Operations ──────────────────────────────
    { mode:'oca', target:null, placement:'center',
      setup: () => {
        api.setTfcSelectedMissionId(null);
        api.setMmSelectedMissionId(null);
        api.setActiveMode('oca');
      },
      section:'OCA Attack Ops', stepLabel:'1 / 5',
      title:'Mission-type workspaces',
      body:`OCA Attack Ops is the first mission-type workspace. For now, each mission-type MM follows the same pattern — but these will be refined based on mission type as the design matures.` },
    { mode:'oca', target:null, placement:'center',
      section:'OCA Attack Ops', stepLabel:'2 / 5',
      title:'Package framing',
      body:`The COP auto-zooms to the package's working area: targets, tracks, tanker orbits. Everything outside the package is screened back so the active operation reads as the figure.` },
    { mode:'oca', target:null, placement:'corner-bl', dimBackdrop:false,
      section:'OCA Attack Ops', stepLabel:'3 / 5',
      title:'Pairing lines',
      body:`Every striker is paired with its tasked target by a dotted white line. As the airframe orbits, the line follows it in real time — making strike intent instantly scannable.` },
    { mode:'oca', target:null, placement:'corner-bl', dimBackdrop:false,
      section:'OCA Attack Ops', stepLabel:'4 / 5',
      title:'Tanker tracks',
      body:`Tanker racetracks render as amber dashed ovals — these are the TAR ACMs published in the ACO, sized to each tanker's actual orbit ellipse. They stay visible even when the global ACM toggle is off.` },
    { mode:'oca', target:'mm-gantt-drawer', placement:'top',
      setup: () => { api.setOcaDrawerOpen(true); },
      // After FINISH, return the operator to Tactical Fluid Control —
      // the natural home base between mission cycles.
      onFinish: () => {
        api.setTfcSelectedMissionId(null);
        api.setMmSelectedMissionId(null);
        api.setActiveMode('tfc');
      },
      section:'OCA Attack Ops', stepLabel:'5 / 5',
      title:'Mission timeline drawer',
      body:`The bottom drawer is the same Gantt component as TFC, scoped to just the OCA package. Click the chevron to collapse it and give the map full screen. You're all set — we'll drop you into Tactical Fluid Control to explore from.` },
  ];
}

function OnboardingTour({ stepIdx, onStepChange, onExit, api }) {
  // The script is rebuilt on each render to keep `api` fresh, but it's
  // cheap and the script is small.
  const script = React.useMemo(() => _tourBuildScript(api), [api]);
  const step = script[stepIdx];
  // Resolve the spotlight target's bounding rect — re-run on step
  // change and on window resize so the callout stays anchored.
  const [rect, setRect] = React.useState(null);
  React.useLayoutEffect(() => {
    if (!step) return;
    // Fire the step's setup (mode switch, drawer open, mission select)
    // before measuring so the target exists when we look for it.
    if (typeof step.setup === 'function') {
      try { step.setup(); } catch(err) {}
    }
    let cancelled = false;
    let attempt = 0;
    const measure = () => {
      if (cancelled) return;
      const el = step.target ? document.querySelector(`[data-tour="${step.target}"]`) : null;
      if (el) {
        const r = el.getBoundingClientRect();
        setRect({ left:r.left, top:r.top, width:r.width, height:r.height });
      } else {
        setRect(null);
      }
    };
    // First measure on the next frame so React has flushed any setup-
    // driven re-renders. Additional deferred measures catch late mounts
    // (drawer slide animations, mode-switch dispatches, rail expansions
    // — drawer slide is ~180ms, rail slide is ~180ms, so we sample at
    // 80/240/420ms to lock the rect after animations settle).
    const r1 = requestAnimationFrame(measure);
    const r2 = setTimeout(() => { if (!cancelled) measure(); }, 80);
    const r3 = setTimeout(() => { if (!cancelled) measure(); }, 240);
    const r4 = setTimeout(() => { if (!cancelled) measure(); }, 420);
    // Also re-measure on resize.
    const onResize = () => measure();
    window.addEventListener('resize', onResize);
    return () => {
      cancelled = true;
      cancelAnimationFrame(r1);
      clearTimeout(r2);
      clearTimeout(r3);
      clearTimeout(r4);
      window.removeEventListener('resize', onResize);
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [stepIdx]);

  // ESC = exit
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') onExit();
      else if (e.key === 'ArrowRight' || e.key === 'Enter') onStepChange(stepIdx + 1);
      else if (e.key === 'ArrowLeft') onStepChange(Math.max(0, stepIdx - 1));
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [stepIdx, onExit, onStepChange]);

  if (!step) return null;
  const total = script.length;
  const isFirst = stepIdx === 0;
  const isLast  = stepIdx === total - 1;
  // Spotlight: center-mode skips the cutout (just dark backdrop with
  // a centered callout). Targeted-mode adds a glowing ring around the
  // target rect (using box-shadow for the cutout effect).
  const PAD = 8;
  const ringRect = rect && step.target ? {
    left: rect.left - PAD,
    top:  rect.top - PAD,
    width:  rect.width + PAD * 2,
    height: rect.height + PAD * 2,
  } : null;

  // Place the callout near the target. Fallbacks if the target is
  // off-screen or missing. Estimated card size 320 × 200; if real
  // height differs by ~10% the placement still reads fine.
  const W = window.innerWidth, H = window.innerHeight;
  const CARD_W = 360, CARD_H = 220;
  const calloutPos = (() => {
    // Corner placements ignore ringRect — used when the spotlight is the
    // map itself and we want the callout out of the way so the operator
    // can read the visualization.
    const p0 = step.placement || (ringRect ? 'right' : 'center');
    if (p0 === 'corner-bl') return { left: 16, top: H - CARD_H - 16 };
    if (p0 === 'corner-br') return { left: W - CARD_W - 16, top: H - CARD_H - 16 };
    if (p0 === 'corner-tl') return { left: 16, top: 60 };
    if (p0 === 'corner-tr') return { left: W - CARD_W - 16, top: 60 };
    if (!ringRect || step.placement === 'center') {
      return { left: (W - CARD_W) / 2, top: (H - CARD_H) / 2 };
    }
    const GAP = 16;
    const p = step.placement || 'right';
    if (p === 'right') {
      return {
        left: Math.min(W - CARD_W - 12, ringRect.left + ringRect.width + GAP),
        top:  Math.max(12, Math.min(H - CARD_H - 12, ringRect.top + ringRect.height/2 - CARD_H/2)),
      };
    }
    if (p === 'left') {
      return {
        left: Math.max(12, ringRect.left - CARD_W - GAP),
        top:  Math.max(12, Math.min(H - CARD_H - 12, ringRect.top + ringRect.height/2 - CARD_H/2)),
      };
    }
    if (p === 'top') {
      return {
        left: Math.max(12, Math.min(W - CARD_W - 12, ringRect.left + ringRect.width/2 - CARD_W/2)),
        top:  Math.max(12, ringRect.top - CARD_H - GAP),
      };
    }
    // 'bottom'
    return {
      left: Math.max(12, Math.min(W - CARD_W - 12, ringRect.left + ringRect.width/2 - CARD_W/2)),
      top:  Math.min(H - CARD_H - 12, ringRect.top + ringRect.height + GAP),
    };
  })();

  // Steps can opt out of the dim backdrop — used when we want the
  // operator to see live map context (e.g. pairing lines, tanker tracks).
  const dim = step.dimBackdrop !== false;

  return (
    <div style={{
      position:'fixed', inset:0, zIndex:1000,
      // When dim is off and there's no target, let map clicks pass
      // through everywhere except the callout itself.
      pointerEvents: (!dim && !ringRect) ? 'none' : 'auto',
    }}>
      {/* Backdrop — semi-transparent black. When there's a target ring
          we use a giant inset box-shadow on the ring div to "cut" the
          backdrop, leaving the target lit. */}
      {!ringRect && dim && (
        <div style={{ position:'absolute', inset:0, background:'rgba(0,0,0,0.62)' }}/>
      )}
      {ringRect && dim && (
        <>
          {/* Four-segment backdrop around the ring — keeps the ring
              area un-darkened while still dimming everything else. */}
          <div style={{ position:'absolute', left:0, top:0, width:'100vw', height: ringRect.top, background:'rgba(0,0,0,0.62)' }}/>
          <div style={{ position:'absolute', left:0, top: ringRect.top + ringRect.height, width:'100vw', bottom:0, background:'rgba(0,0,0,0.62)' }}/>
          <div style={{ position:'absolute', left:0, top: ringRect.top, width: ringRect.left, height: ringRect.height, background:'rgba(0,0,0,0.62)' }}/>
          <div style={{ position:'absolute', left: ringRect.left + ringRect.width, top: ringRect.top, right:0, height: ringRect.height, background:'rgba(0,0,0,0.62)' }}/>
          {/* Glowing ring around the target */}
          <div style={{
            position:'absolute',
            left: ringRect.left, top: ringRect.top,
            width: ringRect.width, height: ringRect.height,
            border: `2px solid ${C.accent}`,
            borderRadius: 6,
            boxShadow: `0 0 0 4px ${C.accent}33, 0 0 24px ${C.accent}66`,
            pointerEvents:'none',
            animation:'pulseFade 1.6s ease-in-out infinite',
          }}/>
        </>
      )}
      {/* Callout card */}
      <div style={{
        position:'absolute',
        left: calloutPos.left, top: calloutPos.top,
        width: CARD_W,
        background: C.s2,
        border: `1px solid ${C.accent}`,
        borderRadius: 6,
        boxShadow: '0 16px 48px rgba(0,0,0,0.65)',
        display:'flex', flexDirection:'column',
        overflow:'hidden',
        // Re-enable pointer events on the callout when the outer
        // container has them disabled (dimBackdrop:false case).
        pointerEvents:'auto',
      }}>
        {/* Header */}
        <div style={{
          padding:'10px 14px',
          borderBottom:`1px solid ${C.border}`,
          display:'flex', alignItems:'center', gap:8,
        }}>
          <div style={{
            width:20, height:20, borderRadius:'50%',
            background:`${C.accent}22`,
            color:C.accent,
            display:'flex', alignItems:'center', justifyContent:'center',
            fontSize:11, fontWeight:700,
            flexShrink:0,
          }}>✦</div>
          <div style={{ flex:1, minWidth:0 }}>
            <div data-typo="label" style={{
              fontFamily:'Roboto Mono', fontSize:8, color:C.accent,
              letterSpacing:'0.14em', textTransform:'uppercase',
              lineHeight:1,
            }}>
              {step.section} · {step.stepLabel}
            </div>
            <div style={{
              fontFamily:'Roboto', fontSize:13, fontWeight:600,
              color:C.fg1, marginTop:3, lineHeight:1.25,
            }}>{step.title}</div>
          </div>
          <button
            onClick={onExit}
            title="Exit tour (Esc)"
            style={{
              background:'transparent', border:'none',
              color:C.fg3, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:11, padding:'2px 4px',
              flexShrink:0,
            }}
            onMouseEnter={e => e.currentTarget.style.color = C.fg1}
            onMouseLeave={e => e.currentTarget.style.color = C.fg3}
          >✕</button>
        </div>
        {/* Body */}
        <div style={{
          padding:'12px 14px',
          fontFamily:'Roboto', fontSize:12, color:C.fg2,
          lineHeight:1.5,
        }}>
          {step.body}
        </div>
        {/* Footer — progress + actions */}
        <div style={{
          padding:'8px 12px 10px',
          borderTop:`1px solid ${C.border}`,
          display:'flex', alignItems:'center', gap:10,
        }}>
          {/* Progress dots */}
          <div style={{ display:'flex', gap:3, flex:1, flexWrap:'wrap' }}>
            {script.map((_, i) => (
              <div key={i} style={{
                width: i === stepIdx ? 14 : 6,
                height: 4, borderRadius:2,
                background: i <= stepIdx ? C.accent : C.fg4,
                transition:'width 0.15s, background 0.15s',
              }}/>
            ))}
          </div>
          {!isFirst && (
            <button
              onClick={() => onStepChange(stepIdx - 1)}
              data-typo="label"
              style={{
                padding:'5px 10px', borderRadius:3,
                background:'transparent', border:`1px solid ${C.border2}`,
                color:C.fg2, cursor:'pointer',
                fontFamily:'Roboto Mono', fontSize:9, fontWeight:500,
                letterSpacing:'0.10em', textTransform:'uppercase',
              }}>BACK</button>
          )}
          <button
            onClick={onExit}
            data-typo="label"
            style={{
              padding:'5px 10px', borderRadius:3,
              background:'transparent', border:`1px solid ${C.border2}`,
              color:C.fg3, cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:9, fontWeight:500,
              letterSpacing:'0.10em', textTransform:'uppercase',
            }}>SKIP</button>
          <button
            onClick={() => {
              if (isLast) {
                // Fire any step-defined finish hook (e.g. return-to-mode)
                // before exiting so the operator lands somewhere coherent.
                if (typeof step.onFinish === 'function') {
                  try { step.onFinish(); } catch(err) {}
                }
                onExit();
              } else {
                onStepChange(stepIdx + 1);
              }
            }}
            data-typo="label"
            style={{
              padding:'5px 12px', borderRadius:3,
              background:C.accent, border:'none',
              color:'#000', cursor:'pointer',
              fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
              letterSpacing:'0.10em', textTransform:'uppercase',
            }}>{isLast ? 'FINISH' : 'NEXT ▸'}</button>
        </div>
      </div>
    </div>
  );
}

// ── Root App ──────────────────────────────────────────────────
function App() {
  // Hash router seeds initial view state from `window.location.hash`,
  // so deep links like `#/admin/atos/platforms?ato=doc-ato-ip-001`
  // land the operator on the right page on cold load. Computed once
  // in a lazy initializer so each useState reads the same parse.
  const initialRoute = React.useMemo(() => {
    try { return parseHashRoute(window.location.hash); }
    catch (e) { return { mode:'home', subview:'dashboard', atoIds:new Set(), search:'' }; }
  }, []);

  // Master mode is the primary workspace key. CSAR is one of nine doctrinal
  // modes (see data.js MASTER_MODES); v1 only builds CSAR + Home, the rest
  // are stubs. The legacy `appState` enum (normal|alert|csar) is derived
  // below from `activeMode` + `csarAlertPending` so existing CSAR code that
  // reads `appState`/`isCsar` keeps working unchanged.
  // CSAR has special boot ordering (J3.1 fires alert first, only switches
  // to csar mode on ack), so a `#/csar` deep link still boots into 'home'
  // and lets the scenario script flip on its own.
  const [activeMode,        setActiveMode]        = React.useState(
    initialRoute.mode === 'csar' ? 'home' : initialRoute.mode
  );
  const [activeRole,        setActiveRole]        = React.useState('mc');
  const [drawerOpen,        setDrawerOpen]        = React.useState(false);
  // PR 30 — Onboarding tour. Auto-launches on first load (gated by a
  // localStorage flag) and can be restarted from the hamburger drawer.
  // `tourStep` is null when not running; an integer 0..N during a tour.
  const [tourStep, setTourStep] = React.useState(null);
  React.useEffect(() => {
    try {
      const done = window.localStorage.getItem(TOUR_STORAGE_KEY);
      if (!done) setTourStep(0);
    } catch(err) {}
  }, []);
  const exitTour = React.useCallback(() => {
    setTourStep(null);
    try { window.localStorage.setItem(TOUR_STORAGE_KEY, '1'); } catch(err) {}
  }, []);
  const restartTour = React.useCallback(() => {
    try { window.localStorage.removeItem(TOUR_STORAGE_KEY); } catch(err) {}
    setTourStep(0);
  }, []);
  // Manage System workspace — separate from MASTER_MODES; rendered as a
  // secondary nav row in the drawer above the role selector. Two tabs:
  // System Data (feed health snapshot, all rows seeded from window.SYSTEM_FEEDS)
  // and System Config. Config holds operator-controlled feature flags:
  // which MASTER_MODES are visible in the strip + drawer, and which
  // OPERATOR_ROLES are listed in the role switcher. Both default to all
  // visible. Toggling a master mode off removes its strip icon and drawer
  // row entirely (the workspace itself remains in code — toggling back on
  // restores it instantly). Roles can be hidden but not deleted; the live
  // active role is locked-on so an operator can't hide themselves out of
  // the suite.
  const [manageSystemTab,   setManageSystemTab]   = React.useState('data');
  // Diagnostic toggle — swaps Roboto Mono labels to Roboto + strict
  // letter-spacing across the whole app. Not part of the operator UI.
  const [typeAudit,         setTypeAudit]         = React.useState(false);
  // Hidden by default; triple-tap `t` (within 600ms) reveals/hides the
  // floating chip. Reviewer-friendly demo path with no on-screen
  // affordance. Listener installed below.
  const [typeAuditVisible,  setTypeAuditVisible]  = React.useState(false);
  React.useEffect(() => {
    const taps = [];
    const onKey = (e) => {
      // Ignore when the operator is typing into an input/textarea so
      // hammering the `t` key in a form field doesn't accidentally
      // surface the dev chip during a real interaction.
      const tag = (e.target && e.target.tagName) || '';
      if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
      if (e.key !== 't' && e.key !== 'T') return;
      const now = Date.now();
      taps.push(now);
      while (taps.length && now - taps[0] > 600) taps.shift();
      if (taps.length >= 3) {
        taps.length = 0;
        setTypeAuditVisible(v => !v);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  const [hiddenModeIds,     setHiddenModeIds]     = React.useState(new Set());
  const [hiddenRoleIds,     setHiddenRoleIds]     = React.useState(new Set());
  const [pendingMode,       setPendingMode]       = React.useState(null);     // mode id awaiting confirm
  const [csarAlertPending,  setCsarAlertPending]  = React.useState(false);    // J3.1 fired, awaiting ack
  const [csarSessionActive, setCsarSessionActive] = React.useState(false);    // ever entered CSAR this run
  const [csarUnreadCount,   setCsarUnreadCount]   = React.useState(0);        // unread alerts while away

  const [elapsed,    setElapsed]    = React.useState(0);
  const [dayProgress,setDayProgress]= React.useState(0);
  // ATO runtime React shim — when the cascade fires (from console
  // or from a UI action), we bump a generation counter and React
  // re-renders. The runtime object itself lives on window.ATO.runtime
  // and is mutated in place; we don't deep-clone every tick.
  const [atoGen, setAtoGen] = React.useState(0);
  const atoRuntime = (window.ATO && window.ATO.runtime) || null;
  // PR 23 — Demo prep: clear all decisions at boot. The ATO pipeline's
  // AMA-13 / AMA-14 scans accumulate held alerts (NLT-breach, coord-gap)
  // for any mission whose NET/NLT falls inside the demo clock window;
  // mission.impactFlags fill in further. For demo/SME-review we want a
  // clean "everything in good standing" baseline. This mutates the
  // existing alert + flag objects with `actedOn:true` (the same field
  // operator actions use) so UI filters (`f => !f.actedOn`) suppress
  // them. The state machine continues to function — operator actions
  // will still produce new decisions; AMA scans will keep ticking — but
  // the pre-existing seed-time decisions are demoted to history.
  React.useEffect(() => {
    if (!atoRuntime) return;
    const t = atoRuntime.lastTickT || 0;
    const ack = (a) => {
      if (!a.actedOn) {
        a.actedOn = true;
        a.actedOnAt = t;
        a.actedOnBy = 'demo-baseline';
      }
    };
    (atoRuntime.heldCoordAlerts || []).forEach(ack);
    (atoRuntime.heldNltBreaches || []).forEach(ack);
    (atoRuntime.missions || []).forEach(m => {
      (m.impactFlags || []).forEach(ack);
    });
    setAtoGen(g => g + 1);
  // Run once on first mount — atoRuntime is stable across re-renders.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  // TFC workspace selection state (selected mission for the right rail).
  const [tfcSelectedMissionId, setTfcSelectedMissionId] = React.useState(null);
  // PR 12 — Mission Gantt grouping mode ('mission' = type-banded,
  // 'package' = stub for future package layer). Persists for the
  // TFC session.
  const [tfcGroupBy, setTfcGroupBy] = React.useState('mission');
  // PR 15 — Mission Gantt is anchored to a fixed calendar day. The
  // viewed-day offset is days from the scenario base day (28 APR 2026,
  // the day containing T+0=281430Z). 0 = 28 APR, +1 = 29 APR, -1 = 27.
  // The operator can walk forward/back with the day-selector arrows.
  const [tfcViewedDay, setTfcViewedDay] = React.useState(0);
  // PR 25 — Mission-type MM (OCA, DCA, etc.) workspace state.
  // mmSelectedMissionId is shared across mission-type MMs but resets
  // on mode switch so each workspace starts with a clean selection.
  // Drawer open/collapse is per-MM so each workspace remembers its
  // own drawer pose across mode swaps. Keyed by mmId.
  const [mmSelectedMissionId, setMmSelectedMissionId] = React.useState(null);
  const [mmDrawerOpen, setMmDrawerOpen] = React.useState({
    oca:true, dca:true, dt:true, cas:true, sof:true, tacs:true,
  });
  React.useEffect(() => { setMmSelectedMissionId(null); }, [activeMode]);
  // On entry into a mission-type MM, frame the COP camera around the
  // package's working area. The map's pendingFitBounds consumer runs
  // fitBounds and pins the camera (manualCameraRef latch). Re-fires
  // every time the operator returns to the MM, so they always land on
  // the right frame.
  React.useEffect(() => {
    if (!window.isMmTypeMode || !window.isMmTypeMode(activeMode)) return;
    const runtime = window.ATO && window.ATO.runtime;
    const filterFn = window.getMmMissionFilter && window.getMmMissionFilter(activeMode);
    if (!runtime || !filterFn) return;
    const missions = (runtime.missions || []).filter(filterFn);
    const bbox = _mmPackageBbox(missions);
    if (!bbox) return;
    setPendingFitBounds({
      bbox,
      // Top padding clears the workspace title strip + main top bar;
      // bottom padding clears the drawer when open (~280px) plus the
      // chevron handle. Right padding leaves room for the Mission
      // Detail drawer if it's open.
      padding: { top: 60, bottom: 320, left: 80, right: 80 },
      duration: 1200,
    });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeMode]);
  const [actions,    setActions]    = React.useState(window.INIT_ACTIONS);
  const [alerts,     setAlerts]     = React.useState(window.INIT_ALERTS);
  const [jprcDone,   setJprcDone]   = React.useState(false);
  const [oscDone,    setOscDone]    = React.useState(false);
  const [jprcDoneAt, setJprcDoneAt] = React.useState(null);
  const [oscDoneAt,  setOscDoneAt]  = React.useState(null);
  const [oscOpen,    setOscOpen]    = React.useState(false);
  const [oscId,      setOscId]      = React.useState(null);
  const [tweaksVis,  setTweaksVis]  = React.useState(false);
  const [tweaks,     setTweaks]     = React.useState(TWEAK_DEFAULTS);
  const [jprcDraft,   setJprcDraft]   = React.useState(window.JPRC_DRAFT);
  const [draftOpen,   setDraftOpen]   = React.useState(false);
  const [toasts,      setToasts]      = React.useState([]);
  const [escalated,   setEscalated]   = React.useState({ jprc:false, osc:false, j35Alpha:false, j35Bravo:false });

  // Phase 1 state — per-IP state is split so the Survivor Roster can drive each crew independently
  const [phase,            setPhase]            = React.useState(0);
  const [beaconAckAlpha,   setBeaconAckAlpha]   = React.useState(false);
  const [beaconAckBravo,   setBeaconAckBravo]   = React.useState(false);
  const [authAlpha,        setAuthAlpha]        = React.useState(false);
  const [authBravo,        setAuthBravo]        = React.useState(false);
  const [txSentAlpha,      setTxSentAlpha]      = React.useState(false);
  const [txSentBravo,      setTxSentBravo]      = React.useState(false);
  const [uarSent,          setUarSent]          = React.useState(false);
  // ISR retasking — submitting UAR pulls the closest GHOST MQ-9 from its
  // standing orbit to wide IP overwatch at FL280. ghostOnStationAt fires
  // when the realistic transit completes (one-shot) and gates the on-station
  // alert + comm. Transit duration scales with distance at MQ-9 cruise
  // (150 kn) under the 10× demo tempo — close GHOSTs arrive sooner.
  // ghostOnStationTargetSec holds the elapsed-seconds gate for the alert,
  // so spacebar fast-forward (which speeds the elapsed ticker) speeds the
  // alert too — matching the map's t-unit-driven transit animation.
  const [onTaskedGhostId,  setOnTaskedGhostId]  = React.useState(null);
  const [ghostOnStationAt, setGhostOnStationAt] = React.useState(null);
  const [ghostOnStationTargetSec, setGhostOnStationTargetSec] = React.useState(null);
  const [ghostTerminalLogged, setGhostTerminalLogged] = React.useState(false);
  const [j35AlphaDone,     setJ35AlphaDone]     = React.useState(false);
  const [j35BravoDone,     setJ35BravoDone]     = React.useState(false);
  const [bravoLocated,     setBravoLocated]     = React.useState(false);
  const [beaconAckAlphaAt, setBeaconAckAlphaAt] = React.useState(null);
  const [beaconAckBravoAt, setBeaconAckBravoAt] = React.useState(null);
  const [authAlphaAt,      setAuthAlphaAt]      = React.useState(null);
  const [authBravoAt,      setAuthBravoAt]      = React.useState(null);
  const [txSentAlphaAt,    setTxSentAlphaAt]    = React.useState(null);
  const [txSentBravoAt,    setTxSentBravoAt]    = React.useState(null);
  const [uarSentAt,        setUarSentAt]        = React.useState(null);
  const [j35AlphaDoneAt,   setJ35AlphaDoneAt]   = React.useState(null);
  const [j35BravoDoneAt,   setJ35BravoDoneAt]   = React.useState(null);
  const [bravoLocatedAt,   setBravoLocatedAt]   = React.useState(null);
  // Modal open state — authOpenCrew/txOpenCrew disambiguate which crewmember the modal is acting on
  const [authOpen,         setAuthOpen]         = React.useState(false);
  const [authOpenCrew,     setAuthOpenCrew]     = React.useState('alpha');
  const [txOpen,           setTxOpen]           = React.useState(false);
  const [txOpenCrew,       setTxOpenCrew]       = React.useState('alpha');
  const [uarOpen,          setUarOpen]          = React.useState(false);
  // Lifted CommsLog state so BRAVO discovery can push scripted RX messages in
  const [msgs,             setMsgs]             = React.useState(window.INIT_MSGS);
  // Late-activating SAM threats — placement fixed, timing randomized per run
  const [activeLateThreats, setActiveLateThreats] = React.useState([]);
  const lateDelaysRef      = React.useRef([]);
  // 5× time multiplier applied to all tickers while spacebar is held.
  const fastForwardRef     = React.useRef(false);

  // J3.5 confirmation modal state — gates J3.5 conversion behind a confirm step.
  const [j35ConfirmOpen,    setJ35ConfirmOpen]    = React.useState(false);
  const [j35ConfirmCrew,    setJ35ConfirmCrew]    = React.useState('alpha');
  const [j35ConfirmTrigger, setJ35ConfirmTrigger] = React.useState('manual'); // 'manual' | 'beacon-ack'

  // Phase 2 — COA (Course of Action) state. See SME-REVIEW tag on AMA-50 in data.js.
  const [coas,        setCoas]        = React.useState([]);
  const [activeCoa,   setActiveCoa]   = React.useState(null);
  const [activeCoaAt, setActiveCoaAt] = React.useState(null);
  const [coaOpen,     setCoaOpen]     = React.useState(false);
  const [coaBreach,   setCoaBreach]   = React.useState(false);

  // Phase 3 — Tactical Execution state. See SME-REVIEW tags on AMA-51..53 (prototype-only) in data.js.
  const [launched,          setLaunched]          = React.useState(false);
  const [launchedAt,        setLaunchedAt]        = React.useState(null);
  const [launchOpen,        setLaunchOpen]        = React.useState(false);
  const [missionProgress,   setMissionProgress]   = React.useState(0);     // 0 → 1 ingress, then 1 → 0 egress
  const [missionPhase,      setMissionPhase]      = React.useState('idle'); // idle|ingress|terminal|pickup|egress|complete|aborted
  // Each Phase 3 run fires all three disruption archetypes: new SAM first
  // (5–15 s after launch), then battle-damage + medical at random progress
  // points before the HH-60s reach the IP.
  const [samFired,          setSamFired]          = React.useState(false);
  const [damageFired,       setDamageFired]       = React.useState(false);
  const [medicalFired,      setMedicalFired]      = React.useState(false);
  const disruptionScheduleRef = React.useRef({ samTimerId:null, damageAtProgress:0, medicalAtProgress:0 });
  const [divertOpen,        setDivertOpen]        = React.useState(false);
  const [divertDecision,    setDivertDecision]    = React.useState(null); // 'continue-solo' | 'abort'
  const [divertDecisionAt,  setDivertDecisionAt]  = React.useState(null);
  const [terminalOpen,      setTerminalOpen]      = React.useState(false);
  const [terminalDelegated, setTerminalDelegated] = React.useState(false);
  const [terminalDelegatedAt, setTerminalDelegatedAt] = React.useState(null);
  const [pickupOpen,        setPickupOpen]        = React.useState(false);
  const [pickupDone,        setPickupDone]        = React.useState(false);
  const [pickupDoneAt,      setPickupDoneAt]      = React.useState(null);
  // 15-Line transmissions — five staged drafts across the mission lifecycle.
  // A = pickup (existing), B = launch, C = border crossing, D = terminal
  // area, CLOSE = recovery complete. Each has its own draft + sent flag +
  // timestamp so the operator's edits persist through other state changes
  // and the completed log can show all five transmissions.
  const [fifteenLineDraft,    setFifteenLineDraft]    = React.useState(window.FIFTEEN_LINE_DRAFT);   // Section A
  const [fifteenLineBDraft,   setFifteenLineBDraft]   = React.useState(window.FIFTEEN_LINE_B_DRAFT); // Section B
  const [fifteenLineCDraft,   setFifteenLineCDraft]   = React.useState(window.FIFTEEN_LINE_C_DRAFT); // Section C
  const [fifteenLineDDraft,   setFifteenLineDDraft]   = React.useState(window.FIFTEEN_LINE_D_DRAFT); // Section D
  const [fifteenLineCloseDraft, setFifteenLineCloseDraft] = React.useState(window.FIFTEEN_LINE_CLOSE_DRAFT);
  const [fifteenSection,      setFifteenSection]      = React.useState(null); // 'A'|'B'|'C'|'D'|'CLOSE' — which is open
  const [fifteenBSent,   setFifteenBSent]   = React.useState(false); const [fifteenBSentAt,   setFifteenBSentAt]   = React.useState(null);
  const [fifteenCSent,   setFifteenCSent]   = React.useState(false); const [fifteenCSentAt,   setFifteenCSentAt]   = React.useState(null);
  const [fifteenDSent,   setFifteenDSent]   = React.useState(false); const [fifteenDSentAt,   setFifteenDSentAt]   = React.useState(null);
  const [fifteenCloseSent, setFifteenCloseSent] = React.useState(false); const [fifteenCloseSentAt, setFifteenCloseSentAt] = React.useState(null);
  // PR ROZ Drop (JTBD-15) — Phase 1 doctrinal task. ROZ ring on the COP
  // is gated on rozPublished; transmit publishes + renders the ring.
  const [rozOpen,        setRozOpen]        = React.useState(false);
  const [rozPublished,   setRozPublished]   = React.useState(false);
  const [rozPublishedAt, setRozPublishedAt] = React.useState(null);
  const [rozDraft,       setRozDraft]       = React.useState(window.ROZ_DROP_DRAFT);
  const [recoveryOpen,      setRecoveryOpen]      = React.useState(false);
  const [recoveryDone,      setRecoveryDone]      = React.useState(false);
  const [recoveryDoneAt,    setRecoveryDoneAt]    = React.useState(null);
  // 9-Line medevac — PJ-drafted patient assessment transmitted to JPRC after
  // pickup. Action card seeded once egress kicks off; modal lets MC/SL review
  // and authorise the relay.
  const [medevacOpen,    setMedevacOpen]    = React.useState(false);
  const [medevacSent,    setMedevacSent]    = React.useState(false);
  const [medevacSentAt,  setMedevacSentAt]  = React.useState(null);
  const [nineLineDraft,  setNineLineDraft]  = React.useState(window.NINE_LINE_DRAFT);

  // 15-Line transmissions ship in three stages, all relayed through AMC
  // (DUDE 11). Initial Section A is sent at border crossing (~25% ingress);
  // an update goes out at terminal area entry; final transmission at pickup.
  // Each flag prevents the same alert firing on every progress tick.
  const [fifteenLineBorderFired,   setFifteenLineBorderFired]   = React.useState(false);
  const [fifteenLineTerminalFired, setFifteenLineTerminalFired] = React.useState(false);
  // IP persona: when active the map tilts to reveal terrain, hides threat
  // rings + COA clutter, and surfaces pattern-of-life + C-cell layers.
  // Useful for talking through the terminal-area picture without the rest
  // of the operational chrome competing for attention. Tilt is CSAR-only
  // (button hidden outside CSAR in TacticalMap). A useEffect below force-
  // resets persona to 'ops' on CSAR exit so we never re-enter tilted.
  const [personaMode, setPersonaMode] = React.useState('ops'); // 'ops' | 'ip'
  // Night tint for the darkness overlay — lifted to App so the segmented
  // control can sit in the top bar (under the dark scale) instead of on
  // the map. 'black' = dead night, 'navy' = deep cool, 'nvg' = green NVG.
  // Display mode for the basemap. STD (false) = satellite ↔ Carto Dark
  // Matter crossfade with dayProgress. NVG (true) = satellite stays as
  // basemap with a green window-treatment overlay whose intensity ramps
  // with dayProgress.
  const [nvgMode, setNvgMode] = React.useState(false);
  // Drawing tool selection — moved out of the map's left strip into a
  // dedicated top-bar cell after the brand. Suppressed entirely in CSAR
  // master mode (the case workspace doesn't surface ad-hoc drawing).
  const [activeTool, setActiveTool] = React.useState('select');

  // Basemap selection for Home/COP mode. CSAR mode always uses
  // satellite (it's the only basemap the CSAR scenario was designed
  // against — darkness fade, threat-ring colour logic, NVG composite
  // all assume satellite). Outside CSAR the operator picks via a
  // dropdown in the Map & Environment rail.
  const [basemap, setBasemap] = React.useState('carto-dark');
  // Imagery dimming — applied as a black DOM overlay above the map
  // canvas when the satellite basemap is active. Default 0.5 because
  // the raw Esri imagery is too bright for a tactical COP picture;
  // half-strength dim is the right baseline for reading red/blue
  // tracks against the basemap. Only the operator-selected satellite
  // basemap reads this; carto-dark and carto-light render as authored.
  const [imageryDimming, setImageryDimming] = React.useState(0.5);

  // Projection mode — 'flat' (Mercator) or 'globe'. Defaults to 'flat'
  // because at AOR scale (e.g. INDOPACOM end-to-end) a flat strip
  // shows the entire AOR clearly, while the globe wraps the edges
  // toward the horizon and loses SA. Operator can flip to globe via
  // the FLAT/GLOBE pill in the top bar (suppressed in CSAR mode —
  // CSAR is a tactical zoom where projection choice doesn't matter).
  const [projection, setProjection] = React.useState('flat');

  // Map layer visibility toggles. Lives in the Map Layers panel in
  // Home/COP mode's outer right rail. Default-on layers are the ones
  // that read as the SA baseline on cold load — friendly tracks, their
  // callsign labels, threat rings, AOR boundaries, and the MGRS grid.
  // Everything else (ACMs, trans-AOR routes, the political-airspace
  // washes, commercial air, GARS) ships off and the operator opts in.
  // Keeping cold-load minimal makes the first frame readable and
  // scales the right rail's information density to operator intent.
  // Map layers — split into a flat "global" bucket for things that span
  // every AOR, plus a `perAor` map keyed by AOR id holding the four
  // tactical layer toggles that *do* scope to a specific COCOM:
  // friendly tracks, hostile land tracks, threat (SAM) rings, and ACMs.
  // The Map Layers panel renders this as one Global section + one
  // collapsible section per AOR. Cold-load defaults: every AOR's
  // tactical content ON so the operator sees the full posture, in line
  // with the locked decisions on this design.
  //
  // `friendlyTracks` and `hostileTrackStyle` stay flat — friendly tracks
  // is per-AOR (under `perAor[aor].friendly`) but `callsignLabels` is a
  // global rendering decision (labels-on-or-off applies to whatever
  // markers ARE visible). hostileTrackStyle is the generic-vs-by-type
  // glyph picker; it's a rendering style choice, not a visibility flag.
  const _aorIds = (window.AORS || []).map(a => a.id);
  const _initialPerAor = {};
  _aorIds.forEach(id => {
    // bases: blue installations (control-tower icons, always-on label).
    // Default ON — operator wants the "where do these guys come from"
    // context by default. Cheap to render (~40 fixtures total).
    _initialPerAor[id] = { friendly:true, hostile:true, threats:true, acms:false, bases:true };
  });
  const [mapLayers, setMapLayers] = React.useState({
    // ── Global toggles ─────────────────────────────────────────
    callsignLabels:       true,   // small label near each visible track marker
    aorBoundaries:        true,   // dashed COCOM boundary polygons
    transAorRoutes:       false,  // BONE 11 planned/flown polylines
    commercialAir:        false,  // live OpenSky ADS-B feed
    russianAirspace:      false,  // RU + BY + Crimea wash
    chineseAirspace:      false,  // PRC mainland + Hainan wash
    natoAirspace:         false,  // NATO + UA-aligned wash
    restrictedAirspaceUS: false,  // FAA SUA polygons (CONUS-only, NORTHCOM)
    mgrsGrid:             true,   // MGRS GZD + UTM 100k/10k/1k cells
    garsGrid:             false,  // GARS 30/15/5 min cells
    // ── Per-AOR tactical overlays ───────────────────────────────
    perAor: _initialPerAor,
    // ── Rendering style picker (not a visibility flag) ──────────
    hostileTrackStyle:    'generic',  // 'generic' | 'byType'
  });
  const toggleMapLayer = React.useCallback((key) => {
    setMapLayers(m => ({ ...m, [key]: !m[key] }));
  }, []);
  // Per-AOR toggle helper — flips one of {friendly, hostile, threats,
  // acms} for a single AOR. Map render code reads
  // `mapLayers.perAor[aor].friendly` etc. directly.
  const toggleAorLayer = React.useCallback((aorId, key) => {
    setMapLayers(m => ({
      ...m,
      perAor: { ...m.perAor, [aorId]: { ...m.perAor[aorId], [key]: !m.perAor[aorId][key] } },
    }));
  }, []);
  // Bulk set — the AOR section header has an "all on / all off" button.
  // Sets every per-AOR layer for that AOR to the same boolean at once.
  const setAorLayerBulk = React.useCallback((aorId, value) => {
    setMapLayers(m => ({
      ...m,
      perAor: { ...m.perAor,
        [aorId]: { friendly:value, hostile:value, threats:value, acms:value, bases:value } },
    }));
  }, []);
  // Set-arbitrary-value setter — used by sub-controls like the
  // hostile-track style picker (segmented control with 2+ values).
  const setMapLayerValue = React.useCallback((key, value) => {
    setMapLayers(m => ({ ...m, [key]: value }));
  }, []);

  // ── COP Watchlist rail state ───────────────────────────────
  // Active + resolved buckets for the Mission Overview rail. Cold
  // load reads from window fixtures; "Resolve" moves an event from
  // active → resolved (session-scoped). Reset returns to seeded.
  const [watchEvents, setWatchEvents]         = React.useState(() => window.WATCH_EVENTS || []);
  const [watchResolved, setWatchResolved]     = React.useState(() => window.WATCH_EVENTS_RESOLVED || []);
  const resolveWatchEvent = React.useCallback((eventId) => {
    setWatchEvents(es => {
      const found = es.find(e => e.id === eventId);
      if (!found) return es;
      const detail = [found.detail1, found.detail2].filter(Boolean).join(' · ');
      setWatchResolved(rs => [{ id:`wer-${eventId}`, title:found.title, detail, secondsAgo:found.secondsAgo }, ...rs]);
      return es.filter(e => e.id !== eventId);
    });
  }, []);
  // Click-a-watch-event handler: fly the COP camera to the event's
  // coords. Sets pendingFlyTo (existing pipe used by Mission Admin's
  // Objectives table) and pins the manual-camera latch so the
  // active-AOR auto-fit doesn't yank back.
  const flyToWatchEvent = React.useCallback((flyTo) => {
    if (!flyTo || flyTo.lat == null || flyTo.lng == null) return;
    setPendingFlyTo({ lat:flyTo.lat, lng:flyTo.lng, zoom: flyTo.zoom || 7 });
  }, []);

  // Home-mode rail collapse state (parity with CSAR's rail1Expanded).
  // Rail 1 (closer to map): mission/forces panels. Rail 2 (outer):
  // Map Layers panel. Both wrapped in CollapsibleRail so the operator
  // can sliver them when they want more map. Defaults collapsed —
  // boot opens at the strategic COP zoom and we want the global
  // threat picture clean by default. Operator clicks the rail header
  // to expand when they need the panels.
  const [normalRail1Expanded, setNormalRail1Expanded] = React.useState(false);
  const [normalLeftRail2Expanded, setNormalLeftRail2Expanded] = React.useState(true);
  const [missionsSearchQuery, setMissionsSearchQuery] = React.useState('');
  const [missionsViewMode, setMissionsViewMode] = React.useState('missions'); // 'missions' or 'callsigns'

  // Tab system for mission/callsign filtering
  const [openTabs, setOpenTabs] = React.useState([{ id: 'all', label: 'ALL', type: 'all' }]);
  const [activeTabId, setActiveTabId] = React.useState('all');

  // Open a new tab for a mission or callsign
  const openTab = React.useCallback((missionId, label, type, severity) => {
    const tabId = `${type}-${missionId}`;
    // Check if tab already exists
    if (openTabs.find(t => t.id === tabId)) {
      setActiveTabId(tabId);
      return;
    }
    // Add new tab
    setOpenTabs(tabs => [...tabs, { id: tabId, label, type, missionId, severity }]);
    setActiveTabId(tabId);
  }, [openTabs]);

  // Close a tab
  const closeTab = React.useCallback((tabId) => {
    setOpenTabs(tabs => {
      const newTabs = tabs.filter(t => t.id !== tabId);
      // If closing active tab, switch to ALL
      if (activeTabId === tabId) {
        setActiveTabId('all');
      }
      return newTabs;
    });
  }, [activeTabId]);

  // Zoom map when active tab changes
  React.useEffect(() => {
    if (!atoRuntime) return;
    const tab = openTabs.find(t => t.id === activeTabId);
    if (!tab || tab.type === 'all') return;

    const missions = atoRuntime.missions || [];

    if (tab.type === 'mission') {
      // Zoom to fit all missions of this type
      const missionType = tab.missionId.replace('mission-', '');
      const missionGroup = missions.filter(m => m.type === missionType);

      const coords = [];
      missionGroup.forEach(m => {
        if (m.geoPast && m.geoPast.length > 0) {
          m.geoPast.forEach(pt => coords.push([pt.lng, pt.lat]));
        }
        if (m.depGeo && Number.isFinite(m.depGeo.lat) && Number.isFinite(m.depGeo.lng)) {
          coords.push([m.depGeo.lng, m.depGeo.lat]);
        }
        if (m.arrGeo && Number.isFinite(m.arrGeo.lat) && Number.isFinite(m.arrGeo.lng)) {
          coords.push([m.arrGeo.lng, m.arrGeo.lat]);
        }
      });

      if (coords.length > 0) {
        const lngs = coords.map(c => c[0]);
        const lats = coords.map(c => c[1]);
        const bbox = [
          Math.min(...lngs), Math.min(...lats),
          Math.max(...lngs), Math.max(...lats)
        ];
        setPendingFitBounds({ bbox, padding: 80, duration: 1000 });
      }
    } else if (tab.type === 'callsign') {
      // Zoom to specific callsign
      const mission = missions.find(m => m.id === tab.missionId);
      if (mission) {
        let lat = null, lng = null, zoom = 8;

        if (mission.geoPast && mission.geoPast.length > 0) {
          const lastPt = mission.geoPast[mission.geoPast.length - 1];
          lat = lastPt.lat; lng = lastPt.lng;
        } else if (mission.arrGeo && Number.isFinite(mission.arrGeo.lat) && Number.isFinite(mission.arrGeo.lng)) {
          lat = mission.arrGeo.lat; lng = mission.arrGeo.lng; zoom = 6;
        } else if (mission.depGeo && Number.isFinite(mission.depGeo.lat) && Number.isFinite(mission.depGeo.lng)) {
          lat = mission.depGeo.lat; lng = mission.depGeo.lng; zoom = 6;
        }

        if (lat != null && lng != null) {
          setPendingFlyTo({ lat, lng, zoom, duration: 1000 });
        }
      }
    }
  }, [activeTabId, atoRuntime]);

  const [normalRail2Expanded, setNormalRail2Expanded] = React.useState(false);

  // Mission-type MM quick toggles — small corner controls in the top-
  // left of the map that let the operator declutter the workspace
  // without diving into the Map Layers panel. Currently a single ACM
  // visibility flag; future toggles slot in alongside it. Default ON so
  // the package's ACMs are visible on first land.
  const [mmShowAcms, setMmShowAcms] = React.useState(true);

  // Mission Admin workspace state — document library, AOR selection,
  // library tab, and upload modal scope. Refresh returns to the seeded
  // MOCK_DOCS list (matches the prototype's no-persistence model).
  const [adminDocs, setAdminDocs]                 = React.useState(() => window.MOCK_DOCS || []);
  const [adminPrimaryAor, setAdminPrimaryAor]     = React.useState('indopacom');
  const [adminActiveAors, setAdminActiveAors]     = React.useState(() => new Set(['indopacom']));
  const [adminLibraryTab, setAdminLibraryTab]     = React.useState('active');
  // Dashboard view mode — 'cards' (the existing column-grouped doc
  // view) or 'gantt' (validity-window timeline by AOR + doc type).
  // Per locked decision: persists across master-mode toggles, resets
  // on full reset.
  const [adminViewMode, setAdminViewMode]         = React.useState('kanban');
  const [adminUploadModal, setAdminUploadModal]   = React.useState(null);
  // Doc preview drawer — id of the currently-open doc, or null. Drawer
  // slides in from the right when set; backdrop scrim makes the
  // workspace beneath non-interactive while open. Closes on AOR
  // change, tab change, mode change, reset, Esc, or backdrop click.
  const [adminOpenDocId, setAdminOpenDocId]       = React.useState(null);

  // ── Mission Admin sub-page routing ────────────────────────────
  // The Mission Admin master mode hosts a dashboard view (readiness +
  // AOR selector + library tabs + grouped doc table) plus three
  // table sub-pages — Platforms / Objectives / Assets — reachable
  // from the doc preview drawer's "View Platforms / Objectives /
  // Assets" buttons (only when the open doc is type=ATO). The sub-
  // pages persist across master-mode toggles: switching to Home and
  // back puts the operator on the same sub-page with the same source-
  // ATO filter chips and search text. Reset returns to dashboard.
  const [adminSubview, setAdminSubview]           = React.useState(
    initialRoute.mode === 'admin' ? initialRoute.subview : 'dashboard'
  );
  const [adminSubviewFilter, setAdminSubviewFilter] = React.useState(() => ({
    atoIds: initialRoute.mode === 'admin' && initialRoute.atoIds ? initialRoute.atoIds : new Set(),
  }));
  const [adminSubviewSearch, setAdminSubviewSearch] = React.useState(
    initialRoute.mode === 'admin' ? (initialRoute.search || '') : ''
  );

  // Per-table sort state. One slot per sub-page (`platforms` /
  // `objectives` / `assets`); each slot is `null` (source order) or
  // `{ columnId, dir }` where dir is 'asc' | 'desc'. 3-state cycle
  // (asc → desc → null) is owned by the click handler that updates
  // this state. Persists across master-mode toggles, like the filter.
  const [adminSortBySubview, setAdminSortBySubview] = React.useState({
    platforms:  null,
    objectives: null,
    assets:     null,
  });

  // Per-platform overlay so the Platforms-table mission/status edits
  // propagate to the live map tooltip without mutating ATO_TASKUNITS
  // (the static fixture). Keyed by track.id. Empty object on cold
  // load and reset.
  const [platformOverrides, setPlatformOverrides] = React.useState({});

  // Cross-mode flyTo signal — set by the Objectives table when the
  // operator clicks a coordinate cell. App switches activeMode to
  // 'home', and TacticalMap's effect consumes the signal once the
  // map is visible, then nulls it via onFlyToConsumed.
  const [pendingFlyTo, setPendingFlyTo]           = React.useState(null);
  // PR 25 — sibling channel to pendingFlyTo for bbox fits. Used by the
  // mission-type MM workspaces to frame the COP camera around just the
  // package's working area on entry. Shape: `{ bbox:[w,s,e,n], padding?, duration? }`.
  const [pendingFitBounds, setPendingFitBounds]   = React.useState(null);
  // Rail 1 expand/collapse-all state lifted to App so LayoutA can shrink the
  // rail to a sliver when the operator collapses everything — reclaims map
  // width without unmounting the rail content.
  const [rail1Expanded, setRail1Expanded] = React.useState(true);
  // Action Queue can blow out into a full-width 5-column view (Done / Doing /
  // To Do / 15-Line / Chat). When `queueExpanded` is true, Rail 2 takes over
  // every pixel between Rail 1 and the window edge, hiding the map. Rail 1
  // keeps its current width (expanded or sliver) and stays butted against
  // the left nav.
  const [queueExpanded, setQueueExpanded] = React.useState(false);
  // Alerts tray collapse state lifted to App so the toast layer can pick
  // the right rendering mode (above the panel when collapsed, inline when
  // open, centered top when the queue's expanded view hides the panel).
  // Defaults collapsed so the boot-time strategic COP view stays clean —
  // toasts still surface in 'above' mode and the operator can click the
  // tray header to open the full panel when they need history.
  const [alertsCollapsed, setAlertsCollapsed] = React.useState(true);

  const darkScore = getDarkScore(dayProgress);

  // ── Legacy appState shim ───────────────────────────────────
  // The prototype was originally written around a single `appState` enum
  // (normal | alert | csar). The master-mode switcher promotes that to a
  // richer model (activeMode + csarAlertPending + csarSessionActive), but
  // a lot of downstream code still reads `appState` / `isCsar`. We derive
  // both from the new state and provide a `setAppState` shim that routes
  // to the new setters so historical call sites (handleAck, reset, undo
  // applySnapshot) keep working without a sweeping rewrite.
  // `appState === 'csar'` is reserved for a *live* recovery — only
  // true once the operator has acknowledged a J3.1 alert (which sets
  // both `csarSessionActive` and `activeMode='csar'` in one step,
  // see handleAck below). Just navigating to CSAR via the strip /
  // drawer with no live case lands the operator in 'normal' state
  // so the CSAR top bar, IP fly-to, mission timer, and rail-1
  // mission state don't fire — they'd all be referencing a case
  // that doesn't exist. The CSAR workspace itself renders an empty
  // "no active case" placeholder in this state (see render branch
  // below) rather than the live mission layout.
  const appState = csarAlertPending ? 'alert'
                : (activeMode === 'csar' && csarSessionActive ? 'csar' : 'normal');
  const setAppState = React.useCallback((next) => {
    if (next === 'alert') {
      setCsarAlertPending(true);
    } else if (next === 'csar') {
      setCsarAlertPending(false);
      setCsarSessionActive(true);
      setActiveMode('csar');
    } else if (next === 'normal') {
      setCsarAlertPending(false);
      setActiveMode('home');
    }
  }, []);

  // Tilt cleanup on CSAR exit. The tilt toggle button is hidden when
  // appState !== 'csar' (see TacticalMap), so if the operator was
  // tilted at the time of CSAR exit they'd otherwise have no UI path
  // back to ops view. Forcing personaMode back to 'ops' here also
  // pairs with the un-tilt branch of the camera-fly effect in
  // TacticalMap so the camera straightens out correctly on exit.
  React.useEffect(() => {
    if (appState !== 'csar' && personaMode === 'ip') {
      setPersonaMode('ops');
    }
  }, [appState, personaMode]);

  // ── Undo history ───────────────────────────────────────────
  // Cmd/Ctrl-Z steps back one user action. We capture a full state snapshot
  // *before* each user-driven mutation (action card clicks, modal submits,
  // OSC pick, etc.); on undo we pop the latest snapshot and re-apply every
  // setter, which rolls back everything that user action triggered: the
  // case state itself, the action queue, alerts, toasts, mission progress,
  // and the simulated clock. Background timers keep ticking — undo is a
  // prototype affordance, not a true time machine.
  const historyRef = React.useRef([]);
  const HISTORY_LIMIT = 50;

  // Snapshot only functional state — modal-open flags and the modal's
  // intra-session state (active crew, trigger source) are excluded so that
  // undo doesn't pop a closed modal back open or reset which crew you were
  // looking at. Also captures the user-edited drafts so an undo restores
  // the JPRC / 15-Line text exactly as it was pre-action.
  const captureSnapshot = () => ({
    appState, elapsed, dayProgress, actions, alerts, toasts, msgs,
    jprcDone, jprcDoneAt, oscDone, oscDoneAt, oscId,
    jprcDraft, escalated, phase,
    beaconAckAlpha, beaconAckBravo, authAlpha, authBravo,
    txSentAlpha, txSentBravo, uarSent, onTaskedGhostId, ghostOnStationAt, ghostOnStationTargetSec, ghostTerminalLogged,
    j35AlphaDone, j35BravoDone, bravoLocated,
    beaconAckAlphaAt, beaconAckBravoAt, authAlphaAt, authBravoAt,
    txSentAlphaAt, txSentBravoAt, uarSentAt,
    j35AlphaDoneAt, j35BravoDoneAt, bravoLocatedAt,
    activeLateThreats,
    coas, activeCoa, activeCoaAt, coaBreach,
    launched, launchedAt, missionProgress, missionPhase,
    samFired, damageFired, medicalFired,
    divertDecision, divertDecisionAt,
    terminalDelegated, terminalDelegatedAt,
    pickupDone, pickupDoneAt,
    fifteenLineDraft, fifteenLineBorderFired, fifteenLineTerminalFired,
    fifteenLineBDraft, fifteenLineCDraft, fifteenLineDDraft, fifteenLineCloseDraft,
    fifteenBSent, fifteenBSentAt, fifteenCSent, fifteenCSentAt,
    fifteenDSent, fifteenDSentAt, fifteenCloseSent, fifteenCloseSentAt,
    rozPublished, rozPublishedAt, rozDraft,
    medevacSent, medevacSentAt, nineLineDraft,
    recoveryDone, recoveryDoneAt,
  });

  const applySnapshot = (s) => {
    setAppState(s.appState); setElapsed(s.elapsed); setDayProgress(s.dayProgress);
    setActions(s.actions); setAlerts(s.alerts); setToasts(s.toasts); setMsgs(s.msgs);
    setJprcDone(s.jprcDone); setJprcDoneAt(s.jprcDoneAt);
    setOscDone(s.oscDone); setOscDoneAt(s.oscDoneAt); setOscId(s.oscId);
    setJprcDraft(s.jprcDraft); setEscalated(s.escalated); setPhase(s.phase);
    setBeaconAckAlpha(s.beaconAckAlpha); setBeaconAckBravo(s.beaconAckBravo);
    setAuthAlpha(s.authAlpha); setAuthBravo(s.authBravo);
    setTxSentAlpha(s.txSentAlpha); setTxSentBravo(s.txSentBravo);
    setUarSent(s.uarSent);
    setOnTaskedGhostId(s.onTaskedGhostId); setGhostOnStationAt(s.ghostOnStationAt);
    setGhostOnStationTargetSec(s.ghostOnStationTargetSec);
    setGhostTerminalLogged(s.ghostTerminalLogged);
    setJ35AlphaDone(s.j35AlphaDone); setJ35BravoDone(s.j35BravoDone);
    setBravoLocated(s.bravoLocated);
    setBeaconAckAlphaAt(s.beaconAckAlphaAt); setBeaconAckBravoAt(s.beaconAckBravoAt);
    setAuthAlphaAt(s.authAlphaAt); setAuthBravoAt(s.authBravoAt);
    setTxSentAlphaAt(s.txSentAlphaAt); setTxSentBravoAt(s.txSentBravoAt);
    setUarSentAt(s.uarSentAt);
    setJ35AlphaDoneAt(s.j35AlphaDoneAt); setJ35BravoDoneAt(s.j35BravoDoneAt);
    setBravoLocatedAt(s.bravoLocatedAt);
    setActiveLateThreats(s.activeLateThreats);
    setCoas(s.coas); setActiveCoa(s.activeCoa); setActiveCoaAt(s.activeCoaAt);
    setCoaBreach(s.coaBreach);
    setLaunched(s.launched); setLaunchedAt(s.launchedAt);
    setMissionProgress(s.missionProgress); setMissionPhase(s.missionPhase);
    setSamFired(s.samFired); setDamageFired(s.damageFired); setMedicalFired(s.medicalFired);
    setDivertDecision(s.divertDecision); setDivertDecisionAt(s.divertDecisionAt);
    setTerminalDelegated(s.terminalDelegated); setTerminalDelegatedAt(s.terminalDelegatedAt);
    setPickupDone(s.pickupDone); setPickupDoneAt(s.pickupDoneAt);
    setFifteenLineDraft(s.fifteenLineDraft);
    setFifteenLineBorderFired(s.fifteenLineBorderFired);
    setFifteenLineTerminalFired(s.fifteenLineTerminalFired);
    setFifteenLineBDraft(s.fifteenLineBDraft); setFifteenLineCDraft(s.fifteenLineCDraft);
    setFifteenLineDDraft(s.fifteenLineDDraft); setFifteenLineCloseDraft(s.fifteenLineCloseDraft);
    setFifteenBSent(s.fifteenBSent); setFifteenBSentAt(s.fifteenBSentAt);
    setFifteenCSent(s.fifteenCSent); setFifteenCSentAt(s.fifteenCSentAt);
    setFifteenDSent(s.fifteenDSent); setFifteenDSentAt(s.fifteenDSentAt);
    setFifteenCloseSent(s.fifteenCloseSent); setFifteenCloseSentAt(s.fifteenCloseSentAt);
    setRozPublished(s.rozPublished); setRozPublishedAt(s.rozPublishedAt); setRozDraft(s.rozDraft);
    setMedevacSent(s.medevacSent); setMedevacSentAt(s.medevacSentAt);
    setNineLineDraft(s.nineLineDraft);
    setRecoveryDone(s.recoveryDone); setRecoveryDoneAt(s.recoveryDoneAt);
    // Undo is a "pull back" gesture — close any modal that happens to be
    // open so the restored state isn't hidden behind a stale dialog.
    setAuthOpen(false); setTxOpen(false); setUarOpen(false); setDraftOpen(false);
    setCoaOpen(false); setLaunchOpen(false); setDivertOpen(false);
    setTerminalOpen(false); setPickupOpen(false); setRecoveryOpen(false);
    setOscOpen(false); setJ35ConfirmOpen(false); setMedevacOpen(false);
    setRozOpen(false); setFifteenSection(null);
  };

  const pushHistory = () => {
    historyRef.current.push(captureSnapshot());
    if (historyRef.current.length > HISTORY_LIMIT) historyRef.current.shift();
  };
  const undoLast = () => {
    const snap = historyRef.current.pop();
    if (!snap) return false;
    applySnapshot(snap);
    return true;
  };

  // Stable ref so the keydown listener (registered once) always calls the
  // latest undoLast — which closes over the current setters and snapshot
  // capture. Without this the listener would capture a stale closure.
  const undoRef = React.useRef(null);
  undoRef.current = undoLast;

  React.useEffect(() => {
    const onKey = (e) => {
      const meta = e.metaKey || e.ctrlKey;
      if (!meta) return;
      if (e.shiftKey) return; // Shift+Cmd+Z reserved for redo (not yet built)
      if (e.key !== 'z' && e.key !== 'Z') return;
      // Don't override undo inside text inputs / textareas / contentEditable.
      const tgt = e.target;
      if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
      e.preventDefault();
      undoRef.current && undoRef.current();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // Day/night timer (180s full transition). Accumulator-based so holding
  // spacebar multiplies the tick increment by 5× (fast-forward).
  //
  // CSAR-scoped: the day/night ramp represents the operator's mission
  // clock — light at handoff, dark by the time the CSARTF is fully
  // committed. The timer is gated to `appState === 'csar'`: it ticks
  // only while the operator is actively viewing CSAR mode.
  //
  // Pause/resume semantics (matches the `elapsed` mission clock above):
  //   • Entering CSAR for the FIRST time: starts at 0 (full daylight),
  //     ramps over 180 s. The cold-start dayProgress useState(0) handles
  //     this — no explicit reset needed.
  //   • Leaving CSAR (to Home/Admin/etc): the interval clears, so
  //     dayProgress holds at whatever value it reached. Does NOT reset
  //     to 0 — operator can browse the COP or load docs without losing
  //     their place in the day/night arc.
  //   • Re-entering CSAR: resumes ticking from the held value.
  //   • reset() (gear → reset): explicitly zeros dayProgress (line ~4973)
  //     to start a fresh scenario at full daylight.
  //
  // We compute the per-tick increment as `1/180` (or 5/180 in
  // fast-forward) so the absolute value of dayProgress IS the elapsed
  // CSAR-mode seconds / 180. No local accumulator — the state is the
  // accumulator. This avoids the previous bug where `acc` was a local
  // closure variable that reset to 0 every time the effect re-fired
  // on mode change, producing the daylight-restart behavior.
  React.useEffect(()=>{
    if (appState !== 'csar') return;
    const id = setInterval(()=>{
      const mult = fastForwardRef.current ? 5 : 1;
      setDayProgress(p => {
        const next = Math.min(1, p + (mult / 180));
        if (next >= 1) clearInterval(id);
        return next;
      });
    },1000);
    return ()=>clearInterval(id);
  },[appState]);

  // Triple-space demo trigger — context-sensitive:
  //   normal mode                           → fire J3.1 intercept
  //   csar mode, phase≥1, !bravoLocated     → locate BRAVO
  // Also: hold spacebar = 5× fast-forward (all tickers multiply their increment).
  React.useEffect(()=>{
    const tripleListening = (appState==='normal') || (appState==='csar' && phase>=1 && !bravoLocated);
    const taps = [];
    const WINDOW = 1000;
    const FF_HOLD_MS = 500; // must hold spacebar >500ms before fast-forward engages
    let ffTimer = null;
    const down = e=>{
      if (e.code!=='Space') return;
      const tag = e.target && e.target.tagName;
      if (tag==='INPUT' || tag==='TEXTAREA') return;
      e.preventDefault();
      // Fast-forward arms on first keydown and activates after the hold window.
      // A quick tap (< FF_HOLD_MS) clears the timer in keyup without engaging.
      if (!e.repeat && !ffTimer) {
        ffTimer = setTimeout(()=>{
          fastForwardRef.current = true;
          window.__fastForward = true;
          ffTimer = null;
        }, FF_HOLD_MS);
      }
      // Key-repeat fires keydown many times while held; only count the first
      // press for triple-tap, otherwise a held space would spuriously trigger.
      if (e.repeat) return;
      if (!tripleListening) return;
      const now = Date.now();
      while (taps.length && now - taps[0] > WINDOW) taps.shift();
      taps.push(now);
      if (taps.length>=3) {
        taps.length=0;
        if (appState==='normal') setAppState('alert');
        else                     locateBravo();
      }
    };
    const up = e=>{
      if (e.code!=='Space') return;
      if (ffTimer) { clearTimeout(ffTimer); ffTimer = null; }
      fastForwardRef.current = false;
      window.__fastForward = false;
    };
    window.addEventListener('keydown', down);
    window.addEventListener('keyup',   up);
    return ()=>{
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup',   up);
      if (ffTimer) { clearTimeout(ffTimer); ffTimer = null; }
      fastForwardRef.current = false;
      window.__fastForward = false;
    };
  },[appState, phase, bravoLocated]);

  // Case clock when in CSAR mode
  React.useEffect(()=>{
    if (appState!=='csar') return;
    const id = setInterval(()=>setElapsed(e=>e + (fastForwardRef.current ? 5 : 1)),1000);
    return ()=>clearInterval(id);
  },[appState]);

  // ATO pipeline tick — advances ATO/AMA-13/14/16/30/34 (window-state,
  // NLT breach, coord gap, time-critical escalation, track projection)
  // on every scenario-clock advance. The runtime is mutated in place;
  // we bump atoGen so React re-renders subscribed panels.
  React.useEffect(() => {
    if (!window.ATO || !window.ATO.runtime) return;
    window.ATO.runtime.lastTickT = elapsed;
    window.ATO.tick(elapsed, window.ATO.runtime);
    setAtoGen(g => g + 1);
  }, [elapsed]);

  // AMA-07 / AMA-08 — pre-deadline escalation at T+0:50 / T+1:50
  // Auto-opens the HCR affordance and fires a critical toast explaining why.
  React.useEffect(()=>{
    if (appState!=='csar') return;
    if (!escalated.jprc && !jprcDone && elapsed>=50) {
      setEscalated(e=>({ ...e, jprc:true }));
      setDraftOpen(true);
      setToasts(t=>[...t.filter(x=>x.id!=='esc-jprc'), {
        id:'esc-jprc',
        ref:'AMA-07 · JTBD-02',
        title:'JPRC Notify deadline in 10s — draft auto-opened',
        body:'Phase 0 clock at T+0:50. Per AFTTP 4.19.4.2, JPRC must be notified within 1 min of PR awareness. Review and submit now to keep case PR-2804-001 within the notify window.',
      }]);
    }
    if (!escalated.osc && !oscDone && elapsed>=110) {
      setEscalated(e=>({ ...e, osc:true }));
      setOscOpen(true);
      setToasts(t=>[...t.filter(x=>x.id!=='esc-osc'), {
        id:'esc-osc',
        ref:'AMA-08 · JTBD-03',
        title:'OSC Appoint deadline in 10s — shortlist auto-opened',
        body:'Phase 0 clock at T+1:50. Initial OSC must be appointed by T+2:00 to maintain command of the engagement. DUKE 32 ranked #1 (on scene, SANDY-qual). Appoint to proceed.',
      }]);
    }
  },[elapsed, appState, jprcDone, oscDone, escalated.jprc, escalated.osc]);

  // Phase 0 → 1 transition — both Phase-0 HCR actions complete
  React.useEffect(()=>{
    if (appState!=='csar') return;
    if (phase===0 && jprcDone && oscDone) {
      setPhase(1);
      // Seed Phase 1 cards at the top of the queue
      setActions(a=>[...window.P1_ACTIONS, ...a]);
      setAlerts(al=>[{ id:'p1-enter', level:'info', time:fmtZulu(elapsed),
        title:'PHASE 1 · LOCATE / PLOT',
        body:'Phase 0 complete. New HCR queue seeded: beacon ack, ISOP auth, optional directives. J3.5 conversion deadline at T+10:00.' }, ...al]);
    }
  },[phase, appState, jprcDone, oscDone, elapsed]);

  // Phase 1 → 2 transition — both survivor tracks converted to J3.5
  // SME-REVIEW: transitioning on BOTH conversions assumes the phase ends when
  // all tracks are J3.5. If BRAVO is never located, this prototype stays in
  // Phase 1 indefinitely — confirm this matches operational expectations.
  React.useEffect(()=>{
    if (phase===1 && j35AlphaDone && j35BravoDone) {
      setPhase(2);
      // Generate initial COAs against current threat picture and seed Phase 2 cards
      const ctx = {
        activeThreats: [...(window.RED_FORCES||[]).filter(r => !r.moving && r.lat != null), ...activeLateThreats],
        ip: window.IP_COORD, staging: window.STAGING_COORD, darkScore,
      };
      setCoas(window.generateCOAs(ctx));
      setActions(a=>[...window.P2_ACTIONS, ...a]);
      setAlerts(al=>[{ id:'p2-enter', level:'info', time:fmtZulu(elapsed),
        title:'PHASE 2 · AIRSPACE / THREAT',
        body:'3 COAs generated against active threat picture. Review and select one to activate the ingress corridor.' }, ...al]);
    }
  },[phase, j35AlphaDone, j35BravoDone, activeLateThreats, darkScore, elapsed]);

  // AMA-10 — per-IP J3.5 conversion deadline at T+10:00. Only fires for BRAVO
  // once BRAVO is located (you can't escalate an inability to convert an
  // unknown track). Upgrades the existing card in place.
  React.useEffect(()=>{
    if (appState!=='csar') return;
    if (phase<1) return;
    // ALPHA — always actionable, deadline from case open
    if (!j35AlphaDone && !escalated.j35Alpha && elapsed>=600) {
      setEscalated(e=>({ ...e, j35Alpha:true }));
      setActions(a=>a.map(x=>x.id==='j35-alpha' ? {
        ...x,
        amaRef:'AMA-10 · JTBD-09',
        title:'J3.5 Overdue · DUKE 31 ALPHA — QC Auto-Promoted',
        body:'AFTTP Table 4.69 deadline exceeded for ALPHA. System auto-promoted J3.1 → J3.5 draft. QC and confirm to restore compliance.',
        fields:[{l:'Track',v:'T-2804-01-A'},{l:'New state',v:'J3.5 draft (AUTO)'},{l:'Fix confidence',v:'HIGH — AMA-33 passed'},{l:'Escalation',v:'AMA-10 · AUTO-PROMOTED'}],
      } : x));
      setToasts(t=>[...t.filter(x=>x.id!=='esc-j35-alpha'), {
        id:'esc-j35-alpha', ref:'AMA-10 · JTBD-09',
        title:'ALPHA J3.5 overdue — auto-promoted for QC',
        body:'Phase 1 clock at T+10:00. Per AFTTP Table 4.69, system escalated and auto-promoted ALPHA\'s track to J3.5 draft. Review and confirm.',
      }]);
    }
    // BRAVO — only escalates if BRAVO has been located (AMA-33 validator needs a fix first)
    if (!j35BravoDone && !escalated.j35Bravo && elapsed>=600 && bravoLocated) {
      setEscalated(e=>({ ...e, j35Bravo:true }));
      setActions(a=>a.map(x=>x.id==='j35-bravo' ? {
        ...x,
        amaRef:'AMA-10 · JTBD-09',
        title:'J3.5 Overdue · DUKE 31 BRAVO — QC Auto-Promoted',
        body:'AFTTP Table 4.69 deadline exceeded for BRAVO. System auto-promoted J3.1 → J3.5 draft. QC and confirm to restore compliance.',
        fields:[{l:'Track',v:'T-2804-01-B'},{l:'New state',v:'J3.5 draft (AUTO)'},{l:'Fix confidence',v:'HIGH — AMA-33 passed'},{l:'Escalation',v:'AMA-10 · AUTO-PROMOTED'}],
      } : x));
      setToasts(t=>[...t.filter(x=>x.id!=='esc-j35-bravo'), {
        id:'esc-j35-bravo', ref:'AMA-10 · JTBD-09',
        title:'BRAVO J3.5 overdue — auto-promoted for QC',
        body:'Phase 1 clock at T+10:00. Per AFTTP Table 4.69, system escalated and auto-promoted BRAVO\'s track to J3.5 draft. Review and confirm.',
      }]);
    }
  },[elapsed, appState, phase, j35AlphaDone, j35BravoDone, bravoLocated, escalated.j35Alpha, escalated.j35Bravo]);

  // Late SAM threat pop-ups — each threat activates at its own seeded random delay
  React.useEffect(()=>{
    if (appState!=='csar') return;
    window.LATE_THREATS.forEach((threat, i)=>{
      const delay = lateDelaysRef.current[i];
      if (delay == null) return;
      if (elapsed < delay) return;
      if (activeLateThreats.some(t => t.id === threat.id)) return;
      setActiveLateThreats(prev=>[...prev, threat]);
      setAlerts(al=>[{
        id: `threat-${threat.id}`,
        level: 'critical',
        time: fmtZulu(elapsed),
        title: `${threat.label} DETECTED`,
        body: `New ${threat.type} site active near IP. Range ${threat.radiusKm} km. Review corridors and threat envelopes.`,
      }, ...al]);
    });
  },[elapsed, appState, activeLateThreats]);

  // Tweaks messaging
  React.useEffect(()=>{
    const h = e=>{
      if (e.data?.type==='__activate_edit_mode')   setTweaksVis(true);
      if (e.data?.type==='__deactivate_edit_mode') setTweaksVis(false);
    };
    window.addEventListener('message',h);
    window.parent.postMessage({type:'__edit_mode_available'},'*');
    return ()=>window.removeEventListener('message',h);
  },[]);

  const doTweak = (k,v)=>setTweaks(t=>{
    const n={...t,[k]:v};
    window.parent.postMessage({type:'__edit_mode_set_keys',edits:n},'*');
    return n;
  });

  const handleAck = ()=>{
    pushHistory();
    setAppState('csar');
    // Seed random activation delays (5–240s window) for each late SAM threat
    lateDelaysRef.current = window.LATE_THREATS.map(() => Math.floor(Math.random() * 235) + 5);
  };
  const dismissToast = id=>setToasts(t=>t.filter(x=>x.id!==id));
  // TTL-elapsed dismissal: instead of vaporising the toast, demote it into
  // the Alerts feed so the operator can still read it later. We map the
  // toast shape onto an alert (level/title/body/ref/time match exactly).
  // Skip toasts that already have an alert sibling — escalation toasts
  // (`esc-*`) and disruption toasts (`dsr-*`) are paired with explicit
  // `setAlerts` writes elsewhere, so demoting them would create dupes.
  const demoteToastToAlert = (id) => {
    setToasts(t => {
      const toast = t.find(x => x.id === id);
      if (toast && !/^(esc-|dsr-)/.test(toast.id)) {
        const alert = {
          id: 'demoted-' + id,
          level: toast.level || 'critical',
          time: fmtZulu(elapsed),
          title: toast.title || '',
          body: toast.body || '',
          ref: toast.ref,
        };
        setAlerts(al => [alert, ...al]);
      }
      return t.filter(x => x.id !== id);
    });
  };
  // Complete-level toast — fired whenever an action card is satisfied, either
  // by direct action on the card or by a side-effect (e.g., beacon ack flipping
  // J3.5). Auto-dismiss in 5s.
  const completeToast = (title, body)=>setToasts(t=>[...t, {
    id:'complete-'+Date.now()+'-'+Math.random().toString(36).slice(2,6),
    level:'complete', title, body:body||'',
  }]);
  // Advisory-level toast — informational/notable, but neither a confirmation
  // nor an emergency. Amber palette, same 5s auto-dismiss as complete.
  const advisoryToast = (title, body)=>setToasts(t=>[...t, {
    id:'advisory-'+Date.now()+'-'+Math.random().toString(36).slice(2,6),
    level:'advisory', title, body:body||'',
  }]);
  // ── Group-comms helper ─────────────────────────────────────
  // Push one or more messages onto the comms log. Each entry is
  // [from, text] OR [from, text, opts] where opts can carry { delayMs, dir }.
  // Used at the same sites we fire toasts/alerts so the operator sees
  // realistic radio chatter alongside the structured event log. The
  // staggered delays (~0–4 s between messages) keep multi-party exchanges
  // from arriving as one chunk.
  const commLog = (entries) => {
    const stamp = fmtZulu(elapsed);
    let cumulative = 0;
    entries.forEach((entry, idx) => {
      const [from, text, opts] = entry;
      const role = (window.COMM_ROSTER && window.COMM_ROSTER[from]) || 'amc';
      const dir  = (opts && opts.dir) || (from === 'SUNDOWN' ? 'tx' : 'rx');
      const delayMs = (opts && typeof opts.delayMs === 'number') ? opts.delayMs : 600 * idx;
      cumulative = Math.max(cumulative, delayMs);
      setTimeout(() => {
        setMsgs(m => [...m, {
          id: Date.now() + Math.floor(Math.random() * 1000) + idx,
          dir, from, role, text, time: stamp,
        }]);
      }, delayMs);
    });
  };
  const confirm = id=>{
    // J3.5 confirmations route through the ConfirmJ35Modal so MC/SL sees the
    // supporting-evidence summary before the track classification flips. The
    // modal's confirm button (commitJ35) snapshots history itself; we skip
    // here so opening the modal doesn't add a redundant undo step.
    if (id === 'j35-alpha') { setJ35ConfirmCrew('alpha'); setJ35ConfirmTrigger('manual'); setJ35ConfirmOpen(true); return; }
    if (id === 'j35-bravo') { setJ35ConfirmCrew('bravo'); setJ35ConfirmTrigger('manual'); setJ35ConfirmOpen(true); return; }
    pushHistory();
    setActions(a=>a.filter(x=>x.id!==id));
    if(id==='jprc-notify') { setJprcDone(true); setJprcDoneAt(fmtZulu(elapsed)); setDraftOpen(false); dismissToast('esc-jprc'); completeToast('JPRC notification sent');
      commLog([
        ['SUNDOWN', 'JPRC, SUNDOWN — case PR-2804-001 in your push. ISOPREP attached.'],
        ['JPRC',    'SUNDOWN, JPRC — case received, accountability open. Network on standby.'],
      ]);
    }
  };
  // Per-crew handlers — fired from inline Survivor Roster affordances
  // Beacon ack confirms survivor signal + position — sufficient to flip J3.1 →
  // J3.5 for that IP's track (AFTTP 4.69). AUTH remains a separate confidence
  // gate, but the track classification itself advances on beacon ack. Gated
  // through the confirm modal so the operator sees exactly what the ack commits.
  const ackBeacon = crew=>{
    setJ35ConfirmCrew(crew);
    setJ35ConfirmTrigger('beacon-ack');
    setJ35ConfirmOpen(true);
  };
  // Actual commit — called from the modal's Confirm button.
  const commitJ35 = ()=>{
    pushHistory();
    const crew = j35ConfirmCrew;
    const trigger = j35ConfirmTrigger;
    const at = fmtZulu(elapsed);
    if (trigger === 'beacon-ack') {
      if (crew==='alpha') {
        setBeaconAckAlpha(true); setBeaconAckAlphaAt(at);
        setJ35AlphaDone(true);   setJ35AlphaDoneAt(at);
        setActions(a => a.filter(x => x.id !== 'j35-alpha'));
        dismissToast('esc-j35-alpha');
        completeToast('Beacon acknowledged · ALPHA', 'J3.5 auto-confirmed for DUKE 31 ALPHA.');
        commLog([
          ['DUKE 31 ALPHA', 'SUNDOWN, ALPHA — beacon up, position 51RTH 8451 1234. Ambulatory. Hostile traffic faint to the south.'],
          ['SUNDOWN',       'ALPHA, SUNDOWN — copy beacon, J3.5 promoted. Stay quiet on TX, recovery package spinning up.'],
        ]);
      } else {
        setBeaconAckBravo(true); setBeaconAckBravoAt(at);
        setJ35BravoDone(true);   setJ35BravoDoneAt(at);
        setActions(a => a.filter(x => x.id !== 'j35-bravo'));
        dismissToast('esc-j35-bravo');
        completeToast('Beacon acknowledged · BRAVO', 'J3.5 auto-confirmed for DUKE 31 BRAVO.');
        commLog([
          ['DUKE 31 BRAVO', 'SUNDOWN, BRAVO — beacon up. Ambulatory, vis approx 750m NE of ALPHA. Closing on his pos.'],
          ['SUNDOWN',       'BRAVO, SUNDOWN — copy. J3.5 promoted. Hold position if you cannot link safely.'],
        ]);
      }
    } else {
      if (crew==='alpha') {
        setJ35AlphaDone(true); setJ35AlphaDoneAt(at);
        setActions(a => a.filter(x => x.id !== 'j35-alpha'));
        dismissToast('esc-j35-alpha');
        completeToast('J3.5 confirmed · DUKE 31 ALPHA');
        commLog([['SUNDOWN', 'All stations — DUKE 31 ALPHA promoted to J3.5.']]);
      } else {
        setJ35BravoDone(true); setJ35BravoDoneAt(at);
        setActions(a => a.filter(x => x.id !== 'j35-bravo'));
        dismissToast('esc-j35-bravo');
        completeToast('J3.5 confirmed · DUKE 31 BRAVO');
        commLog([['SUNDOWN', 'All stations — DUKE 31 BRAVO promoted to J3.5.']]);
      }
    }
    setJ35ConfirmOpen(false);
  };
  const openAuth = crew=>{ setAuthOpenCrew(crew); setAuthOpen(true); };
  const openTx   = crew=>{ setTxOpenCrew(crew);   setTxOpen(true); };
  const submitAuth = crew=>{
    pushHistory();
    if (crew==='alpha') { setAuthAlpha(true); setAuthAlphaAt(fmtZulu(elapsed)); }
    else                { setAuthBravo(true); setAuthBravoAt(fmtZulu(elapsed)); }
    setAuthOpen(false);
    completeToast(`ISOPREP auth complete · ${crew==='bravo'?'BRAVO':'ALPHA'}`);
    const cs = crew === 'bravo' ? 'DUKE 31 BRAVO' : 'DUKE 31 ALPHA';
    commLog([
      ['SUNDOWN', `${cs}, SUNDOWN — authenticate, statement of pre-issued color word.`],
      [cs,       'SUNDOWN — color word GOLF. AUTH GOLF GOLF GOLF.'],
      ['SUNDOWN', `${cs}, SUNDOWN — authentication confirmed. ISOPREP closed.`],
    ]);
  };
  const submitTx = crew=>{
    pushHistory();
    if (crew==='alpha') { setTxSentAlpha(true); setTxSentAlphaAt(fmtZulu(elapsed)); }
    else                { setTxSentBravo(true); setTxSentBravoAt(fmtZulu(elapsed)); }
    setTxOpen(false);
    completeToast(`TX Limit sent · ${crew==='bravo'?'BRAVO':'ALPHA'}`);
    const cs = crew === 'bravo' ? 'DUKE 31 BRAVO' : 'DUKE 31 ALPHA';
    commLog([
      ['SUNDOWN', `${cs}, SUNDOWN — TX LIMIT directive: minimal CSEL transmissions until pickup. Beacon-only otherwise.`],
      [cs,       'SUNDOWN — wilco. Going beacon-only. Will break out if compromise.'],
    ]);
  };
  const submitUar = ()=>{
    pushHistory();
    setUarSent(true); setUarSentAt(fmtZulu(elapsed));
    setActions(a=>a.filter(x=>x.id!=='uar-task'));
    setUarOpen(false);
    completeToast('UAR tasking submitted');
    // Pull the closest GHOST MQ-9 to the IP for wide overwatch. The map reads
    // onTaskedGhostId and runs the transit + IP wide-orbit override. Distance
    // is a flat geodetic approximation — fine at this scale.
    const ip = window.IP_COORD;
    const ghosts = window.ISR_ASSETS || [];
    let closest = null;
    for (const g of ghosts) {
      const midLat = (g.base.lat + ip.lat) / 2;
      const lngKm = 111.32 * Math.cos(midLat * Math.PI / 180);
      const dLat = (g.base.lat - ip.lat) * 111.32;
      const dLng = (g.base.lng - ip.lng) * lngKm;
      const dKm = Math.hypot(dLat, dLng);
      if (!closest || dKm < closest.dKm) closest = { g, dKm };
    }
    const ghostCs = closest ? closest.g.cs : 'GHOST';
    if (closest) setOnTaskedGhostId(closest.g.id);
    // Realistic ETA at MQ-9 cruise (150 kn ≈ 4.63 km/min real-world). Reported
    // in real minutes, since ATO/ABM brevity is always real-world time even
    // when the demo runs at compressed tempo.
    const etaRealMin = closest ? Math.max(1, Math.round(closest.dKm / 4.63)) : 5;
    // Schedule the on-station alert by *elapsed seconds*, not wall-clock — the
    // elapsed ticker honours fast-forward (5×) while wall-clock setTimeout
    // doesn't, so this keeps the alert in sync with the map's transit anim.
    // demoElapsedSec = realMin / DEMO_TEMPO × 60.
    const DEMO_TEMPO = 10;
    const transitDemoSec = closest ? (closest.dKm / 4.63 / DEMO_TEMPO) * 60 : 0;
    setGhostOnStationTargetSec(elapsed + transitDemoSec);
    commLog([
      ['SUNDOWN',       'HOSTCOUNTRY-2, SUNDOWN — UAR tasking inbound. Need ground confirm on ISOP LKP and patrol activity vic IP.'],
      ['HOSTCOUNTRY-2', 'SUNDOWN — copy task. Network spinning. Will report when assets are tipped.'],
      ['SUNDOWN',       `${ghostCs} — retasking to IP overwatch, FL280 wide orbit. On-station ETA approx ${etaRealMin} min.`],
    ]);
  };
  // ── Phase 2: COA (Course of Action) handlers ──────────────
  const regenerateCoas = ()=>{
    // Anchor the COA's INGRESS from PEDRO's live position when CSARTF is
    // already airborne — otherwise a re-COA after a breach would draw the
    // ingress from the original staging area, leaving the recovery party
    // visibly off the new route. Egress still ends at the actual staging
    // (the helos have to land somewhere real).
    const inFlight = launched && (missionPhase === 'ingress' || missionPhase === 'terminal');
    const ingressStart = (inFlight && typeof window.getPedroPos === 'function')
      ? window.getPedroPos()
      : null;
    const ctx = {
      activeThreats: [...(window.RED_FORCES||[]).filter(r => !r.moving && r.lat != null), ...activeLateThreats],
      ip: window.IP_COORD, staging: window.STAGING_COORD, ingressStart, darkScore,
    };
    setCoas(window.generateCOAs(ctx));
  };
  const openCoa = ()=>{
    // Always regenerate against the current threat picture so the operator
    // sees COAs reflecting any SAMs that popped since the last open (not just
    // after a corridor breach).
    regenerateCoas();
    // Clear any outstanding corridor-breach toast — the operator is now
    // actively addressing it, so the alarm shouldn't linger in the chrome.
    setToasts(t => t.filter(x => !x.id.startsWith('coa-breach-')));
    setCoaOpen(true);
  };
  const submitCoa = coaId=>{
    const chosen = coas.find(c => c.id === coaId);
    if (!chosen) return;
    pushHistory();
    setActiveCoa(chosen);
    setActiveCoaAt(fmtZulu(elapsed));
    setCoaBreach(false);
    setActions(a => a.filter(x => x.id !== 'coa-review' && x.id !== 'coa-rereview'));
    setCoaOpen(false);
    // Mid-flight reroute: the freshly-committed COA's ingressRoute starts
    // from PEDRO's live position (regenerateCoas anchors `ingressStart` at
    // getPedroPos()), so progress=0 on the new route IS where PEDRO is right
    // now. We must reset missionProgress to 0 — otherwise PEDRO snaps to the
    // old fraction along the new route, which puts him past his real spot
    // and visually "continues on the old path" toward the threat. Egress
    // beats (>0.85, etc.) re-fire correctly from the ticker after this reset.
    const inFlight = launched && (missionPhase === 'ingress' || missionPhase === 'terminal');
    if (inFlight) {
      setMissionProgress(0);
      // Drop back to ingress in case we were in 'terminal' on the old route —
      // the new corridor starts at PEDRO and runs to IP, terminal beat will
      // re-fire as the new route is flown.
      if (missionPhase === 'terminal') setMissionPhase('ingress');
    }
    completeToast(`COA committed · ${chosen.label}`, 'Ingress corridor active on the COP.');
    commLog([
      ['SUNDOWN', `All stations — committing ${chosen.label}. Ingress corridor active on the COP.`],
      ['DUDE 11', `SUNDOWN — DUDE 11 copies ${chosen.label}. Corridor logged, deconflicting adjacent ATO.`],
    ]);
    // AMC/ABM-HOOK: when the AMC/ABM prototype lands, this is where the inbound
    // lateral tasking arrives. For now, surface as a non-critical toast + alert entry.
    if (chosen.requiresSEAD && chosen.seadTargets.length) {
      const tgts = chosen.seadTargets.map(t => t.threatLabel).join(', ');
      setToasts(t=>[...t, {
        id:'coord-sead-' + Date.now(), ref:'AMC/ABM · SEAD coord',
        title:'SEAD tasking sent to AMC/ABM',
        body:`SEAD prosecution requested against: ${tgts}. BRONCO 21/22 will receive tasking via lateral coord.`,
      }]);
      setAlerts(al=>[{ id:'coord-sead-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
        title:'LATERAL COORD · SEAD REQUEST',
        body:`Targets: ${tgts}. Awaiting AMC/ABM assignment of SEAD package.` }, ...al]);
      commLog([
        ['SUNDOWN', `ABM, SUNDOWN — SEAD request: ${tgts}. Tasking BRONCO via lateral coord.`],
        ['ABM',     `SUNDOWN, ABM — copy SEAD on ${tgts}. BRONCO 21/22 cued.`],
      ]);
    }
  };

  // ── COA route breach detection — fires when a late SAM pops onto the
  // active flight path itself (not just inside the looser corridor swath).
  // We test the threat ring against the actual ingress + egress polyline
  // legs with NO half-width buffer: a threat ring that brushes the corridor
  // edge but doesn't actually cross the route line is not a breach. Drives
  // the demo behaviour MC/SL expects — re-COA only when CSARTF would
  // physically fly into the engagement zone.
  React.useEffect(()=>{
    if (!activeCoa) return;
    if (coaBreach) return; // already alerted; waiting on operator to re-review
    const known = new Set(activeCoa.knownThreatIds || []);
    const live = window.__activeFlightRoute;
    // Routes-only (no halfW). If the COP refinement hasn't published a live
    // route yet, fall back to the COA's own ingress/egress polylines.
    const routes = [];
    if (live && live.ingressRoute) routes.push(live.ingressRoute);
    else if (activeCoa.ingressRoute) routes.push(activeCoa.ingressRoute);
    if (live && live.egressRoute)  routes.push(live.egressRoute);
    else if (activeCoa.egressRoute) routes.push(activeCoa.egressRoute);

    // True line-vs-ring test: threat blocks a route iff it lies within
    // threat.radiusKm of any segment of that route.
    const lineBlocked = (line, threat) => {
      for (let i = 0; i < line.length - 1; i++) {
        if (window.doesCorridorIntersect({ _centerline:[line[i], line[i+1]], _halfWidthKm:0 }, threat)) return true;
      }
      return false;
    };

    for (const threat of activeLateThreats) {
      // Skip threats the COA was generated against — those aren't breaches,
      // they were part of the picture when this COA was designed.
      if (known.has(threat.id)) continue;
      const blocks = routes.some(line => lineBlocked(line, threat));
      if (blocks) {
        setCoaBreach(true);
        setToasts(t=>[...t, {
          id:'coa-breach-'+Date.now(), ref:'AMA-21 · JTBD-26',
          title:`Corridor breach — ${activeCoa.label} no longer viable`,
          body:`New ${threat.label} ring intersects the active flight path. Acknowledge and review fresh COAs against the updated threat picture.`,
        }]);
        setActions(a=>[{
          id:'coa-rereview', amaRef:'AMA-21 → AMA-50 [SME-REVIEW]', title:'Review COAs — corridor breach',
          urgency:'critical', hcr:'yes', deadline:null,
          body:`Active ${activeCoa.label} corridor breached by ${threat.label}. Fresh COAs will be generated against the current threat picture on open.`,
          fields:[{l:'Breached COA',v:activeCoa.label},{l:'Cause',v:`${threat.label} range ${threat.radiusKm} km`},{l:'Action',v:'Acknowledge + reselect'}],
        }, ...a]);
        commLog([
          ['DUKE 32', `SUNDOWN, DUKE 32 — new contact, ${threat.label} active. Corridor exposure changed.`],
          ['ABM',     `SUNDOWN, ABM — ${threat.label} on the system. Recommend re-COA.`],
          ['SUNDOWN', `All stations — corridor breach. ${activeCoa.label} no longer viable. Re-reviewing.`],
        ]);
        break;
      }
    }
  },[activeLateThreats, activeCoa, coaBreach]);

  // ── Phase 3: Tactical Execution handlers ─────────────────────
  const openLaunch    = ()=>setLaunchOpen(true);
  const submitLaunch  = verdict=>{
    if (verdict === 'no-go') { setLaunchOpen(false); return; }
    pushHistory();
    setLaunched(true);
    setLaunchedAt(fmtZulu(elapsed));
    setPhase(3);
    setMissionPhase('ingress');
    setMissionProgress(0);
    setActions(a => a.filter(x => x.id !== 'launch-ready'));
    setLaunchOpen(false);
    // Schedule all three disruptions for this run.
    //  1) New SAM pops within 5–15 s of launch (setTimeout).
    //  2) Battle damage fires at random progress 0.25–0.50.
    //  3) Medical degradation fires at random progress 0.55–0.75.
    // All three before the HH-60s reach terminal area at progress 0.85.
    const sched = disruptionScheduleRef.current;
    sched.damageAtProgress  = 0.25 + Math.random() * 0.25;
    sched.medicalAtProgress = 0.55 + Math.random() * 0.20;
    const samDelayMs = 5000 + Math.random() * 10000;
    if (sched.samTimerId) clearTimeout(sched.samTimerId);
    sched.samTimerId = setTimeout(()=>{
      const dt = (window.DISRUPTION_TYPES || []).find(d => d.id === 'new-sam');
      if (!dt || !dt.spawnsThreat) return;
      const t = dt.spawnsThreat;
      setActiveLateThreats(curr => curr.some(x => x.id === t.id) ? curr : [...curr, t]);
      setAlerts(al=>[{ id:'dsr-sam-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
        title:'NEW THREAT · '+t.label,
        body:`${t.label} ring appeared mid-ingress. Corridor exposure re-evaluating.` }, ...al]);
      commLog([
        ['CHECK 41', `All stations, CHECK 41 — emitter ID, ${t.label}, lat ${t.lat.toFixed(2)} lng ${t.lng.toFixed(2)}, lethal radius ${t.radiusKm} km.`],
        ['SUNDOWN',  `CHECK 41 — SUNDOWN, copies. Re-evaluating corridor exposure.`],
      ]);
      setSamFired(true);
      sched.samTimerId = null;
    }, samDelayMs);
    setAlerts(al=>[{ id:'p3-launch', level:'info', time:fmtZulu(elapsed),
      title:'PHASE 3 · TACTICAL EXECUTION',
      body:'CSARTF launched. PEDRO 61 + JOLLY 62 departing staging for corridor ingress.' }, ...al]);
    completeToast('CSARTF launched', 'PEDRO 61 + JOLLY 62 departing for corridor ingress.');
    commLog([
      ['SUNDOWN',  'CSARTF — SUNDOWN. CLEARED FOR LAUNCH. Committed COA on the COP. Pickup is two ISOP at the IP.'],
      ['PEDRO 61', 'SUNDOWN, PEDRO 61 — wheels-up, wingman tucked. Pushing the corridor now.'],
      ['JOLLY 62', 'PEDRO 61, JOLLY 62 — visual, station two left.'],
      ['DUDE 11',  'CSARTF — DUDE 11 overhead, you have AMC accountability through pickup.'],
    ]);
    // Seed 15-Line Section B card — initial CSARTF tasking transmission.
    setActions(a => a.some(x => x.id === '15line-b') ? a : [{
      id:'15line-b', amaRef:'JTBD-38 [SME-REVIEW]',
      title:'15-Line Section B — CSARTF launched',
      urgency:'advisory', hcr:'yes', deadline:null,
      body:'CSARTF airborne and committed to ingress. Review and transmit initial 15-Line update to JPRC via AMC.',
      fields:[{l:'Triggered by',v:'Launch authorization'},{l:'Recipient',v:'JPRC via AMC (DUDE 11)'}],
    }, ...a]);
  };

  const openDivert    = ()=>{
    // Clear the battle-damage toast — MC/SL is now addressing the decision,
    // so the alarm shouldn't linger.
    setToasts(t => t.filter(x => !x.id.startsWith('dsr-bd-')));
    setDivertOpen(true);
  };
  const submitDivert  = choice=>{
    pushHistory();
    setDivertDecision(choice);
    setDivertDecisionAt(fmtZulu(elapsed));
    setActions(a => a.filter(x => x.id !== 'disruption-divert'));
    setDivertOpen(false);
    if (choice === 'abort') {
      setMissionPhase('aborted');
      setAlerts(al=>[{ id:'p3-abort-'+Date.now(), level:'critical', time:fmtZulu(elapsed),
        title:'MISSION ABORT · RTB',
        body:'Both HH-60s reversing course. Case requires re-planning from FARP.' }, ...al]);
      completeToast('Divert decision · ABORT', 'Both HH-60s reversing course to staging.');
      commLog([
        ['SUNDOWN',  'CSARTF — SUNDOWN. ABORT, ABORT, ABORT. Reverse course, RTB FARP.'],
        ['PEDRO 61', 'SUNDOWN — copy abort, breaking off. Reversing along corridor.'],
        ['JOLLY 62', 'Two, JOLLY 62 — copy, on PEDRO\'s wing.'],
      ]);
    } else {
      setAlerts(al=>[{ id:'p3-solo-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
        title:'JOLLY 62 · SOLO DIVERT',
        body:'JOLLY 62 RTB direct. PEDRO 61 continuing to IP solo for recovery.' }, ...al]);
      completeToast('Divert decision · SOLO', 'JOLLY 62 RTB. PEDRO 61 continues to IP.');
      commLog([
        ['SUNDOWN',  'CSARTF — SUNDOWN. JOLLY 62 RTB direct. PEDRO 61 press to IP, SOLO.'],
        ['JOLLY 62', 'SUNDOWN — JOLLY 62 copies, breaking off. Direct to base.'],
        ['PEDRO 61', 'SUNDOWN — PEDRO 61 pressing solo. Confirming pickup of two.'],
      ]);
    }
  };

  const openTerminal   = ()=>setTerminalOpen(true);
  const submitTerminal = ()=>{
    pushHistory();
    setTerminalDelegated(true);
    setTerminalDelegatedAt(fmtZulu(elapsed));
    setActions(a => a.filter(x => x.id !== 'terminal-handoff'));
    setTerminalOpen(false);
    setAlerts(al=>[{ id:'p3-terminal-'+Date.now(), level:'info', time:fmtZulu(elapsed),
      title:'TERMINAL AUTHORITY · OSC',
      body:'Close-in control delegated to OSC. MC/SL standing by for pickup ack.' }, ...al]);
    completeToast('Terminal control delegated to OSC');
    const oscCs = oscNameById(oscId);
    commLog([
      ['SUNDOWN', `${oscCs}, SUNDOWN — you have terminal control. Call POPCORN on pickup.`],
      [oscCs,    `SUNDOWN, ${oscCs} — copies terminal control. PEDRO inbound. Eyes on IP.`],
    ]);
  };

  // Pickup card opens the unified 15-Line modal on the Section A tab.
  // The modal's tab strip lets the operator review every section (sent or
  // pending) without closing — submit on Section A still routes through
  // the pickup flow's special-case handling (POPCORN + ROZ collapse).
  const openPickup    = ()=>setFifteenSection('A');
  const submitPickup  = ()=>{
    pushHistory();
    const at = fmtZulu(elapsed);
    setPickupDone(true);
    setPickupDoneAt(at);
    setActions(a => a.filter(x => x.id !== 'pickup-ack'));
    setPickupOpen(false);
    setFifteenSection(null);
    setMissionPhase('egress');
    setMissionProgress(1); // reset ticker boundary — egress counts 1→0
    // POPCORN — code word for human pickup count, transmitted from the
    // recovery vehicle the instant survivors are aboard. Critical-level
    // toast (J3.1 importance) — same visual weight as the original J3.1
    // alert because everyone listening on the net needs to hear the count.
    const headcount = bravoLocated ? 2 : 1; // BRAVO included only if confirmed
    // POPCORN is a brevity code-word for humans-aboard count, not an emergency
    // — render the toast as a successful-action notice (green) rather than
    // a red critical bar. The corresponding entry in the alert feed below
    // keeps a higher level so it shows up clearly in the operational log.
    // Combined POPCORN + 15-Line A toast — both events fire at the same
    // moment (pickup ack triggers the headcount call AND the Section A
    // transmission), so stacking two toasts adds noise without conveying
    // anything the merged toast doesn't. Body covers both sub-events.
    setToasts(t=>[...t, {
      id:'popcorn-'+Date.now(),
      level:'complete',
      ref:'POPCORN · J3.1 · 15-LINE A',
      title:`POPCORN ${headcount}`,
      body:`${headcount}× ISOP secured aboard PEDRO 61. 15-Line Section A transmitted; PR ROZ collapsed; egress initiated.`,
      // POPCORN demands explicit operator ack — no auto-dismiss. The number
      // is the highest-importance datum on the net at this moment; the
      // operator should consciously confirm they saw it before it clears.
      requiresAck: true,
    }]);
    setAlerts(al=>[
      { id:'p3-popcorn-'+Date.now(), level:'critical', time:at,
        title:`POPCORN ${headcount}`,
        body:`Recovery vehicle reports POPCORN ${headcount} — ${headcount}× ISOP on board PEDRO 61.` },
      { id:'p3-roz-'+Date.now(),    level:'info', time:at,
        title:'PR ROZ · COLLAPSED',
        body:'Personnel Recovery ROZ at IP released. Adjacent air assets advised airspace is open.' },
      { id:'p3-15line-'+Date.now(), level:'info', time:at,
        title:'15-LINE SECTION A · TRANSMITTED',
        body:'Section A update sent to JPRC / AMC / ABM. Survivors aboard PEDRO 61.' },
      { id:'p3-pickup-'+Date.now(), level:'info', time:at,
        title:'PICKUP COMPLETE',
        body:`${headcount === 2 ? 'ALPHA + BRAVO' : 'ALPHA'} on board PEDRO 61. Egress initiated.` },
      ...al,
    ]);
    // (15-Line Section A transmitted toast merged into the POPCORN toast above
    // — the two events fire together, so a single combined toast is cleaner.)
    const both = (headcount === 2);
    commLog([
      ['PEDRO 61',     `ALL STATIONS, PEDRO 61 — POPCORN ${headcount}. ${both?'ALPHA + BRAVO':'ALPHA'} aboard.`],
      ['PEDRO 61 PJ', both
        ? 'PJ — both crew aboard, ambulatory, vitals stable. Beginning assessment.'
        : 'PJ — ALPHA aboard, ambulatory, vitals stable. Beginning assessment.'],
      ['SUNDOWN',     `PEDRO — copy POPCORN ${headcount}. ROZ collapsing, AMC notified. Egress when able.`],
      ['DUDE 11',     `PEDRO — DUDE 11 has accountability through egress. Tanker ready post-corridor.`],
    ]);
  };

  // ── 15-Line Sections B / C / D / CLOSEOUT ─────────────────
  // Build the section spec consumed by FifteenLineModal. Title, AMA tag,
  // right-pane context blocks, and footer copy vary by section. Drafts
  // stay independent state so operator edits persist.
  const oscNameById = (id) => {
    if (!id) return 'DUKE 32';
    const t = (window.ALL_TRACKS || []).find(x => x.id === id);
    return t ? t.cs : 'OSC';
  };
  const knownThreatBriefs = () => {
    const all = [
      ...(window.RED_FORCES || []).filter(r => r && r.lat != null && r.radiusKm > 0),
      ...activeLateThreats.filter(r => r && r.lat != null && r.radiusKm > 0),
    ];
    if (all.length === 0) return 'No active SAM threats touching corridor.';
    return all.map(r => `${r.label} (${r.radiusKm}km)`).join(' · ');
  };
  const sectionSpec = (id) => {
    if (id === 'A') return {
      title: '15-Line Section A — Pickup Complete + PR ROZ Drop',
      amaRef: 'JTBD-38 [SME-REVIEW]',
      contextLabel: 'Pickup context · read-only',
      contextBlocks: [
        { label:'PICKUP CONFIRMED', tone:'good', mono:true,
          body:`VEHICLE  · PEDRO 61\nONBOARD  · DUKE 31 ALPHA, DUKE 31 BRAVO\nMEDICAL  · ambulatory, minor, stable\nONWARD   · corridor egress → FARP → medical` },
        { label:'PR ROZ · COLLAPSING', tone:'advisory',
          body:'On transmit, the Personnel Recovery Restricted Operations Zone around the IP is released. AMC/ABM notified; adjacent air assets receive airspace-open advisory.' },
        { label:'DOWNSTREAM EFFECTS',
          body:'• JPRC track updated: survivors aboard recovery vehicle\n• Egress corridor activation remains in place until CSARTF clears threat envelopes\n• KING 11 tanker cued for egress-leg top-off\n• Terminal-area OSC authority returns to MC/SL on RTB' },
      ],
      footerCopy: 'Transmit logs pickup + PR ROZ drop and initiates egress.',
      submitLabel: 'Transmit & Acknowledge',
    };
    if (id === 'B') return {
      title: '15-Line Section B — Initial / CSARTF Launched',
      amaRef: 'JTBD-38 [SME-REVIEW]',
      contextLabel: 'Launch context · read-only',
      contextBlocks: [
        { label:'CSARTF LAUNCHED', tone:'good', mono:true, body:`PEDRO 61 (HH-60W primary)\nJOLLY 62 (HH-60W reserve)\nESCORT  · ${oscNameById(oscId)} on station\nAMC     · DUDE 11 (MC-130J) overwatch` },
        { label:'COMMITTED COA', body: activeCoa ? `${activeCoa.label} — ingress corridor active. Risk: ${activeCoa.risk?activeCoa.risk.airParity:'—'}.` : 'Active COA pending — confirm before transmit.' },
        { label:'RECIPIENTS', body:'JPRC (primary), AMC (DUDE 11), ABM. Section C update due at border crossing.' },
      ],
      footerCopy: 'Transmit logs CSARTF launch and onward 15-Line cadence.',
    };
    if (id === 'C') return {
      title: '15-Line Section C — Border Crossed / Ingress Update',
      amaRef: 'JTBD-38 [SME-REVIEW]',
      contextLabel: 'Ingress context · read-only',
      contextBlocks: [
        { label:'CSARTF POSITION', tone:'good', mono:true, body:'Crossed friendly/contested boundary on committed corridor.\nIn-trail formation: PEDRO 61 lead, JOLLY 62 wing, escort SARCAP trailing.' },
        { label:'THREAT PICTURE', tone:'advisory', body: knownThreatBriefs() },
        { label:'REMARKS', body:'AMC retains 15-line accountability. SANDY snorkel at terminal area entry. No re-COA at transmit time.' },
      ],
      footerCopy: 'Transmit confirms CSARTF in contested airspace; updates JPRC.',
    };
    if (id === 'D') return {
      title: '15-Line Section D — Terminal Area / OSC Close-In Control',
      amaRef: 'JTBD-38 [SME-REVIEW]',
      contextLabel: 'Terminal area context · read-only',
      contextBlocks: [
        { label:'TERMINAL AREA', tone:'good', mono:true, body:`OSC      · ${oscNameById(oscId)} (close-in control)\nMC/SL    · monitoring\nIP       · within visual range\nACOUSTIC · detection rings active around HH-60s` },
        { label:'AMC ACCOUNTABILITY', tone:'advisory', body:'SANDY snorkel — AMC retains 15-line accountability until pickup ack.' },
        { label:'NEXT EVENT', body:'PR ROZ collapse + 15-Line Section A on pickup confirmation.' },
      ],
      footerCopy: 'Transmit confirms terminal-area entry and OSC delegation.',
    };
    if (id === 'CLOSE') return {
      title: '15-Line Closeout — Recovery Complete · Case Closing',
      amaRef: 'AMA-34 · JTBD-44 [SME-REVIEW]',
      contextLabel: 'Closeout context · read-only',
      contextBlocks: [
        { label:'RECOVERY COMPLETE', tone:'good', mono:true, body:'PEDRO 61 + JOLLY 62 RTB FARP HUALIEN.\n2× ISOP recovered, accounted for at FARP.\n9-line relayed; surface transport ready.' },
        { label:'ACCOUNTABILITY', body:'PR ROZ already collapsed. JPRC track closed. After-action with SANDY/AMC scheduled per SOP.' },
        { label:'REMARKS', body:'Case PR-2804-001 closing on transmit. Lessons captured for next sortie debrief.' },
      ],
      footerCopy: 'Transmit logs recovery complete and closes the case.',
      submitLabel: 'Transmit & Close Case',
    };
    return null;
  };
  const draftForSection = (id) => {
    if (id === 'A')     return fifteenLineDraft;
    if (id === 'B')     return fifteenLineBDraft;
    if (id === 'C')     return fifteenLineCDraft;
    if (id === 'D')     return fifteenLineDDraft;
    if (id === 'CLOSE') return fifteenLineCloseDraft;
    return null;
  };
  const setDraftForSection = (id, d) => {
    if (id === 'A')     setFifteenLineDraft(d);
    if (id === 'B')     setFifteenLineBDraft(d);
    if (id === 'C')     setFifteenLineCDraft(d);
    if (id === 'D')     setFifteenLineDDraft(d);
    if (id === 'CLOSE') setFifteenLineCloseDraft(d);
  };
  const openFifteen = (id) => setFifteenSection(id);
  const submitFifteen = () => {
    const id = fifteenSection;
    if (!id) return;
    // Section A is owned by the pickup-acknowledgement flow — its submit
    // hook does PR ROZ collapse, pickup completion, egress kickoff, etc.
    // Route through the dedicated handler so all those side-effects fire.
    if (id === 'A') { submitPickup(); return; }
    pushHistory();
    const at = fmtZulu(elapsed);
    const cardId = id === 'CLOSE' ? '15line-close' : `15line-${id.toLowerCase()}`;
    setActions(a => a.filter(x => x.id !== cardId));
    setFifteenSection(null);
    if (id === 'B')     { setFifteenBSent(true);     setFifteenBSentAt(at); }
    if (id === 'C')     { setFifteenCSent(true);     setFifteenCSentAt(at); }
    if (id === 'D')     { setFifteenDSent(true);     setFifteenDSentAt(at); }
    if (id === 'CLOSE') { setFifteenCloseSent(true); setFifteenCloseSentAt(at); }
    setAlerts(al=>[{ id:`p3-15line-${id}-${Date.now()}`, level:'info', time:at,
      title:`15-LINE SECTION ${id === 'CLOSE' ? 'CLOSEOUT' : id} · TRANSMITTED`,
      body:`AMC relayed Section ${id === 'CLOSE' ? 'Closeout' : id} to JPRC.` }, ...al]);
    completeToast(`15-Line Section ${id === 'CLOSE' ? 'Closeout' : id} transmitted`);
    const labelMap = { B:'Section B', C:'Section C', D:'Section D', CLOSE:'Closeout' };
    const lbl = labelMap[id] || `Section ${id}`;
    commLog([
      ['SUNDOWN', `DUDE 11, SUNDOWN — relay 15-Line ${lbl} to JPRC.`],
      ['DUDE 11', `SUNDOWN, DUDE 11 — copy ${lbl}, pushing to JPRC now.`],
      ['JPRC',    `DUDE 11, JPRC — ${lbl} received. Accountability updated.`],
    ]);
  };

  // ── PR ROZ Drop (JTBD-15) — Phase 1 airspace publish ───────
  const openRoz   = () => setRozOpen(true);
  const submitRoz = () => {
    pushHistory();
    const at = fmtZulu(elapsed);
    setRozPublished(true);
    setRozPublishedAt(at);
    setActions(a => a.filter(x => x.id !== 'roz-drop'));
    setRozOpen(false);
    setAlerts(al=>[{ id:'p1-roz-pub-'+Date.now(), level:'info', time:at,
      title:'PR ROZ · PUBLISHED',
      body:'5 NM PR ROZ around DUKE 31 IP active. AMC published on ATO supplement; ABM updating airspace deconfliction.' }, ...al]);
    completeToast('PR ROZ published', 'Airspace measure active around the IP.');
    commLog([
      ['SUNDOWN', 'DUDE 11, SUNDOWN — publishing PR ROZ, 5 NM around DUKE 31 IP, SFC to FL180. Effect immediate.'],
      ['DUDE 11', 'SUNDOWN — DUDE 11 copies PR ROZ. Pushing to ABM and ATO supplement now.'],
      ['ABM',     'DUDE 11 — ABM has it. Adjacent SARCAP and strike packages are being deconflicted.'],
    ]);
  };

  const openMedevac     = ()=>setMedevacOpen(true);
  const submitMedevac   = ()=>{
    pushHistory();
    const at = fmtZulu(elapsed);
    setMedevacSent(true);
    setMedevacSentAt(at);
    setActions(a => a.filter(x => x.id !== 'medevac-9line'));
    setMedevacOpen(false);
    setAlerts(al=>[{ id:'p3-9line-'+Date.now(), level:'info', time:at,
      title:'9-LINE MEDEVAC · TRANSMITTED',
      body:'PJ 9-line relayed to JPRC via AMC. Both ISOP cleared as ROUTINE; surface transport at FARP.' }, ...al]);
    completeToast('9-Line medevac transmitted', 'PJ assessment relayed to JPRC.');
    commLog([
      ['PEDRO 61 PJ', 'JPRC, PEDRO 61 PJ — 9-line: 2× ROUTINE, both ambulatory, vitals stable, surface transport at FARP. SpO2 96/94.'],
      ['JPRC',        'PEDRO 61 — JPRC copies 9-line. Surface transport coordinated. Standing by for FARP arrival.'],
    ]);
  };
  const openRecovery    = ()=>setRecoveryOpen(true);
  const submitRecovery  = ()=>{
    pushHistory();
    setRecoveryDone(true);
    setRecoveryDoneAt(fmtZulu(elapsed));
    setActions(a => a.filter(x => x.id !== 'recovery-complete'));
    setRecoveryOpen(false);
    setAlerts(al=>[{ id:'p3-recovery-'+Date.now(), level:'info', time:fmtZulu(elapsed),
      title:'RECOVERY COMPLETE · CASE CLOSED',
      body:'CSARTF back at FARP. Both survivors recovered. Case closed.' }, ...al]);
    completeToast('Case closed · Recovery complete');
    // Release the on-tasked GHOST — the map watches onTaskedGhostId clearing
    // and runs the return transit back to the standing orbit centre.
    const releasedGhost = onTaskedGhostId
      ? (window.ISR_ASSETS || []).find(g => g.id === onTaskedGhostId)
      : null;
    if (onTaskedGhostId) setOnTaskedGhostId(null);
    commLog([
      ['PEDRO 61', 'SUNDOWN, PEDRO 61 — wheels-down FARP HUALIEN. Survivors offloaded to surface transport.'],
      ['JOLLY 62', 'SUNDOWN, JOLLY 62 — wheels-down, holding alert.'],
      ...(releasedGhost ? [
        ['SUNDOWN',         `${releasedGhost.cs} — release from IP overwatch, RTB standing orbit. Thanks for the eyes.`],
        [releasedGhost.cs,  'SUNDOWN — copy release. Returning to standing orbit. Standing by.'],
      ] : []),
      ['SUNDOWN',  'CSARTF — SUNDOWN. Outstanding work. Closing case PR-2804-001.'],
      ['JPRC',     'SUNDOWN — JPRC concurs. Final accountability complete. Case closed.'],
    ]);
    // Seed 15-Line CLOSEOUT card — final transmission to JPRC closing
    // the case formally. Operator reviews and transmits.
    setActions(a => a.some(x => x.id === '15line-close') ? a : [{
      id:'15line-close', amaRef:'AMA-34 · JTBD-44 [SME-REVIEW]',
      title:'15-Line Closeout — case closing',
      urgency:'advisory', hcr:'yes', deadline:null,
      body:'CSARTF recovered, case closing. Review and transmit closeout 15-Line so JPRC + AMC can finalize accountability.',
      fields:[{l:'Triggered by',v:'Recovery complete'},{l:'Recipient',v:'JPRC via AMC'}],
    }, ...a]);
  };

  // ── GHOST terminal overwatch passive feed — fires once when CSARTF crosses
  // into terminal phase with an on-tasked GHOST already on station. Cues the
  // operator that the UAV's tighter orbit means active overwatch on the IP.
  React.useEffect(() => {
    if (!onTaskedGhostId || !ghostOnStationAt) return;
    if (missionPhase !== 'terminal' && missionPhase !== 'pickup') return;
    if (ghostTerminalLogged) return;
    const ghost = (window.ISR_ASSETS || []).find(g => g.id === onTaskedGhostId);
    if (!ghost) return;
    setGhostTerminalLogged(true);
    commLog([
      [ghost.cs, 'SUNDOWN — terminal area clear of patrol activity. Tightening orbit, holding overwatch through pickup.'],
    ]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [onTaskedGhostId, ghostOnStationAt, missionPhase, ghostTerminalLogged]);

  // ── GHOST on-station effect — fires once when the retasked MQ-9 has
  // realistically transited from its standing orbit to the IP overwatch ring.
  // Gated on `elapsed` (not setTimeout/wall-clock) so spacebar fast-forward
  // (5×) speeds the alert in sync with the map's t-unit-driven transit anim.
  React.useEffect(() => {
    if (!uarSent || !onTaskedGhostId || ghostOnStationAt) return;
    if (ghostOnStationTargetSec == null) return;
    if (elapsed < ghostOnStationTargetSec) return;
    const ghost = (window.ISR_ASSETS || []).find(g => g.id === onTaskedGhostId);
    if (!ghost) return;
    const stamp = fmtZulu(elapsed);
    setGhostOnStationAt(stamp);
    setAlerts(al => [{
      id:`ghost-onsta-${Date.now()}`, level:'info', time:stamp,
      title:`${ghost.cs} ON STATION · IP OVERWATCH`,
      body:'CIA UAV established at FL280, ~25 km wide orbit. EO/IR feed active to JOC.',
    }, ...al]);
    commLog([
      [ghost.cs, 'SUNDOWN — on station IP overwatch. EO/IR steady, no patrol activity vic LKP. ISOP signature confirmed.'],
      ['SUNDOWN', `${ghost.cs} — copy on station. Hold overwatch, advise on any change to patrol picture vic IP.`],
    ]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [uarSent, onTaskedGhostId, ghostOnStationAt, ghostOnStationTargetSec, elapsed]);

  // ── Phase 2→3 seed: launch-ready card when a COA is active and launch hasn't fired.
  React.useEffect(()=>{
    if (phase !== 2) return;
    if (!activeCoa) return;
    if (launched) return;
    if (coaBreach) return; // wait out re-review first
    // Don't re-seed if either review card is still in the queue
    setActions(a=>{
      if (a.some(x => x.id === 'coa-review' || x.id === 'coa-rereview' || x.id === 'launch-ready')) return a;
      return [...(window.P3_LAUNCH_ACTIONS || []), ...a];
    });
  },[phase, activeCoa, launched, coaBreach]);

  // ── Mission progress ticker — drives HH-60 motion + phase beats.
  // Rate: full ingress traverses in ~(ingressMin * 60) s scaled by DEMO_TEMPO
  // (10× faster) so demos stay tight. Flips missionPhase at progress beats.
  React.useEffect(()=>{
    if (!launched) return;
    if (recoveryDone) return;
    if (missionPhase === 'aborted') return;
    if (missionPhase === 'complete') return;
    const DEMO_TEMPO = 10;
    const ingressMin = activeCoa && activeCoa.risk ? activeCoa.risk.ingressTimeMin : 18;
    const perSec = 1 / (ingressMin * 60 / DEMO_TEMPO); // fraction advanced per tick
    const id = setInterval(()=>{
      const mult = fastForwardRef.current ? 5 : 1;
      setMissionProgress(p=>{
        if (missionPhase === 'ingress' || missionPhase === 'terminal') {
          const np = Math.min(1, p + perSec * mult);
          return np;
        }
        if (missionPhase === 'pickup') {
          return p; // hold at IP
        }
        if (missionPhase === 'egress') {
          const np = Math.max(0, p - perSec * mult);
          return np;
        }
        return p;
      });
    }, 1000);
    return ()=>clearInterval(id);
  },[launched, recoveryDone, missionPhase, activeCoa]);

  // Phase beat transitions — react to progress crossings.
  React.useEffect(()=>{
    if (!launched) return;
    if (missionPhase === 'ingress' && missionProgress >= 0.85) setMissionPhase('terminal');
    if (missionPhase === 'terminal' && missionProgress >= 1)   setMissionPhase('pickup');
    if (missionPhase === 'egress'   && missionProgress <= 0)   setMissionPhase('complete');
  },[launched, missionPhase, missionProgress]);

  // Battle-damage disruption — fires once missionProgress crosses the random
  // threshold scheduled at launch. The damage event is announced immediately;
  // the pilot/aircrew makes the divert call autonomously (real-world: MC/SL
  // doesn't get a vote — they only learn the outcome via comms or pattern of
  // life). We surface the pilot's choice 30 s later as a separate alert.
  React.useEffect(()=>{
    if (!launched || damageFired) return;
    if (missionPhase !== 'ingress' && missionPhase !== 'terminal') return;
    if (missionProgress < disruptionScheduleRef.current.damageAtProgress) return;
    const dt = (window.DISRUPTION_TYPES || []).find(d => d.id === 'battle-damage');
    if (!dt) return;
    setDamageFired(true);
    setToasts(t=>[...t, { id:'dsr-bd-'+Date.now(), ref:dt.amaRef, title:dt.toast.title, body:dt.toast.body }]);
    setAlerts(al=>[{ id:'dsr-bd-'+Date.now(), level:dt.alert.level, time:fmtZulu(elapsed), title:dt.alert.title, body:dt.alert.body }, ...al]);
    commLog([
      ['JOLLY 62', 'MAYDAY — JOLLY 62 took fragmentation, engine #2 caution, fuel leak rate 6%/min.'],
      ['PEDRO 61', 'JOLLY, PEDRO — copy. Stay in formation, run damage check. Crew assess and call.'],
    ]);
    // Schedule the pilot-decision relay. 30 s wall-clock — a deliberate gap
    // representing the time it takes for SAFIRE / pattern-of-life / aircrew
    // comms to surface the call back to the C2 picture.
    const sched = disruptionScheduleRef.current;
    if (sched.pilotDecisionTimerId) clearTimeout(sched.pilotDecisionTimerId);
    sched.pilotDecisionTimerId = setTimeout(()=>{
      // Two outcomes — never abort (that ends the demo). Either both HH-60s
      // press on (damage assessed as non-critical) or JOLLY peels off solo
      // and PEDRO continues to the IP. ~60/40 toward the divert so MC/SL
      // sees the divert behaviour most runs.
      const choice = Math.random() < 0.6 ? 'continue-solo' : 'press-on';
      setDivertDecisionAt(fmtZulu(elapsed));
      if (choice === 'press-on') {
        // No course change — just announce the decision.
        setAlerts(al=>[{ id:'pilot-decision-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
          title:'PILOT DECISION · BOTH PRESS ON',
          body:'Aircrew assessed damage as non-critical. JOLLY 62 + PEDRO 61 continuing to IP.' }, ...al]);
        setToasts(t=>[...t, { id:'pilot-decision-'+Date.now(), level:'complete', ref:'PILOT · DIVERT',
          title:'Pilot decision · BOTH PRESS ON',
          body:'Damage non-critical. JOLLY 62 + PEDRO 61 continuing to IP.' }]);
        commLog([
          ['JOLLY 62', 'PEDRO, JOLLY — leak controlled, engine nominal. Pressing on, two minutes to IP.'],
          ['PEDRO 61', 'JOLLY, PEDRO — copy press-on. Tighten formation, eyes outside.'],
        ]);
      } else {
        setDivertDecision('continue-solo');
        setAlerts(al=>[{ id:'pilot-decision-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
          title:'PILOT DECISION · CONTINUE SOLO',
          body:'JOLLY 62 RTB direct for damage assessment. PEDRO 61 pressing alone — recovery proceeding.' }, ...al]);
        setToasts(t=>[...t, { id:'pilot-decision-'+Date.now(), ref:'PILOT · DIVERT',
          title:'Pilot decision · CONTINUE SOLO',
          body:'JOLLY 62 RTB. PEDRO 61 pressing to IP solo.' }]);
        commLog([
          ['JOLLY 62', 'PEDRO, JOLLY — leak rate climbing. Diverting, RTB direct base.'],
          ['PEDRO 61', 'JOLLY, PEDRO — copy divert, you have priority. SUNDOWN, PEDRO 61 — solo to IP, confirming pickup of two.'],
          ['SUNDOWN',  'PEDRO 61 — copy solo, press to IP. JOLLY, mark when feet wet.'],
        ]);
      }
      sched.pilotDecisionTimerId = null;
    }, 30000);
  },[launched, damageFired, missionPhase, missionProgress, elapsed]);

  // Medical disruption — fires at its scheduled progress point. Opens the
  // existing TX Limit composer pre-filled for BRAVO.
  React.useEffect(()=>{
    if (!launched || medicalFired) return;
    if (missionPhase !== 'ingress' && missionPhase !== 'terminal') return;
    if (missionProgress < disruptionScheduleRef.current.medicalAtProgress) return;
    const dt = (window.DISRUPTION_TYPES || []).find(d => d.id === 'medical');
    if (!dt) return;
    setMedicalFired(true);
    setToasts(t=>[...t, { id:'dsr-med-'+Date.now(), ref:dt.amaRef, title:dt.toast.title, body:dt.toast.body }]);
    setAlerts(al=>[{ id:'dsr-med-'+Date.now(), level:dt.alert.level, time:fmtZulu(elapsed), title:dt.alert.title, body:dt.alert.body }, ...al]);
    commLog([
      ['DUKE 31 BRAVO', 'SUNDOWN, BRAVO — getting tight. HR 132, breathing labored. Need pickup.'],
      ['SUNDOWN',       'BRAVO, SUNDOWN — copy, expediting. Going beacon-only, hold position.'],
    ]);
    // Note: TX Limit update is available on the Survivor Roster per-IP — the
    // toast surfaces the medical state so MC/SL can act on it (e.g., expedite
    // pickup or send a new TX), but we don't auto-hijack focus with a modal.
  },[launched, medicalFired, missionPhase, missionProgress, elapsed]);

  // 15-Line · SECTION C (Border Crossing) — seed action card when CSARTF
  // crosses the friendly/contested boundary (~25% of ingress). MC/SL
  // reviews the draft and transmits to JPRC via AMC.
  React.useEffect(()=>{
    if (!launched || fifteenLineBorderFired || fifteenCSent) return;
    if (missionPhase !== 'ingress') return;
    if (missionProgress < 0.25) return;
    setFifteenLineBorderFired(true);
    setActions(a => a.some(x => x.id === '15line-c') ? a : [{
      id:'15line-c', amaRef:'JTBD-38 [SME-REVIEW]',
      title:'15-Line Section C — border crossing',
      urgency:'advisory', hcr:'yes', deadline:null,
      body:'CSARTF crossed friendly/contested boundary on committed corridor. Review and transmit ingress update to JPRC.',
      fields:[{l:'Triggered by',v:'Corridor border crossing'},{l:'Recipient',v:'JPRC via AMC (DUDE 11)'}],
    }, ...a]);
  },[launched, fifteenLineBorderFired, fifteenCSent, missionPhase, missionProgress, elapsed]);

  // 15-Line · SECTION D (Terminal Area) — seed action card when phase flips
  // to 'terminal' at ~85% ingress. SANDY snorkel begins; AMC accountability
  // holds until pickup ack.
  React.useEffect(()=>{
    if (!launched || fifteenLineTerminalFired || fifteenDSent) return;
    if (missionPhase !== 'terminal') return;
    setFifteenLineTerminalFired(true);
    setActions(a => a.some(x => x.id === '15line-d') ? a : [{
      id:'15line-d', amaRef:'JTBD-38 [SME-REVIEW]',
      title:'15-Line Section D — terminal area',
      urgency:'advisory', hcr:'yes', deadline:null,
      body:'CSARTF entering terminal area. SANDY snorkel — AMC retains 15-line accountability. Review and transmit terminal update.',
      fields:[{l:'Triggered by',v:'Terminal area entry'},{l:'Recipient',v:'JPRC via AMC'}],
    }, ...a]);
  },[launched, fifteenLineTerminalFired, fifteenDSent, missionPhase, elapsed]);

  // Seed terminal / pickup / recovery cards as their phase beats fire.
  React.useEffect(()=>{
    if (!launched) return;
    if (missionPhase === 'terminal' && !terminalDelegated) {
      setActions(a => a.some(x => x.id === 'terminal-handoff') ? a : [{
        id:'terminal-handoff', amaRef:'AMA-53 · JTBD-33 [SME-REVIEW]', title:'Terminal area handoff to OSC',
        urgency:'advisory', hcr:'optional', deadline:null,
        body:'CSARTF approaching terminal area. Delegate close-in control to OSC so MC/SL can monitor while pickup executes.',
        fields:[{l:'Satisfies',v:'JTBD-33 Terminal Control'},{l:'Recipient',v:'OSC (SANDY on station)'}],
      }, ...a]);
    }
    if (missionPhase === 'pickup' && !pickupDone) {
      setActions(a => a.some(x => x.id === 'pickup-ack') ? a : [{
        id:'pickup-ack', amaRef:'JTBD-38 [SME-REVIEW]', title:'Acknowledge pickup · ALPHA + BRAVO',
        urgency:'critical', hcr:'yes', deadline:null,
        body:'PEDRO 61 holding at IP. Both survivors reported on board. Acknowledge pickup to initiate egress.',
        fields:[{l:'Vehicle',v:'PEDRO 61'},{l:'Onboard',v:'ALPHA + BRAVO'},{l:'Next',v:'Egress via committed corridor'}],
      }, ...a]);
    }
    if (missionPhase === 'complete' && !recoveryDone) {
      setActions(a => a.some(x => x.id === 'recovery-complete') ? a : [{
        id:'recovery-complete', amaRef:'AMA-34 · JTBD-44 [SME-REVIEW]', title:'Close case · Recovery complete',
        urgency:'normal', hcr:'yes', deadline:null,
        body:'CSARTF back at FARP. Survivors recovered. Close the case to finalize the log.',
        fields:[{l:'Satisfies',v:'JTBD-44 Case Closure'},{l:'Outcome',v:'2× recovered'}],
      }, ...a]);
    }
    // 9-Line medevac — seeded once pickup is done and PJ has had a moment to
    // assess (egress phase). Lives in the queue alongside recovery-complete
    // so MC/SL can authorise both before case closure.
    if ((missionPhase === 'egress' || missionPhase === 'complete') && pickupDone && !medevacSent) {
      setActions(a => a.some(x => x.id === 'medevac-9line') ? a : [{
        id:'medevac-9line', amaRef:'JTBD-45 [SME-REVIEW]', title:'Authorize 9-Line medevac',
        urgency:'advisory', hcr:'yes', deadline:null,
        body:'PJ aboard PEDRO 61 has completed patient assessment. Review and authorize the 9-Line relay to JPRC.',
        fields:[{l:'Drafter',v:'PEDRO 61 PJ'},{l:'Patients',v:'2× ROUTINE · ambulatory'},{l:'Recipient',v:'JPRC via AMC'}],
      }, ...a]);
    }
  },[launched, missionPhase, terminalDelegated, pickupDone, recoveryDone, medevacSent]);

  // BRAVO discovery — scripted event at T+4:30, or fired manually from Tweaks
  // (or by the operator triple-tapping spacebar in CSAR mode). Fires an
  // advisory toast so the operator gets a clear "WSO position confirmed" cue
  // when they triggered the discovery themselves — the alert log entry alone
  // can be missed in the chrome.
  const locateBravo = ()=>{
    if (bravoLocated) return;
    pushHistory();
    setBravoLocated(true);
    setBravoLocatedAt(fmtZulu(elapsed));
    setAlerts(al=>[{ id:'bravo-found', level:'critical', time:fmtZulu(elapsed),
      title:'DUKE 31 BRAVO LOCATED',
      body:'CSEL-8104 burst received. Position 51RTH 8523 1283 confirmed by UAR report + DUKE 32 visual reacquire. BRAVO actions now available.' }, ...al]);
    advisoryToast('WSO position confirmed', 'DUKE 31 BRAVO located — grid 51RTH 8523 1283. BRAVO actions now available.');
    commLog([
      ['DUKE 32', 'SUNDOWN, DUKE 32 — BRAVO visual reacquired, grid 51RTH 8523 1283. CSEL-8104 active. Ambulatory, moving west.'],
      ['SUNDOWN', 'DUKE 32 — copy BRAVO position confirmed. Updating J3.5.'],
    ]);
  };
  const reject  = id=>{
    pushHistory();
    // Dismissing the launch-ready card means MC/SL wants to reconsider the
    // COA selection — clear the committed COA and re-seed the review card
    // instead of silently removing the launch prompt.
    if (id === 'launch-ready') {
      setActiveCoa(null);
      setActiveCoaAt(null);
      setActions(a => {
        const filtered = a.filter(x => x.id !== 'launch-ready' && x.id !== 'coa-review' && x.id !== 'coa-rereview');
        return [...(window.P2_ACTIONS || []), ...filtered];
      });
      setAlerts(al=>[{ id:'p3-launch-dismissed-'+Date.now(), level:'advisory', time:fmtZulu(elapsed),
        title:'LAUNCH DISMISSED · COA RE-OPENED',
        body:'Launch authorization dismissed. Active corridor cleared — re-review COAs to proceed.' }, ...al]);
      return;
    }
    setActions(a=>a.filter(x=>x.id!==id));
  };
  const appoint = id=>{
    pushHistory();
    setOscId(id); setOscDone(true); setOscDoneAt(fmtZulu(elapsed));
    setActions(a=>a.filter(x=>x.id!=='osc-appoint'));
    dismissToast('esc-osc');
    const osc = (window.ASSETS || []).concat(window.BLUE_FORCES || []).find(a => a.id === id);
    completeToast('OSC appointed', osc ? `${osc.cs} assumed On-Scene Commander duties.` : 'On-Scene Commander assigned.');
    if (osc) {
      commLog([
        ['SUNDOWN', `${osc.cs}, SUNDOWN — you are appointed OSC for case PR-2804-001. Assume on-scene authority over the IP.`],
        [osc.cs,    `SUNDOWN, ${osc.cs} — copies OSC. Assuming on-scene authority. Tightening orbit overhead.`],
      ]);
    }
  };
  const reset   = ()=>{
    historyRef.current = []; // wipe undo history — reset is the new baseline
    // Master-mode state clears back to the home placeholder.
    setActiveMode('home'); setActiveRole('mc');
    setDrawerOpen(false); setPendingMode(null);
    // Manage System — clear feature-flag overrides + tab state.
    setManageSystemTab('data');
    setHiddenModeIds(new Set());
    setHiddenRoleIds(new Set());
    setCsarSessionActive(false); setCsarUnreadCount(0); setCsarAlertPending(false);
    setNvgMode(false);  // display mode returns to STD
    // COP Watchlist — back to seeded fixtures.
    setWatchEvents(window.WATCH_EVENTS || []);
    setWatchResolved(window.WATCH_EVENTS_RESOLVED || []);
    // Mission Admin returns to seeded state.
    setAdminDocs(window.MOCK_DOCS || []);
    setAdminPrimaryAor('indopacom');
    setAdminActiveAors(new Set(['indopacom']));
    setAdminLibraryTab('active');
    setAdminViewMode('cards');
    setAdminUploadModal(null);
    setAdminOpenDocId(null);
    setAdminSubview('dashboard');
    setAdminSubviewFilter({ atoIds: new Set() });
    setAdminSubviewSearch('');
    setAdminSortBySubview({ platforms:null, objectives:null, assets:null });
    setPlatformOverrides({});
    setPendingFlyTo(null);
    setBasemap('carto-dark');
    setAppState('normal'); setElapsed(0); setDayProgress(0);
    setActions(window.INIT_ACTIONS); setAlerts(window.INIT_ALERTS);
    setJprcDone(false); setOscDone(false); setJprcDoneAt(null); setOscDoneAt(null); setOscId(null);
    setJprcDraft(window.JPRC_DRAFT); setDraftOpen(false);
    setToasts([]); setEscalated({ jprc:false, osc:false, j35:false });
    setPhase(0);
    setBeaconAckAlpha(false); setBeaconAckBravo(false);
    setAuthAlpha(false); setAuthBravo(false);
    setTxSentAlpha(false); setTxSentBravo(false);
    setUarSent(false); setJ35AlphaDone(false); setJ35BravoDone(false); setBravoLocated(false);
    setOnTaskedGhostId(null); setGhostOnStationAt(null); setGhostOnStationTargetSec(null); setGhostTerminalLogged(false);
    setBeaconAckAlphaAt(null); setBeaconAckBravoAt(null);
    setAuthAlphaAt(null); setAuthBravoAt(null);
    setTxSentAlphaAt(null); setTxSentBravoAt(null);
    setUarSentAt(null); setJ35AlphaDoneAt(null); setJ35BravoDoneAt(null); setBravoLocatedAt(null);
    setAuthOpen(false); setTxOpen(false); setUarOpen(false);
    setMsgs(window.INIT_MSGS);
    setActiveLateThreats([]); lateDelaysRef.current = [];
    setCoas([]); setActiveCoa(null); setActiveCoaAt(null); setCoaOpen(false); setCoaBreach(false);
    setJ35ConfirmOpen(false); setJ35ConfirmCrew('alpha'); setJ35ConfirmTrigger('manual');
    // Phase 3 clears
    setLaunched(false); setLaunchedAt(null); setLaunchOpen(false);
    setMissionProgress(0); setMissionPhase('idle');
    if (disruptionScheduleRef.current.samTimerId) {
      clearTimeout(disruptionScheduleRef.current.samTimerId);
      disruptionScheduleRef.current.samTimerId = null;
    }
    if (disruptionScheduleRef.current.pilotDecisionTimerId) {
      clearTimeout(disruptionScheduleRef.current.pilotDecisionTimerId);
      disruptionScheduleRef.current.pilotDecisionTimerId = null;
    }
    setSamFired(false); setDamageFired(false); setMedicalFired(false);
    setDivertOpen(false); setDivertDecision(null); setDivertDecisionAt(null);
    setTerminalOpen(false); setTerminalDelegated(false); setTerminalDelegatedAt(null);
    setPickupOpen(false); setPickupDone(false); setPickupDoneAt(null);
    setFifteenLineDraft(window.FIFTEEN_LINE_DRAFT);
    setFifteenLineBorderFired(false); setFifteenLineTerminalFired(false);
    setFifteenLineBDraft(window.FIFTEEN_LINE_B_DRAFT);
    setFifteenLineCDraft(window.FIFTEEN_LINE_C_DRAFT);
    setFifteenLineDDraft(window.FIFTEEN_LINE_D_DRAFT);
    setFifteenLineCloseDraft(window.FIFTEEN_LINE_CLOSE_DRAFT);
    setFifteenSection(null);
    setFifteenBSent(false); setFifteenBSentAt(null);
    setFifteenCSent(false); setFifteenCSentAt(null);
    setFifteenDSent(false); setFifteenDSentAt(null);
    setFifteenCloseSent(false); setFifteenCloseSentAt(null);
    setRozOpen(false); setRozPublished(false); setRozPublishedAt(null);
    setRozDraft(window.ROZ_DROP_DRAFT);
    setMedevacOpen(false); setMedevacSent(false); setMedevacSentAt(null);
    setNineLineDraft(window.NINE_LINE_DRAFT);
    setRecoveryOpen(false); setRecoveryDone(false); setRecoveryDoneAt(null);
  };
  const openDraft = ()=>setDraftOpen(true);

  // ── Master-mode switching ─────────────────────────────────
  // Drawer open/close + mode change handlers. When leaving an active CSAR
  // session, surface a confirmation modal first (mission keeps running in
  // the background — see csarSessionActive). Returning to CSAR clears the
  // unread badge.
  const openDrawer  = React.useCallback(() => setDrawerOpen(true),  []);
  const closeDrawer = React.useCallback(() => setDrawerOpen(false), []);
  const toggleDrawer = React.useCallback(() => setDrawerOpen(v => !v), []);
  const requestModeSwitch = React.useCallback((modeId) => {
    // Clicking the active mode toggles the drawer open/closed. From the
    // strip (closed) → opens the drawer for full mode labels + role
    // switcher. From the drawer (open) → closes it (active row is
    // already visible there).
    if (modeId === activeMode) { setDrawerOpen(v => !v); return; }
    // `manage-system` is a valid target even though it isn't a Master Mode
    // (it's a smaller secondary nav row in the drawer that owns its own
    // workspace). Pass it through without the MASTER_MODES lookup.
    const target = (window.MASTER_MODES || []).find(m => m.id === modeId);
    if (!target && modeId !== 'manage-system') return;
    if (activeMode === 'csar' && csarSessionActive && modeId !== 'csar') {
      setPendingMode(modeId);  // drawer stays open behind confirm modal
      return;
    }
    setActiveMode(modeId);
    setDrawerOpen(false);
  }, [activeMode, csarSessionActive]);
  const confirmModeSwitch = React.useCallback(() => {
    setPendingMode(p => {
      if (p) { setActiveMode(p); setDrawerOpen(false); }
      return null;
    });
  }, []);
  const cancelModeSwitch = React.useCallback(() => setPendingMode(null), []);

  // ── Mission Admin handlers ───────────────────────────────
  // simulateUpload returns a Promise that resolves AFTER the modal's
  // own progress animation has run (1.5s upload + 2s parse). Modal
  // owns the visual pacing; this handler just materializes the new
  // doc with a randomized parse status when the modal calls it.
  const simulateUpload = React.useCallback((typeId, aor) => {
    return new Promise((resolve) => {
      const id = `doc-${typeId}-${aor}-${Date.now().toString(36)}`;
      const typeMeta = (window.DOC_TYPES || []).find(t => t.id === typeId);
      // Validity windows by type — ATO/ACO/JPTL run 24h, OTL runs 7 days,
      // reference docs are evergreen (no expiry).
      const validityHours = typeId === 'otl' ? 168 : (typeId === 'ref' ? null : 24);
      const startIso = new Date().toISOString();
      const endIso   = validityHours ? new Date(Date.now() + validityHours * 3600 * 1000).toISOString() : null;
      // 80% clean, 20% warnings — but otl/ref are preview-only.
      const parseStatus = (typeId === 'otl' || typeId === 'ref')
        ? 'preview'
        : (Math.random() < 0.8 ? 'clean' : 'warnings');
      const warningPool = [
        '3 platforms missing call signs',
        '2 ACMs with overlapping floors',
        '1 target priority unset',
        '4 IFF codes outside expected range',
      ];
      const parseDetails = parseStatus === 'warnings'
        ? [warningPool[Math.floor(Math.random() * warningPool.length)]]
        : null;
      // Date stamp the filename in the project's standard format
      // (DD-MMM, e.g. "06-MAY"). Matches the seeded MOCK_DOCS naming
      // convention (ATO 28-APR / ACO 28-APR / etc.) so newly uploaded
      // docs slot in cleanly alongside the cycle-named seeded ones.
      const _months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
      const _now = new Date();
      const _stamp = `${_now.getDate().toString().padStart(2,'0')}-${_months[_now.getMonth()]}`;
      const newDoc = {
        id, type:typeId,
        name:`${typeMeta.label} ${_stamp}`,
        aor, start:startIso, end:endIso,
        format: (typeId === 'ato' || typeId === 'aco') ? 'XML' : 'PDF',
        uploader:'Maj Reyes', uploadedAt:startIso,
        parseStatus, parseDetails,
        replacedBy:null, isArchived:false,
      };
      setAdminDocs(d => [newDoc, ...d]);
      resolve(newDoc);
    });
  }, []);

  // Replace a doc → opens the upload modal pre-scoped + locked, marked
  // as a replacement. The modal will call finalizeReplace on success.
  const replaceDoc = React.useCallback((oldDocId) => {
    const oldDoc = adminDocs.find(d => d.id === oldDocId);
    if (!oldDoc) return;
    setAdminUploadModal({ typeId:oldDoc.type, aor:oldDoc.aor, replacingId:oldDocId });
  }, [adminDocs]);

  // Force-archive a doc — operator wants it out of the active library
  // without uploading a replacement (e.g., a doc that's no longer
  // relevant to the current op). Stamps `isArchived` so the dashboard
  // filter (`!d.isArchived` in active view) drops it from the active
  // group; doc still surfaces under the Archive tab. We DON'T set
  // `replacedBy` here — that field is reserved for the supersede link
  // when an actual replacement uploads, so the archived row reads as
  // "manually archived" rather than "superseded by …".
  const archiveDoc = React.useCallback((docId) => {
    setAdminDocs(docs => docs.map(d =>
      d.id === docId ? { ...d, isArchived:true } : d
    ));
  }, []);

  // Apply a partial override to a platform (mission / status). The
  // map tooltip reads through this overlay so the operator's edits
  // in the Platforms table propagate live to the COP. Reset returns
  // to {}.
  const applyPlatformOverride = React.useCallback((trackId, partial) => {
    setPlatformOverrides(o => ({
      ...o,
      [trackId]: { ...(o[trackId] || {}), ...partial },
    }));
  }, []);

  // Sub-page navigation helpers. Bound to:
  //   - DocPreviewDrawer's three "View …" buttons (jump to a sub-page
  //     pre-filtered by the open doc's atoId)
  //   - Cross-table callsign cross-links (callsign filter via search)
  //   - Breadcrumb navigation back to dashboard
  const openAdminSubviewForAto = React.useCallback((view, atoId) => {
    setAdminSubview(view);
    setAdminSubviewFilter({ atoIds: new Set(atoId ? [atoId] : []) });
    setAdminSubviewSearch('');
    setAdminOpenDocId(null);
  }, []);

  const navigateAdminSubview = React.useCallback((view, opts) => {
    setAdminSubview(view);
    if (opts && Object.prototype.hasOwnProperty.call(opts, 'search')) {
      setAdminSubviewSearch(opts.search || '');
    }
    if (opts && opts.filter) {
      setAdminSubviewFilter(opts.filter);
    }
  }, []);

  // Objectives "click MGRS" handler: queue a flyTo, swap to the home
  // master mode so the COP is visible. The Mission Admin sub-page
  // state persists, so when the operator returns they land on the
  // same Objectives view with the same filter chips.
  const flyToObjective = React.useCallback((obj) => {
    if (!obj || obj.lat == null || obj.lng == null) return;
    setPendingFlyTo({ lat:obj.lat, lng:obj.lng, zoom:8 });
    setActiveMode('home');
    setAdminOpenDocId(null);
  }, []);

  // Sort header click — cycle the active column through the 3-state
  // sequence: clicking a fresh column → 'asc'; clicking an active 'asc'
  // column → 'desc'; clicking an active 'desc' column → null (back to
  // source order). Clicking a different column resets the cycle.
  const cycleAdminSort = React.useCallback((subviewKey, columnId) => {
    setAdminSortBySubview(prev => {
      const cur = prev[subviewKey];
      let next;
      if (!cur || cur.columnId !== columnId)        next = { columnId, dir:'asc' };
      else if (cur.dir === 'asc')                    next = { columnId, dir:'desc' };
      else                                            next = null;
      return { ...prev, [subviewKey]: next };
    });
  }, []);

  // Move the replaced doc to archive + stamp the supersede link.
  const finalizeReplace = React.useCallback((oldDocId, newDocId) => {
    setAdminDocs(docs => docs.map(d =>
      d.id === oldDocId ? { ...d, isArchived:true, replacedBy:newDocId } : d
    ));
  }, []);

  // Close the preview drawer whenever the operator's view changes — AOR
  // selection, library tab, or active master mode. Keeps the drawer
  // from showing a doc that's no longer in the operator's current scope.
  React.useEffect(() => {
    setAdminOpenDocId(null);
  }, [adminPrimaryAor, adminActiveAors, adminLibraryTab, activeMode]);

  // Esc closes the preview drawer. Wired only when there's an open doc
  // so we don't intercept Esc for other modes.
  React.useEffect(() => {
    if (!adminOpenDocId) return;
    const onKey = (e) => { if (e.key === 'Escape') setAdminOpenDocId(null); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [adminOpenDocId]);

  // Returning to CSAR clears the unread badge.
  React.useEffect(() => {
    if (activeMode === 'csar') setCsarUnreadCount(0);
  }, [activeMode]);

  // ── Hash router: write + listen ──────────────────────────────
  // The internal router-write tracks the last hash we wrote so the
  // hashchange listener can ignore round-trips (otherwise our own
  // pushState ping-pongs against the listener and creates extra
  // history entries). External hash changes — back/forward, manual
  // edits, link clicks — fall through to setState.
  const _routerLastHashRef = React.useRef(null);
  React.useEffect(() => {
    const next = buildHashRoute({
      mode: activeMode,
      subview: adminSubview,
      atoIds: (adminSubviewFilter && adminSubviewFilter.atoIds) || new Set(),
      search: adminSubviewSearch,
    });
    if (next === _routerLastHashRef.current) return;
    if (window.location.hash === next) return;
    _routerLastHashRef.current = next;
    // pushState (not replaceState) so the back button walks the
    // operator's actual path. Skip the rebuild on identical hashes.
    try {
      window.history.pushState(null, '', next);
    } catch (e) {
      window.location.hash = next;
    }
  }, [activeMode, adminSubview, adminSubviewFilter, adminSubviewSearch]);

  // Listen for back/forward / manual hash edits and reflect them into
  // state. We compare against the last value we wrote to skip echoes.
  React.useEffect(() => {
    const onHashChange = () => {
      const incoming = window.location.hash;
      if (incoming === _routerLastHashRef.current) return;
      const r = parseHashRoute(incoming);
      // CSAR mode isn't directly settable via URL — see initial-route
      // handling above. A back-button onto /csar before the J3.1 alert
      // would land in an inconsistent state, so coerce to home.
      const safeMode = r.mode === 'csar' ? 'home' : r.mode;
      _routerLastHashRef.current = incoming;
      if (safeMode !== activeMode) setActiveMode(safeMode);
      if (safeMode === 'admin') {
        if (r.subview !== adminSubview) setAdminSubview(r.subview);
        // Replace the filter set object so React notices the change.
        setAdminSubviewFilter({ atoIds: r.atoIds || new Set() });
        setAdminSubviewSearch(r.search || '');
      }
    };
    window.addEventListener('hashchange', onHashChange);
    window.addEventListener('popstate',   onHashChange);
    return () => {
      window.removeEventListener('hashchange', onHashChange);
      window.removeEventListener('popstate',   onHashChange);
    };
  }, [activeMode, adminSubview]);

  // While the operator is in another mode but a CSAR session is live,
  // every newly-posted alert bumps the unread badge. Critical toasts
  // separately burn through via the global ToastStack render branch.
  const prevAlertsLenRef = React.useRef(alerts.length);
  React.useEffect(() => {
    const prev = prevAlertsLenRef.current;
    prevAlertsLenRef.current = alerts.length;
    if (activeMode === 'csar' || !csarSessionActive) return;
    if (alerts.length > prev) setCsarUnreadCount(c => c + (alerts.length - prev));
  }, [alerts.length, activeMode, csarSessionActive]);

  const isCsar = appState==='csar';
  // Decide which mode the toast layer should render in based on whether
  // the alerts tray and the action queue are visible:
  //   - Queue expanded → alerts panel is hidden behind columns → render
  //     toasts in the centered top-bar slot ('top') so they're visible.
  //   - Alerts panel OPEN → render toasts INLINE inside the panel header
  //     (above the existing alerts list).
  //   - Alerts panel COLLAPSED → render toasts STACKED ABOVE the alerts
  //     header, so they cluster right where the operator's eye is.
  const toastsSuppressed = (
    oscOpen || draftOpen || authOpen || txOpen || uarOpen ||
    j35ConfirmOpen || coaOpen || launchOpen || divertOpen ||
    terminalOpen || pickupOpen || rozOpen || recoveryOpen ||
    medevacOpen || fifteenSection != null
  );
  // Cross-mode burn-through: when the operator is OUT of CSAR but a CSAR
  // session is live, only critical toasts surface — they render with a
  // "CSAR ·" prefix and are click-to-return. Non-critical toasts queue
  // silently (they're still in `toasts`, just not rendered) and bump the
  // unread badge for when the operator comes back.
  //
  // Placement when away from CSAR:
  //   • Map-visible workspaces (Home/COP)         → 'above' — lower-left
  //     stack near the FloatingAlerts panel, where the operator's eye
  //     already lives for ambient alerts. Treats CSAR criticals like a
  //     louder alert riding alongside the COP's own alert flow.
  //   • Map-hidden workspaces (Mission Admin, stub modes) → 'global' —
  //     viewport-top-center. There's no alerts tray for the toast to
  //     dock against in these modes; top-center is the highest-priority
  //     position and matches the queueExpanded behavior in CSAR.
  // When the operator IS in CSAR, the existing top/above/inline modes
  // apply unchanged.
  const inAnotherMode = activeMode !== 'csar' && csarSessionActive;
  const mapVisibleHere = activeMode === 'home' || activeMode === 'csar';
  const toastMode = inAnotherMode
    ? (mapVisibleHere ? 'above' : 'global')
    : ((isCsar && queueExpanded) ? 'top' : (alertsCollapsed ? 'above' : 'inline'));
  // Cross-mode burn-through filters to critical-only regardless of
  // placement (top-center 'global' or lower-left 'above'). When the
  // operator is in CSAR, all toasts surface as before.
  const visibleToasts = inAnotherMode
    ? toasts.filter(t => t.level === 'critical')
    : toasts;
  const inlineToastsEl = (toastMode === 'inline' && toasts.length > 0) ? (
    <ToastStack toasts={toasts} onDismiss={dismissToast} onAutoDismiss={demoteToastToAlert}
                suppressed={toastsSuppressed} mode="inline" isCsar={isCsar} />
  ) : null;
  const mapEl  = (
    <div style={{ position:'relative', flex:1, display:'flex', flexDirection:'column', minHeight:0 }}>
      <TacticalMap appState={appState} dayProgress={dayProgress} bravoLocated={bravoLocated} oscId={oscId} activeLateThreats={activeLateThreats} activeCoa={activeCoa} coaBreach={coaBreach} launched={launched} missionProgress={missionProgress} missionPhase={missionPhase} divertDecision={divertDecision} rozPublished={rozPublished} pickupDone={pickupDone} onTaskedGhostId={onTaskedGhostId} j35AlphaDone={j35AlphaDone} j35BravoDone={j35BravoDone} personaMode={personaMode} onTogglePersona={()=>setPersonaMode(m => m === 'ip' ? 'ops' : 'ip')} queueExpanded={queueExpanded} nvgMode={nvgMode} activeMode={activeMode} csarUnreadCount={csarUnreadCount} csarSessionActive={csarSessionActive} onSelectMode={requestModeSwitch} mapLayers={mapLayers} basemap={basemap} projection={projection}
        platformOverrides={platformOverrides}
        imageryDimming={imageryDimming}
        pendingFlyTo={pendingFlyTo} onFlyToConsumed={() => setPendingFlyTo(null)}
        pendingFitBounds={pendingFitBounds} onFitBoundsConsumed={() => setPendingFitBounds(null)}
        /* Mission Admin's active-AOR set drives the COP camera —
           single-AOR flies to that AOR's center+zoom, multi-AOR
           fitBounds across the union of their bboxes. */
        activeAors={adminActiveAors}
        /* Rail widths so map fly-to can offset center past rails */
        rail1Width={isCsar ? (rail1Expanded ? 308 : 28) : (normalRail1Expanded ? 308 : 28)}
        rail2Width={isCsar ? 308 : (normalRail2Expanded ? 308 : 28)}
        /* Mission-type MM quick toggles (corner controls) */
        mmShowAcms={mmShowAcms}
        onMmShowAcmsChange={setMmShowAcms}
        /* Drawing tools */
        activeTool={activeTool}
        onActiveToolChange={setActiveTool} />
      {/* FloatingAlerts hidden in mission-type MMs — the workspace
          title strip covers the same screen area and the alerts panel
          is visually redundant when an MM is driving. */}
      {(!window.isMmTypeMode || !window.isMmTypeMode(activeMode)) && (
        <FloatingAlerts
          isCsar={isCsar}
          alerts={alerts}
          collapsed={alertsCollapsed}
          onCollapsedChange={setAlertsCollapsed}
          headerExtras={inlineToastsEl}
        />
      )}
    </div>
  );

  // Case-level J3.5 completeness — used by top bar clock + Mission Timeline.
  const j35Done = j35AlphaDone && j35BravoDone;

  // Completed case-wide actions shown at the top of the Action Queue (chronological).
  // Per-IP completions surface on the Survivor Roster instead.
  const completed = [
    jprcDone      && { id:'jprc-notify', label:'JPRC NOTIFY',      at:jprcDoneAt },
    oscDone       && { id:'osc-appoint', label:'OSC APPOINT',      at:oscDoneAt },
    rozPublished  && { id:'roz-drop',    label:'PR ROZ PUBLISHED', at:rozPublishedAt },
    uarSent       && { id:'uar-task',    label:'UAR TASKING',      at:uarSentAt },
    j35AlphaDone  && { id:'j35-alpha',   label:'J3.5 · ALPHA',     at:j35AlphaDoneAt },
    j35BravoDone  && { id:'j35-bravo',   label:'J3.5 · BRAVO',     at:j35BravoDoneAt },
    activeCoa         && { id:'coa',       label:`COA · ${activeCoa.label}`, at:activeCoaAt },
    launched          && { id:'launch',    label:'CSARTF LAUNCHED',          at:launchedAt },
    fifteenBSent      && { id:'15line-b',  label:'15-LINE · SECTION B',      at:fifteenBSentAt },
    fifteenCSent      && { id:'15line-c',  label:'15-LINE · SECTION C',      at:fifteenCSentAt },
    divertDecision    && { id:'divert',    label:divertDecision === 'abort' ? 'MISSION ABORT' : 'JOLLY 62 · SOLO DIVERT', at:divertDecisionAt },
    terminalDelegated && { id:'terminal',  label:'TERMINAL HANDOFF',         at:terminalDelegatedAt },
    fifteenDSent      && { id:'15line-d',  label:'15-LINE · SECTION D',      at:fifteenDSentAt },
    pickupDone        && { id:'pickup',    label:'PICKUP COMPLETE',          at:pickupDoneAt },
    recoveryDone      && { id:'recovery',  label:'RECOVERY COMPLETE',        at:recoveryDoneAt },
    fifteenCloseSent  && { id:'15line-close', label:'15-LINE · CLOSEOUT',    at:fifteenCloseSentAt },
  ].filter(Boolean).sort((a,b)=>(a.at||'').localeCompare(b.at||''));

  // Panel content by mode
  // Home/COP mode now uses Layout A's two-rail pattern (parity with CSAR).
  // Rail 1 (inner, closer to map): Active Missions + Blue Force Overview
  // — the operational picture summaries. Rail 2 (outer): Map Layers
  // toggle panel + weather. Both wrapped in CollapsibleRail so each can
  // sliver-collapse independently for more map width.
  // PR 28 — Mission Overview rail content is now sourced from the TFC
  // left-column blocks (Cycle Board / ATO Details / Cycle Summary) plus
  // a vertical MissionListPanel underneath. All read from the shared
  // ATO runtime so changes in TFC mirror here and vice versa. Each
  // sub-block is independently collapsible via <Sec collapsible>; the
  // outer CollapsibleRail still drives a collapse-all toggle for the
  // whole rail.
  //
  // Click handler: selecting a mission in the COP rail picks the mission
  // (same shared `tfcSelectedMissionId` state TFC uses) AND flies the
  // map to the mission's target / orbit / dep geo so the operator sees
  // where it's operating.
  const onSelectCopMission = React.useCallback((missionId) => {
    setTfcSelectedMissionId(missionId);
    const rt = (window.ATO && window.ATO.runtime) || null;
    const m = rt && (rt.missions || []).find(x => x.id === missionId);
    if (!m) return;
    // Pick the best geo to frame: target.geo if it has one, otherwise
    // capOrbitGeo center, else depGeo. Mission has no geo → no fly.
    let lat = null, lng = null, zoom = 8;
    if (m.target && m.target.geo
        && Number.isFinite(m.target.geo.lat) && Number.isFinite(m.target.geo.lng)) {
      lat = m.target.geo.lat; lng = m.target.geo.lng; zoom = 8;
    } else if (m.capOrbitGeo
        && Number.isFinite(m.capOrbitGeo.centerLat) && Number.isFinite(m.capOrbitGeo.centerLng)) {
      lat = m.capOrbitGeo.centerLat; lng = m.capOrbitGeo.centerLng; zoom = 7;
    } else if (m.depGeo
        && Number.isFinite(m.depGeo.lat) && Number.isFinite(m.depGeo.lng)) {
      lat = m.depGeo.lat; lng = m.depGeo.lng; zoom = 6;
    }
    if (lat != null && lng != null) setPendingFlyTo({ lat, lng, zoom });
  }, [setTfcSelectedMissionId, setPendingFlyTo]);

  const Sec = window.Sec;

  // Detail Panel Component - shown when a mission or callsign tab is active
  function DetailPanel({ tab, runtime, onOpenTab }) {
    const missions = runtime?.missions || [];

    if (tab.type === 'mission') {
      // Mission detail: find all missions of this type
      const missionType = tab.missionId.replace('mission-', '');
      const missionGroup = missions.filter(m => m.type === missionType);
      const firstMission = missionGroup[0];

      if (!firstMission) return <div style={{ padding:'14px 12px', color:C.fg3 }}>Mission not found</div>;

      const openFlags = missionGroup.flatMap(m => (m.impactFlags || []).filter(f => !f.actedOn));
      const hasCritical = openFlags.some(f => f.severity === 'critical');

      const [missionTab, setMissionTab] = React.useState('details');

      return (
        <div style={{ display:'flex', flexDirection:'column', height:'100%', overflowY:'auto' }}>
          <div style={{ padding:'14px 12px 0' }}>
            <div style={{ fontFamily:'Roboto', fontSize:14, fontWeight:600, color:C.fg1, marginBottom:4 }}>
              {tab.label}
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.08em', marginBottom:12 }}>
              {TFC_TYPE_LONG[missionType] || missionType}
            </div>
          </div>

          {/* Tabs */}
          <div style={{ display:'flex', borderBottom:`1px solid ${C.border}`, padding:'0 12px' }}>
            <button
              onClick={() => setMissionTab('details')}
              style={{
                padding:'8px 14px',
                background: 'transparent',
                border:'none',
                borderBottom: missionTab === 'details' ? `2px solid ${C.accent}` : '2px solid transparent',
                color: missionTab === 'details' ? C.fg1 : C.fg3,
                fontFamily:'Roboto Mono',
                fontSize:9,
                fontWeight:600,
                letterSpacing:'0.10em',
                textTransform:'uppercase',
                cursor:'pointer',
                transition:'all 0.15s ease',
                marginBottom:'-1px',
              }}
              onMouseEnter={(e) => {
                if (missionTab !== 'details') e.currentTarget.style.color = C.fg2;
              }}
              onMouseLeave={(e) => {
                if (missionTab !== 'details') e.currentTarget.style.color = C.fg3;
              }}
            >
              Details
            </button>
            <button
              onClick={() => setMissionTab('elements')}
              style={{
                padding:'8px 14px',
                background: 'transparent',
                border:'none',
                borderBottom: missionTab === 'elements' ? `2px solid ${C.accent}` : '2px solid transparent',
                color: missionTab === 'elements' ? C.fg1 : C.fg3,
                fontFamily:'Roboto Mono',
                fontSize:9,
                fontWeight:600,
                letterSpacing:'0.10em',
                textTransform:'uppercase',
                cursor:'pointer',
                transition:'all 0.15s ease',
                marginBottom:'-1px',
              }}
              onMouseEnter={(e) => {
                if (missionTab !== 'elements') e.currentTarget.style.color = C.fg2;
              }}
              onMouseLeave={(e) => {
                if (missionTab !== 'elements') e.currentTarget.style.color = C.fg3;
              }}
            >
              Elements
            </button>
            <button
              onClick={() => setMissionTab('timeline')}
              style={{
                padding:'8px 14px',
                background: 'transparent',
                border:'none',
                borderBottom: missionTab === 'timeline' ? `2px solid ${C.accent}` : '2px solid transparent',
                color: missionTab === 'timeline' ? C.fg1 : C.fg3,
                fontFamily:'Roboto Mono',
                fontSize:9,
                fontWeight:600,
                letterSpacing:'0.10em',
                textTransform:'uppercase',
                cursor:'pointer',
                transition:'all 0.15s ease',
                marginBottom:'-1px',
              }}
              onMouseEnter={(e) => {
                if (missionTab !== 'timeline') e.currentTarget.style.color = C.fg2;
              }}
              onMouseLeave={(e) => {
                if (missionTab !== 'timeline') e.currentTarget.style.color = C.fg3;
              }}
            >
              Timeline
            </button>
          </div>

          {missionTab === 'details' && (
            <div style={{ display:'flex', flexDirection:'column', gap:16, padding:'14px 12px', flex:1 }}>
              {hasCritical && (
                <div style={{
                  padding:'8px 12px', background:C.criticalM,
                  border:`1px solid ${C.critical}`, borderRadius:3,
                  fontFamily:'Roboto', fontSize:11, color:C.fg1,
                }}>
                  ⚠ Critical alerts active
                </div>
              )}

              <div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                  TIME WINDOW
                </div>
                <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                  NET {firstMission.netScenario || 'TBD'} / NLT {firstMission.nltScenario || 'TBD'}
                </div>
              </div>

              {/* Origin & Destination */}
              {(firstMission.depIcao || firstMission.arrIcao) && (
                <div>
                  <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                    ROUTE
                  </div>
                  <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                    {firstMission.depIcao && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          ORIGIN
                        </div>
                        <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                          {firstMission.depIcao}
                        </div>
                      </div>
                    )}
                    {firstMission.arrIcao && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          DESTINATION
                        </div>
                        <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                          {firstMission.arrIcao}
                        </div>
                      </div>
                    )}
                  </div>
                </div>
              )}

              {/* Comms Section */}
              {firstMission.controllerCard && (
                <div>
                  <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                    COMMS
                  </div>
                  <div style={{ display:'flex', flexDirection:'column', gap:8, padding:'10px 12px', background:C.s3, border:`1px solid ${C.border}`, borderRadius:3 }}>
                    {firstMission.controllerCard.controllerCallsign && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          CONTROLLER
                        </div>
                        <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                          {firstMission.controllerCard.controllerCallsign}
                        </div>
                      </div>
                    )}
                    {firstMission.controllerCard.primaryFreq && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          PRIMARY FREQ
                        </div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:12, color:C.accent }}>
                          {firstMission.controllerCard.primaryFreq}
                        </div>
                      </div>
                    )}
                    {firstMission.controllerCard.secondaryFreq && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          SECONDARY FREQ
                        </div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:12, color:C.fg2 }}>
                          {firstMission.controllerCard.secondaryFreq}
                        </div>
                      </div>
                    )}
                    {firstMission.controllerCard.poc && (
                      <div>
                        <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:2 }}>
                          POC
                        </div>
                        <div style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>
                          {firstMission.controllerCard.poc.rank && `${firstMission.controllerCard.poc.rank} `}
                          {firstMission.controllerCard.poc.name}
                        </div>
                      </div>
                    )}
                  </div>
                </div>
              )}

              {/* Targets */}
              {(() => {
                const missionsWithTargets = missionGroup.filter(m => m.target).slice(0, 3);
                if (missionsWithTargets.length === 0) return null;

                return (
                  <div>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                      TARGETS ({missionsWithTargets.length})
                    </div>
                    <div style={{ display:'flex', flexDirection:'column', gap:10 }}>
                      {missionsWithTargets.map((mission, idx) => (
                        <div key={mission.id} style={{ padding:'10px 12px', background:C.s3, border:`1px solid ${C.border}`, borderRadius:3 }}>
                          <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:600, color:C.fg1, marginBottom:8 }}>
                            {mission.target.name || `Fixed Target ${idx + 1}`}
                          </div>

                          {/* Assigned Callsigns */}
                          <div style={{ marginBottom:8, paddingBottom:8, borderBottom:`1px solid ${C.border}` }}>
                            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:4 }}>
                              ASSIGNED
                            </div>
                            <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
                              {mission.airframeRoster && mission.airframeRoster.map(airframe => (
                                <span key={airframe.id} style={{
                                  padding:'3px 8px',
                                  borderRadius:2,
                                  background:C.s2,
                                  border:`1px solid ${C.border2}`,
                                  fontFamily:'Roboto Mono',
                                  fontSize:9,
                                  fontWeight:500,
                                  color:C.fg1,
                                }}>
                                  {airframe.callsign}
                                </span>
                              ))}
                            </div>
                          </div>

                          {mission.target.elevFt != null && (
                            <div style={{ marginBottom:6 }}>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>Elevation: </span>
                              <span style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>{mission.target.elevFt} ft</span>
                            </div>
                          )}
                          {mission.target.detail && (
                            <div style={{ marginBottom:6 }}>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>Description: </span>
                              <span style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>{mission.target.detail}</span>
                            </div>
                          )}
                          {mission.target.geo && (
                            <div>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3 }}>Location: </span>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg2 }}>
                                (apprx) N{Math.abs(mission.target.geo.lat).toFixed(3)}W{Math.abs(mission.target.geo.lng).toFixed(3)}
                              </span>
                            </div>
                          )}
                        </div>
                      ))}
                    </div>
                  </div>
                );
              })()}

              {/* Related Documents */}
              <div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                  RELATED DOCS
                </div>
                <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
                  {/* ATO */}
                  <div style={{
                    display:'flex', alignItems:'center', gap:8,
                    padding:'8px 10px', background:C.s3,
                    border:`1px solid ${C.border}`, borderRadius:3,
                  }}>
                    <span style={{ fontFamily:'Roboto Mono', fontSize:10, fontWeight:600, color:C.accent }}>
                      ATO
                    </span>
                    <span style={{ flex:1, fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>
                      {firstMission.atoId || 'ATO-INDOPACOM-281200Z'}
                    </span>
                  </div>
                  {/* ACO */}
                  <div style={{
                    display:'flex', alignItems:'center', gap:8,
                    padding:'8px 10px', background:C.s3,
                    border:`1px solid ${C.border}`, borderRadius:3,
                  }}>
                    <span style={{ fontFamily:'Roboto Mono', fontSize:10, fontWeight:600, color:C.accent }}>
                      ACO
                    </span>
                    <span style={{ flex:1, fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>
                      ACO-INDOPACOM-281200Z
                    </span>
                  </div>
                  {/* SPINS */}
                  <div style={{
                    display:'flex', alignItems:'center', gap:8,
                    padding:'8px 10px', background:C.s3,
                    border:`1px solid ${C.border}`, borderRadius:3,
                  }}>
                    <span style={{ fontFamily:'Roboto Mono', fontSize:10, fontWeight:600, color:C.accent }}>
                      SPINS
                    </span>
                    <span style={{ flex:1, fontFamily:'Roboto', fontSize:11, color:C.fg2 }}>
                      SPINS-{firstMission.package || 'DUKE31'}
                    </span>
                  </div>
                </div>
              </div>
            </div>
          )}

          {missionTab === 'elements' && (
            <div style={{ display:'flex', flexDirection:'column', gap:16, padding:'14px 12px', flex:1 }}>
              <div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                  ASSETS ({missionGroup.length})
                </div>
                <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                  {missionGroup.map(m => {
                    const callsignOpenFlags = (m.impactFlags || []).filter(f => !f.actedOn);
                    const callsignSeverity = callsignOpenFlags.some(f => f.severity === 'critical') ? 'critical'
                                           : callsignOpenFlags.length > 0 ? 'impact'
                                           : null;

                    // Generate tags based on mission properties
                    const tags = [];

                    // Add priority/effort tags
                    if (m.priority === 1) tags.push({ label: 'EFFORT 1', color: C.advisory });

                    // Add mission type tags
                    if (m.type === 'OCA-AO') tags.push({ label: 'PULSE 1', color: C.accent });
                    if (m.type === 'DCA') tags.push({ label: 'DCA', color: C.critical });

                    // Add variant/role tags
                    if (m.variant === 'strike') tags.push({ label: 'STRIKE', color: C.fg2 });
                    if (m.variant === 'cap') tags.push({ label: 'CAP', color: C.fg2 });
                    if (m.variant === 'sarcap') tags.push({ label: 'SARCAP', color: C.fg2 });
                    if (m.variant === 'csar-alert') tags.push({ label: 'CSAR', color: C.fg2 });

                    return (
                      <div
                        key={m.id}
                        style={{
                          display:'flex', flexDirection:'column', alignItems:'stretch', gap:8,
                          padding:'10px 12px', background:C.s3,
                          border:`1px solid ${C.border}`, borderRadius:3,
                        }}
                      >
                        <button
                          onClick={() => onOpenTab && onOpenTab(m.id, m.callsign, 'callsign', callsignSeverity)}
                          style={{
                            display:'flex', alignItems:'center', gap:8,
                            padding:0, background:'transparent', border:'none',
                            cursor:'pointer',
                            textAlign:'left',
                            width:'100%',
                          }}
                        >
                          <TfcPlatformIcon acftType={m.acftType} size={16}/>
                          <span style={{ flex:1, fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                            {m.callsign}
                          </span>
                          {callsignSeverity && (
                            <div style={{
                              width:6, height:6, borderRadius:'50%',
                              background: callsignSeverity === 'critical' ? C.critical : C.advisory
                            }}/>
                          )}
                        </button>

                        {tags.length > 0 && (
                          <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
                            {tags.map((tag, i) => (
                              <span key={i} style={{
                                padding:'2px 6px',
                                borderRadius:2,
                                fontSize:8,
                                fontFamily:'Roboto Mono',
                                fontWeight:600,
                                letterSpacing:'0.04em',
                                ...(tag.label === 'PULSE 1' ? {
                                  background:C.accentM, color:C.accent, border:`1px solid ${C.accent}55`
                                } : tag.label === 'EFFORT 1' ? {
                                  background:`${C.advisory}22`, color:C.advisory, border:`1px solid ${C.advisory}55`
                                } : tag.label === 'DCA' ? {
                                  background:`${C.critical}22`, color:C.critical, border:`1px solid ${C.critical}55`
                                } : {
                                  background:C.s1, color:C.fg3, border:`1px solid ${C.border}`
                                })
                              }}>
                                {tag.label}
                              </span>
                            ))}
                          </div>
                        )}

                        {/* Comms Section */}
                        <div style={{ paddingTop:8, borderTop:`1px solid ${C.border}` }}>
                          <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:4 }}>
                            COMMS
                          </div>
                          <div style={{ display:'flex', flexDirection:'column', gap:4 }}>
                            {m.commsBinding && m.commsBinding.freq ? (
                              <div style={{ display:'flex', gap:8 }}>
                                <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, minWidth:50 }}>
                                  PRIMARY
                                </span>
                                <span style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:500, color:C.accent }}>
                                  {m.commsBinding.freq}
                                </span>
                              </div>
                            ) : (
                              <div style={{ display:'flex', gap:8 }}>
                                <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, minWidth:50 }}>
                                  PRIMARY
                                </span>
                                <span style={{ fontFamily:'Roboto Mono', fontSize:9, fontWeight:500, color:C.accent }}>
                                  {m.type === 'OCA-AO' ? '281.4 MHz' : m.type === 'DCA' ? '238.6 MHz' : '256.8 MHz'}
                                </span>
                              </div>
                            )}
                            <div style={{ display:'flex', gap:8 }}>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, minWidth:50 }}>
                                GUARD
                              </span>
                              <span style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg2 }}>
                                243.0 MHz
                              </span>
                            </div>
                          </div>
                        </div>

                        {/* J-Messaging Standards */}
                        <div style={{ paddingTop:8, borderTop:`1px solid ${C.border}` }}>
                          <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:C.fg4, marginBottom:4 }}>
                            J-MESSAGING
                          </div>
                          <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
                            <span style={{
                              padding:'2px 6px', borderRadius:2,
                              background:C.s2, border:`1px solid ${C.border2}`,
                              fontFamily:'Roboto Mono', fontSize:8, color:C.fg2,
                            }}>
                              J-3.2 STRIKE
                            </span>
                            <span style={{
                              padding:'2px 6px', borderRadius:2,
                              background:C.s2, border:`1px solid ${C.border2}`,
                              fontFamily:'Roboto Mono', fontSize:8, color:C.fg2,
                            }}>
                              J-12.6 BDA
                            </span>
                            <span style={{
                              padding:'2px 6px', borderRadius:2,
                              background:C.s2, border:`1px solid ${C.border2}`,
                              fontFamily:'Roboto Mono', fontSize:8, color:C.fg2,
                            }}>
                              J-3.7 ROE
                            </span>
                          </div>
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          )}

          {missionTab === 'timeline' && (
            <div style={{ display:'flex', flexDirection:'column', gap:16, padding:'14px 12px', flex:1 }}>
              {/* Timeline Events */}
              <div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:10 }}>
                  MISSION TIMELINE
                </div>
                <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                  {/* IPOE */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      2330-2355Z
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        IPOE
                      </div>
                    </div>
                  </div>

                  {/* Lowdown / Roll Call */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      2355Z
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        Lowdown / Roll Call
                      </div>
                    </div>
                  </div>

                  {/* Light Call */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      2359Z
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        Light Call
                      </div>
                    </div>
                  </div>

                  {/* AI/DT Push */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      0000-0035
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        AI/DT Push
                      </div>
                    </div>
                  </div>

                  {/* Escort Swap-out */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      0035-0045
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        Escort Swap-out a/r
                      </div>
                      <div style={{ fontFamily:'Roboto', fontSize:10, color:C.fg3, marginTop:2 }}>
                        (DT restricted to Zone A)
                      </div>
                    </div>
                  </div>

                  {/* 2ND AI Push */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      0055-0115Z
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        2<sup style={{ fontSize:8 }}>ND</sup> AI Push
                      </div>
                      <div style={{ fontFamily:'Roboto', fontSize:10, color:C.fg3, marginTop:2 }}>
                        (DT ongoing, including Zone B)
                      </div>
                    </div>
                  </div>

                  {/* Mass Debrief */}
                  <div style={{ display:'flex', gap:10, padding:'8px 0', borderBottom:`1px solid ${C.border}` }}>
                    <div style={{ fontFamily:'Roboto Mono', fontSize:11, fontWeight:600, color:C.accent, minWidth:80 }}>
                      0130Z
                    </div>
                    <div style={{ flex:1 }}>
                      <div style={{ fontFamily:'Roboto', fontSize:12, fontWeight:500, color:C.fg1 }}>
                        Mass Debrief
                      </div>
                    </div>
                  </div>
                </div>
              </div>

              {/* Notes Section */}
              <div>
                <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:10 }}>
                  NOTES
                </div>
                <div style={{ padding:'10px 12px', background:C.s3, border:`1px solid ${C.border}`, borderRadius:3 }}>
                  <ul style={{ listStyle:'disc', paddingLeft:18, margin:0 }}>
                    <li style={{ fontFamily:'Roboto', fontSize:11, color:C.fg2, lineHeight:1.6 }}>
                      Fight altitude deconfliction at Msn/CC discretion
                    </li>
                  </ul>
                </div>
              </div>
            </div>
          )}
        </div>
      );
    } else if (tab.type === 'callsign') {
      // Callsign detail: find specific mission
      const mission = missions.find(m => m.id === tab.missionId);

      if (!mission) return <div style={{ padding:'14px 12px', color:C.fg3 }}>Callsign not found</div>;

      const openFlags = (mission.impactFlags || []).filter(f => !f.actedOn);
      const hasCritical = openFlags.some(f => f.severity === 'critical');

      return (
        <div style={{ display:'flex', flexDirection:'column', gap:16, padding:'14px 12px', overflowY:'auto' }}>
          <div>
            <div style={{ fontFamily:'Roboto', fontSize:14, fontWeight:600, color:C.fg1, marginBottom:4 }}>
              {mission.callsign}
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:10, color:C.fg3, letterSpacing:'0.08em' }}>
              {mission.acftType} • {mission.variant || 'Standard'}
            </div>
          </div>

          {hasCritical && (
            <div style={{
              padding:'8px 12px', background:C.criticalM,
              border:`1px solid ${C.critical}`, borderRadius:3,
              fontFamily:'Roboto', fontSize:11, color:C.fg1,
            }}>
              ⚠ Critical alerts active
            </div>
          )}

          <div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
              MISSION TYPE
            </div>
            <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
              {TFC_TYPE_LONG[mission.type] || mission.type}
            </div>
          </div>

          <div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
              TIME WINDOW
            </div>
            <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
              NET {mission.netScenario || 'TBD'} / NLT {mission.nltScenario || 'TBD'}
            </div>
          </div>

          {mission.dep && (
            <div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                DEPARTURE
              </div>
              <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                {mission.depGeo?.name || mission.dep}
              </div>
            </div>
          )}

          {mission.arr && (
            <div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                ARRIVAL
              </div>
              <div style={{ fontFamily:'Roboto', fontSize:12, color:C.fg1 }}>
                {mission.arrGeo?.name || mission.arr}
              </div>
            </div>
          )}

          {openFlags.length > 0 && (
            <div>
              <div style={{ fontFamily:'Roboto Mono', fontSize:9, color:C.fg3, letterSpacing:'0.10em', marginBottom:6 }}>
                ACTIVE ALERTS ({openFlags.length})
              </div>
              <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
                {openFlags.map((flag, i) => (
                  <div key={i} style={{
                    padding:'8px 10px',
                    background: flag.severity === 'critical' ? C.criticalM : C.advisoryM,
                    border: `1px solid ${flag.severity === 'critical' ? C.critical : C.advisory}`,
                    borderRadius:3,
                    fontFamily:'Roboto', fontSize:11, color:C.fg1,
                  }}>
                    {flag.text}
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      );
    }

    return null;
  }

  // First left rail: Mission Overview (Cycle Board, ATO Details, Cycle Status)
  const normalLeft1 = <div data-tour="rail-mission-overview" style={{ height:'100%', display:'flex', flexDirection:'column' }}><CollapsibleRail
    label="Mission Overview"
    expanded={normalRail1Expanded}
    onExpandedChange={setNormalRail1Expanded}
    minimized={!normalRail1Expanded}
  >
    {atoRuntime && Sec ? (
      <>
        <Sec title="Cycle Board" collapsible defaultOpen flush>
          <TfcCycleBoardHeader
            runtime={atoRuntime}
            onSelectMission={onSelectCopMission}
            selectedMissionId={tfcSelectedMissionId}
          />
        </Sec>
        <Sec title="ATO Details" collapsible defaultOpen flush>
          <TfcAtoDetails runtime={atoRuntime} />
        </Sec>
        <Sec title="Cycle Status" collapsible defaultOpen flush>
          <TfcCycleSummary runtime={atoRuntime} />
        </Sec>
      </>
    ) : (
      <div style={{ padding:'14px 12px', color:C.fg3, fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.10em' }}>
        ATO RUNTIME PENDING…
      </div>
    )}
  </CollapsibleRail></div>;
  // Second left rail: Missions & Callsigns (or detail panel when a tab is active)
  const activeTab = openTabs.find(t => t.id === activeTabId);
  const showDetailPanel = activeTab && activeTab.type !== 'all';

  const normalLeft2 = <div data-tour="rail-missions" style={{ height:'100%', display:'flex', flexDirection:'column' }}><CollapsibleRail
    label={showDetailPanel ? (activeTab && activeTab.type === 'mission' ? 'Mission Details' : 'Callsign Details') : "Missions"}
    expanded={normalLeftRail2Expanded}
    onExpandedChange={setNormalLeftRail2Expanded}
    minimized={!normalLeftRail2Expanded}
  >
    {atoRuntime && Sec ? (
      showDetailPanel ? (
        <DetailPanel
          tab={activeTab}
          runtime={atoRuntime}
          onOpenTab={openTab}
        />
      ) : (
        <>
          {/* Search bar */}
          <div style={{ padding:'10px 12px 8px', background:C.s1 }}>
            <input
              type="text"
              placeholder="Search missions..."
              value={missionsSearchQuery}
              onChange={(e) => setMissionsSearchQuery(e.target.value)}
              style={{
                width:'100%', padding:'7px 10px',
                background:C.s3, border:`1px solid ${C.border}`, borderRadius:3,
                color:C.fg1, fontFamily:'Roboto', fontSize:11,
                outline:'none', transition:'border-color 0.15s',
              }}
              onFocus={(e) => e.currentTarget.style.borderColor = C.accent}
              onBlur={(e) => e.currentTarget.style.borderColor = C.border}
            />
          </div>
          {/* View mode toggle */}
          <div style={{ padding:'0 12px 10px', borderBottom:`1px solid ${C.border}`, background:C.s1 }}>
            <div style={{ display:'flex', background:C.s3, border:`1px solid ${C.border}`, borderRadius:3, overflow:'hidden', width:'100%' }}>
              <button
                onClick={() => setMissionsViewMode('missions')}
                style={{
                  flex:1,
                  padding:'6px 14px',
                  background: missionsViewMode === 'missions' ? C.accent : 'transparent',
                  color: missionsViewMode === 'missions' ? '#000' : C.fg2,
                  border:'none',
                  cursor: missionsViewMode === 'missions' ? 'default' : 'pointer',
                  fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
                  letterSpacing:'0.10em', transition:'background 0.12s, color 0.12s',
                }}>
                MISSIONS
              </button>
              <button
                onClick={() => setMissionsViewMode('callsigns')}
                style={{
                  flex:1,
                  padding:'6px 14px',
                  background: missionsViewMode === 'callsigns' ? C.accent : 'transparent',
                  color: missionsViewMode === 'callsigns' ? '#000' : C.fg2,
                  border:'none',
                  cursor: missionsViewMode === 'callsigns' ? 'default' : 'pointer',
                  fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
                  letterSpacing:'0.10em', transition:'background 0.12s, color 0.12s',
                }}>
                CALLSIGNS
              </button>
            </div>
          </div>
          <MissionListPanel
            runtime={atoRuntime}
            selectedMissionId={tfcSelectedMissionId}
            onSelectMission={onSelectCopMission}
            searchQuery={missionsSearchQuery}
            viewMode={missionsViewMode}
            onOpenTab={openTab}
          />
        </>
      )
    ) : (
      <div style={{ padding:'14px 12px', color:C.fg3, fontFamily:'Roboto Mono', fontSize:9, letterSpacing:'0.10em' }}>
        ATO RUNTIME PENDING…
      </div>
    )}
  </CollapsibleRail></div>;
  const normalRightAlt = <div data-tour="rail-map-layers" style={{ height:'100%', display:'flex', flexDirection:'column' }}><CollapsibleRail
    label="Map"
    expanded={normalRail2Expanded}
    onExpandedChange={setNormalRail2Expanded}
    minimized={!normalRail2Expanded}
  >{<window.BasemapPicker value={basemap} onChange={setBasemap} dimming={imageryDimming} onDimmingChange={setImageryDimming}/>}{<window.MapLayersPanel layers={mapLayers} onToggle={toggleMapLayer} onSetValue={setMapLayerValue} onToggleAor={toggleAorLayer} onSetAorBulk={setAorLayerBulk}/>}</CollapsibleRail></div>;
  const caseSummaryEl = <CaseSummary elapsed={elapsed} darkScore={darkScore}
    phaseLabel={phase===0?'IMMEDIATE ACTIONS':phase===1?'LOCATE / PLOT':phase===2?'AIRSPACE / THREAT':'TACTICAL EXECUTION'}
    phaseColor={phase===1?C.accent:C.advisory}
    authStatus={authAlpha && authBravo ? 'AUTHENTICATED' : authAlpha ? 'ALPHA AUTH\u2019D · BRAVO PENDING' : authBravo ? 'BRAVO AUTH\u2019D · ALPHA PENDING' : 'PENDING'}
    beaconAck={beaconAckAlpha || beaconAckBravo}
  />;
  const actionQueueEl = <ActionQueue actions={actions} elapsed={elapsed} completed={completed} state={{ bravoLocated }} onConfirm={confirm} onReject={reject} onOsc={()=>setOscOpen(true)} onPreview={openDraft} onAuth={()=>setAuthOpen(true)} onTx={()=>setTxOpen(true)} onUar={()=>setUarOpen(true)} onCoa={openCoa} onLaunch={openLaunch} onDivert={openDivert} onTerminal={openTerminal} onPickup={openPickup} onRecovery={openRecovery} onMedevac={openMedevac} onRoz={openRoz} onFifteen={openFifteen} expanded={queueExpanded} onExpandedChange={setQueueExpanded}/>;
  // Static order of 15-Line sections — rendered in the expanded queue's
  // 4th column. Each row reflects whether the section's been transmitted
  // and clicking it re-opens the composer for review/resend.
  const fifteenStatuses = [
    { id:'A',     label:'Section A — Survivors aboard / pickup', sent: pickupDone,       sentAt: pickupDoneAt },
    { id:'B',     label:'Section B — CSARTF launched / ingress', sent: fifteenBSent,     sentAt: fifteenBSentAt },
    { id:'C',     label:'Section C — Border crossed',            sent: fifteenCSent,     sentAt: fifteenCSentAt },
    { id:'D',     label:'Section D — Terminal area',             sent: fifteenDSent,     sentAt: fifteenDSentAt },
    { id:'CLOSE', label:'Closeout — Recovery complete',          sent: fifteenCloseSent, sentAt: fifteenCloseSentAt },
  ];
  // Build the "to-do forecast" — every UPCOMING_ACTIONS row that's not
  // currently in the live queue and not already completed. Drives the
  // To Do column in the expanded view, so MC/SL can see what's coming.
  const upcomingState = {
    jprcDone, oscDone,
    beaconAckAlpha, beaconAckBravo,
    authAlpha, authBravo,
    txSentAlpha, txSentBravo,
    j35AlphaDone, j35BravoDone,
    rozPublished, uarSent,
    activeCoa,
    launched, fifteenBSent, fifteenCSent, fifteenDSent,
    terminalDelegated, pickupDone, recoveryDone, fifteenCloseSent,
    medevacSent,
  };
  const activeIdSet = new Set(actions.map(a => a.id));
  const upcoming = (window.UPCOMING_ACTIONS || []).filter(u => {
    if (u.doneCheck && u.doneCheck(upcomingState)) return false;
    if (u.activeIds && u.activeIds.some(id => activeIdSet.has(id))) return false;
    return true;
  });
  const actionQueueExpandedEl = <ActionQueueExpanded
    actions={actions} elapsed={elapsed} completed={completed} state={{ bravoLocated }}
    upcoming={upcoming}
    fifteenStatuses={fifteenStatuses}
    msgs={msgs} setMsgs={setMsgs}
    onCollapse={()=>setQueueExpanded(false)}
    onConfirm={confirm} onReject={reject}
    onOsc={()=>setOscOpen(true)} onPreview={openDraft}
    onAuth={()=>setAuthOpen(true)} onTx={()=>setTxOpen(true)} onUar={()=>setUarOpen(true)}
    onCoa={openCoa} onLaunch={openLaunch} onDivert={openDivert} onTerminal={openTerminal}
    onPickup={openPickup} onRecovery={openRecovery} onMedevac={openMedevac} onRoz={openRoz}
    onFifteen={openFifteen}
  />;
  const rosterEl = <SurvivorRoster
    phase={phase} bravoLocated={bravoLocated}
    beaconAckAlpha={beaconAckAlpha} beaconAckAlphaAt={beaconAckAlphaAt}
    beaconAckBravo={beaconAckBravo} beaconAckBravoAt={beaconAckBravoAt}
    authAlpha={authAlpha} authAlphaAt={authAlphaAt}
    authBravo={authBravo} authBravoAt={authBravoAt}
    txSentAlpha={txSentAlpha} txSentAlphaAt={txSentAlphaAt}
    txSentBravo={txSentBravo} txSentBravoAt={txSentBravoAt}
    onAckBeacon={ackBeacon} onOpenAuth={openAuth} onOpenTx={openTx}
  />;
  const commsEl    = <CommsLog msgs={msgs} setMsgs={setMsgs}/>;
  const assetEl    = <AssetStatus oscId={oscId}/>;
  const envEl      = <EnvWidget darkScore={darkScore}/>;
  const timelineEl = <MissionTimeline elapsed={elapsed} jprcDone={jprcDone} oscDone={oscDone} j35Done={j35Done} phase={phase} rozPublished={rozPublished} fifteenLineSent={pickupDone}/>;

  // Two-rail right side in CSAR mode:
  //   Rail 1 (closest to map) — case context: summary, survivor roster, comms.
  //   Rail 2 (window edge)    — action queue (cards on top, completed
  //                             collapsible at bottom), then asset status,
  //                             environmental, mission timeline.
  // Rail 1 (closest to map) — case context + status/timeline reference info.
  // Rail 2 (window edge)    — action queue (cards on top, completed
  //                           collapsible at bottom) + comms log so the
  //                           operator can read radio chatter alongside the
  //                           actions they're driving.
  // Rail 1 wrapped in CollapsibleRail so the operator can collapse every
  // section in one click (and re-expand the same way). Each section keeps
  // its individual chevron for granular control. State is lifted to App so
  // LayoutA can shrink the rail to a 28px sliver when fully collapsed —
  // reclaiming horizontal space for the map.
  // Rail 1 surfaces a count of pending IP-specific actions in its header —
  // beacon-ack, ISOPREP auth, TX-limit per survivor. The header should
  // ONLY light up once those actions are actually actionable inside the
  // survivor card. The RosterActionStrip is gated on `contact && phase>=1`
  // (see SurvivorRoster), so we mirror that gate here:
  //   - ALPHA's sub-tasks count from phase 1 onward (contact = true at case open).
  //   - BRAVO's only count once located AND phase >= 1.
  // Pre-phase-1 the count + urgent treatment stay off, so the header
  // doesn't tease pending work the operator can't act on yet.
  const alphaActionsActive = phase >= 1;                  // contact is always true for ALPHA
  const bravoActionsActive = phase >= 1 && bravoLocated;
  const ipPendingAlpha = alphaActionsActive ? (beaconAckAlpha?0:1) + (authAlpha?0:1) + (txSentAlpha?0:1) : 0;
  const ipPendingBravo = bravoActionsActive ? (beaconAckBravo?0:1) + (authBravo?0:1) + (txSentBravo?0:1) : 0;
  const railPendingCount = ipPendingAlpha + ipPendingBravo;
  // Urgency (red + pulsing) is reserved for the beacon-ack moment — that's
  // the time-critical "I see you" handshake — but only when the Ack Beacon
  // button is actually visible (same gate as the strip itself).
  const beaconAckPending =
    (alphaActionsActive && !beaconAckAlpha) ||
    (bravoActionsActive && !beaconAckBravo);
  const csarRight    = <CollapsibleRail
    label="CSAR Mission Details"
    expanded={rail1Expanded}
    onExpandedChange={setRail1Expanded}
    minimized={!rail1Expanded}
    count={railPendingCount}
    urgent={beaconAckPending}
  >{caseSummaryEl}{rosterEl}{envEl}{assetEl}{timelineEl}</CollapsibleRail>;
  const csarRightAlt = <>{actionQueueEl}{commsEl}</>;

  const normalLeft  = <>{<window.WatchlistPanel
    activeAors={adminActiveAors}
    events={watchEvents}
    resolved={watchResolved}
    onFly={flyToWatchEvent}
    onResolve={resolveWatchEvent}
  />}{<BlueForceOverview/>}</>;
  const csarLeft    = <>{assetEl}{timelineEl}</>;
  const normalRight2 = <>{<NormalWeather darkScore={darkScore}/>}</>;
  const csarRight2   = <>{caseSummaryEl}{actionQueueEl}{rosterEl}{commsEl}{envEl}{timelineEl}</>;

  // Bottom panels for Layout C
  const makeBotCol = (children, key) => (
    <div key={key} style={{ width:310,flexShrink:0,borderRight:`1px solid ${C.border}`,overflowY:'auto',display:'flex',flexDirection:'column' }}>{children}</div>
  );
  const normalBot = [
    makeBotCol(<><window.WatchlistPanel
      activeAors={adminActiveAors}
      events={watchEvents}
      resolved={watchResolved}
      onFly={flyToWatchEvent}
      onResolve={resolveWatchEvent}
    /></>,0),
    makeBotCol(<><BlueForceOverview/></>,1),
    makeBotCol(<><NormalWeather darkScore={darkScore}/></>,2),
  ];
  const csarBot = [
    makeBotCol(<>{caseSummaryEl}{actionQueueEl}</>, 0),
    makeBotCol(<>{rosterEl}{commsEl}</>, 1),
    makeBotCol(<>{assetEl}{envEl}{timelineEl}</>, 2),
  ];

  let mainContent;
  if (tweaks.layout==='B') {
    mainContent = <LayoutB map={mapEl} left={isCsar?csarLeft:normalLeft} right={isCsar?csarRight2:normalRight2}/>;
  } else if (tweaks.layout==='C') {
    mainContent = <LayoutC map={mapEl} bottom={isCsar?csarBot:normalBot}/>;
  } else {
    // Both CSAR and Home/COP modes use the two-rail layout. Rail 1 is
    // operational/case context (closer to map); Rail 2 is action queue
    // (CSAR) or map layers + weather (Home). Each rail wraps in
    // CollapsibleRail so the operator can sliver them independently.
    // Mission-type MM workspaces (OCA, DCA, DT, CAS, SOF, TACS) overlay
    // the map with their own workspace chrome (title strip, bottom
    // Gantt drawer, Mission Detail right rail). The default rails would
    // render underneath and get covered by the MM chrome anyway — and
    // the alerts panel collides with the workspace title strip — so we
    // hide them while in those modes. Uses the central isMmTypeMode
    // helper from data.js so adding a new MM is one config change.
    const isMmTypeMode = !!(window.isMmTypeMode && window.isMmTypeMode(activeMode));
    mainContent = <LayoutA
      map={mapEl}
      left={isMmTypeMode ? null : (!isCsar ? normalLeft1 : null)}
      left2={isMmTypeMode ? null : (!isCsar ? normalLeft2 : null)}
      right={isMmTypeMode ? null : (isCsar ? csarRight : null)}
      right2={isMmTypeMode ? null : (isCsar?csarRightAlt:normalRightAlt)}
      right2Expanded={isCsar && queueExpanded ? actionQueueExpandedEl : null}
      leftMinimized={!isCsar ? !normalRail1Expanded : false}
      left2Minimized={!isCsar ? !normalLeftRail2Expanded : false}
      rightMinimized={isCsar ? !rail1Expanded : false}
      right2Minimized={isCsar ? false : !normalRail2Expanded}
      // Map Layers panel grew taller with the new per-AOR sectioning —
      // need vertical scroll on the outer rail wrapper. Only applies in
      // normal ops; CSAR Rail 2 keeps overflow:hidden so its comms log
      // can flex from the bottom against a bounded rail height.
      right2Scroll={!isCsar}
      queueExpanded={isCsar && queueExpanded}
    />;
  }

  // Hamburger badge: only meaningful when the operator is OUT of CSAR
  // while a session is live. The number is the unread alert count that's
  // accumulated since they switched away.
  const hamburgerBadge = (activeMode !== 'csar' && csarSessionActive) ? csarUnreadCount : 0;
  const activeModeMeta = (window.MASTER_MODES || []).find(m => m.id === activeMode) || null;
  const activeRoleMeta = (window.OPERATOR_ROLES || []).find(r => r.id === activeRole) || null;
  // Stub-mode dispatch: when the operator is in a non-implemented mode,
  // the underlying COP layout stays mounted (so the MapLibre instance is
  // preserved) and a full-bleed overlay card sits on top.
  const isStubMode = activeMode !== 'csar' && activeMode !== 'home' && activeModeMeta && !activeModeMeta.implemented;
  const isAdminMode = activeMode === 'admin';
  const isManageSystem = activeMode === 'manage-system';
  const isTfcMode = activeMode === 'tfc';
  // PR 25 — Mission-type MM workspaces. All 6 share the same shell
  // pattern (map + bottom Gantt drawer + Mission Detail rail). The
  // active package + camera frame + emphasis dim + ACM override key
  // off whichever mmId matches activeMode.
  const isOcaMode  = activeMode === 'oca';
  const isDcaMode  = activeMode === 'dca';
  const isDtMode   = activeMode === 'dt';
  const isCasMode  = activeMode === 'cas';
  const isSofMode  = activeMode === 'sof';
  const isTacsMode = activeMode === 'tacs';
  const isMmType   = !!(window.isMmTypeMode && window.isMmTypeMode(activeMode));
  // CSAR master mode entered without a live case — operator just
  // navigated here from the strip / drawer; no J3.1 has fired this
  // session. We DON'T light up the CSAR top bar, mission timer, or
  // rail-1 mission state for this — appState stays 'normal' (see
  // shim above) and the workspace renders a "no active case"
  // placeholder in place of the live CSAR layout.
  const isCsarIdle = activeMode === 'csar' && !csarSessionActive && !csarAlertPending;

  // Filter MASTER_MODES + OPERATOR_ROLES by Manage System config flags.
  // The strip + drawer both consume `effectiveModes`; the drawer's role
  // section reads `effectiveRoles`. Active mode and active role are
  // locked-on so an operator can't hide themselves out of the workspace.
  const effectiveModes = (window.MASTER_MODES || []).filter(m =>
    !hiddenModeIds.has(m.id) || m.id === activeMode
  );
  const effectiveRoles = (window.OPERATOR_ROLES || []).filter(r =>
    !hiddenRoleIds.has(r.id) || r.id === activeRole
  );

  // mainContent (with the map) stays mounted always so MapLibre's
  // internal state (markers, layers, animation refs) survives mode
  // switches — switching back to Home/CSAR is instant. When in Admin
  // mode (or any stub mode), the underlying mainContent is hidden via
  // the overlay workspace that paints on top of it.

  return (
    <div style={{ display:'flex',flexDirection:'column',height:'100vh',background:C.s1,overflow:'hidden',fontFamily:'Roboto,sans-serif',position:'relative' }}>
      {/* Classification banner — pinned at the very top across every mode.
          DoD convention is a solid green bar with centered white caps for
          UNCLASSIFIED material. The advisory line clarifies that all
          tactical content in this prototype is simulated; only the live
          ADS-B feed pulls real data. Sized small (20px) so it doesn't
          eat workspace real estate, but unmistakable. */}
      <ClassificationBanner/>
      {/* Diagnostic-only floating chip + DOM walker. The chip is dev-styled
          (dashed border, top-right) so it can never be confused with an
          operator control. When ON, the walker tags every Roboto Mono
          inline-style element with data-typo / data-typo-size, and the
          CSS block in the HTML head swaps fonts + spacing accordingly. */}
      <TypeAuditToggle active={typeAudit} onToggle={setTypeAudit} visible={typeAuditVisible}/>
      <TypeAuditWalker active={typeAudit}/>
      {isCsar
        ? <CSARTopBar elapsed={elapsed} jprcDone={jprcDone} oscDone={oscDone} phase={phase} j35Done={j35Done} darkScore={darkScore} nvgMode={nvgMode} onNvgModeChange={setNvgMode} onOpenDrawer={openDrawer} onToggleDrawer={toggleDrawer} badgeCount={hamburgerBadge}/>
        : <NormalTopBar dtg={window.CASE.dtg} darkScore={darkScore} nvgMode={nvgMode} onNvgModeChange={setNvgMode} onOpenDrawer={openDrawer} onToggleDrawer={toggleDrawer} badgeCount={hamburgerBadge} modeLabel={isManageSystem ? 'Manage System' : (activeModeMeta ? activeModeMeta.label : 'Mission COP')} roleShort={activeRoleMeta ? activeRoleMeta.short : 'MC/SL'} activeTool={activeTool} onActiveToolChange={setActiveTool} hideMapChrome={isAdminMode || isManageSystem || isTfcMode} projection={projection} onProjectionChange={setProjection} openTabs={openTabs} activeTabId={activeTabId} onTabChange={setActiveTabId} onTabClose={closeTab}/>}
      {/* Workspace area — master-mode strip removed, navigation now accessible via hover on hamburger menu */}
      <div style={{ flex:1, display:'flex', overflow:'hidden', minHeight:0 }}>
        <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, position:'relative' }}>
          {mainContent}

          {/* Navigation Tabs Overlay - To the right of details panel */}
          {!isAdminMode && !isManageSystem && openTabs.length > 0 && (
            <div style={{
              position:'absolute',
              top:12,
              left: (() => {
                let leftPos = 12;
                // First rail (alerts/left1)
                if (normalRail1Expanded) leftPos += 280;
                else leftPos += 40;
                // Second rail (missions/details)
                if (normalLeftRail2Expanded) leftPos += 300;
                else leftPos += 40;
                return `${leftPos}px`;
              })(),
              zIndex:200,
              display:'flex',
              gap:4,
              pointerEvents:'auto',
              transition:'left 0.2s ease',
            }}>
              {openTabs.map(tab => {
                const isActive = tab.id === activeTabId;
                const isAll = tab.id === 'all';

                // Determine tab color based on severity
                let tabBg = isActive ? C.s2 : 'rgba(21, 21, 23, 0.9)';
                let tabBorder = isActive ? C.accent : C.border2;
                let tabColor = isActive ? C.fg1 : C.fg3;
                let indicatorColor = null;

                if (!isAll && tab.severity === 'critical') {
                  tabBg = isActive ? 'rgba(220, 38, 38, 0.25)' : 'rgba(220, 38, 38, 0.18)';
                  tabBorder = 'rgba(220, 38, 38, 0.6)';
                  tabColor = C.fg1;
                  indicatorColor = C.critical;
                } else if (!isAll && tab.severity === 'advisory') {
                  tabBg = isActive ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.18)';
                  tabBorder = 'rgba(245, 158, 11, 0.6)';
                  tabColor = C.fg1;
                  indicatorColor = C.advisory;
                }

                return (
                  <div
                    key={tab.id}
                    style={{
                      display:'flex', alignItems:'center', gap:6,
                      padding:'8px 12px',
                      background: tabBg,
                      border: `1px solid ${tabBorder}`,
                      borderRadius:4,
                      cursor:'pointer',
                      transition:'all 0.15s ease',
                      backdropFilter:'blur(8px)',
                      boxShadow: isActive ? '0 4px 12px rgba(0,0,0,0.3)' : '0 2px 6px rgba(0,0,0,0.2)',
                    }}
                    onMouseEnter={e => {
                      if (!isActive && !indicatorColor) {
                        e.currentTarget.style.background = C.s3;
                        e.currentTarget.style.borderColor = C.accent;
                      }
                    }}
                    onMouseLeave={e => {
                      if (!isActive && !indicatorColor) {
                        e.currentTarget.style.background = 'rgba(21, 21, 23, 0.9)';
                        e.currentTarget.style.borderColor = C.border2;
                      }
                    }}
                  >
                    {indicatorColor && (
                      <div style={{
                        width:6, height:6, borderRadius:'50%',
                        background: indicatorColor,
                        flexShrink:0,
                      }}/>
                    )}
                    <span
                      onClick={() => setActiveTabId(tab.id)}
                      style={{
                        fontFamily:'Roboto Mono', fontSize:9, fontWeight:600,
                        color: tabColor,
                        letterSpacing:'0.08em',
                      }}
                    >
                      {tab.label}
                    </span>
                    {!isAll && (
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          closeTab(tab.id);
                        }}
                        style={{
                          background:'none', border:'none', padding:0,
                          color:C.fg3, cursor:'pointer',
                          display:'flex', alignItems:'center', justifyContent:'center',
                          width:14, height:14,
                        }}
                        title="Close tab"
                      >
                        <span style={{ fontSize:12, lineHeight:1 }}>×</span>
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
          )}
          {/* Mission Admin workspace overlays mainContent so the map stays
              mounted underneath. Card-shaped placeholder cards for stub
              modes work the same way. Both cover the full content area
              between the strip and the right edge. */}
          {isManageSystem && (
            <div style={{ position:'absolute', inset:0, zIndex:110, background:C.s1, display:'flex', flexDirection:'column' }}>
              <ManageSystemWorkspace
                tab={manageSystemTab}
                onTabChange={setManageSystemTab}
                hiddenModeIds={hiddenModeIds}
                onHiddenModeIdsChange={setHiddenModeIds}
                hiddenRoleIds={hiddenRoleIds}
                onHiddenRoleIdsChange={setHiddenRoleIds}
                activeMode={activeMode}
                activeRole={activeRole}
              />
            </div>
          )}
          {isCsarIdle && (
            <div style={{ position:'absolute', inset:0, zIndex:110, background:C.s1, display:'flex', flexDirection:'column' }}>
              <CsarIdleWorkspace onOpenDrawer={openDrawer} />
            </div>
          )}
          {isTfcMode && (
            <div style={{ position:'absolute', inset:0, zIndex:110, background:C.s1, display:'flex', flexDirection:'column' }}>
              <TfcWorkspace
                runtime={atoRuntime}
                selectedMissionId={tfcSelectedMissionId}
                onSelectMission={setTfcSelectedMissionId}
                atoGen={atoGen}
                onActionFired={() => setAtoGen(g => g + 1)}
                groupBy={tfcGroupBy}
                onGroupByChange={setTfcGroupBy}
                viewedDay={tfcViewedDay}
                onViewedDayChange={setTfcViewedDay}
              />
            </div>
          )}
          {isMmType && (
            <div style={{
              position:'absolute', inset:0, zIndex:110,
              display:'flex', flexDirection:'column',
              pointerEvents:'none',  /* let map below stay interactive between header & drawer */
            }}>
              <MissionTypeWorkspace
                mmId={activeMode}
                mmMeta={(window.MASTER_MODES || []).find(m => m.id === activeMode)
                  || { label:activeMode.toUpperCase(), short:activeMode.toUpperCase(), desc:'' }}
                runtime={atoRuntime}
                selectedMissionId={mmSelectedMissionId}
                onSelectMission={setMmSelectedMissionId}
                atoGen={atoGen}
                onActionFired={() => setAtoGen(g => g + 1)}
                viewedDay={tfcViewedDay}
                onViewedDayChange={setTfcViewedDay}
                drawerOpen={!!mmDrawerOpen[activeMode]}
                onDrawerToggle={() =>
                  setMmDrawerOpen(prev => ({ ...prev, [activeMode]: !prev[activeMode] }))}
              />
            </div>
          )}
          {isAdminMode && (
            <div style={{ position:'absolute', inset:0, zIndex:110, background:C.s1, display:'flex', flexDirection:'column' }}>
              <MissionAdminWorkspace
                docs={adminDocs}
                primaryAor={adminPrimaryAor}
                activeAors={adminActiveAors}
                onPrimaryAorChange={setAdminPrimaryAor}
                onActiveAorsChange={setAdminActiveAors}
                libraryTab={adminLibraryTab}
                onLibraryTabChange={setAdminLibraryTab}
                viewMode={adminViewMode}
                onViewModeChange={setAdminViewMode}
                onOpenUpload={(scope) => setAdminUploadModal(scope || {})}
                onOpenDoc={(id) => setAdminOpenDocId(id)}
                subview={adminSubview}
                onSubviewChange={setAdminSubview}
                onSubviewNavigate={navigateAdminSubview}
                filter={adminSubviewFilter}
                onFilterChange={setAdminSubviewFilter}
                search={adminSubviewSearch}
                onSearchChange={setAdminSubviewSearch}
                platformOverrides={platformOverrides}
                onApplyPlatformOverride={applyPlatformOverride}
                onFlyToObjective={flyToObjective}
                sortBySubview={adminSortBySubview}
                onSortColumn={cycleAdminSort}
              />
            </div>
          )}
        </div>
      </div>
      {isStubMode && (
        <StubModeWorkspace
          mode={activeModeMeta}
          onReturnCsar={() => setActiveMode('csar')}
          csarSessionActive={csarSessionActive}
          onOpenDrawer={openDrawer}
        />
      )}
      <MasterModeDrawer
        open={drawerOpen}
        activeMode={activeMode}
        activeRole={activeRole}
        csarUnreadCount={csarUnreadCount}
        csarSessionActive={csarSessionActive}
        onClose={closeDrawer}
        onSelectMode={requestModeSwitch}
        onSelectRole={(id)=>setActiveRole(id)}
        modes={effectiveModes}
        roles={effectiveRoles}
        onRestartTour={restartTour}
      />
      <ModeSwitchConfirmModal
        pendingMode={pendingMode}
        csarStatus={{ phase, launched, recoveryDone, missionPhase, missionProgress, elapsed, bravoLocated, pickupDone }}
        onConfirm={confirmModeSwitch}
        onCancel={cancelModeSwitch}
      />
      <MissionAdminUploadModal
        scope={adminUploadModal}
        onClose={() => setAdminUploadModal(null)}
        onUpload={async (typeId, aor) => {
          const newDoc = await simulateUpload(typeId, aor);
          if (adminUploadModal && adminUploadModal.replacingId) {
            finalizeReplace(adminUploadModal.replacingId, newDoc.id);
          }
        }}
      />
      <DocPreviewDrawer
        doc={adminOpenDocId ? adminDocs.find(d => d.id === adminOpenDocId) : null}
        allDocs={adminDocs}
        onClose={() => setAdminOpenDocId(null)}
        onReplace={(id) => {
          // Close the preview drawer + open the upload modal pre-scoped
          // for replacement. Same flow as the old card-row REPLACE
          // button, just routed through the drawer footer now.
          setAdminOpenDocId(null);
          replaceDoc(id);
        }}
        onArchive={(id) => {
          // Force-archive without a replacement — push the doc to the
          // Archive tab and close the drawer. Operator confirms via a
          // lightweight inline confirm in the drawer (see footer);
          // App-side this is a one-line state change.
          archiveDoc(id);
          setAdminOpenDocId(null);
        }}
        onOpenSubview={openAdminSubviewForAto}
      />
      <AcknowledgeOverlay visible={appState==='alert'} onAck={handleAck}/>
      <OSCDrawer open={oscOpen} onClose={()=>setOscOpen(false)} onAppoint={appoint} appointedId={oscId}/>
      <JPRCDraftModal
        open={draftOpen}
        draft={jprcDraft}
        onChange={setJprcDraft}
        onClose={()=>setDraftOpen(false)}
        onSend={()=>confirm('jprc-notify')}
        isoprep={window.ISOPREP}
      />
      <ISOPAuthModal
        open={authOpen}
        initialTab={authOpenCrew==='bravo'?1:0}
        onClose={()=>setAuthOpen(false)}
        onSubmit={submitAuth}
        isoprep={window.ISOPREP}
        methods={window.AUTH_METHODS}
        bravoContact={bravoLocated}
      />
      <ComposerModal
        open={txOpen}
        onClose={()=>setTxOpen(false)}
        onSubmit={()=>submitTx(txOpenCrew)}
        title={`TX Limit Directive — DUKE 31 ${txOpenCrew==='bravo'?'BRAVO':'ALPHA'}`}
        subtitle={`JTBD-11 · CSEL directive to DUKE 31 ${txOpenCrew==='bravo'?'BRAVO':'ALPHA'} · reduces SIGINT exposure`}
        fields={[
          { key:'recipient', label:'RECIPIENT', type:'input',    readOnly:true,
            default: txOpenCrew==='bravo' ? 'DUKE 31 BRAVO (CSEL-8104)' : 'DUKE 31 ALPHA (CSEL-7821)' },
          { key:'window',    label:'TX WINDOW', type:'input',    default:'30 SEC EVERY 10 MIN' },
          { key:'duration',  label:'DURATION',  type:'input',    default:'UNTIL AUTH OR EXTRACT' },
          { key:'reason',    label:'DIRECTIVE TEXT', type:'textarea', rows:3,
            default:`SUNDOWN: ${txOpenCrew==='bravo'?'BRAVO':'ALPHA'} MINIMIZE TX TO 30 SEC WINDOWS EVERY 10 MIN. BREAK ONLY FOR EMERGENCY OR AUTH CHALLENGE. OUT.` },
        ]}
        submitLabel="Transmit"
        submitColor="serious"
      />
      <ComposerModal
        open={uarOpen}
        onClose={()=>setUarOpen(false)}
        onSubmit={submitUar}
        title="UAR / CIA Network Tasking"
        subtitle="JTBD-12 · HOSTCOUNTRY-2 UAR cell · ground collection request"
        fields={[
          { key:'network',  label:'NETWORK',        type:'input',    readOnly:true, default:'HOSTCOUNTRY-2 UAR cell' },
          { key:'lkp',      label:'ISOP LKP',       type:'input',    readOnly:true, default:'51RTH 8451 1234 (ALPHA)' },
          { key:'collect',  label:'COLLECTION',     type:'textarea', rows:4,
            default:'- Verify patrol activity vic ISOP LKP (2km)\n- Road/trail trafficability to LZ candidates\n- Presence of hostile indicators (checkpoints, spotters)\n- Local time to moonrise LSA' },
          { key:'deadline', label:'REPORT BY',      type:'input',    default:'NLT T+60 MIN' },
        ]}
        submitLabel="Submit tasking"
        submitColor="serious"
      />
      <COAReviewModal
        open={coaOpen}
        coas={coas}
        breachActive={coaBreach}
        threats={[...(window.RED_FORCES||[]).filter(r=>!r.moving && r.lat!=null), ...activeLateThreats]}
        ip={window.IP_COORD}
        staging={window.STAGING_COORD}
        onClose={()=>setCoaOpen(false)}
        onSubmit={submitCoa}
      />
      <LaunchReadinessModal
        open={launchOpen}
        readiness={window.buildLaunchReadiness ? window.buildLaunchReadiness({ assets: window.ASSETS, activeCoa, darkScore, oscId }) : null}
        onClose={()=>setLaunchOpen(false)}
        onSubmit={submitLaunch}
      />
      <DivertModal
        open={divertOpen}
        onClose={()=>setDivertOpen(false)}
        onSubmit={submitDivert}
      />
      <TerminalHandoffModal
        open={terminalOpen}
        oscLabel={(() => {
          const osc = (window.ASSETS || []).find(a => a.id === oscId);
          return osc ? osc.cs : 'OSC on station';
        })()}
        onClose={()=>setTerminalOpen(false)}
        onSubmit={submitTerminal}
      />
      {/* Unified 15-Line modal — driven by `fifteenSection` ('A'/'B'/'C'/
          'D'/'CLOSE'). Pickup card opens Section A; the other 15-Line
          cards open their respective tab. The tab strip lets the operator
          jump across sections to review previously transmitted material
          (locked) or peek ahead at upcoming sections (still pending). */}
      <FifteenLineModal
        open={!!fifteenSection}
        section={sectionSpec(fifteenSection)}
        draft={draftForSection(fifteenSection)}
        onChange={d => setDraftForSection(fifteenSection, d)}
        onClose={()=>setFifteenSection(null)}
        onSubmit={submitFifteen}
        tabs={[
          { id:'A',     label:'Section A · Pickup',  sent: pickupDone,        sentAt: pickupDoneAt },
          { id:'B',     label:'Section B · Launch',  sent: fifteenBSent,      sentAt: fifteenBSentAt },
          { id:'C',     label:'Section C · Border',  sent: fifteenCSent,      sentAt: fifteenCSentAt },
          { id:'D',     label:'Section D · Terminal',sent: fifteenDSent,      sentAt: fifteenDSentAt },
          { id:'CLOSE', label:'Closeout',            sent: fifteenCloseSent,  sentAt: fifteenCloseSentAt },
        ]}
        activeTab={fifteenSection}
        onTabChange={setFifteenSection}
      />
      <RozDropModal
        open={rozOpen}
        draft={rozDraft}
        onChange={setRozDraft}
        onClose={()=>setRozOpen(false)}
        onSubmit={submitRoz}
      />
      <Medevac9LineModal
        open={medevacOpen}
        draft={nineLineDraft}
        onChange={setNineLineDraft}
        onClose={()=>setMedevacOpen(false)}
        onSubmit={submitMedevac}
      />
      <RecoveryCompleteModal
        open={recoveryOpen}
        completed={completed}
        onClose={()=>setRecoveryOpen(false)}
        onSubmit={submitRecovery}
      />
      <ConfirmJ35Modal
        open={j35ConfirmOpen}
        crew={j35ConfirmCrew}
        trigger={j35ConfirmTrigger}
        state={{ beaconAckAlpha, beaconAckBravo, authAlpha, authBravo, bravoLocated }}
        onClose={()=>setJ35ConfirmOpen(false)}
        onConfirm={commitJ35}
      />
      {/* Toast layer at the App root only renders for the floating modes
          ('top' or 'above'). Inline mode is rendered inside FloatingAlerts'
          headerExtras slot above. Skip render entirely when mode is inline
          to avoid double-rendering. */}
      {toastMode !== 'inline' && (
        <ToastStack
          toasts={visibleToasts}
          onDismiss={dismissToast}
          onAutoDismiss={demoteToastToAlert}
          suppressed={toastsSuppressed}
          mode={toastMode}
          isCsar={isCsar}
          /* Cross-mode toasts (in COP/Admin/etc while a CSAR session
             is live) are click-to-return regardless of placement. */
          onReturn={inAnotherMode ? () => setActiveMode('csar') : null}
        />
      )}
      <TweaksPanel visible={tweaksVis} tweaks={tweaks} onTweak={doTweak} onReset={reset}/>
      {tourStep != null && (
        <OnboardingTour
          stepIdx={tourStep}
          onStepChange={setTourStep}
          onExit={exitTour}
          api={{
            setActiveMode,
            setTfcSelectedMissionId,
            setMmSelectedMissionId,
            setOcaDrawerOpen: (open) => setMmDrawerOpen(prev => ({ ...prev, oca: open })),
            openDrawer,
            closeDrawer,
            setRail1Expanded: setNormalRail1Expanded,
            setRail2Expanded: setNormalRail2Expanded,
          }}
        />
      )}
    </div>
  );
}

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