/* WPSiteBeam Scanner — Shared UI Components
   Auto-split from Scanner.jsx. Load before all other scanner/ files.
   Exports: ScanErrorBanner, TabErrorBoundary, ScanLowConfidenceCard,
            ScanAllowlistPanel, ScanConfidenceWidget, ScanConfidenceCompact,
            ConfirmDialog, useConfirmDialog, ScanProgressBar, ScanEmptyState
*/
/* WP Site Beam — Scanner (shared UI components)
   All action buttons are wired:
   • Copy → wpsbCopy (clipboard + toast)
   • Download → wpsbDownload (real blob download)
   • Filters → stateful toggle
   • View / Visit → safe noop with toast preview
*/

/* ── SCAN ERROR BANNER (Session 7) ─────────────────────────────
   Displays when the scan hook's `error` state is set.
   Handles distinct UI per error code:
     PLAN_LIMIT_SCANS  → upgrade CTA + buy-more-scans options
     PAYMENT_FAILED    → billing CTA
     SCAN_RATE_LIMITED → wait-and-retry
     INVALID_URL       → fix URL
     NETWORK / other   → generic retry */
function ScanErrorBanner({ error, onDismiss }) {
  if (!error) return null;
  const code = error.code || 'UNKNOWN';

  let title, body, cta;
  if (code === 'PLAN_LIMIT_SCANS') {
    title = 'Monthly scan limit reached';
    body = `You've used ${error.used || '?'} of ${error.limit || '?'} scans on your ${(error.plan || 'current').toUpperCase()} plan this month.`;
    cta = (
      <div style={{ display:'flex', gap:8, flexWrap:'wrap', marginTop:10 }}>
        <button className="btn btn-primary btn-sm" onClick={() => { if (window.WPSBD?.switchTab) window.WPSBD.switchTab('billing'); }}>
          <Icon name="billing" size={13}/> Upgrade plan
        </button>
        {error.upsell?.extra_scans?.length && error.upsell.extra_scans.map(opt => (
          <button key={opt.price_key} className="btn btn-ghost btn-sm"
                  onClick={() => window.wpsbToast(`Buy ${opt.label} — coming soon`, 'info')}>
            {opt.label} · {opt.price}
          </button>
        ))}
      </div>
    );
  } else if (code === 'PAYMENT_FAILED') {
    title = 'Billing issue on your account';
    body = 'Your most recent payment failed. Update your billing to continue scanning.';
    cta = (
      <div style={{ marginTop:10 }}>
        <button className="btn btn-primary btn-sm" onClick={() => { if (window.WPSBD?.switchTab) window.WPSBD.switchTab('billing'); }}>
          <Icon name="billing" size={13}/> Update billing
        </button>
      </div>
    );
  } else if (code === 'SCAN_RATE_LIMITED') {
    title = 'Scanning too quickly';
    body = error.message || 'You\'re scanning faster than allowed. Wait a minute and try again.';
    cta = null;
  } else if (code === 'INVALID_URL') {
    title = 'URL not valid';
    body = error.message || 'Check the URL and try again.';
    cta = null;
  } else if (code === 'NETWORK') {
    title = 'Can\'t reach the scanner';
    body = error.message || 'Check your internet connection and try again.';
    cta = null;
  } else {
    title = 'Scan failed';
    body = error.message || 'Something went wrong. Please try again.';
    cta = null;
  }

  return (
    <div role="alert" style={{
      marginBottom: 12, padding: '14px 16px',
      background: 'var(--red-dim)',
      border: '1px solid var(--red-dim)',
      borderLeft: '3px solid var(--red)',
      borderRadius: 6,
      display: 'flex', alignItems: 'flex-start', gap: 12,
    }}>
      <div style={{ flex: 1 }}>
        <div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>
          {title}
        </div>
        <div style={{ fontSize: '.82rem', color: 'var(--text-2)', lineHeight: 1.5 }}>
          {body}
        </div>
        {cta}
      </div>
      <button type="button" aria-label="Dismiss" onClick={onDismiss}
              style={{ background:'transparent', border:'none', color:'var(--dim)', cursor:'pointer', padding:4, lineHeight:1 }}>
        ✕
      </button>
    </div>
  );
}
window.ScanErrorBanner = ScanErrorBanner;

/* ═════════════════════════════════════════════════════════════════════
   TabErrorBoundary — catches rendering errors inside a tab so a crash in
   one tab doesn't blank-screen the whole Scanner.

   React error boundaries must be class components (no hook equivalent
   as of React 19). Any thrown error during render in child → fallback UI.
   Error is logged to console and shown inline with a "Report" link.

   Wrap each tab dispatch like: <TabErrorBoundary tabName="Pages">...</TabErrorBoundary>
   ═════════════════════════════════════════════════════════════════════ */
class TabErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  componentDidCatch(error, info) {
    console.error('[WPSB Scanner] Tab render error in', this.props.tabName, ':', error, info);
  }
  render() {
    if (this.state.hasError) {
      return (
        <div style={{
          padding: 20, border: '1px solid rgba(239, 68, 68, 0.3)',
          background: 'rgba(239, 68, 68, 0.05)', borderRadius: 8,
        }}>
          <h3 style={{ color: 'var(--err, #ef4444)', margin: '0 0 8px 0', fontSize: '.9rem' }}>
            ⚠ {this.props.tabName || 'This tab'} failed to render
          </h3>
          <p style={{ fontSize: '.78rem', color: 'var(--dim)', lineHeight: 1.6, margin: '0 0 8px 0' }}>
            Something broke while rendering this tab. Other tabs should still work —
            try a different one, or click Reset and re-run the scan.
          </p>
          {this.state.error && (
            <details style={{ fontSize: '.72rem', color: 'var(--dim)' }}>
              <summary style={{ cursor: 'pointer' }}>Technical details</summary>
              <pre style={{
                marginTop: 8, padding: 8, background: 'var(--surface-2, rgba(0,0,0,0.2))',
                borderRadius: 4, overflow: 'auto', fontSize: '.68rem',
              }}>{String(this.state.error.message || this.state.error)}</pre>
            </details>
          )}
          <button
            className="btn btn-ghost btn-sm"
            style={{ marginTop: 10 }}
            onClick={() => this.setState({ hasError: false, error: null })}
          >
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}
window.TabErrorBoundary = TabErrorBoundary;

/* ═════════════════════════════════════════════════════════════════════
   ScanProgressBar — animated progress indicator for active scan.

   Since /brain/scan is a single non-streaming POST (no SSE, no chunked
   phase updates), we can't reflect real server-side progress. Instead:
     • Bar animates smoothly from 0 → 90% over ~25 seconds
     • Holds at 90% until scan actually completes (prevents false "done" feel)
     • Component unmounts when `scanning` becomes false, so we never need to
       explicitly snap to 100% — the wizard moves on to step 3 instead.
     • Phase text rotates every ~3s through plausible server-side activities
       based on which modes are active, so it feels informative not fake.

   The bar uses CSS transitions + a React state tick every ~400ms. No rAF,
   no external deps. Cleanup on unmount prevents stale setInterval firing.
   ═════════════════════════════════════════════════════════════════════ */
/* ═════════════════════════════════════════════════════════════════════
   ScanConfidenceWidget — visual breakdown of scan_confidence (Phase 1A)

   Renders the confidence score, band-colored progress bar, expandable
   signals list, advisory cards, and pre-migration checklist. Designed
   to slot into the Scan Overview area when the scan completes.

   Score bands → color:
     ≥80 = high   (green)
     ≥60 = medium (warn)
     <60 = low    (rose) — banner becomes prominent
   ═════════════════════════════════════════════════════════════════════ */

/* ── ScanLowConfidenceCard ─────────────────────────────────────────────
   The dedicated low-band layout (band='low' only). Lays out a 50/50
   horizontal split:
     LEFT 50%:  Issue list — title + 1-line action per advisory.
                Critical issues get a rose accent stripe; warns/info get
                amber. Always visible (no toggle).
     RIGHT 50%: Tabbed panel with three tabs:
                  - Breakdown    — per-signal met/unmet table (was the
                                   "Show breakdown" toggle)
                  - Pre-migration — pre-migration prep checklist (was the
                                   "Show pre-migration checklist" toggle)
                  - Allowlist    — scanner IP + UA + per-platform copy
                                   for users to allowlist us in
                                   Wordfence/Sucuri/Defender/iThemes/CF.

   Tabs default to whichever is most relevant: Allowlist if the issues
   suggest blocking, Breakdown otherwise. User can switch any time.
   On narrow screens (<720px), stacks vertically (left full-width, right
   full-width below).
   ═════════════════════════════════════════════════════════════════════ */
function ScanLowConfidenceCard({
  score, band, wasBlocked,
  criticalAdvisories, otherAdvisories,
  signals, pre_migration_checklist,
}) {
  const { useState, useMemo } = React;

  /* Detect "blocking" advisories — sitemap/robots blocked, bot challenge,
     SPA shell, "few pages discovered". When ANY of these are present, we
     default the right panel to the Allowlist tab so the user immediately
     sees how to fix the block. Otherwise default to Breakdown. */
  const allAdvisories = [...criticalAdvisories, ...otherAdvisories];
  const hasBlockingAdvisory = useMemo(() => allAdvisories.some(a =>
    /block|firewall|cloudflare|wordfence|sucuri|defender|ithemes|challenge|few pages|robots\.txt is blocked|sitemap\.xml is blocked/i.test(a.title || '')
  ), [allAdvisories]);

  const tabs = [];
  tabs.push({ id: 'allowlist',  label: 'Allowlist scanner', count: null });
  if (signals && signals.length > 0) {
    tabs.push({ id: 'breakdown', label: 'Breakdown', count: `${signals.filter(s => s.met).length}/${signals.length}` });
  }
  if (pre_migration_checklist && pre_migration_checklist.length > 0) {
    tabs.push({ id: 'checklist', label: 'Pre-migration', count: pre_migration_checklist.length });
  }

  const defaultTab = hasBlockingAdvisory ? 'allowlist' : (signals?.length > 0 ? 'breakdown' : tabs[0]?.id);
  const [activeTab, setActiveTab] = useState(defaultTab);

  /* Fetch scanner identity info once per session — used by Allowlist tab */
  const { info: scannerInfo, loading: scannerLoading } = (window.WPSB_Scanner?.useScannerInfo)
    ? window.WPSB_Scanner.useScannerInfo()
    : { info: null, loading: false };

  return (
    <div style={{
      marginTop:12, padding:'12px 14px', borderRadius:8,
      background:'rgba(244, 63, 94, 0.08)',
      border:'1px solid rgba(244, 63, 94, 0.35)',
    }}>
      {/* Header — alert icon + title + score + "didn't count" badge.
          No toggle buttons on the right anymore — those move into the
          tabbed panel below. */}
      <div style={{ display:'flex', gap:12, alignItems:'flex-start', marginBottom:12, flexWrap:'wrap' }}>
        <div style={{
          flexShrink:0, width:32, height:32, borderRadius:'50%',
          background:'rgba(244, 63, 94, 0.15)',
          display:'inline-flex', alignItems:'center', justifyContent:'center',
          color:'var(--rose, #f43f5e)',
        }}>
          <Icon name="alert" size={18}/>
        </div>
        <div style={{ flex:'1 1 280px', minWidth:0 }}>
          <div style={{ display:'flex', alignItems:'center', gap:8, flexWrap:'wrap', marginBottom:2 }}>
            <span style={{ fontSize:'.92rem', fontWeight:700, color:'var(--rose, #f43f5e)' }}>
              Scan results may be incomplete · {score}/100
            </span>
            {wasBlocked && (
              <span style={{
                fontSize:'.62rem', fontWeight:700, padding:'2px 8px',
                borderRadius:3, background:'rgba(34, 197, 94, 0.15)',
                color:'var(--green, #22c55e)', letterSpacing:'.05em',
                textTransform:'uppercase',
              }}>
                Didn't count toward your usage
              </span>
            )}
          </div>
          <div style={{ fontSize:'.78rem', color:'var(--muted)', lineHeight:1.5 }}>
            {allAdvisories.length} issue{allAdvisories.length === 1 ? '' : 's'} found. Common causes: bot protection (Cloudflare, Wordfence, Sucuri, Defender), JavaScript-only content, login walls, or geo-blocked regions.
          </div>
        </div>
      </div>

      {/* 50/50 SPLIT: issues (left) + tabbed panel (right).
          Each side has flex:'1 1 320px', so they collapse to single
          column on viewports below ~720px wide via flexWrap. */}
      <div style={{ display:'flex', gap:14, flexWrap:'wrap' }}>
        {/* ── LEFT: Issue list ───────────────────────────────────── */}
        <div style={{ flex:'1 1 320px', minWidth:0 }}>
          {/* Header row: section label + general docs link.
              The docs link opens a guide that explains how to fix each
              type of advisory (Cloudflare allowlist, Wordfence allowlist,
              etc.) with screenshots and copy-paste rules. Opens in a new
              tab so users don't lose their scan view. */}
          <div style={{
            display:'flex', alignItems:'center', justifyContent:'space-between',
            marginBottom:6, gap:8,
          }}>
            <div style={{
              fontSize:'.7rem', fontFamily:'var(--font-mono)', color:'var(--dim)',
              letterSpacing:'.08em',
            }}>
              ISSUES FOUND
            </div>
            <a href="https://wpsitebeam.io/docs/scanner/troubleshooting"
               target="_blank" rel="noopener noreferrer"
               style={{
                 fontSize:'.68rem', color:'var(--beam, #00c8ef)',
                 textDecoration:'none', display:'inline-flex', alignItems:'center', gap:4,
               }}
               title="Open the troubleshooting guide in a new tab">
              <Icon name="book" size={11}/>Troubleshooting guide
              <span style={{ fontSize:'.62rem', color:'var(--dim)' }}>↗</span>
            </a>
          </div>
          <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
            {allAdvisories.map((a, i) => {
              const isCritical = a.severity === 'critical';
              /* Per-advisory doc topic — match title fragments to known
                 doc slugs. Lets us deep-link straight to the relevant
                 fix article instead of the general troubleshooting page.
                 Falls back to general guide for unmatched titles. */
              const docSlug = (() => {
                const t = (a.title || '').toLowerCase();
                if (/robots\.txt is blocked|sitemap\.xml is blocked/.test(t)) return 'security-allowlist';
                if (/bot challenge|cloudflare|akamai|datadome|perimeter/.test(t)) return 'bot-protection';
                if (/spa shell|javascript-only|empty body/.test(t)) return 'javascript-rendered-sites';
                if (/few pages discovered|sitemap\.xml not found/.test(t)) return 'page-discovery';
                if (/platform unidentified/.test(t)) return 'platform-detection';
                if (/https|ssl/.test(t)) return 'ssl-issues';
                return 'troubleshooting';
              })();
              return (
                <div key={`adv-${i}`} style={{
                  padding:'8px 10px 8px 12px', borderRadius:5,
                  background: isCritical ? 'rgba(244, 63, 94, 0.06)' : 'rgba(251, 191, 36, 0.05)',
                  borderLeft: `3px solid ${isCritical ? 'rgba(244, 63, 94, 0.6)' : 'rgba(251, 191, 36, 0.5)'}`,
                  fontSize:'.78rem',
                }}>
                  <div style={{ display:'flex', alignItems:'center', gap:6, fontWeight:600, color: isCritical ? 'var(--rose, #f43f5e)' : 'var(--text)' }}>
                    {isCritical && <span style={{
                      fontSize:'.6rem', padding:'1px 5px', borderRadius:3,
                      background:'rgba(244, 63, 94, 0.18)', color:'var(--rose, #f43f5e)',
                      letterSpacing:'.05em', textTransform:'uppercase', fontWeight:700,
                    }}>Critical</span>}
                    {a.title}
                  </div>
                  {a.action && (
                    <div style={{ fontSize:'.72rem', color:'var(--muted)', lineHeight:1.5, marginTop:3 }}>
                      → {a.action}
                    </div>
                  )}
                  {/* Per-issue "How do I fix this?" link — deep-links to
                      the specific doc topic for this advisory. */}
                  <div style={{ marginTop:5 }}>
                    <a href={`https://wpsitebeam.io/docs/scanner/${docSlug}`}
                       target="_blank" rel="noopener noreferrer"
                       style={{
                         fontSize:'.66rem', color:'var(--beam, #00c8ef)',
                         textDecoration:'none', display:'inline-flex', alignItems:'center', gap:3,
                       }}
                       title="Open step-by-step fix guide for this issue in a new tab">
                      How do I fix this? <span style={{ fontSize:'.6rem', color:'var(--dim)' }}>↗</span>
                    </a>
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        {/* ── RIGHT: Tabbed panel ───────────────────────────────────
            Wrapped in a darker container so it visually separates from
            the rose-tinted left panel. Background uses --bg or near-black
            with subtle border. The tab strip sits inside this container
            with button-style pills (active = beam, inactive = ghost). */}
        <div style={{
          flex:'1 1 320px', minWidth:0,
          display:'flex', flexDirection:'column',
          background:'var(--bg, #0a0e14)',
          border:'1px solid var(--border)',
          borderRadius:6,
          overflow:'hidden',  /* clip rounded corners on inner content */
        }}>
          {/* Tab strip — pill-style buttons inside a header bar.
              Active tab uses the beam color (matches the SaaS button
              system); inactive tabs are ghost-style. The header bar has
              its own subtle bottom border separating it from content. */}
          <div style={{
            display:'flex', gap:6,
            padding:'8px 8px 8px 8px',
            borderBottom:'1px solid var(--border)',
            background:'rgba(255,255,255,.02)',  /* slightly lighter header strip */
          }}>
            {tabs.map(t => {
              const isActive = activeTab === t.id;
              return (
                <button
                  key={t.id}
                  onClick={() => setActiveTab(t.id)}
                  style={{
                    background: isActive ? 'var(--beam-dim, var(--beam-dim))' : 'transparent',
                    border: '1px solid ' + (isActive ? 'var(--beam, var(--beam-dim))' : 'transparent'),
                    color: isActive ? 'var(--beam, #00c8ef)' : 'var(--muted)',
                    fontSize:'.72rem',
                    fontWeight: isActive ? 600 : 500,
                    padding:'5px 11px',
                    borderRadius:4,
                    cursor:'pointer',
                    fontFamily:'var(--font-sans, inherit)',
                    transition:'background .15s, color .15s, border-color .15s',
                    display:'inline-flex', alignItems:'center', gap:5,
                  }}
                  aria-pressed={isActive}
                >
                  {t.label}
                  {t.count != null && (
                    <span style={{
                      fontSize:'.6rem', padding:'1px 5px',
                      borderRadius:3,
                      background: isActive ? 'var(--beam-dim)' : 'rgba(255,255,255,.06)',
                      color: isActive ? 'var(--beam, #00c8ef)' : 'var(--dim)',
                      fontWeight:600,
                      fontFamily:'var(--font-mono, monospace)',
                    }}>{t.count}</span>
                  )}
                </button>
              );
            })}
          </div>

          {/* Tab content — padded inner area so content doesn't touch
              the container edges. minHeight keeps the panel from
              jumping size as the user clicks between tabs. */}
          <div style={{ flex:'1 1 auto', minHeight:140, padding:'10px 12px' }}>
            {activeTab === 'breakdown' && signals.length > 0 && (
              <div id="confidence-signals">
                <table className="table" style={{ fontSize:'.72rem', margin:0 }}>
                  <tbody>
                    {signals.map(s => (
                      <tr key={s.id}>
                        <td style={{ width:24, paddingTop:5, paddingBottom:5 }}>
                          {s.met
                            ? <Icon name="check" size={12}/>
                            : <span style={{ color:'var(--rose, #f43f5e)', fontFamily:'var(--font-mono)' }}>✗</span>}
                        </td>
                        <td style={{ color: s.met ? 'var(--text)' : 'var(--dim)', paddingTop:5, paddingBottom:5 }}>{s.label}</td>
                        <td className="mono" style={{ textAlign:'right', color:'var(--dim)', paddingTop:5, paddingBottom:5 }}>+{s.weight}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}

            {activeTab === 'checklist' && pre_migration_checklist.length > 0 && (
              <div id="pre-migration-checklist" style={{ display:'flex', flexDirection:'column', gap:5 }}>
                {pre_migration_checklist.map((item, i) => (
                  <div key={i} style={{
                    padding:'6px 10px', borderRadius:4,
                    background:'rgba(255,255,255,.02)',
                    border:'1px solid var(--border)',
                    fontSize:'.74rem',
                  }}>
                    <div style={{ fontWeight:500, color:'var(--text)', marginBottom: item.note ? 2 : 0 }}>
                      {item.title || item}
                    </div>
                    {item.note && (
                      <div style={{ fontSize:'.68rem', color:'var(--muted)', lineHeight:1.5 }}>
                        {item.note}
                      </div>
                    )}
                  </div>
                ))}
              </div>
            )}

            {activeTab === 'allowlist' && (
              <ScanAllowlistPanel scannerInfo={scannerInfo} loading={scannerLoading} />
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
window.ScanLowConfidenceCard = ScanLowConfidenceCard;

/* ── ScanAllowlistPanel ───────────────────────────────────────────────
   Renders inside the Low-Confidence Card's Allowlist tab. Shows the
   scanner's outbound IP + User-Agent and per-platform allowlist guidance
   pulled from /scanner/info. Customers copy the IP into their security
   plugin's allowlist, then re-scan.

   States:
     - loading         → skeleton "Loading scanner info…"
     - no IP yet       → "Scanner IP not resolved yet — try again in a
                         minute or contact support"
     - have IP         → IP block + UA block + collapsible per-platform
                         instructions
   ═════════════════════════════════════════════════════════════════════ */

/* Official vendor documentation URLs for each platform. Surfaced as
   secondary links in the allowlist panel — when our docs aren't enough
   or vendor changed their UI, customers can go to the source. Verified
   working as of 2026-04-26; should be re-validated by the docs auto-pull
   system (Phase 11) when implemented. */
const VENDOR_DOC_LINKS = {
  cloudflare: 'https://developers.cloudflare.com/waf/tools/ip-access-rules/',
  wordfence:  'https://www.wordfence.com/help/firewall/options/#allowlisted-ip-addresses',
  sucuri:     'https://docs.sucuri.net/website-firewall/access-control/whitelist-ip-address/',
  ithemes:    'https://help.ithemes.com/hc/en-us/articles/360014987514-Allowing-Specific-IP-Addresses',
  defender:   'https://wpmudev.com/docs/wpmu-dev-plugins/defender/#firewall',
  htaccess:   'https://httpd.apache.org/docs/current/howto/access.html',
};

function ScanAllowlistPanel({ scannerInfo, loading }) {
  const { useState } = React;
  const [openPlatform, setOpenPlatform] = useState(null);

  if (loading) {
    return <div style={{ fontSize:'.74rem', color:'var(--dim)', padding:'4px 0' }}>Loading scanner info…</div>;
  }
  if (!scannerInfo) {
    return (
      <div style={{ fontSize:'.74rem', color:'var(--muted)', padding:'4px 0', lineHeight:1.55 }}>
        Couldn't reach the scanner identity service. Manual allowlist info: User-Agent contains <code style={{ background:'rgba(255,255,255,.05)', padding:'1px 4px', borderRadius:3, fontSize:'.7rem' }}>WPSiteBeam-Scanner</code> — allowlist by UA is the most reliable option since IPs can change.
      </div>
    );
  }

  const { ip, user_agent, hostname, allowlist_guides } = scannerInfo;
  const platforms = Object.entries(allowlist_guides || {});

  function copyToClipboard(text) {
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(text);
      window.wpsbToast?.('Copied to clipboard', 'ok');
    }
  }

  return (
    <div style={{ fontSize:'.74rem' }}>
      {/* IP + UA block — always visible */}
      <div style={{
        padding:'8px 10px', borderRadius:5, marginBottom:8,
        background:'rgba(255,255,255,.03)', border:'1px solid var(--border)',
      }}>
        <div style={{ fontSize:'.66rem', color:'var(--dim)', marginBottom:4, fontFamily:'var(--font-mono)', letterSpacing:'.06em' }}>
          ALLOWLIST THIS:
        </div>
        {ip ? (
          <div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:5 }}>
            <span style={{ fontSize:'.68rem', color:'var(--muted)', minWidth:30 }}>IP:</span>
            <code style={{ fontFamily:'var(--font-mono)', fontSize:'.78rem', color:'var(--beam, #00c8ef)', fontWeight:600, flex:'1 1 auto' }}>{ip}</code>
            <button
              className="btn btn-ghost btn-sm"
              onClick={() => copyToClipboard(ip)}
              style={{ fontSize:'.6rem', padding:'2px 8px' }}
              title="Copy scanner IP"
            >Copy</button>
          </div>
        ) : (
          <div style={{ fontSize:'.7rem', color:'var(--warn, #fbbf24)', marginBottom:5 }}>
            IP not yet resolved — refresh in a minute
          </div>
        )}
        <div style={{ display:'flex', alignItems:'center', gap:6 }}>
          <span style={{ fontSize:'.68rem', color:'var(--muted)', minWidth:30 }}>UA:</span>
          <code style={{ fontFamily:'var(--font-mono)', fontSize:'.66rem', color:'var(--text)', flex:'1 1 auto', wordBreak:'break-all' }}>{user_agent}</code>
          <button
            className="btn btn-ghost btn-sm"
            onClick={() => copyToClipboard(user_agent)}
            style={{ fontSize:'.6rem', padding:'2px 8px' }}
            title="Copy User-Agent"
          >Copy</button>
        </div>
      </div>

      {/* Per-platform accordion */}
      <div style={{ fontSize:'.66rem', color:'var(--dim)', marginBottom:6, fontFamily:'var(--font-mono)', letterSpacing:'.06em' }}>
        WHERE TO ADD IT:
      </div>
      <div style={{ display:'flex', flexDirection:'column', gap:3 }}>
        {platforms.map(([id, guide]) => {
          const isOpen = openPlatform === id;
          return (
            <div key={id} style={{
              borderRadius:4,
              background: isOpen ? 'rgba(255,255,255,.03)' : 'transparent',
              border: '1px solid ' + (isOpen ? 'var(--border)' : 'transparent'),
            }}>
              <button
                onClick={() => setOpenPlatform(isOpen ? null : id)}
                style={{
                  width:'100%', textAlign:'left',
                  background:'none', border:'none', cursor:'pointer',
                  padding:'6px 10px',
                  fontSize:'.74rem', color:'var(--text)', fontWeight: isOpen ? 600 : 500,
                  display:'flex', justifyContent:'space-between', alignItems:'center',
                  fontFamily:'var(--font-sans, inherit)',
                }}
                aria-expanded={isOpen}
              >
                <span>{guide.label}</span>
                <span style={{ color:'var(--dim)', fontSize:'.7rem' }}>{isOpen ? '−' : '+'}</span>
              </button>
              {isOpen && (
                <div style={{ padding:'2px 10px 8px 10px', fontSize:'.7rem', color:'var(--muted)', lineHeight:1.55 }}>
                  <ol style={{ margin:0, paddingLeft:18, display:'flex', flexDirection:'column', gap:3 }}>
                    {(guide.steps || []).map((step, j) => (
                      <li key={j} style={{
                        whiteSpace: step.includes('\n') ? 'pre-wrap' : 'normal',
                        fontFamily: step.includes('\n') ? 'var(--font-mono)' : 'inherit',
                        fontSize: step.includes('\n') ? '.66rem' : 'inherit',
                        color: step.includes('\n') ? 'var(--text)' : 'inherit',
                        background: step.includes('\n') ? 'rgba(255,255,255,.03)' : 'transparent',
                        padding: step.includes('\n') ? '6px 8px' : 0,
                        borderRadius: step.includes('\n') ? 4 : 0,
                        marginTop: step.includes('\n') ? 4 : 0,
                      }}>
                        {step}
                      </li>
                    ))}
                  </ol>
                  {/* Two help links — our deep-dive doc + the vendor's
                      official allowlist documentation (when known). Both
                      open in new tabs to keep the user's scan view intact. */}
                  <div style={{ display:'flex', gap:12, marginTop:8, flexWrap:'wrap' }}>
                    <a href={`https://wpsitebeam.io/docs/scanner/allowlist/${id}`}
                       target="_blank" rel="noopener noreferrer"
                       style={{
                         fontSize:'.66rem', color:'var(--beam, #00c8ef)',
                         textDecoration:'none', display:'inline-flex', alignItems:'center', gap:3,
                       }}>
                      <Icon name="book" size={10}/>Our guide for {guide.label}
                      <span style={{ fontSize:'.6rem', color:'var(--dim)' }}>↗</span>
                    </a>
                    {VENDOR_DOC_LINKS[id] && (
                      <a href={VENDOR_DOC_LINKS[id]}
                         target="_blank" rel="noopener noreferrer"
                         style={{
                           fontSize:'.66rem', color:'var(--muted)',
                           textDecoration:'none', display:'inline-flex', alignItems:'center', gap:3,
                         }}>
                        Official {guide.label} docs
                        <span style={{ fontSize:'.6rem', color:'var(--dim)' }}>↗</span>
                      </a>
                    )}
                  </div>
                </div>
              )}
            </div>
          );
        })}
      </div>

      {scannerInfo.notes && scannerInfo.notes.length > 0 && (
        <div style={{
          marginTop:8, padding:'7px 10px', borderRadius:4,
          background:'var(--beam-dim)', border:'1px solid var(--beam-dim)',
          fontSize:'.66rem', color:'var(--muted)', lineHeight:1.55,
        }}>
          {scannerInfo.notes[0]}
        </div>
      )}
    </div>
  );
}
window.ScanAllowlistPanel = ScanAllowlistPanel;

function ScanConfidenceWidget({
  confidence,
  skipScore = false,
  wasBlocked = false,
  /* Controlled toggle props (Apr 26 update). When provided, the widget uses
     these instead of its own internal state — letting the Actions row
     render the toggle buttons inline with JSON / Switch to ongoing SEO /
     etc. instead of forcing them into a separate row above. Callers that
     don't pass these get the original self-managed behavior. */
  showSignalsExternal,
  setShowSignalsExternal,
  showChecklistExternal,
  setShowChecklistExternal,
}) {
  const { useState } = React;
  const [showSignalsLocal, setShowSignalsLocal] = useState(false);
  const [showChecklistLocal, setShowChecklistLocal] = useState(false);

  /* Use external (controlled) state if both getter+setter were supplied,
     otherwise fall back to internal state. */
  const showSignals = (typeof setShowSignalsExternal === 'function') ? !!showSignalsExternal : showSignalsLocal;
  const setShowSignals = (typeof setShowSignalsExternal === 'function') ? setShowSignalsExternal : setShowSignalsLocal;
  const showChecklist = (typeof setShowChecklistExternal === 'function') ? !!showChecklistExternal : showChecklistLocal;
  const setShowChecklist = (typeof setShowChecklistExternal === 'function') ? setShowChecklistExternal : setShowChecklistLocal;

  // Tolerate null/undefined — widget renders nothing rather than crash.
  // Older Railway versions won't include scan_confidence in their response.
  if (!confidence || typeof confidence.score !== 'number') return null;

  const { score, band, signals = [], advisories = [], pre_migration_checklist = [] } = confidence;

  // Color by band — uses CSS vars where available, falls back to hex.
  const bandColor =
    band === 'high'   ? 'var(--green, #22c55e)'
  : band === 'medium' ? 'var(--warn, #fbbf24)'
                      : 'var(--rose, #f43f5e)';
  const bandBg =
    band === 'high'   ? 'rgba(34, 197, 94, 0.08)'
  : band === 'medium' ? 'rgba(251, 191, 36, 0.08)'
                      : 'rgba(244, 63, 94, 0.08)';
  const bandBorder =
    band === 'high'   ? 'rgba(34, 197, 94, 0.3)'
  : band === 'medium' ? 'rgba(251, 191, 36, 0.3)'
                      : 'rgba(244, 63, 94, 0.3)';
  const bandLabel = band === 'high' ? 'High' : band === 'medium' ? 'Medium' : 'Low';

  // Critical advisories (bot challenge, SPA shell, no SSL) deserve top
  // surfacing even if user hasn't clicked to expand. Lower-severity ones
  // stay hidden behind the expand toggle.
  const criticalAdvisories = advisories.filter(a => a.severity === 'critical');
  const otherAdvisories    = advisories.filter(a => a.severity !== 'critical');

  return (
    <div style={{
      marginTop: skipScore ? 0 : 12,
      paddingTop: skipScore ? 0 : 12,
      borderTop: skipScore ? 'none' : '1px solid var(--border)',
    }}>
      {/* Header row — score + band label + progress bar.
          Hidden when skipScore=true (caller is rendering ScanConfidenceCompact
          inline in the page header instead, this widget just provides the
          expandable details below). */}
      {!skipScore && (
        <div style={{ display:'flex', alignItems:'center', gap:14, flexWrap:'wrap' }}>
          <div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
            <span style={{ fontSize:'.7rem', color:'var(--dim)', fontFamily:'var(--font-mono)', letterSpacing:'.06em' }}>
              SCAN CONFIDENCE
            </span>
            <span style={{ fontSize:'1.4rem', fontWeight:700, color: bandColor, fontFamily:'var(--font-mono)' }}>
              {score}
              <span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:2 }}>/100</span>
            </span>
            <span style={{
              padding:'2px 8px', borderRadius:4, fontSize:'.65rem', fontWeight:600,
              background: bandBg, color: bandColor, border: `1px solid ${bandBorder}`,
              letterSpacing:'.04em', textTransform:'uppercase',
            }}>{bandLabel}</span>
          </div>
          {/* Bar */}
          <div style={{ flex:'1 1 200px', minWidth:200, height:6, borderRadius:3, background:'var(--surface-2, rgba(255,255,255,.04))', overflow:'hidden' }}>
            <div style={{
              width: `${score}%`, height:'100%', background: bandColor, transition:'width .4s ease',
            }}/>
          </div>
          {/* Toggle: signal breakdown */}
          <button className="btn btn-ghost btn-sm"
                  onClick={() => setShowSignals(s => !s)}
                  aria-expanded={showSignals}
                  aria-controls="confidence-signals"
                  style={{ fontSize:'.7rem' }}>
            {showSignals ? 'Hide' : 'Show'} breakdown ({signals.filter(s => s.met).length}/{signals.length})
          </button>
        </div>
      )}

      {/* When skipScore=true AND no external toggle setters were provided,
          we still need a "Show breakdown" toggle row inside the widget
          since the inline compact widget doesn't have one. When external
          setters ARE provided, the caller (e.g. the Actions toolbar) is
          rendering the toggle buttons themselves — skip the in-widget row. */}
      {skipScore && typeof setShowSignalsExternal !== 'function' && (
        <div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
          <button className="btn btn-ghost btn-sm"
                  onClick={() => setShowSignals(s => !s)}
                  aria-expanded={showSignals}
                  aria-controls="confidence-signals"
                  style={{ fontSize:'.7rem' }}>
            {showSignals ? 'Hide' : 'Show'} breakdown ({signals.filter(s => s.met).length}/{signals.length})
          </button>
          {pre_migration_checklist.length > 0 && (
            <button className="btn btn-ghost btn-sm"
                    onClick={() => setShowChecklist(c => !c)}
                    aria-expanded={showChecklist}
                    aria-controls="pre-migration-checklist"
                    style={{ fontSize:'.7rem' }}>
              {showChecklist ? 'Hide' : 'Show'} pre-migration checklist ({pre_migration_checklist.length} items)
            </button>
          )}
        </div>
      )}

      {/* Low-confidence consolidated card — when band='low', combines what
          was previously 4 separate stacked blocks (banner + 3 advisory cards)
          into a single compact card. Header line summarizes the situation;
          condensed issue list shows what failed + how to fix.
          Critical advisories get an inline alert badge. Click "What can I
          do?" to expand the full action plan instead of showing it inline.

          When band !== 'low', this whole block is skipped. Critical
          advisories still render in their own block below for high/medium
          band scans (where the score is acceptable but specific signals
          like bot challenges still need attention). */}
      {band === 'low' && (criticalAdvisories.length > 0 || otherAdvisories.length > 0) && (
        <ScanLowConfidenceCard
          score={score}
          band={band}
          wasBlocked={wasBlocked}
          criticalAdvisories={criticalAdvisories}
          otherAdvisories={otherAdvisories}
          signals={signals}
          pre_migration_checklist={pre_migration_checklist}
        />
      )}

      {/* Critical advisories outside low-band — for high/medium scans where
          the cumulative score is fine but a specific critical signal (bot
          challenge, SPA shell) still needs attention. */}
      {band !== 'low' && criticalAdvisories.length > 0 && (
        <div style={{ marginTop:10, display:'flex', flexDirection:'column', gap:8 }}>
          {criticalAdvisories.map((a, i) => (
            <div key={`crit-${i}`} style={{
              padding:'10px 12px', borderRadius:6,
              background: 'rgba(244, 63, 94, 0.08)',
              border: '1px solid rgba(244, 63, 94, 0.3)',
            }}>
              <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:4 }}>
                <Icon name="alert" size={14}/>
                <strong style={{ fontSize:'.86rem', color:'var(--rose, #f43f5e)' }}>{a.title}</strong>
              </div>
              <div style={{ fontSize:'.78rem', color:'var(--text)', lineHeight:1.5, marginBottom: a.action ? 6 : 0 }}>
                {a.body}
              </div>
              {a.action && (
                <div style={{ fontSize:'.74rem', color:'var(--muted)', lineHeight:1.5, fontStyle:'italic' }}>
                  → {a.action}
                </div>
              )}
            </div>
          ))}
        </div>
      )}

      {/* Warn-level advisories outside low-band — only show when user
          clicks "Show breakdown" toggle (diagnostic info, not blocking). */}
      {band !== 'low' && showSignals && otherAdvisories.length > 0 && (
        <div style={{ marginTop:10, display:'flex', flexDirection:'column', gap:6 }}>
          {otherAdvisories.map((a, i) => (
            <div key={`adv-${i}`} style={{
              padding:'8px 10px', borderRadius:5,
              background: a.severity === 'warn' ? 'rgba(251, 191, 36, 0.06)' : 'rgba(255,255,255,.02)',
              border: a.severity === 'warn' ? '1px solid rgba(251, 191, 36, 0.2)' : '1px solid var(--border)',
              fontSize: '.76rem',
            }}>
              <div style={{ fontWeight:600, marginBottom:2 }}>{a.title}</div>
              <div style={{ color:'var(--muted)', lineHeight:1.5 }}>{a.body}</div>
              {a.action && (
                <div style={{ color:'var(--dim)', fontStyle:'italic', marginTop:4 }}>→ {a.action}</div>
              )}
            </div>
          ))}
        </div>
      )}

      {/* Signal breakdown table — always behind the toggle (it's diagnostic
          detail, not actionable info). Even when band=low, the table only
          appears once user clicks "Show breakdown".
          2026-05-18 v2 per Jordan: wrap in a clearly-styled notice card with
          cyan accent + title so the diagnostic panel visually pops rather
          than blending into surrounding content. Was "informational but
          mis-placed and lost on the page". */}
      {showSignals && (
        <div id="confidence-signals" style={{
          marginTop:12,
          padding:'12px 14px',
          borderRadius:8,
          background:'rgba(0, 194, 209, 0.04)',
          border:'1px solid rgba(0, 194, 209, 0.25)',
          borderLeft:'4px solid var(--beam, #00C2D1)',
        }}>
          <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
            <Icon name="spark" size={14}/>
            <span style={{ fontSize:'.72rem', fontWeight:700, color:'var(--beam, #00C2D1)', fontFamily:'var(--font-mono)', letterSpacing:'.08em', textTransform:'uppercase' }}>
              Scan Diagnostic — Signal Breakdown
            </span>
            <span style={{ fontSize:'.66rem', color:'var(--dim)', marginLeft:'auto' }}>
              {signals.filter(s => s.met).length} of {signals.length} signals met
            </span>
          </div>
          {/* Signal table — every check, met/unmet */}
          <table className="table" style={{ fontSize:'.74rem', marginBottom:0 }}>
            <thead>
              <tr>
                <th scope="col" style={{ width:30 }}></th>
                <th scope="col">Signal</th>
                <th scope="col" style={{ width:60, textAlign:'right' }}>Weight</th>
              </tr>
            </thead>
            <tbody>
              {signals.map(s => (
                <tr key={s.id}>
                  <td>
                    {s.met
                      ? <Icon name="check" size={13}/>
                      : <span style={{ color:'var(--rose, #f43f5e)', fontFamily:'var(--font-mono)' }}>✗</span>}
                  </td>
                  <td style={{ color: s.met ? 'var(--text)' : 'var(--dim)' }}>{s.label}</td>
                  <td className="mono" style={{ textAlign:'right', color:'var(--dim)' }}>{s.weight}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {/* Pre-migration checklist — separate toggle. Even on a 100% confident
          scan, the agency still has prep work to do before migrating.
          When skipScore=true, the toggle button is rendered in the
          unified toggles row above; here we only render the panel content. */}
      {pre_migration_checklist.length > 0 && (
        <div style={{ marginTop:10 }}>
          {!skipScore && (
            <button className="btn btn-ghost btn-sm"
                    onClick={() => setShowChecklist(c => !c)}
                    aria-expanded={showChecklist}
                    aria-controls="pre-migration-checklist"
                    style={{ fontSize:'.7rem' }}>
              {showChecklist ? 'Hide' : 'Show'} pre-migration checklist ({pre_migration_checklist.length} items)
            </button>
          )}
          {showChecklist && (
            <div id="pre-migration-checklist" style={{
              marginTop:8,
              padding:'12px 14px',
              borderRadius:8,
              background:'rgba(251, 191, 36, 0.05)',
              border:'1px solid rgba(251, 191, 36, 0.25)',
              borderLeft:'4px solid var(--warn, #fbbf24)',
            }}>
              <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:10 }}>
                <Icon name="alert" size={14}/>
                <span style={{ fontSize:'.72rem', fontWeight:700, color:'var(--warn, #fbbf24)', fontFamily:'var(--font-mono)', letterSpacing:'.08em', textTransform:'uppercase' }}>
                  Pre-Migration Action Items
                </span>
                <span style={{ fontSize:'.66rem', color:'var(--dim)', marginLeft:'auto' }}>
                  {pre_migration_checklist.length} items · review before migration
                </span>
              </div>
              <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
                {pre_migration_checklist.map(item => {
                  const sevColor =
                    item.severity === 'must'   ? 'var(--rose, #f43f5e)'
                  : item.severity === 'should' ? 'var(--warn, #fbbf24)'
                                               : 'var(--dim)';
                  const sevLabel = item.severity === 'must' ? 'MUST' : item.severity === 'should' ? 'SHOULD' : 'FYI';
                  return (
                    <div key={item.id} style={{
                      padding:'8px 10px', borderRadius:5, border:'1px solid var(--border)',
                      background:'var(--surface-2, rgba(255,255,255,.02))', fontSize:'.76rem',
                    }}>
                      <div style={{ display:'flex', gap:8, alignItems:'baseline', marginBottom:2 }}>
                        <span className="mono" style={{
                          fontSize:'.6rem', color: sevColor, letterSpacing:'.06em', fontWeight:700,
                        }}>{sevLabel}</span>
                        <strong style={{ fontSize:'.8rem' }}>{item.title}</strong>
                      </div>
                      <div style={{ color:'var(--muted)', lineHeight:1.5, paddingLeft:38 }}>{item.action}</div>
                      {item.autodetected_reason && (
                        <div style={{ fontSize:'.66rem', color:'var(--dim)', marginTop:3, paddingLeft:38, fontStyle:'italic' }}>
                          Auto-flagged because: {item.autodetected_reason}
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}
window.ScanConfidenceWidget = ScanConfidenceWidget;

/* ── ScanConfidenceCompact ─────────────────────────────────────────────
   Slimmed-down score display for inline use in the Scan Overview header.
   Shows: SCAN CONFIDENCE label · score/100 · band tag · progress bar.
   No expand toggle, no advisories, no checklist — those are rendered
   below in <ScanConfidenceWidget skipScore={true}/>.

   Reuses the same band color logic so the two pieces stay visually
   coordinated. */
function ScanConfidenceCompact({ confidence }) {
  if (!confidence || typeof confidence.score !== 'number') return null;
  const { score, band } = confidence;

  const bandColor =
    band === 'high'   ? 'var(--green, #22c55e)'
  : band === 'medium' ? 'var(--warn, #fbbf24)'
                      : 'var(--rose, #f43f5e)';
  const bandBg =
    band === 'high'   ? 'rgba(34, 197, 94, 0.08)'
  : band === 'medium' ? 'rgba(251, 191, 36, 0.08)'
                      : 'rgba(244, 63, 94, 0.08)';
  const bandBorder =
    band === 'high'   ? 'rgba(34, 197, 94, 0.3)'
  : band === 'medium' ? 'rgba(251, 191, 36, 0.3)'
                      : 'rgba(244, 63, 94, 0.3)';
  const bandLabel = band === 'high' ? 'High' : band === 'medium' ? 'Medium' : 'Low';

  return (
    <div style={{ display:'flex', alignItems:'center', gap:12, flexWrap:'wrap' }}>
      <span style={{ fontSize:'.7rem', color:'var(--dim)', fontFamily:'var(--font-mono)', letterSpacing:'.06em', whiteSpace:'nowrap' }}>
        SCAN CONFIDENCE
      </span>
      <span style={{ fontSize:'1.4rem', fontWeight:700, color: bandColor, fontFamily:'var(--font-mono)', whiteSpace:'nowrap' }}>
        {score}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:2 }}>/100</span>
      </span>
      <span style={{
        padding:'2px 8px', borderRadius:4, fontSize:'.65rem', fontWeight:600,
        background: bandBg, color: bandColor, border: `1px solid ${bandBorder}`,
        letterSpacing:'.04em', textTransform:'uppercase', whiteSpace:'nowrap',
      }}>{bandLabel}</span>
      <div style={{ flex:'1 1 100px', minWidth:100, height:6, borderRadius:3, background:'var(--surface-2, rgba(255,255,255,.04))', overflow:'hidden' }}>
        <div style={{ width: `${score}%`, height:'100%', background: bandColor, transition:'width .4s ease' }}/>
      </div>
    </div>
  );
}
window.ScanConfidenceCompact = ScanConfidenceCompact;

/* ── ConfirmDialog ────────────────────────────────────────────────────
   Reusable confirmation modal that replaces native window.confirm().
   Native confirm dialogs can't be styled or centered consistently
   across browsers/OSes — they look jarring next to our branded UI and
   appear in different positions on different platforms (top of viewport
   on iOS, center on macOS Chrome, etc.).

   This component:
   - Renders a fixed-position overlay covering the whole viewport
   - Centers the dialog vertically + horizontally via flexbox
   - Dims + blurs the page behind via rgba(0,0,0,.75) + backdrop-filter
   - Uses the same .modal / .btn-confirm / .btn-cancel classes the rest
     of the SaaS uses, so it inherits the brand's color tokens, fonts,
     and animations automatically
   - Closes on Escape key or backdrop click (standard a11y patterns)
   - Returns a Promise<boolean> so callers can `await` the user's choice
     instead of writing callback chains

   Body scroll is locked while the dialog is open — prevents the page
   from scrolling under the overlay if the user happens to scroll wheel
   while reading the dialog.

   Usage (via hook):
     const confirm = useConfirmDialog();
     const ok = await confirm({
       title: 'Sign out',
       message: 'Are you sure?',
       confirmLabel: 'Sign out',
       cancelLabel: 'Cancel',
       danger: true,  // optional, makes confirm button rose
     });
     if (ok) doLogout();

   For one-line text confirmations, message accepts plain string. For
   richer content with formatting, message can be a React node. */
function ConfirmDialog({ open, title, message, confirmLabel, cancelLabel, danger, onConfirm, onCancel }) {
  const { useEffect } = React;

  /* Body scroll lock + Escape key handler — only active while dialog
     is open. Cleanup function removes both on unmount or close. */
  useEffect(() => {
    if (!open) return;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    function onKey(e) {
      if (e.key === 'Escape') onCancel?.();
    }
    document.addEventListener('keydown', onKey);
    return () => {
      document.body.style.overflow = previousOverflow;
      document.removeEventListener('keydown', onKey);
    };
  }, [open, onCancel]);

  if (!open) return null;

  return (
    <div
      onClick={(e) => {
        /* Backdrop click cancels (standard modal a11y).
           Only trigger if user clicked the overlay itself, not the
           dialog content — checking e.target === e.currentTarget
           prevents accidental cancels when clicking inside the modal. */
        if (e.target === e.currentTarget) onCancel?.();
      }}
      style={{
        position:'fixed', inset:0, zIndex:10000,
        background:'rgba(0, 0, 0, 0.72)',
        backdropFilter:'blur(4px)', WebkitBackdropFilter:'blur(4px)',
        display:'flex', alignItems:'center', justifyContent:'center',
        padding:20,
        animation:'wpsbModalFadeIn .2s ease both',
      }}
      role="dialog" aria-modal="true" aria-labelledby="wpsb-confirm-title"
    >
      {/* Inline keyframes — defined once per render, no global CSS needed */}
      <style>{`
        @keyframes wpsbModalFadeIn { from { opacity:0; } to { opacity:1; } }
        @keyframes wpsbModalSlideIn { from { opacity:0; transform:translateY(-12px) scale(.97); } to { opacity:1; transform:translateY(0) scale(1); } }
      `}</style>
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          background:'var(--surface, #0f1620)',
          border:'1px solid var(--border, rgba(255,255,255,.08))',
          borderRadius:12,
          width:'100%', maxWidth:480,
          padding:'22px 24px',
          boxShadow:'0 24px 60px rgba(0, 0, 0, 0.5)',
          animation:'wpsbModalSlideIn .25s ease both',
        }}
      >
        <div id="wpsb-confirm-title" style={{
          fontSize:'1rem', fontWeight:700, color:'var(--text)',
          marginBottom:8,
        }}>
          {title}
        </div>
        <div style={{
          fontSize:'.84rem', color:'var(--muted)',
          lineHeight:1.6, marginBottom:18,
          whiteSpace: typeof message === 'string' ? 'pre-line' : 'normal',
        }}>
          {message}
        </div>
        <div style={{
          display:'flex', gap:10, justifyContent:'flex-end',
          flexWrap:'wrap',
        }}>
          <button
            className="btn btn-ghost btn-sm"
            onClick={onCancel}
            autoFocus={!danger}  /* Cancel default-focused for safe actions */
          >
            {cancelLabel || 'Cancel'}
          </button>
          <button
            className={danger ? 'btn btn-danger btn-sm' : 'btn btn-primary btn-sm'}
            onClick={onConfirm}
            autoFocus={danger ? false : false}
            style={danger ? {
              /* Fallback styling if .btn-danger isn't defined globally */
              background:'var(--rose, #f43f5e)',
              borderColor:'var(--rose, #f43f5e)',
              color:'#fff',
            } : undefined}
          >
            {confirmLabel || 'Confirm'}
          </button>
        </div>
      </div>
    </div>
  );
}
window.ConfirmDialog = ConfirmDialog;

/* ── useConfirmDialog hook ────────────────────────────────────────────
   Replaces window.confirm() with our branded ConfirmDialog. Returns a
   function that takes options and returns Promise<boolean>.

   The hook also returns the dialog state — caller renders <ConfirmDialog
   {...dialogProps}/> once at the bottom of their tree, then uses the
   confirm() function freely throughout the component. */
function useConfirmDialog() {
  const { useState, useCallback, useMemo } = React;
  const [state, setState] = useState({ open:false });

  const confirm = useCallback((opts) => {
    return new Promise((resolve) => {
      setState({
        open: true,
        title: opts.title || 'Confirm',
        message: opts.message || '',
        confirmLabel: opts.confirmLabel || 'Confirm',
        cancelLabel: opts.cancelLabel || 'Cancel',
        danger: !!opts.danger,
        _resolve: resolve,
      });
    });
  }, []);

  const handleConfirm = useCallback(() => {
    state._resolve?.(true);
    setState({ open:false });
  }, [state]);

  const handleCancel = useCallback(() => {
    state._resolve?.(false);
    setState({ open:false });
  }, [state]);

  const dialogProps = useMemo(() => ({
    open: state.open,
    title: state.title,
    message: state.message,
    confirmLabel: state.confirmLabel,
    cancelLabel: state.cancelLabel,
    danger: state.danger,
    onConfirm: handleConfirm,
    onCancel: handleCancel,
  }), [state, handleConfirm, handleCancel]);

  return { confirm, dialogProps };
}
window.useConfirmDialog = useConfirmDialog;

function ScanProgressBar({ modes }) {
  const { useState, useEffect } = React;
  const [pct, setPct] = useState(0);
  const [phaseIdx, setPhaseIdx] = useState(0);
  const [pageProgress, setPageProgress] = useState(null);  // {index, total, label}

  /* Phases — tuned to what server actually does in parallel.
     Order matters: we cycle through them to show activity, but the user
     shouldn't assume linear progression (many happen concurrently). */
  const phases = (() => {
    const base = [
      'Connecting to scanner service…',
      'Fetching homepage + contact pages…',
      'Detecting platform + CMS signatures…',
    ];
    if (modes.brand) {
      base.push('Extracting colors, fonts, logo…');
      base.push('Parsing contact info from pages…');
    }
    if (modes.platform) {
      base.push('Crawling sitemap + navigation…');
      base.push('Analyzing migration complexity…');
    }
    if (modes.ada) {
      base.push('Running accessibility checks…');
      base.push('Computing WCAG violations…');
    }
    base.push('Extracting per-page content + images…');
    base.push('Finalizing scan report…');
    return base;
  })();

  useEffect(() => {
    let mounted = true;

    /* Progress animation — tick every 400ms.
       Acceleration curve: moves faster early (user sees immediate motion),
       slows as it approaches 90% (caps there until real completion).

       Expected total scan duration is ~40-80s with per-page enrichment,
       so we aim for a slower curve that reaches 90% at around 60s. */
    const progressInterval = setInterval(() => {
      if (!mounted) return;
      setPct(prev => {
        if (prev >= 90) return prev;                     // hold at 90%
        const remaining = 90 - prev;
        const step = Math.max(0.2, remaining * 0.022);   // slower curve for longer scans
        return Math.min(90, prev + step);
      });
    }, 400);

    /* Phase text rotation — every 3s, advance to next phase.
       Stays on last phase when we run out. */
    const phaseInterval = setInterval(() => {
      if (!mounted) return;
      setPhaseIdx(prev => Math.min(prev + 1, phases.length - 1));
    }, 3000);

    /* Session pages-tab: listen for per-page enrichment progress events.
       enrichPagesData calls window.wpsbScanProgress({kind:'page', index, total, label})
       for each URL it fetches. We display the live page name in place of
       the rotating phase text while enrichment is running. */
    window.wpsbScanProgress = (evt) => {
      if (!mounted) return;
      if (evt && evt.kind === 'page') {
        setPageProgress({ index: evt.index, total: evt.total, label: evt.label });
      }
    };

    return () => {
      mounted = false;
      clearInterval(progressInterval);
      clearInterval(phaseInterval);
      /* Only detach if it's still our handler (defensive against race) */
      if (window.wpsbScanProgress) {
        try { delete window.wpsbScanProgress; } catch { window.wpsbScanProgress = null; }
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  /* Per-page enrichment text overrides the phase rotation when active */
  const currentPhase = pageProgress
    ? `Scanning page ${pageProgress.index + 1} of ${pageProgress.total}: ${pageProgress.label}…`
    : (phases[phaseIdx] || phases[0]);

  return (
    <div style={{
      marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)',
      display: 'flex', flexDirection: 'column', gap: 10,
    }}>
      {/* Status line: spinner + current phase */}
      <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
        <div className="spinner" aria-hidden="true"/>
        <span style={{ fontSize: '.78rem', color: 'var(--beam)', flex: 1 }}
              aria-live="polite" aria-atomic="true">
          {currentPhase}
        </span>
        <span style={{ fontSize: '.72rem', color: 'var(--dim)', fontFamily: 'var(--font-mono)', minWidth: 36, textAlign: 'right' }}
              aria-label={`${Math.round(pct)} percent complete`}>
          {Math.round(pct)}%
        </span>
      </div>

      {/* Progress bar track + fill */}
      <div role="progressbar"
           aria-valuenow={Math.round(pct)}
           aria-valuemin={0}
           aria-valuemax={100}
           aria-label="Scan progress"
           style={{
             width: '100%', height: 6,
             background: 'var(--surface-2, rgba(255,255,255,0.06))',
             borderRadius: 3, overflow: 'hidden',
           }}>
        <div style={{
          width: `${pct}%`,
          height: '100%',
          background: 'linear-gradient(90deg, var(--beam, #06b6d4), var(--beam-hi, #22d3ee))',
          borderRadius: 3,
          transition: 'width 400ms cubic-bezier(0.4, 0, 0.2, 1)',
          boxShadow: '0 0 8px rgba(6, 182, 212, 0.35)',
        }}/>
      </div>

      {/* Subtle note so users know this isn't stuck.
          Only show after we've been at ~90% for a bit (pct stable for 2+ ticks). */}
      {pct >= 89 && (
        <div style={{
          fontSize: '.68rem', color: 'var(--dim)',
          fontFamily: 'var(--font-mono)', fontStyle: 'italic',
        }}>
          Large sites can take up to a minute — still working…
        </div>
      )}
    </div>
  );
}
window.ScanProgressBar = ScanProgressBar;

/* ── ScanEmptyState (moved from Scanner.jsx line 2663) ─── */
function ScanEmptyState({ tab, reason, bare = false }) {
  const note = reason || 'This data will appear here when a future scan engine update adds it.';
  /* Map tab name → appropriate Icon. Falls back to 'search' for tabs
     we don't have a specific icon for. Using real icons (not Unicode
     glyphs like ◌) keeps the visual language consistent across the app. */
  const iconFor = (() => {
    const t = (tab || '').toLowerCase();
    if (t.includes('brand')) return 'palette';
    if (t.includes('contact')) return 'phone';
    if (t.includes('image')) return 'image';
    if (t.includes('file') || t.includes('doc')) return 'file';
    if (t.includes('sitemap') || t.includes('page')) return 'sitemap';
    if (t.includes('tech') || t.includes('dns')) return 'server';
    if (t.includes('seo')) return 'search';
    if (t.includes('map')) return 'map-pin';
    if (t.includes('icon')) return 'grid';
    if (t.includes('ada')) return 'shield';
    if (t.includes('rec')) return 'spark';
    if (t.includes('plugin') || t.includes('form')) return 'layers';
    if (t.includes('integration')) return 'server';
    return 'search';
  })();

  /* When `bare` is true, the empty state is being rendered INSIDE another
     card-body (e.g. Plugins & Forms tab's per-section cards) — so we skip
     the outer card wrapper to prevent card-in-card visual nesting (which
     produces the rounded-corner gap artifact at the top edges of the
     parent card). The inner content is identical either way. */
  const inner = (
    <div style={{ display:'flex', gap:16, alignItems:'flex-start', flexWrap:'wrap' }}>
      <div style={{
        display:'inline-flex', alignItems:'center', justifyContent:'center',
        width:48, height:48, flexShrink:0,
        borderRadius:'50%', background:'var(--surface-2, rgba(255,255,255,0.04))',
        border:'1px solid var(--border)',
        color:'var(--dim)',
      }}>
        <Icon name={iconFor} size={22}/>
      </div>
      <div style={{ flex:'1 1 260px', minWidth:0, textAlign:'left' }}>
        <div style={{ fontSize:'.96rem', fontWeight:600, color:'var(--text)', marginBottom:6 }}>
          No {tab} data in this scan yet
        </div>
        <div style={{ fontSize:'.8rem', color:'var(--muted)', lineHeight:1.6, whiteSpace:'pre-line' }}>
          {note}
        </div>
      </div>
    </div>
  );

  if (bare) {
    return <div style={{ padding:'24px 20px' }}>{inner}</div>;
  }

  return (
    <div className="card">
      {/* LEFT-ALIGNED LAYOUT:
          Icon circle sits on the left, text content sits to its right.
          All text is left-aligned, reducing the previous 60px vertical
          padding to a more compact 24px so the empty state doesn't
          dominate the tab. On narrow screens (<480px), the icon stacks
          above the text — both stay left-aligned. */}
      <div className="card-body" style={{ padding:'24px 20px' }}>
        {inner}
      </div>
    </div>
  );
}
window.ScanEmptyState = ScanEmptyState;