/* global React, WORKS, RECORDINGS, RELEASES, AGREEMENTS, Ic, Pill, Section, Btn */
// ──────────────────────────────────────────────────────────────────────────
// conformance.jsx
//
// ScreenConformance — the Import & Conform migration screen.
//
// One screen, two phases:
//   • Setup  — pick sources, map fields, run the pass.
//   • Review — act on the findings, commit the migration.
//
// Designed against:
//   audit/ASTRO Data Model v1.md          (the locked schema)
//   audit/ASTRO Agreements v1.md          (agreements as the spine)
//   audit/ASTRO Conformance v1.md         (this screen's design spec)
//
// This is a Phase 1 deliverable: static screen + seeded findings, no real
// parsers behind it yet. The shape of the screen is what we're validating —
// once it feels right, we wire real Airtable / Neon / CSV parsers in
// Phase 1.5.
// ──────────────────────────────────────────────────────────────────────────

(function () {
  const { useState, useMemo } = React;

  // ────────────────────────────────────────────────────────── deterministic PRNG
  const seed = (s) => {
    let h = 2166136261;
    for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
    return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
  };
  const fmt = (n) => Number(n).toLocaleString();
  const pct = (n, d) => d === 0 ? '0%' : `${Math.round(n / d * 100)}%`;

  // ────────────────────────────────────────────────────────── finding taxonomy
  // The 20 finding types from the design spec, grouped into the 3 severity tiers.
  // Each has: label, group (asset|party|agreement|registration|statement|metadata),
  // severity (blocker|recommended|fyi), playbook (the recommended action).
  const FINDING_TYPES = {
    // ── Asset hierarchy (the duplicate-killing rules)
    duplicate_recording:    { label:'Duplicate Recording',         group:'asset',        sev:'recommended', playbook:'Merge into single Recording; point all releases at survivor' },
    duplicate_work:         { label:'Duplicate Work',              group:'asset',        sev:'recommended', playbook:'Merge; collapse writer-share rows' },
    rec_release_confusion:  { label:'Recording-as-Release',        group:'asset',        sev:'recommended', playbook:'Split into Recording + Release + Edition rows' },
    edition_collapse:       { label:'Edition collapse',            group:'asset',        sev:'recommended', playbook:'Promote one to Release; rest become Editions' },
    orphan_recording:       { label:'Orphan Recording',            group:'asset',        sev:'blocker',     playbook:'Create matching Work or link to existing' },
    orphan_release:         { label:'Orphan Release',              group:'asset',        sev:'recommended', playbook:'Investigate; usually missing tracklist' },
    isrc_collision:         { label:'ISRC collision',              group:'asset',        sev:'blocker',     playbook:'Resolve which Recording owns the ISRC' },
    pre_iswc_work:          { label:'Pre-ISWC Work',               group:'asset',        sev:'fyi',         playbook:'Generate provisional ID; queue for ISWC request' },
    pre_isrc_recording:     { label:'Pre-ISRC Recording',          group:'asset',        sev:'fyi',         playbook:'Queue for ISRC issuance' },
    // ── Parties & indicators
    imprint_flatten:        { label:'Imprint flatten',             group:'party',        sev:'recommended', playbook:'Promote text column to child Party of correct parent publisher' },
    party_kind_drift:       { label:'Party kind drift',            group:'party',        sev:'recommended', playbook:'Reclassify Party with correct kind' },
    missing_party_ref:      { label:'Missing Party reference',     group:'party',        sev:'recommended', playbook:'Create Party from name; flag for verification' },
    indicator_unclear:      { label:'Indicator unclear',           group:'party',        sev:'recommended', playbook:'Pick which active agreement establishes the indicator' },
    // ── Agreements & shares
    agreement_no_parties:   { label:'Agreement without parties',   group:'agreement',    sev:'blocker',     playbook:'Assign parties before commit' },
    agreement_scope_drift:  { label:'Agreement scope mismatch',    group:'agreement',    sev:'recommended', playbook:'Confirm scope or update affected works' },
    splits_not_100:         { label:'Splits ≠ 100%',               group:'agreement',    sev:'blocker',     playbook:'Correct shares to total exactly 100%' },
    controlled_drift:       { label:'Controlled drift',            group:'agreement',    sev:'recommended', playbook:'Demote to non-controlled; flag for review' },
    // ── Registrations
    stale_registration:     { label:'Stale registration',          group:'registration', sev:'recommended', playbook:'Mark for re-issue under correct imprint' },
    // ── Statements & data hygiene
    statement_unmatched:    { label:'Statement unmatched',         group:'statement',    sev:'fyi',         playbook:'Quarantine; learning matcher trains on your decisions' },
    date_format_chaos:      { label:'Date format chaos',           group:'metadata',     sev:'fyi',         playbook:'Normalize to ISO; show before/after diff' },
    currency_assumption:    { label:'Currency assumption',         group:'metadata',     sev:'fyi',         playbook:'Infer from vendor + period; flag low-confidence' },
  };

  const SEV = {
    blocker:     { label:'BLOCKER',     color:'#a04432', rank:0, sub:'must resolve before commit' },
    recommended: { label:'RECOMMENDED', color:'#c79538', rank:1, sub:'should review; can defer' },
    fyi:         { label:'FYI',         color:'#5a6a7a', rank:2, sub:'informational' },
  };

  const GROUP_META = {
    asset:        { label:'Catalog assets',     color:'#1a5d8c' },
    party:        { label:'Parties & imprints', color:'#7a5a8c' },
    agreement:    { label:'Agreements & shares',color:'#a04432' },
    registration: { label:'Registrations',      color:'#c79538' },
    statement:    { label:'Statements',         color:'#2d6a3a' },
    metadata:     { label:'Data hygiene',       color:'#5a6a7a' },
  };

  // ────────────────────────────────────────────────────────── seeded findings
  // Realistic mock data tuned to Paul's actual catalog scale:
  //   • 1,247 Works, 2,031 Recordings, 218 Releases (post-conform)
  //   • 1,892 Parties (incl. 8 owned imprints), 94 Agreements
  // Findings are deterministic so the screen looks the same every reload.
  function buildFindings() {
    if (window.__CONFORM_CACHE) return window.__CONFORM_CACHE;
    const r = seed('astro·conformance·v1');
    const works = (window.WORKS || []).slice(0, 200);
    const recs  = (window.RECORDINGS || []).slice(0, 200);
    const rels  = (window.RELEASES || []).slice(0, 100);

    // Counts per finding type — tuned to feel real, not random
    const counts = {
      duplicate_recording:   847,  // the big one — the Airtable problem
      duplicate_work:        87,
      rec_release_confusion: 134,
      edition_collapse:      62,
      orphan_recording:      4,
      orphan_release:        2,
      isrc_collision:        3,    // blocker, but small
      pre_iswc_work:         312,
      pre_isrc_recording:    47,
      imprint_flatten:       431,  // 8 imprints × hundreds of refs
      party_kind_drift:      29,
      missing_party_ref:     94,
      indicator_unclear:     6,
      agreement_no_parties:  12,   // blocker
      agreement_scope_drift: 8,
      splits_not_100:        34,   // blocker
      controlled_drift:      57,
      stale_registration:    23,
      statement_unmatched:   2107,
      date_format_chaos:     1431,
      currency_assumption:   218,
    };

    // Build representative sample rows for each type (used in drawer detail).
    // Cap at ~12 samples per type — enough for the drawer to feel populated.
    const findings = [];
    Object.entries(counts).forEach(([type, total]) => {
      const meta = FINDING_TYPES[type];
      const sampleCap = Math.min(12, total);
      // Auto-resolution confidence — drives the "X auto / Y needs review" split.
      // Blockers never auto-resolve; recommended split 80/20; fyi 95/5.
      const autoPct = meta.sev === 'blocker' ? 0 : meta.sev === 'fyi' ? 0.95 : 0.85;
      const autoCount = Math.round(total * autoPct);
      const reviewCount = total - autoCount;

      findings.push({
        type,
        meta,
        total,
        autoCount,
        reviewCount,
        samples: Array.from({ length: sampleCap }, (_, i) => buildSample(type, i, r, works, recs, rels)),
      });
    });

    // Sort: blocker first, then by group, then by total desc within each
    findings.sort((a, b) => {
      const s = SEV[a.meta.sev].rank - SEV[b.meta.sev].rank;
      if (s !== 0) return s;
      const g = a.meta.group.localeCompare(b.meta.group);
      if (g !== 0) return g;
      return b.total - a.total;
    });

    const result = { findings, totals: counts };
    window.__CONFORM_CACHE = result;
    return result;
  }

  // Build one representative sample row for a given finding type.
  function buildSample(type, i, r, works, recs, rels) {
    const w = works[Math.floor(r() * Math.max(1, works.length))] || { id:`w-${i}`, title:'Untitled Work', iswc:'T-300.000.000-0' };
    const rec = recs[Math.floor(r() * Math.max(1, recs.length))] || { id:`r-${i}`, title:'Untitled', isrc:'USXX0000000' };
    const rel = rels[Math.floor(r() * Math.max(1, rels.length))] || { id:`rel-${i}`, title:'Untitled Release', upc:`00000000000${i}` };

    const imprints = ['Uroyan Publishing','Not Rocket Science Publishing','Worthless Melodies','rocktscience Music Publishing','De Respeto Publishing','Rocket Science Tunes','Rocket Science Songs UK','Rocket Science Songs Europe'];
    const parents  = ['Rocket Science Music Publishing Group LLC'];
    const writers  = ['Paul Llanos','J. Rivera','M. Diaz','Jane Co-writer','S. Park','TOS Writer','D. Aguilar'];

    switch (type) {
      case 'duplicate_recording': {
        const a = ['Late Bloomer','Sundown','Marfil','Ancla','Verbena','Cardinal','Tideline'][i % 7];
        const variant = ['(Original)','(Mix v2)','(Master)','(Album version)','(Final)','(Premaster)'][i % 6];
        return {
          headline: `"${a}" appears as ${2 + (i%3)} separate Recordings`,
          detail: `ISRC ${rec.isrc} appears on rows ${1+i*2} and ${2+i*2}; titles differ only by suffix ${variant}`,
          confidence: 92 + (i % 8),
          merge_target: `row-${1+i*2}`,
          rows_affected: 2 + (i % 3),
        };
      }
      case 'duplicate_work': {
        return {
          headline: `"${w.title}" — possible duplicate`,
          detail: `Two Works share writers ${writers[i%writers.length]} + ${writers[(i+1)%writers.length]} within ±90d`,
          confidence: 87 + (i % 13),
          rows_affected: 2,
        };
      }
      case 'rec_release_confusion': {
        return {
          headline: `Row mixes Recording + Release fields`,
          detail: `Single row carries ISRC ${rec.isrc} and UPC ${rel.upc} — should be separated`,
          rows_affected: 1,
        };
      }
      case 'edition_collapse': {
        const k = ['Clean','Explicit','Radio Edit','Acoustic','Spanish','Deluxe'][i%6];
        return {
          headline: `"${rel.title}" — "${k}" stored as separate Release`,
          detail: `UPC differs but Recordings overlap 100% — promote to Edition of parent Release`,
          rows_affected: 2,
        };
      }
      case 'orphan_recording': {
        return {
          headline: `Recording "${rec.title}" has no Work`,
          detail: `Cannot collect publishing royalties without a parent composition`,
          rows_affected: 1,
        };
      }
      case 'orphan_release': {
        return {
          headline: `Release "${rel.title}" has no tracklist`,
          detail: `UPC ${rel.upc} but no linked Recordings`,
          rows_affected: 1,
        };
      }
      case 'isrc_collision': {
        return {
          headline: `ISRC ${rec.isrc} on two different Recordings`,
          detail: `Hard collision; one must be reissued or merged`,
          rows_affected: 2,
        };
      }
      case 'pre_iswc_work': {
        return {
          headline: `"${w.title}" has no ISWC`,
          detail: `Eligible for ISWC registration`,
          rows_affected: 1,
        };
      }
      case 'pre_isrc_recording': {
        return {
          headline: `Recording has no ISRC`,
          detail: `Required before DSP delivery`,
          rows_affected: 1,
        };
      }
      case 'imprint_flatten': {
        const imp = imprints[i % imprints.length];
        return {
          headline: `"${imp}" — ${30+(i*7)%200} references as text column`,
          detail: `Promote to child Party under ${parents[0]}`,
          target_parent: parents[0],
          rows_affected: 30+(i*7)%200,
        };
      }
      case 'party_kind_drift': {
        return {
          headline: `"${writers[i%writers.length]}" stored as label, not person`,
          detail: `Reclassify Party kind: label → person`,
          rows_affected: 1,
        };
      }
      case 'missing_party_ref': {
        return {
          headline: `"${writers[i%writers.length]}" — name with no Party row`,
          detail: `Appears on ${1+(i%4)} writer shares; create Party for verification`,
          rows_affected: 1+(i%4),
        };
      }
      case 'indicator_unclear': {
        return {
          headline: `${imprints[i%imprints.length]} — multiple potential indicator paths`,
          detail: `Two active agreements grant different relationships`,
          rows_affected: 1,
        };
      }
      case 'agreement_no_parties': {
        const types = ['Songwriter','Pub Admin','Distribution','Producer','Sub-publishing','Management'];
        return {
          headline: `${types[i%types.length]} agreement #${1000+i} has no party links`,
          detail: `Cannot compute indicator until parties assigned`,
          rows_affected: 1,
        };
      }
      case 'agreement_scope_drift': {
        return {
          headline: `TOS Pub Admin scope claims ${20+i*3} works; only ${15+i*2} match`,
          detail: `${5+i} works in scope filter not present in catalog`,
          rows_affected: 1,
        };
      }
      case 'splits_not_100': {
        const total = (85 + (i*3)%18).toFixed(2);
        return {
          headline: `"${w.title}" — writer splits sum to ${total}%`,
          detail: parseFloat(total) > 100 ? `Over-allocated by ${(total-100).toFixed(2)}%` : `Unallocated remainder: ${(100-total).toFixed(2)}%`,
          total_pct: parseFloat(total),
          rows_affected: 1,
        };
      }
      case 'controlled_drift': {
        return {
          headline: `Share marked controlled but agreement expired ${30+i*5}d ago`,
          detail: `Demote to non-controlled; flag any income received post-expiry`,
          rows_affected: 1,
        };
      }
      case 'stale_registration': {
        const soc = ['ASCAP','BMI','PRS','STIM','GEMA','SOCAN'][i%6];
        return {
          headline: `${soc} registration points at superseded record`,
          detail: `Re-issue under ${imprints[i%imprints.length]}`,
          rows_affected: 1,
        };
      }
      case 'statement_unmatched': {
        const vendors = ['ASCAP','BMI','Symphonic','SoundExchange','MLC','HFA','AWAL','The Orchard'];
        return {
          headline: `${vendors[i%vendors.length]} line could not match any Recording`,
          detail: `Title "${w.title.substring(0,18)}…" / amount $${(20+i*7).toFixed(2)} / period 2025-Q4`,
          rows_affected: 1,
        };
      }
      case 'date_format_chaos': {
        const formats = ['MM/DD/YYYY','DD-MM-YYYY','YYYY.MM.DD','M/D/YY'];
        return {
          headline: `"${formats[i%formats.length]}" dates in source column "${['release_date','signed_at','effective_date','term_end'][i%4]}"`,
          detail: `Normalizing to ISO 8601`,
          rows_affected: 1,
        };
      }
      case 'currency_assumption': {
        const cur = ['USD','EUR','GBP','SEK','CAD'][i%5];
        return {
          headline: `Income line — currency inferred as ${cur}`,
          detail: `Vendor + period inference; confidence ${75+(i%20)}%`,
          rows_affected: 1,
        };
      }
      default: return { headline:'Unknown finding', detail:'', rows_affected:0 };
    }
  }

  // ────────────────────────────────────────────────────────── primitives
  function Stat({ label, value, sub, accent, last }) {
    return (
      <div style={{ padding: '18px 22px', borderRight: last ? 'none' : '1px solid var(--rule)', flex: 1, minWidth: 0 }}>
        <div className="ff-mono upper" style={{ fontSize: 9, color: 'var(--ink-3)', letterSpacing: '.1em', marginBottom: 8 }}>{label}</div>
        <div className="ff-display num" style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.04em', lineHeight: 1, color: accent || 'var(--ink)' }}>{value}</div>
        {sub && <div className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 6, letterSpacing: '.02em' }}>{sub}</div>}
      </div>
    );
  }

  function SevChip({ s }) {
    const m = SEV[s];
    return (
      <span className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.12em', padding: '3px 7px',
        border: `1px solid ${m.color}`, color: m.color, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
        <span style={{ width: 5, height: 5, background: m.color }} />
        {m.label}
      </span>
    );
  }

  function GroupDot({ g }) {
    return <span style={{ width: 6, height: 6, background: GROUP_META[g]?.color || 'var(--ink-3)', display: 'inline-block', flexShrink: 0 }} />;
  }

  // ────────────────────────────────────────────────────────── migration summary
  function MigrationSummary({ findings }) {
    // Pull derived input/output counts. These reflect the spec's "12,847 → 8,392"
    // example and feel right for Paul's actual catalog scale.
    const cells = [
      { label: 'WORKS',        out: 1247,  delta: '+12 from sources',     sub: '87 merged duplicates' },
      { label: 'RECORDINGS',   out: 2031,  delta: '−847 dupes merged',    sub: 'Was 2,878 across sources' },
      { label: 'RELEASES',     out: 218,   delta: 'unchanged',             sub: 'Editions extracted: 312' },
      { label: 'EDITIONS',     out: 312,   delta: 'auto-promoted',         sub: 'From flattened "Release" rows' },
      { label: 'PARTIES',      out: 1892,  delta: '+431 imprints',         sub: 'Owned ★: 11 · Admin ◆: 47' },
      { label: 'AGREEMENTS',   out: 94,    delta: '12 incomplete',         sub: 'Block commit until resolved' },
      { label: 'STATEMENTS',   out: 214,   delta: '8 quarantined',         sub: 'Vendor parsers v2.1' },
      { label: 'INCOME LINES', out: 48217, delta: '2,107 unmatched',       sub: 'Quarantined for matching' },
    ];

    return (
      <div style={{ border: '1px solid var(--rule)', marginBottom: 32 }}>
        <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--rule)', background: 'var(--bg-2)',
          display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <span className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.1em' }}>MIGRATION SUMMARY</span>
          <span className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '.06em' }}>
            12,847 input rows → 8,392 conformed records · pass completed in 2m 14s
          </span>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)' }}>
          {cells.map((c, i) => (
            <div key={c.label} style={{
              padding: '16px 18px',
              borderRight: (i+1) % 4 === 0 ? 'none' : '1px solid var(--rule-soft)',
              borderBottom: i < 4 ? '1px solid var(--rule-soft)' : 'none',
            }}>
              <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.1em', color: 'var(--ink-3)', marginBottom: 6 }}>{c.label}</div>
              <div className="ff-display num" style={{ fontSize: 26, fontWeight: 600, letterSpacing: '-0.03em', lineHeight: 1 }}>{fmt(c.out)}</div>
              <div className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-2)', marginTop: 6, letterSpacing: '.02em' }}>{c.delta}</div>
              <div className="ff-mono" style={{ fontSize: 9.5, color: 'var(--ink-3)', marginTop: 3, letterSpacing: '.02em' }}>{c.sub}</div>
            </div>
          ))}
        </div>
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── findings card
  function FindingCard({ f, accepted, onOpen, onAcceptAuto }) {
    const sev = SEV[f.meta.sev];
    return (
      <div style={{ borderBottom: '1px solid var(--rule-soft)', padding: '16px 18px',
        background: accepted ? 'rgba(45,106,58,.04)' : 'transparent',
        display: 'grid', gridTemplateColumns: '14px 1fr auto auto', gap: 14, alignItems: 'center' }}>
        <GroupDot g={f.meta.group} />
        <div style={{ minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
            <span className="ff-display" style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>{f.meta.label}</span>
            <SevChip s={f.meta.sev} />
            <span className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '.04em' }}>
              {GROUP_META[f.meta.group]?.label}
            </span>
            {accepted && (
              <span className="ff-mono upper" style={{ fontSize: 9, padding: '2px 6px', letterSpacing: '.1em',
                border: '1px solid #2d6a3a', color: '#2d6a3a' }}>✓ STAGED</span>
            )}
          </div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-2)', letterSpacing: '-0.005em', marginBottom: 4 }}>
            {f.meta.playbook}.
          </div>
          {f.autoCount > 0 && (
            <div className="ff-mono" style={{ fontSize: 10.5, color: 'var(--ink-3)', letterSpacing: '.02em' }}>
              {fmt(f.autoCount)} can auto-resolve at ≥85% confidence
              {f.reviewCount > 0 && <> · {fmt(f.reviewCount)} need review</>}
            </div>
          )}
          {f.autoCount === 0 && f.reviewCount > 0 && (
            <div className="ff-mono" style={{ fontSize: 10.5, color: sev.color, letterSpacing: '.02em' }}>
              {fmt(f.reviewCount)} require manual resolution
            </div>
          )}
        </div>
        <div className="ff-display num" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.03em', textAlign: 'right', minWidth: 80 }}>
          {fmt(f.total)}
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          {f.autoCount > 0 && !accepted && (
            <button onClick={onAcceptAuto} className="ff-mono upper"
              style={{ padding: '7px 12px', background: 'transparent', color: 'var(--ink)', border: '1px solid var(--rule)',
                fontSize: 10, letterSpacing: '.08em', cursor: 'pointer', whiteSpace: 'nowrap' }}>
              Accept {fmt(f.autoCount)}
            </button>
          )}
          <button onClick={onOpen} className="ff-mono upper"
            style={{ padding: '7px 12px', background: f.meta.sev === 'blocker' ? sev.color : 'var(--ink)', color: 'var(--bg)',
              border: 0, fontSize: 10, letterSpacing: '.08em', cursor: 'pointer', whiteSpace: 'nowrap' }}>
            {f.meta.sev === 'blocker' ? 'Resolve →' : 'Review →'}
          </button>
        </div>
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── drawer
  function FindingDrawer({ f, onClose }) {
    const [acceptedIds, setAcceptedIds] = useState(new Set());
    const [rejectedIds, setRejectedIds] = useState(new Set());
    const [viewMode, setViewMode] = useState('sample');  // 'sample' | 'full'
    const [pageSize, setPageSize] = useState(50);
    const [query, setQuery] = useState('');

    if (!f) return null;
    const sev = SEV[f.meta.sev];

    const decide = (id, decision) => {
      if (decision === 'accept') { acceptedIds.add(id); rejectedIds.delete(id); }
      else { rejectedIds.add(id); acceptedIds.delete(id); }
      setAcceptedIds(new Set(acceptedIds));
      setRejectedIds(new Set(rejectedIds));
    };

    // Lazily generate full rows on demand. Real impl pulls from the conformance
    // pass results table; we synthesize on the fly so the UI proves out before
    // the parser is built.
    const fullRows = useMemo(() => {
      if (viewMode === 'sample') return f.samples;
      // Generate up to `f.total` rows using the same builder. Cheap because we
      // only render a windowed slice of these via pageSize.
      const r = seed(`drawer·${f.type}`);
      const list = Array.from({ length: f.total }, (_, i) =>
        buildSample(f.type, i, r, window.WORKS || [], window.RECORDINGS || [], window.RELEASES || []));
      return list;
    }, [f, viewMode]);

    // Filter by query. Cheap text match across headline + detail.
    const filteredRows = useMemo(() => {
      if (!query.trim()) return fullRows;
      const q = query.toLowerCase();
      return fullRows.filter(r =>
        (r.headline || '').toLowerCase().includes(q) ||
        (r.detail   || '').toLowerCase().includes(q));
    }, [fullRows, query]);

    const renderedRows = filteredRows.slice(0, pageSize);
    const hasMore      = filteredRows.length > pageSize;

    const acceptAllVisible = () => {
      const next = new Set(acceptedIds);
      renderedRows.forEach((_, i) => next.add(`s-${i}`));
      setAcceptedIds(next);
    };

    return (
      <div style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', justifyContent: 'flex-end' }}>
        <div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(8, 12, 18, 0.4)' }} />
        <div style={{ position: 'relative', width: '60%', maxWidth: 920, minWidth: 560, height: '100%',
          background: 'var(--bg)', borderLeft: '1px solid var(--rule)', display: 'flex', flexDirection: 'column',
          boxShadow: '-12px 0 40px -12px rgba(0,0,0,.18)' }}>

          {/* drawer header */}
          <div style={{ padding: '20px 26px', borderBottom: '1px solid var(--rule)' }}>
            <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
              <div>
                <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 8 }}>
                  <SevChip s={f.meta.sev} />
                  <span className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--ink-3)' }}>
                    {GROUP_META[f.meta.group]?.label}
                  </span>
                </div>
                <h2 className="ff-display" style={{ fontSize: 28, fontWeight: 600, letterSpacing: '-0.025em', margin: 0, marginBottom: 6 }}>
                  {f.meta.label}
                </h2>
                <div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{f.meta.playbook}.</div>
              </div>
              <button onClick={onClose} className="ff-mono upper"
                style={{ padding: '6px 10px', background: 'transparent', color: 'var(--ink-3)',
                  border: '1px solid var(--rule)', fontSize: 10, letterSpacing: '.1em', cursor: 'pointer' }}>
                CLOSE
              </button>
            </div>
            {/* counts strip */}
            <div style={{ display: 'flex', gap: 24, marginTop: 18, paddingTop: 16, borderTop: '1px solid var(--rule-soft)' }}>
              <CountChip label="TOTAL"        value={f.total} />
              <CountChip label="AUTO-RESOLVE" value={f.autoCount} accent="#2d6a3a" />
              <CountChip label="NEEDS REVIEW" value={f.reviewCount} accent={sev.color} />
              <CountChip label="ACCEPTED"     value={acceptedIds.size} accent="#2d6a3a" />
              <CountChip label="REJECTED"     value={rejectedIds.size} accent="#a04432" />
            </div>
          </div>

          {/* sample list */}
          <div style={{ flex: 1, overflow: 'auto' }}>
            <div style={{ padding: '14px 26px 12px', borderBottom: '1px solid var(--rule-soft)',
              background:'var(--bg-2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
              <div style={{ display:'flex', gap: 4 }}>
                {[
                  { k:'sample', label:`Sample (${f.samples.length})` },
                  { k:'full',   label:`Full list (${fmt(f.total)})` },
                ].map(m => (
                  <button key={m.k} onClick={() => { setViewMode(m.k); setPageSize(50); }} className="ff-mono upper"
                    style={{ padding:'5px 10px',
                      background: viewMode === m.k ? 'var(--ink)' : 'transparent',
                      color: viewMode === m.k ? 'var(--bg)' : 'var(--ink-2)',
                      border: `1px solid ${viewMode === m.k ? 'var(--ink)' : 'var(--rule)'}`,
                      fontSize:9.5, letterSpacing:'.08em', cursor:'pointer' }}>
                    {m.label}
                  </button>
                ))}
              </div>
              <div style={{ display:'flex', gap: 8, alignItems:'center', flex: 1, maxWidth: 360, justifyContent:'flex-end' }}>
                {viewMode === 'full' && (
                  <input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPageSize(50); }}
                    placeholder="Search rows…"
                    style={{ flex: 1, maxWidth: 200, padding:'5px 9px', fontSize: 11,
                      background:'var(--bg)', color:'var(--ink)',
                      border:'1px solid var(--rule)', outline:'none', fontFamily:'inherit' }} />
                )}
                {f.autoCount > 0 && (
                  <button onClick={acceptAllVisible} className="ff-mono upper"
                    style={{ padding: '6px 10px', background: '#2d6a3a', color: 'var(--bg)', border: 0,
                      fontSize: 9.5, letterSpacing: '.08em', cursor: 'pointer', whiteSpace:'nowrap' }}>
                    Accept all visible
                  </button>
                )}
              </div>
            </div>
            {viewMode === 'full' && (
              <div style={{ padding:'8px 26px', fontSize:10.5, color:'var(--ink-3)', letterSpacing:'.04em',
                borderBottom:'1px solid var(--rule-soft)' }} className="ff-mono">
                {query
                  ? `${fmt(filteredRows.length)} match${filteredRows.length === 1 ? '' : 'es'} of ${fmt(fullRows.length)} · showing top ${fmt(renderedRows.length)}`
                  : `Showing ${fmt(renderedRows.length)} of ${fmt(filteredRows.length)} rows`}
              </div>
            )}
            {renderedRows.map((s, i) => {
              const id = `s-${i}`;
              const isAccepted = acceptedIds.has(id);
              const isRejected = rejectedIds.has(id);
              return (
                <div key={id} style={{
                  padding: '14px 26px',
                  borderBottom: '1px solid var(--rule-soft)',
                  background: isAccepted ? 'rgba(45,106,58,.06)' : isRejected ? 'rgba(160,68,50,.05)' : 'transparent',
                  display: 'grid', gridTemplateColumns: '1fr auto', gap: 16, alignItems: 'flex-start',
                }}>
                  <div style={{ minWidth: 0 }}>
                    <div className="ff-display" style={{ fontSize: 14, fontWeight: 500, letterSpacing: '-0.005em', marginBottom: 4 }}>
                      {s.headline}
                    </div>
                    <div style={{ fontSize: 12, color: 'var(--ink-2)', letterSpacing: '-0.005em', marginBottom: 6 }}>
                      {s.detail}
                    </div>
                    <div style={{ display: 'flex', gap: 14, fontSize: 10.5 }} className="ff-mono">
                      {s.confidence != null && (
                        <span style={{ color: s.confidence >= 95 ? '#2d6a3a' : s.confidence >= 85 ? 'var(--ink-2)' : '#c79538' }}>
                          {s.confidence}% confidence
                        </span>
                      )}
                      {s.rows_affected != null && (
                        <span style={{ color: 'var(--ink-3)' }}>{s.rows_affected} row{s.rows_affected === 1 ? '' : 's'} affected</span>
                      )}
                      {s.target_parent && (
                        <span style={{ color: 'var(--ink-3)' }}>→ {s.target_parent}</span>
                      )}
                    </div>
                  </div>
                  <div style={{ display: 'flex', gap: 6 }}>
                    <button onClick={() => decide(id, 'accept')} className="ff-mono upper"
                      style={{ padding: '6px 10px', background: isAccepted ? '#2d6a3a' : 'transparent',
                        color: isAccepted ? 'var(--bg)' : '#2d6a3a',
                        border: '1px solid #2d6a3a', fontSize: 10, letterSpacing: '.08em', cursor: 'pointer' }}>
                      {isAccepted ? '✓ Accepted' : 'Accept'}
                    </button>
                    <button onClick={() => decide(id, 'reject')} className="ff-mono upper"
                      style={{ padding: '6px 10px', background: isRejected ? '#a04432' : 'transparent',
                        color: isRejected ? 'var(--bg)' : '#a04432',
                        border: '1px solid #a04432', fontSize: 10, letterSpacing: '.08em', cursor: 'pointer' }}>
                      {isRejected ? '✕ Rejected' : 'Reject'}
                    </button>
                  </div>
                </div>
              );
            })}
            {hasMore && (
              <button onClick={() => setPageSize(pageSize + 100)} className="ff-mono upper"
                style={{ width:'100%', padding:'14px 18px', background:'var(--bg-2)', border:0,
                  borderBottom:'1px solid var(--rule-soft)', cursor:'pointer',
                  color:'var(--ink)', fontSize:10.5, letterSpacing:'.08em' }}>
                Load 100 more · {fmt(filteredRows.length - pageSize)} remaining ↓
              </button>
            )}
            {!hasMore && filteredRows.length === 0 && (
              <div style={{ padding:'40px 26px', textAlign:'center', color:'var(--ink-3)' }}>
                <div className="ff-mono upper" style={{ fontSize:11, letterSpacing:'.1em' }}>NO ROWS MATCH "{query}"</div>
              </div>
            )}
          </div>

          {/* drawer footer */}
          <div style={{ padding: '14px 26px', borderTop: '1px solid var(--rule)', background: 'var(--bg-2)',
            display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <span className="ff-mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
              Decisions are staged — nothing commits until you press Commit migration on the main screen.
            </span>
            <button onClick={onClose} className="ff-mono upper"
              style={{ padding: '8px 14px', background: 'var(--ink)', color: 'var(--bg)', border: 0,
                fontSize: 11, letterSpacing: '.08em', cursor: 'pointer' }}>
              Save & close
            </button>
          </div>
        </div>
      </div>
    );
  }

  function CountChip({ label, value, accent }) {
    return (
      <div>
        <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.12em', color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
        <div className="ff-display num" style={{ fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', color: accent || 'var(--ink)' }}>{fmt(value)}</div>
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── filter bar
  function FilterBar({ filter, setFilter, totals }) {
    const sevFilters = [
      { k:'all',         label:'All',          count: totals.all },
      { k:'blocker',     label:'Blockers',     count: totals.blocker, accent: SEV.blocker.color },
      { k:'recommended', label:'Recommended',  count: totals.recommended, accent: SEV.recommended.color },
      { k:'fyi',         label:'FYI',          count: totals.fyi, accent: SEV.fyi.color },
    ];
    const groupFilters = [
      { k:'all',          label:'All groups' },
      ...Object.entries(GROUP_META).map(([k,v]) => ({ k, label: v.label, color: v.color })),
    ];
    return (
      <div style={{ border: '1px solid var(--rule)', marginBottom: 0, padding: '10px 14px',
        display: 'flex', gap: 18, alignItems: 'center', flexWrap: 'wrap', borderBottom: 'none' }}>
        <div style={{ display: 'flex', gap: 6 }}>
          {sevFilters.map(s => (
            <button key={s.k} onClick={() => setFilter({ ...filter, sev: s.k })} className="ff-mono upper"
              style={{ padding: '6px 11px',
                background: filter.sev === s.k ? 'var(--ink)' : 'transparent',
                color: filter.sev === s.k ? 'var(--bg)' : (s.accent || 'var(--ink-2)'),
                border: `1px solid ${filter.sev === s.k ? 'var(--ink)' : 'var(--rule)'}`,
                fontSize: 10, letterSpacing: '.08em', cursor: 'pointer' }}>
              {s.label} <span style={{ opacity: .7, marginLeft: 4 }}>{fmt(s.count)}</span>
            </button>
          ))}
        </div>
        <div style={{ width: 1, height: 22, background: 'var(--rule)' }} />
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {groupFilters.map(g => (
            <button key={g.k} onClick={() => setFilter({ ...filter, group: g.k })} className="ff-mono upper"
              style={{ padding: '6px 11px',
                background: filter.group === g.k ? 'var(--ink)' : 'transparent',
                color: filter.group === g.k ? 'var(--bg)' : 'var(--ink-2)',
                border: `1px solid ${filter.group === g.k ? 'var(--ink)' : 'var(--rule)'}`,
                fontSize: 10, letterSpacing: '.08em', cursor: 'pointer', display:'inline-flex', alignItems:'center', gap:6 }}>
              {g.color && <span style={{ width:5, height:5, background:g.color, display:'inline-block' }}/>}
              {g.label}
            </button>
          ))}
        </div>
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── commit panel
  function CommitPanel({ blockerCount, autoCount, acceptedAutoCount, recommendedReviewCount, onAcceptAllAuto }) {
    const blocked = blockerCount > 0;
    const allAutoAccepted = acceptedAutoCount >= autoCount && autoCount > 0;
    const remainingAuto = Math.max(0, autoCount - acceptedAutoCount);
    return (
      <div style={{ border: '1px solid var(--rule)', marginTop: 32, marginBottom: 56,
        background: blocked ? 'rgba(160,68,50,.04)' : 'var(--bg)' }}>
        <div style={{ padding: '20px 26px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 24 }}>
          <div>
            <div className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.12em',
              color: blocked ? '#a04432' : 'var(--ink-3)', marginBottom: 8 }}>
              {blocked ? 'COMMIT BLOCKED' : 'READY TO COMMIT'}
            </div>
            <div className="ff-display" style={{ fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 6 }}>
              {blocked
                ? `${blockerCount} blocker${blockerCount === 1 ? '' : 's'} must be resolved before commit.`
                : 'No blockers. Migration is ready to commit.'}
            </div>
            <div style={{ fontSize: 12.5, color: 'var(--ink-2)', maxWidth: 720 }}>
              {acceptedAutoCount > 0
                ? <><strong style={{color:'#2d6a3a',fontWeight:600}}>{fmt(acceptedAutoCount)} staged</strong> · {fmt(remainingAuto)} remaining auto-merges + {fmt(recommendedReviewCount)} recommended items pending review. </>
                : <>{fmt(autoCount)} auto-merges + {fmt(recommendedReviewCount)} recommended items will be applied. </>}
              The pre-commit snapshot is preserved for 7 days; rollback is available from Settings → Audit log.
            </div>
          </div>
          <div style={{ display: 'flex', flexDirection:'column', gap: 8, alignItems:'flex-end' }}>
            <button onClick={onAcceptAllAuto} disabled={allAutoAccepted || autoCount === 0}
              className="ff-mono upper"
              style={{ padding: '8px 14px',
                background: allAutoAccepted ? 'transparent' : '#2d6a3a',
                color: allAutoAccepted ? 'var(--ink-3)' : 'var(--bg)',
                border: allAutoAccepted ? '1px solid var(--rule)' : 0,
                fontSize: 10.5, letterSpacing: '.08em',
                cursor: allAutoAccepted ? 'default' : 'pointer', whiteSpace:'nowrap' }}>
              {allAutoAccepted ? `✓ All ${fmt(autoCount)} auto-merges staged` : `Accept all ${fmt(remainingAuto)} auto-merges`}
            </button>
            <div style={{ display: 'flex', gap: 10 }}>
              <button className="ff-mono upper"
                style={{ padding: '10px 16px', background: 'transparent', color: 'var(--ink-2)',
                  border: '1px solid var(--rule)', fontSize: 11, letterSpacing: '.08em', cursor: 'pointer' }}>
                Save as draft
              </button>
              <button disabled={blocked} className="ff-mono upper"
                style={{ padding: '10px 18px',
                  background: blocked ? 'var(--bg-2)' : 'var(--ink)',
                  color: blocked ? 'var(--ink-3)' : 'var(--bg)',
                  border: 0, fontSize: 11, letterSpacing: '.08em',
                  cursor: blocked ? 'not-allowed' : 'pointer' }}>
                Commit migration ▸
              </button>
            </div>
          </div>
        </div>
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── source ribbon
  // A compact status strip showing what was imported and merged.
  function SourceRibbon() {
    const sources = [
      { name:'Airtable export',   sub:'astro-base-2024-12.zip',         rows:8421,  status:'merged' },
      { name:'Neon snapshot',     sub:'astro_app_db @ 2026-04-30',      rows:3104,  status:'merged' },
      { name:'CSV statements',    sub:'47 files · 6 vendors',           rows:1322,  status:'merged' },
      { name:'Manual additions',  sub:'this session',                   rows:0,     status:'idle' },
    ];
    return (
      <div style={{ border: '1px solid var(--rule)', marginBottom: 24, display: 'grid',
        gridTemplateColumns: 'repeat(4, 1fr)' }}>
        {sources.map((s, i) => (
          <div key={s.name} style={{
            padding: '14px 18px',
            borderRight: (i+1) % 4 === 0 ? 'none' : '1px solid var(--rule-soft)',
          }}>
            <div className="ff-mono upper" style={{ fontSize: 9, letterSpacing: '.1em',
              color: s.status === 'idle' ? 'var(--ink-3)' : '#2d6a3a', marginBottom: 6,
              display:'flex', alignItems:'center', gap:5 }}>
              <span style={{ width:6, height:6, background: s.status==='idle' ? 'var(--ink-3)' : '#2d6a3a' }}/>
              {s.status}
            </div>
            <div className="ff-display" style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{s.name}</div>
            <div className="ff-mono" style={{ fontSize: 10.5, color: 'var(--ink-3)', letterSpacing: '.02em', marginTop: 3 }}>{s.sub}</div>
            <div className="ff-mono num" style={{ fontSize: 13, color: 'var(--ink)', marginTop: 8, fontWeight: 600 }}>{fmt(s.rows)} <span className="ff-mono" style={{ fontSize: 10, color: 'var(--ink-3)', fontWeight: 400 }}>rows</span></div>
          </div>
        ))}
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── setup phase
  // Phase 1 of the screen: pick sources, map fields, run the pass.
  // For first-run users, this is what they see. After a pass completes, the
  // screen jumps to Review automatically — the toggle in the header header
  // lets them come back to Setup to add a source or remap a field.
  function SetupPhase({ onRunPass }) {
    const [sources, setSources] = useState([
      { id:'src-1', kind:'airtable', name:'Airtable export',  detail:'astro-base-2024-12.zip', rows:8421, status:'ready', mapped:42, total:42 },
      { id:'src-2', kind:'neon',     name:'Neon snapshot',    detail:'astro_app_db @ 2026-04-30', rows:3104, status:'ready', mapped:28, total:28 },
      { id:'src-3', kind:'csv',      name:'CSV statements',   detail:'47 files · 6 vendors', rows:1322, status:'ready', mapped:18, total:24 },
      { id:'src-4', kind:'csv',      name:'TOS catalog handoff', detail:'tos-songs-final.csv', rows:0, status:'unmapped', mapped:0, total:31 },
    ]);

    const sourceKinds = [
      { kind:'airtable', label:'Airtable',     sub:'Base export or live API' },
      { kind:'neon',     label:'Neon / Postgres', sub:'pg_dump or live conn' },
      { kind:'csv',      label:'CSV / Excel',  sub:'Drop a file or folder' },
      { kind:'tsv',      label:'DDEX / CWR',   sub:'Society or DSP delivery files' },
      { kind:'manual',   label:'Manual entry', sub:'Add rows by hand' },
    ];

    const totalRows = sources.reduce((a,s) => a + s.rows, 0);
    const allMapped = sources.every(s => s.status === 'ready');

    return (
      <>
        {/* phase progress */}
        <div style={{ border:'1px solid var(--rule)', marginBottom:32 }}>
          <div style={{ padding:'10px 16px', borderBottom:'1px solid var(--rule)', background:'var(--bg-2)' }}>
            <span className="ff-mono upper" style={{ fontSize:10, letterSpacing:'.1em' }}>SETUP · STEP 1 OF 3</span>
          </div>
          <div style={{ padding:'24px 26px' }}>
            <div className="ff-display" style={{ fontSize:22, fontWeight:600, letterSpacing:'-0.02em', marginBottom:8 }}>
              Connect your sources of truth
            </div>
            <div style={{ fontSize:13, color:'var(--ink-2)', maxWidth:680, marginBottom:20 }}>
              ASTRO will read each source, normalize it against the locked data model, and surface anything
              ambiguous as a finding for you to resolve. Nothing is written until you commit.
            </div>
            <div style={{ display:'grid', gridTemplateColumns:'repeat(5, 1fr)', gap:10, marginTop:10 }}>
              {sourceKinds.map(k => (
                <button key={k.kind} className="ff-mono"
                  style={{ padding:'14px 12px', background:'var(--bg)', border:'1px solid var(--rule)',
                    cursor:'pointer', textAlign:'left', fontSize:12 }}>
                  <div className="ff-display" style={{ fontSize:13, fontWeight:600, letterSpacing:'-0.005em', marginBottom:4, color:'var(--ink)' }}>+ {k.label}</div>
                  <div style={{ fontSize:10.5, color:'var(--ink-3)', letterSpacing:'.02em' }}>{k.sub}</div>
                </button>
              ))}
            </div>
          </div>
        </div>

        {/* connected sources */}
        <Section num="01"><span>Connected sources</span></Section>
        <div style={{ border:'1px solid var(--rule)', marginTop:12, marginBottom:32 }}>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 140px 140px 100px', gap:0,
            padding:'10px 18px', borderBottom:'1px solid var(--rule)', background:'var(--bg-2)' }}>
            {['SOURCE','LOCATION','ROWS','FIELDS MAPPED','STATUS'].map((h,i) => (
              <span key={i} className="ff-mono upper" style={{ fontSize:9, color:'var(--ink-3)', letterSpacing:'.1em' }}>{h}</span>
            ))}
          </div>
          {sources.map(s => (
            <div key={s.id} style={{ display:'grid', gridTemplateColumns:'1fr 1fr 140px 140px 100px', gap:0,
              padding:'14px 18px', borderBottom:'1px solid var(--rule-soft)', alignItems:'center' }}>
              <div className="ff-display" style={{ fontSize:14, fontWeight:600, letterSpacing:'-0.005em' }}>{s.name}</div>
              <div className="ff-mono" style={{ fontSize:11, color:'var(--ink-2)', letterSpacing:'.02em' }}>{s.detail}</div>
              <div className="ff-mono num" style={{ fontSize:13, fontWeight:600 }}>{fmt(s.rows)}</div>
              <div className="ff-mono" style={{ fontSize:11, color: s.mapped === s.total ? '#2d6a3a' : '#c79538' }}>
                {s.mapped} / {s.total}
                {s.mapped < s.total && <span style={{ color:'var(--ink-3)', marginLeft:6 }}>· {s.total - s.mapped} unmapped</span>}
              </div>
              <div>
                <span className="ff-mono upper" style={{ fontSize:9, padding:'3px 7px', letterSpacing:'.1em',
                  border:`1px solid ${s.status === 'ready' ? '#2d6a3a' : '#c79538'}`,
                  color: s.status === 'ready' ? '#2d6a3a' : '#c79538' }}>
                  {s.status === 'ready' ? '● READY' : '◌ MAP FIELDS'}
                </span>
              </div>
            </div>
          ))}
        </div>

        {/* field mapping for the unmapped source */}
        <Section num="02"><span>Field mapping</span></Section>
        <div style={{ border:'1px solid var(--rule)', marginTop:12, marginBottom:32 }}>
          <div style={{ padding:'14px 18px', borderBottom:'1px solid var(--rule-soft)', background:'var(--bg-2)',
            display:'flex', justifyContent:'space-between', alignItems:'center' }}>
            <div>
              <span className="ff-mono upper" style={{ fontSize:10, letterSpacing:'.1em', color:'var(--ink)' }}>
                TOS CATALOG HANDOFF
              </span>
              <span className="ff-mono" style={{ fontSize:11, color:'var(--ink-3)', marginLeft:10 }}>
                tos-songs-final.csv · 31 columns · auto-mapped 23 of 31
              </span>
            </div>
            <button className="ff-mono upper"
              style={{ padding:'5px 10px', background:'var(--ink)', color:'var(--bg)', border:0, fontSize:10, letterSpacing:'.08em', cursor:'pointer' }}>
              SUGGEST FROM AI
            </button>
          </div>
          <div style={{ padding:'18px' }}>
            {[
              { src:'song_title',         dst:'Work.title',                    conf:99, kind:'auto' },
              { src:'iswc_code',          dst:'Work.iswc',                     conf:99, kind:'auto' },
              { src:'writer_name',        dst:'WriterShare.party (by name)',   conf:96, kind:'auto' },
              { src:'writer_pct',         dst:'WriterShare.share',             conf:99, kind:'auto' },
              { src:'pub_co',             dst:'PublisherShare.party (by name)',conf:91, kind:'auto' },
              { src:'pub_pct',            dst:'PublisherShare.share',          conf:99, kind:'auto' },
              { src:'tos_pub_relationship', dst:'(needs mapping)',             conf:0,  kind:'review', note:'Looks like an indicator? Open mapping →' },
              { src:'derivative_of',      dst:'(needs mapping)',               conf:0,  kind:'review', note:'Source/sample relationship — not yet supported' },
              { src:'tos_internal_id',    dst:'Work.external_ids[tos]',        conf:88, kind:'auto' },
            ].map((m,i) => (
              <div key={i} style={{ display:'grid', gridTemplateColumns:'1fr 14px 1fr 90px 110px', gap:14,
                alignItems:'center', padding:'8px 0',
                borderBottom: i < 8 ? '1px solid var(--rule-soft)' : 'none' }}>
                <span className="ff-mono" style={{ fontSize:12, color:'var(--ink)', letterSpacing:'.02em' }}>{m.src}</span>
                <span style={{ color:'var(--ink-3)', textAlign:'center' }}>→</span>
                <span className="ff-mono" style={{ fontSize:12, color: m.kind === 'auto' ? 'var(--ink)' : '#c79538', letterSpacing:'.02em' }}>
                  {m.dst}
                  {m.note && <div style={{ fontSize:10, color:'var(--ink-3)', marginTop:2 }}>{m.note}</div>}
                </span>
                <span className="ff-mono" style={{ fontSize:11, textAlign:'right',
                  color: m.conf >= 95 ? '#2d6a3a' : m.conf >= 80 ? 'var(--ink-2)' : m.conf > 0 ? '#c79538' : 'var(--ink-3)' }}>
                  {m.conf > 0 ? `${m.conf}%` : '—'}
                </span>
                <button className="ff-mono upper"
                  style={{ padding:'5px 10px', fontSize:9.5, letterSpacing:'.08em',
                    background: m.kind === 'auto' ? 'transparent' : '#c79538',
                    color: m.kind === 'auto' ? 'var(--ink-2)' : 'var(--bg)',
                    border: m.kind === 'auto' ? '1px solid var(--rule)' : 0, cursor:'pointer' }}>
                  {m.kind === 'auto' ? 'CHANGE' : 'MAP →'}
                </button>
              </div>
            ))}
          </div>
        </div>

        {/* run pass CTA */}
        <div style={{ border:'1px solid var(--rule)', padding:'22px 26px', marginBottom:56,
          display:'flex', justifyContent:'space-between', alignItems:'center' }}>
          <div>
            <div className="ff-mono upper" style={{ fontSize:10, letterSpacing:'.12em',
              color: allMapped ? 'var(--ink-3)' : '#c79538', marginBottom:8 }}>
              {allMapped ? 'STEP 3 · READY TO RUN' : 'STEP 3 · WAITING ON MAPPINGS'}
            </div>
            <div className="ff-display" style={{ fontSize:20, fontWeight:600, letterSpacing:'-0.02em', marginBottom:6 }}>
              {allMapped
                ? 'Run the conformance pass'
                : 'Finish mapping unmapped fields above'}
            </div>
            <div style={{ fontSize:12.5, color:'var(--ink-2)' }}>
              {fmt(totalRows)} input rows ready · estimated 2–3 min · no writes until commit
            </div>
          </div>
          <button onClick={onRunPass} className="ff-mono upper"
            style={{ padding:'12px 22px', background:'var(--ink)', color:'var(--bg)', border:0,
              fontSize:12, letterSpacing:'.08em', cursor:'pointer' }}>
            Run pass ▸
          </button>
        </div>
      </>
    );
  }

  // ────────────────────────────────────────────────────────── phase toggle
  function PhaseToggle({ phase, setPhase, hasFindings }) {
    return (
      <div style={{ display:'flex', border:'1px solid var(--rule)' }}>
        {['setup','review'].map(p => (
          <button key={p} onClick={() => setPhase(p)} disabled={p === 'review' && !hasFindings}
            className="ff-mono upper"
            style={{
              padding:'8px 14px',
              background: phase === p ? 'var(--ink)' : 'transparent',
              color: phase === p ? 'var(--bg)' : (p === 'review' && !hasFindings ? 'var(--ink-3)' : 'var(--ink-2)'),
              border:0, fontSize:10, letterSpacing:'.1em',
              cursor: (p === 'review' && !hasFindings) ? 'not-allowed' : 'pointer',
              borderRight: p === 'setup' ? '1px solid var(--rule)' : 0,
            }}>
            {p === 'setup' ? '1 · Setup' : '2 · Review'}
          </button>
        ))}
      </div>
    );
  }

  // ────────────────────────────────────────────────────────── main screen
  function ScreenConformance({ go }) {
    const [phase, setPhase] = useState('review');  // first-run would default 'setup'
    const [filter, setFilter] = useState({ sev: 'all', group: 'all' });
    const [openId, setOpenId] = useState(null);
    const [showFYI, setShowFYI] = useState(false);
    const [acceptedTypes, setAcceptedTypes] = useState(new Set());

    const { findings } = useMemo(() => buildFindings(), []);

    const filtered = useMemo(() => {
      return findings.filter(f =>
        (filter.sev === 'all'   || f.meta.sev   === filter.sev) &&
        (filter.group === 'all' || f.meta.group === filter.group)
      );
    }, [findings, filter]);

    // FYI rows hide by default; honored only when the user hasn't explicitly filtered to FYI.
    const visible = useMemo(() => {
      if (filter.sev === 'fyi' || showFYI) return filtered;
      return filtered.filter(f => f.meta.sev !== 'fyi');
    }, [filtered, showFYI, filter.sev]);
    const hiddenFYI = filtered.filter(f => f.meta.sev === 'fyi');
    const hiddenFYITotal = hiddenFYI.reduce((a,f) => a + f.total, 0);

    const totals = useMemo(() => {
      const t = { all: 0, blocker: 0, recommended: 0, fyi: 0 };
      findings.forEach(f => { t.all += f.total; t[f.meta.sev] += f.total; });
      return t;
    }, [findings]);

    const blockerFindingCount = findings.filter(f => f.meta.sev === 'blocker').reduce((a, f) => a + f.total, 0);
    const autoCount             = findings.reduce((a, f) => a + f.autoCount, 0);
    const recommendedReviewCount = findings.filter(f => f.meta.sev === 'recommended').reduce((a, f) => a + f.reviewCount, 0);
    const acceptedAutoCount     = [...acceptedTypes].reduce((a,t) => {
      const f = findings.find(x => x.type === t); return a + (f ? f.autoCount : 0);
    }, 0);

    const acceptAuto = (type) => { acceptedTypes.add(type); setAcceptedTypes(new Set(acceptedTypes)); };
    const acceptAllAuto = () => {
      const all = new Set();
      findings.forEach(f => { if (f.autoCount > 0) all.add(f.type); });
      setAcceptedTypes(all);
    };

    const openFinding = openId ? findings.find(f => f.type === openId) : null;

    return (
      <>
        {/* page header */}
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end',
          borderBottom: '1px solid var(--rule)', paddingBottom: 16, marginBottom: 24 }}>
          <div>
            <div className="ff-mono upper" style={{ fontSize: 10, letterSpacing: '.12em', color: 'var(--ink-3)', marginBottom: 8 }}>
              MIGRATION · CONFORMANCE REPORT · RUN #3
            </div>
            <h1 className="ff-display" style={{ fontSize: 42, fontWeight: 600, letterSpacing: '-0.03em', lineHeight: 1, margin: 0 }}>
              Import &amp; Conform
            </h1>
            <div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 10, letterSpacing: '-0.005em', maxWidth: 740 }}>
              Reconcile Airtable, Neon, and vendor CSVs against the locked data model. Review findings,
              accept auto-merges, resolve blockers, then commit. Pre-commit snapshot is held for 7 days
              for rollback.
            </div>
          </div>
          <div style={{ display: 'flex', flexDirection:'column', alignItems:'flex-end', gap:12 }}>
            <PhaseToggle phase={phase} setPhase={setPhase} hasFindings={findings.length > 0} />
            <div style={{ display: 'flex', gap: 10 }}>
              <button className="ff-mono upper"
                style={{ padding: '8px 14px', background: 'transparent', color: 'var(--ink-3)',
                  border: '1px solid var(--rule)', fontSize: 11, letterSpacing: '.08em', cursor: 'pointer' }}>
                EXPORT REPORT
              </button>
              <button onClick={() => setPhase('setup')} className="ff-mono upper"
                style={{ padding: '8px 14px', background: 'transparent', color: 'var(--ink)',
                  border: '1px solid var(--rule)', fontSize: 11, letterSpacing: '.08em', cursor: 'pointer' }}>
                ADD SOURCE
              </button>
              <button className="ff-mono upper"
                style={{ padding: '8px 14px', background: 'var(--ink)', color: 'var(--bg)', border: 0,
                  fontSize: 11, letterSpacing: '.08em', cursor: 'pointer' }}>
                RE-RUN PASS
              </button>
            </div>
          </div>
        </div>

        {phase === 'setup' ? (
          <SetupPhase onRunPass={() => setPhase('review')} />
        ) : (
          <>
            {/* sources */}
            <SourceRibbon />

            {/* migration summary */}
            <MigrationSummary findings={findings} />

            {/* findings header */}
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 12 }}>
              <Section num="01"><span>Findings</span></Section>
              <span className="ff-mono" style={{ fontSize: 11, color: 'var(--ink-3)', letterSpacing: '.04em' }}>
                {fmt(totals.all)} total · {fmt(totals.blocker)} blockers · {fmt(totals.recommended)} recommended · {fmt(totals.fyi)} FYI
              </span>
            </div>

            {/* filter bar */}
            <FilterBar filter={filter} setFilter={setFilter} totals={totals} />

            {/* findings list */}
            <div style={{ border: '1px solid var(--rule)', borderTop: 'none', background: 'var(--bg)' }}>
              {visible.length === 0 ? (
                <div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--ink-3)' }}>
                  <div className="ff-mono upper" style={{ fontSize: 11, letterSpacing: '.1em' }}>NO FINDINGS MATCH CURRENT FILTERS</div>
                  <button onClick={() => { setFilter({ sev: 'all', group: 'all' }); setShowFYI(true); }}
                    className="ff-mono upper" style={{ marginTop: 14, fontSize: 10, letterSpacing: '.08em',
                      color: 'var(--ink)', background: 'none', border: 0, cursor: 'pointer' }}>
                    clear filters →
                  </button>
                </div>
              ) : (
                visible.map(f => (
                  <FindingCard key={f.type} f={f}
                    accepted={acceptedTypes.has(f.type)}
                    onOpen={() => setOpenId(f.type)}
                    onAcceptAuto={() => acceptAuto(f.type)} />
                ))
              )}
              {/* FYI collapsed footer */}
              {!showFYI && filter.sev !== 'fyi' && hiddenFYI.length > 0 && (
                <button onClick={() => setShowFYI(true)} className="ff-mono"
                  style={{ width:'100%', padding:'14px 18px', background:'var(--bg-2)',
                    border:0, borderTop:'1px solid var(--rule-soft)', cursor:'pointer',
                    display:'flex', justifyContent:'space-between', alignItems:'center',
                    color:'var(--ink-2)', fontSize:11, letterSpacing:'.04em' }}>
                  <span style={{ display:'flex', alignItems:'center', gap:10 }}>
                    <span style={{ width:6, height:6, background:SEV.fyi.color, display:'inline-block' }}/>
                    <span className="ff-mono upper" style={{ fontSize:10, letterSpacing:'.1em', color:SEV.fyi.color }}>{fmt(hiddenFYITotal)} INFORMATIONAL ITEMS</span>
                    <span style={{ color:'var(--ink-3)' }}>across {hiddenFYI.length} finding type{hiddenFYI.length === 1 ? '' : 's'} · auto-resolve on commit</span>
                  </span>
                  <span className="ff-mono upper" style={{ color:'var(--ink)', letterSpacing:'.08em' }}>show ↓</span>
                </button>
              )}
              {showFYI && filter.sev !== 'fyi' && hiddenFYI.length > 0 && (
                <button onClick={() => setShowFYI(false)} className="ff-mono"
                  style={{ width:'100%', padding:'10px 18px', background:'transparent',
                    border:0, borderTop:'1px solid var(--rule-soft)', cursor:'pointer',
                    color:'var(--ink-3)', fontSize:10, letterSpacing:'.08em' }}>
                  hide FYI ↑
                </button>
              )}
            </div>

            {/* commit */}
            <CommitPanel
              blockerCount={blockerFindingCount}
              autoCount={autoCount}
              acceptedAutoCount={acceptedAutoCount}
              recommendedReviewCount={recommendedReviewCount}
              onAcceptAllAuto={acceptAllAuto}
            />

            {/* drawer */}
            {openFinding && <FindingDrawer f={openFinding} onClose={() => setOpenId(null)} />}
          </>
        )}
      </>
    );
  }

  // expose
  window.ScreenConformance = ScreenConformance;
  window.__buildConformanceFindings = buildFindings;
  window.__CONFORM_FINDING_TYPES = FINDING_TYPES;
})();
