/* Ported from:
 *   apps/web/src/pages/UserLists.tsx            (lists overview, /user/lists)
 *   apps/web/src/features/lists/ListContactsDialog.tsx
 *   apps/web/src/features/lists/list-contacts-model.ts
 *   apps/web/src/features/lists/list-csv.ts
 *   apps/web/src/features/lists/useListEnrichJobProgress.ts (simulated)
 *
 * UserList (/user/lists/:listId) — NOTE: the app's pages/UserList.tsx is the
 * tenant *Members* page (company users), NOT a list-detail page; the app has
 * no /user/lists/:listId route at all. The detail page below is synthesized
 * for the prototype from that file's table skeleton (PageHeader + TableChrome
 * + DataTable + search + pager + OverflowMenu row actions + AlertDialog-style
 * confirm) fed by the lists data the harness provides. See NEEDS-lists.md.
 *
 * The ComposeDrawer (sequence composer) is NOT ported (CONVENTIONS rule 8):
 * every compose entry point toasts instead — the app's flag-off behavior.
 */
(() => {
  const T = window.T;
  const { useState, useMemo, useEffect, useRef } = React;

  /* ======================================================================
   * features/lists/list-contacts-model.ts
   * ==================================================================== */

  /** First screen when gaps are enrichable; otherwise jump straight to export. */
  function initialContactsStep(coverage, canEnrich) {
    if (coverage.totalPeople === 0) return 'export';
    const canOfferEnrich =
      canEnrich && (coverage.enrichableEmail > 0 || coverage.enrichablePhone > 0);
    return canOfferEnrich ? 'enrich' : 'export';
  }

  function resolveContactsStep(coverage, canEnrich, entry = 'auto') {
    if (coverage.totalPeople === 0) return 'export';
    if (entry === 'export') return 'export';
    if (entry === 'enrich') return canEnrich ? 'enrich' : 'export';
    return initialContactsStep(coverage, canEnrich);
  }

  /** One readable sentence summarizing list contact coverage. */
  function listCoverageLine(coverage) {
    if (coverage.totalPeople === 0) return 'This list has no people.';

    const n = coverage.totalPeople;
    const head = `${n} ${n === 1 ? 'person' : 'people'}`;

    if (coverage.missingEmail <= 0 && coverage.missingPhone <= 0) {
      return `${head} — all have email and phone.`;
    }

    const gaps = [];
    if (coverage.missingEmail > 0) {
      gaps.push(`${coverage.missingEmail} without email`);
    }
    if (coverage.missingPhone > 0) {
      gaps.push(`${coverage.missingPhone} without phone`);
    }
    return `${head} — ${gaps.join(', ')}.`;
  }

  /** Credits that will be spent for the current enrich selection. */
  function enrichCreditCost(coverage, fetchEmails, fetchPhones) {
    const emailN = fetchEmails ? coverage.enrichableEmail : 0;
    const phoneN = fetchPhones ? coverage.enrichablePhone : 0;
    return { people: emailN + phoneN, credits: emailN + phoneN };
  }

  function remainingEnrichGaps(coverage) {
    const email = coverage.enrichableEmail;
    const phone = coverage.enrichablePhone;
    return { email, phone, any: email > 0 || phone > 0 };
  }

  /**
   * Readable message for a failed list contacts operation. (The app unpacks
   * Apollo CombinedGraphQLErrors here; the mock only ever throws plain
   * Errors/strings, but the same unwrap order is kept.)
   */
  function listContactsErrorMessage(error, fallback) {
    const errors = error == null ? undefined : error.errors;
    if (Array.isArray(errors) && errors.length > 0) {
      const message = errors[0] == null ? undefined : errors[0].message;
      if (typeof message === 'string' && message.trim()) return message.trim();
    }
    if (typeof error === 'string' && error.trim()) return error.trim();
    if (error instanceof Error && error.message.trim()) {
      return error.message.trim();
    }
    return fallback;
  }

  const EXPORT_FIELDS = ['Name', 'Title', 'Company', 'Email', 'Phone', 'LinkedIn'];

  /* ======================================================================
   * features/lists/list-csv.ts
   * ==================================================================== */

  const CSV_HEADERS = ['name', 'title', 'company', 'email', 'phone', 'LinkedIn URL'];

  /**
   * Cells starting with one of these are executed as a formula by Excel,
   * Sheets, and LibreOffice. Imported contact fields are attacker-controlled,
   * so they get the standard leading-apostrophe guard before quoting (OWASP
   * CSV injection). There is deliberately NO exemption for phone numbers —
   * a leading `+` is a formula introducer too (Lotus compatibility), and an
   * unguarded E.164 number is silently coerced/lossy on re-save.
   */
  const FORMULA_PREFIX = /^[=+\-@\t\r]/;

  /** Characters that force the cell to be wrapped in quotes. */
  const NEEDS_QUOTING = /[",\n\r]/;

  function escapeCsv(value) {
    const guarded = FORMULA_PREFIX.test(value) ? `'${value}` : value;
    if (NEEDS_QUOTING.test(guarded)) return `"${guarded.replace(/"/g, '""')}"`;
    return guarded;
  }

  function listRowsToCsv(rows) {
    const lines = [CSV_HEADERS.join(',')];
    for (const row of rows) {
      lines.push(
        [
          escapeCsv(row.fullName),
          escapeCsv(row.title ?? ''),
          escapeCsv(row.company),
          escapeCsv(row.email ?? ''),
          escapeCsv(row.phone ?? ''),
          escapeCsv(row.linkedinUrl ?? ''),
        ].join(','),
      );
    }
    return lines.join('\n');
  }

  /**
   * Safari and Firefox can cancel an in-flight download when its object URL is
   * revoked, so the URL outlives the click by a wide margin (same delay
   * FileSaver.js uses) instead of being revoked synchronously.
   */
  const REVOKE_DELAY_MS = 40000;

  function downloadListCsv(rows, listName) {
    const csv = listRowsToCsv(rows);
    const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement('a');
    const safeName = listName.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'list';
    anchor.href = url;
    anchor.download = `${safeName}-contacts.csv`;
    anchor.rel = 'noopener';
    anchor.style.display = 'none';
    // Firefox only dispatches the download for an anchor that is in the document.
    document.body.appendChild(anchor);
    anchor.click();
    setTimeout(() => {
      anchor.remove();
      URL.revokeObjectURL(url);
    }, REVOKE_DELAY_MS);
  }

  /* ======================================================================
   * features/lists/useListEnrichJobProgress.ts
   *
   * The app polls WORKFLOW_JOB at 1Hz with stall/terminal-read handling; the
   * port simulates one healthy run per submitted job with setInterval
   * (CONVENTIONS rule 7). `projectListEnrichJob` — the job→progress
   * projection — is ported verbatim so the dialog reads the same shape.
   * ==================================================================== */

  function projectListEnrichJob(job) {
    const jobData = (job && job.data) || null;
    const progressData = (jobData && jobData.progress) || null;
    const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
    const str = (v) => (typeof v === 'string' && v ? v : undefined);
    const processed = num(progressData && progressData.processed) ?? 0;
    const total = num(progressData && progressData.total) ?? 0;
    const label = str(progressData && progressData.label) ?? '';
    const status = (job && job.status) || 'pending';

    const failed = status === 'failed' || status === 'cancelled';
    const ready = status === 'completed';

    const phase = failed
      ? 'failed'
      : ready
        ? 'ready'
        : processed > 0 || status === 'running'
          ? 'running'
          : 'waiting';

    return {
      phase,
      processed,
      total,
      label,
      percent: ready ? 100 : total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0,
      errorMessage: failed
        ? (str(job && job.errorMessage) ?? 'Enrichment failed. Try again.')
        : undefined,
    };
  }

  /** jobId → { total } — how many lookups the simulated run should report.
   *  Registered by the dialog at submit time (the real hook learns this from
   *  the polled workflow job's payload). */
  const enrichSimJobs = new Map();
  function registerListEnrichSim(jobId, total) {
    enrichSimJobs.set(jobId, { total: Math.max(0, total | 0) });
  }

  // Queued beat, then a run long enough for data.jsx's own simulated
  // per-person email enrichment (~3.3s) to land before "Done" — so the
  // coverage the dialog re-reads on completion already shows the finds.
  const SIM_WAIT_MS = 700;
  const SIM_RUN_MS = 4200;
  const SIM_TICK_MS = 250;

  function useListEnrichJobProgress(jobId) {
    const [job, setJob] = useState(null);

    useEffect(() => {
      if (!jobId) {
        setJob(null);
        return undefined;
      }
      const total = (enrichSimJobs.get(jobId) || { total: 6 }).total;
      const startedAt = Date.now();
      setJob({ status: 'pending', data: {} });
      if (total <= 0) {
        // Nothing to look up: complete after the queued beat.
        const t0 = setTimeout(() => {
          setJob({ status: 'completed', data: { progress: { processed: 0, total: 0, label: 'Done' } } });
        }, SIM_WAIT_MS);
        return () => clearTimeout(t0);
      }
      const t = setInterval(() => {
        const elapsed = Date.now() - startedAt;
        if (elapsed < SIM_WAIT_MS) return; // still queued
        const frac = Math.min(1, (elapsed - SIM_WAIT_MS) / SIM_RUN_MS);
        if (frac >= 1) {
          clearInterval(t);
          setJob({
            status: 'completed',
            data: { progress: { processed: total, total, label: 'Done' } },
          });
          return;
        }
        setJob({
          status: 'running',
          data: {
            progress: {
              processed: Math.min(total, Math.floor(frac * total)),
              total,
              label: 'Finding contact details…',
            },
          },
        });
      }, SIM_TICK_MS);
      return () => clearInterval(t);
    }, [jobId]);

    return useMemo(() => projectListEnrichJob(job), [job]);
  }

  /* ======================================================================
   * features/lists/ListContactsDialog.tsx
   * ==================================================================== */

  function ListContactsDialog({ list, open, onOpenChange, entry = 'auto' }) {
    const cn = T.cn;
    const toast = T.toast;
    const {
      Button,
      Checkbox,
      Dialog,
      DialogBody,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
      Progress,
    } = T.UI;
    const { Download, Loader2 } = T.Icons;

    // usePermissions is an auth surface (CONVENTIONS rule 8): the fixture
    // user is an admin, so both enrich channels are permitted.
    const canEmail = true;
    const canPhone = true;
    const canEnrich = canEmail || canPhone;

    const [step, setStep] = useState('export');
    const [stepPinned, setStepPinned] = useState(false);
    const [fetchEmails, setFetchEmails] = useState(canEmail);
    const [fetchPhones, setFetchPhones] = useState(canPhone);
    const [jobId, setJobId] = useState(null);
    const [exporting, setExporting] = useState(false);
    const [submitting, setSubmitting] = useState(false);
    const [submitError, setSubmitError] = useState(null);
    const progress = useListEnrichJobProgress(jobId);
    // Synchronous double-submit latch. `submitting` only flips after the
    // (simulated) round trip resolves, leaving the pending window unguarded;
    // the ref flips before any `await`.
    const submitInFlight = useRef(false);

    // useQuery(LIST_CONTACT_COVERAGE) → T.Data hook. The mock recomputes on
    // every data-version bump, so `refetch` is a formality it still honors.
    const {
      data: coverageData,
      loading: coverageLoading,
      error: coverageError,
      refetch: refetchCoverage,
    } = T.Data.useListContactCoverage({ listId: list ? list.id : '' });

    // useLazyQuery(LIST_EXPORT_PEOPLE) → eager mock hook; downloadCsv reads it.
    const { data: exportData } = T.Data.useListExportPeople({ listId: list ? list.id : '' });

    const coverage = list ? coverageData?.listContactCoverage : undefined;
    const running =
      !!jobId && progress.phase !== 'ready' && progress.phase !== 'failed';
    const enrichDone = progress.phase === 'ready';
    const enrichFailed = progress.phase === 'failed';

    useEffect(() => {
      if (!open) {
        setStepPinned(false);
        setJobId(null);
        setExporting(false);
        setSubmitError(null);
      }
    }, [open]);

    // Released whenever there is no live job: dialog close, and the
    // completion effect below that clears `jobId` before returning to export.
    useEffect(() => {
      if (!jobId) submitInFlight.current = false;
    }, [jobId]);

    useEffect(() => {
      if (!coverage || stepPinned) return;
      setStep(resolveContactsStep(coverage, canEnrich, entry));
      setFetchEmails(canEmail);
      setFetchPhones(canPhone);
      setStepPinned(true);
    }, [coverage, canEnrich, canEmail, canPhone, stepPinned, entry]);

    useEffect(() => {
      if (enrichDone && step === 'enrich') {
        void refetchCoverage();
        setStep('export');
        setJobId(null);
      }
    }, [enrichDone, step, refetchCoverage]);

    const showEnrichOptions =
      canEnrich &&
      !jobId &&
      (coverage?.enrichableEmail ?? 0) + (coverage?.enrichablePhone ?? 0) > 0;

    const enrichCost = coverage
      ? enrichCreditCost(
          coverage,
          canEmail && fetchEmails,
          canPhone && fetchPhones,
        )
      : { people: 0, credits: 0 };

    // Nothing to submit when no permitted channel is checked OR when every
    // checked channel has zero enrichable people.
    const noEnrichWorkSelected = enrichCost.people <= 0;

    const startEnrich = async () => {
      if (!list || submitInFlight.current) return;
      submitInFlight.current = true;
      setSubmitError(null);
      setSubmitting(true);
      try {
        const res = await T.Data.submitListEnrichJob({
          listId: list.id,
          fetchEmails: canEmail && fetchEmails,
          fetchPhones: canPhone && fetchPhones,
        });
        const id = res ? res.id : null;
        if (!id) {
          // Released only on the failure paths — on success the latch is held
          // until `jobId` clears, so React committing setJobId cannot race it.
          submitInFlight.current = false;
          setSubmitError('Could not start enrichment. Try again.');
          return;
        }
        // Tell the simulated progress hook how many lookups this run covers
        // (the app learns this from the polled workflow job's payload).
        registerListEnrichSim(id, enrichCost.people);
        // Enrichment spends credits, so the submitted intent is worth
        // recording whatever the worker later does with it.
        T.homeAnalytics.listEnriched({
          list_id: list.id,
          contacts: enrichCost.people,
          credits: enrichCost.credits,
        });
        setJobId(id);
      } catch (err) {
        submitInFlight.current = false;
        setSubmitError(
          listContactsErrorMessage(err, 'Could not start enrichment. Try again.'),
        );
      } finally {
        setSubmitting(false);
      }
    };

    const reportExportFailure = (err) => {
      toast.error(
        listContactsErrorMessage(err, 'Could not export this list. Try again.'),
      );
    };

    const downloadCsv = async () => {
      if (!list) return;
      setExporting(true);
      try {
        // Simulated fetch beat so the button's loading affordance shows.
        await new Promise((r) => setTimeout(r, 300));
        const rows = (exportData?.listExportPeople ?? []).map((r) => ({
          ...r,
          company: r.company ?? '',
        }));
        if (rows.length === 0) {
          toast('This list has no people to export.');
          return;
        }
        downloadListCsv(rows, list.name);
        T.homeAnalytics.listExported({ list_id: list.id, rows: rows.length });
        toast(`Exported ${rows.length} contacts`);
        onOpenChange(false);
      } catch (err) {
        reportExportFailure(err);
      } finally {
        setExporting(false);
      }
    };

    const exportGaps = coverage ? remainingEnrichGaps(coverage) : null;
    const showExportEnrichFirst =
      !!coverage && canEnrich && !!exportGaps?.any && step === 'export';

    const enrichCta = submitError
      ? 'Try again'
      : enrichCost.credits > 0
        ? `Enrich · ${enrichCost.credits} credit${enrichCost.credits === 1 ? '' : 's'}`
        : 'Enrich';

    // A failed coverage query (or a settled query that produced nothing) used
    // to render an empty dialog with no title at all — show a real error
    // instead. (Unreachable against the mock, kept for structural fidelity.)
    const coverageFailed =
      !!coverageError || (!!list && !coverageLoading && !coverage);

    return (
      <Dialog open={open} onOpenChange={(o) => !running && onOpenChange(o)}>
        <DialogContent size="small" className="gap-4">
          {coverageFailed ? (
            <>
              <DialogHeader className="gap-2">
                <div className="lcd-head">
                  <p className="lcd-kicker">
                    Contacts{list ? ` · ${list.name}` : ''}
                  </p>
                  <DialogTitle>Could not load this list</DialogTitle>
                  <DialogDescription className="lcd-sub">
                    We could not read email and phone coverage for this list.
                  </DialogDescription>
                </div>
              </DialogHeader>
              <DialogBody className="gap-4 py-1">
                <p
                  className="lcd-error"
                  role="alert"
                  data-testid="list-contacts-coverage-error"
                >
                  {listContactsErrorMessage(
                    coverageError,
                    'Something went wrong. Try again.',
                  )}
                </p>
              </DialogBody>
              <DialogFooter className="gap-2 sm:justify-end">
                <Button
                  type="button"
                  variant="secondary"
                  onClick={() => onOpenChange(false)}
                >
                  Close
                </Button>
                <Button
                  type="button"
                  onClick={() => void refetchCoverage()}
                  disabled={coverageLoading}
                  loading={coverageLoading}
                  className="min-w-32 justify-center"
                >
                  Try again
                </Button>
              </DialogFooter>
            </>
          ) : !coverage || !list ? (
            <>
              <DialogHeader className="gap-2">
                <div className="lcd-head">
                  <DialogTitle>Loading contacts…</DialogTitle>
                  <DialogDescription className="lcd-sub">
                    Checking email and phone coverage for this list.
                  </DialogDescription>
                </div>
              </DialogHeader>
              <DialogBody className="py-1">
                <div className="text-body text-text-muted flex items-center justify-center gap-2 py-10">
                  <Loader2 className="size-4 animate-spin" />
                  Loading coverage…
                </div>
              </DialogBody>
            </>
          ) : (
            <>
              <DialogHeader className="gap-2">
                <div className="lcd-head">
                  <p className="lcd-kicker">
                    {step === 'enrich' ? 'Prepare' : 'Export'} · {list.name}
                  </p>
                  <DialogTitle>
                    {step === 'enrich'
                      ? 'Find missing contacts'
                      : 'Download spreadsheet'}
                  </DialogTitle>
                  <DialogDescription className="lcd-sub">
                    {step === 'enrich'
                      ? 'Choose email, phone, or both. Credit cost is shown below.'
                      : 'CSV with name, title, company, email, phone, and LinkedIn.'}
                  </DialogDescription>
                </div>
              </DialogHeader>

              <DialogBody className="gap-4 py-1">
                {step === 'enrich' && (
                  <>
                    <p className="lcd-sub">
                      <strong className="text-text-primary tabular-nums">
                        {coverage.totalPeople.toLocaleString()}
                      </strong>{' '}
                      {coverage.totalPeople === 1 ? 'person' : 'people'} on this
                      list
                    </p>

                    {showEnrichOptions ? (
                      <>
                        <div className="lcd-toggles">
                          {canEmail && (
                            <label
                              className={cn(
                                'lcd-toggle',
                                coverage.enrichableEmail <= 0 && 'is-off',
                              )}
                            >
                              <Checkbox
                                checked={fetchEmails}
                                onCheckedChange={(v) =>
                                  setFetchEmails(v === true)
                                }
                                disabled={coverage.enrichableEmail <= 0}
                                className="size-[15px] rounded-[4px]"
                              />
                              <span className="lcd-toggle-t">Emails</span>
                              <span className="lcd-toggle-meta">
                                {coverage.withEmail.toLocaleString()} have ·{' '}
                                <span className="need">
                                  {coverage.enrichableEmail.toLocaleString()} need
                                </span>
                              </span>
                            </label>
                          )}
                          {canPhone && (
                            <label
                              className={cn(
                                'lcd-toggle',
                                coverage.enrichablePhone <= 0 && 'is-off',
                              )}
                            >
                              <Checkbox
                                checked={fetchPhones}
                                onCheckedChange={(v) =>
                                  setFetchPhones(v === true)
                                }
                                disabled={coverage.enrichablePhone <= 0}
                                className="size-[15px] rounded-[4px]"
                              />
                              <span className="lcd-toggle-t">Phones</span>
                              <span className="lcd-toggle-meta">
                                {coverage.withPhone.toLocaleString()} have ·{' '}
                                <span className="need">
                                  {coverage.enrichablePhone.toLocaleString()} need
                                </span>
                              </span>
                            </label>
                          )}
                        </div>
                        <div className="lcd-cost">
                          <span className="lcd-cost-label">
                            {enrichCost.people > 0
                              ? `Enrich ${enrichCost.people.toLocaleString()} look${enrichCost.people === 1 ? 'up' : 'ups'}`
                              : 'Nothing selected to enrich'}
                          </span>
                          <span className="lcd-cost-value">
                            {enrichCost.credits.toLocaleString()} credit
                            {enrichCost.credits === 1 ? '' : 's'}
                          </span>
                        </div>
                      </>
                    ) : jobId ? (
                      enrichFailed ? (
                        // A dead run is a failure, not a progress caption:
                        // render it like the dialog's other errors.
                        <p className="lcd-error" role="alert">
                          {progress.errorMessage}
                        </p>
                      ) : (
                        <div className="lcd-progress">
                          <div className="lcd-progress-row">
                            <span>
                              {enrichDone ? 'Done' : progress.label || 'Working…'}
                            </span>
                            {progress.total > 0 && (
                              <span>
                                {progress.processed.toLocaleString()} of{' '}
                                {progress.total.toLocaleString()}
                              </span>
                            )}
                          </div>
                          <Progress
                            value={progress.percent}
                            aria-label="Enrichment progress"
                            className="h-1.5"
                          />
                        </div>
                      )
                    ) : coverage.missingEmail <= 0 &&
                      coverage.missingPhone <= 0 ? (
                      <p className="lcd-ready">
                        <strong>All set.</strong> Everyone already has email and
                        phone.
                      </p>
                    ) : (
                      <p className="lcd-sub">
                        Gaps remain, but nothing is enrichable right now (in
                        progress, cooldown, or out of credits). You can still
                        export.
                      </p>
                    )}

                    {submitError && (
                      <p
                        className="lcd-error"
                        role="alert"
                        data-testid="list-contacts-enrich-error"
                      >
                        {submitError}
                      </p>
                    )}
                  </>
                )}

                {step === 'export' && (
                  <>
                    {canEnrich && exportGaps?.any ? (
                      <div className="lcd-nudge">
                        <p>
                          Still missing{' '}
                          <strong>
                            {[
                              exportGaps.email > 0
                                ? `${exportGaps.email.toLocaleString()} email${exportGaps.email === 1 ? '' : 's'}`
                                : null,
                              exportGaps.phone > 0
                                ? `${exportGaps.phone.toLocaleString()} phone${exportGaps.phone === 1 ? '' : 's'}`
                                : null,
                            ]
                              .filter(Boolean)
                              .join(' and ')}
                          </strong>{' '}
                          you can find before you export.
                        </p>
                        <Button
                          type="button"
                          variant="secondary"
                          size="sm"
                          onClick={() => setStep('enrich')}
                        >
                          Enrich first
                        </Button>
                      </div>
                    ) : (
                      <p className="lcd-ready">
                        <strong>Ready.</strong> Coverage looks good — download
                        when you are.
                      </p>
                    )}
                    <p className="lcd-fields">
                      Includes{' '}
                      {EXPORT_FIELDS.map((f, i) => (
                        <span key={f}>
                          {i > 0 && ' · '}
                          <b>{f}</b>
                        </span>
                      ))}
                      .
                    </p>
                  </>
                )}
              </DialogBody>

              <DialogFooter className="gap-2 sm:justify-end">
                {step === 'enrich' && (
                  <div className="flex flex-col-reverse gap-2 sm:flex-row">
                    {!jobId ? (
                      <>
                        <Button
                          type="button"
                          variant="secondary"
                          onClick={() => setStep('export')}
                          disabled={coverageLoading}
                        >
                          Skip to export
                        </Button>
                        {showEnrichOptions && (
                          <Button
                            type="button"
                            onClick={() => void startEnrich()}
                            disabled={
                              submitting ||
                              coverageLoading ||
                              noEnrichWorkSelected
                            }
                            loading={submitting}
                            className="min-w-40 justify-center"
                          >
                            {enrichCta}
                          </Button>
                        )}
                      </>
                    ) : enrichFailed ? (
                      // A failure left `jobId` set, and everything that offers
                      // a re-submit is gated on `!jobId` — clearing it here
                      // restores the options in place.
                      <>
                        <Button
                          type="button"
                          variant="secondary"
                          onClick={() => {
                            setJobId(null);
                            setStep('export');
                          }}
                        >
                          Skip to export
                        </Button>
                        <Button
                          type="button"
                          onClick={() => setJobId(null)}
                          className="min-w-28 justify-center"
                        >
                          Try again
                        </Button>
                      </>
                    ) : (
                      <Button
                        type="button"
                        onClick={() => {
                          if (enrichDone) setStep('export');
                        }}
                        disabled={running}
                        className="min-w-28 justify-center"
                      >
                        {running ? 'Running…' : 'Continue'}
                      </Button>
                    )}
                  </div>
                )}

                {step === 'export' && (
                  <div className="flex flex-col-reverse gap-2 sm:flex-row">
                    {showExportEnrichFirst ? (
                      <Button
                        type="button"
                        onClick={() => setStep('enrich')}
                        disabled={exporting}
                        className="min-w-32 justify-center"
                      >
                        Enrich first
                      </Button>
                    ) : coverage && coverage.totalPeople > 0 && canEnrich ? (
                      <Button
                        type="button"
                        variant="secondary"
                        onClick={() => setStep('enrich')}
                        disabled={exporting}
                      >
                        Back
                      </Button>
                    ) : null}
                    <Button
                      type="button"
                      variant={showExportEnrichFirst ? 'secondary' : 'default'}
                      onClick={() => void downloadCsv()}
                      disabled={
                        exporting ||
                        coverageLoading ||
                        !coverage ||
                        coverage.totalPeople === 0
                      }
                      loading={exporting}
                      className="min-w-32 justify-center"
                    >
                      {showExportEnrichFirst ? 'Export anyway' : 'Download'}
                      <Download />
                    </Button>
                  </div>
                )}
              </DialogFooter>
            </>
          )}
        </DialogContent>
      </Dialog>
    );
  }

  /* ======================================================================
   * pages/UserLists.tsx — /user/lists overview
   * ==================================================================== */

  // Preview caps sized to fit the table columns (~200px Accounts / ~260px
  // People) so the overlapping strip never bleeds into the next column; the
  // rest roll up into the "+N" chip.
  const LOGO_CAP = 6;
  const FACE_CAP = 8;

  function AccountsPreview({ list }) {
    const { LogoAvatar, getInitials } = T.UI;
    const accounts = useMemo(() => {
      const seen = new Map();
      for (const m of list.members) {
        // TRA-1359: account-kind members carry their own meta; person rows
        // contribute their person's account. Exclusions don't preview.
        if (m.excluded) continue;
        const a = m.kind === 'account' ? m.account : m.person?.account;
        if (a && !seen.has(a.id)) seen.set(a.id, a);
      }
      return [...seen.values()];
    }, [list.members]);

    if (accounts.length === 0) {
      return <span className="text-meta text-text-muted">-</span>;
    }
    return (
      <span className="hl-logos">
        {accounts.slice(0, LOGO_CAP).map((a) => (
          <LogoAvatar
            key={a.id}
            src={a.logoUrl}
            domain={a.url}
            alt={a.name}
            fallbackText={getInitials(a.name)}
            size="sm"
            className="size-[26px] rounded-[6px]"
          />
        ))}
        {accounts.length > LOGO_CAP && (
          <span className="hl-chip relative z-[1] grid h-[26px] min-w-[26px] place-items-center rounded-[6px] bg-surface-well px-1 text-2xs font-bold text-text-muted ring-1 ring-border-strong">
            +{T.compactCount(accounts.length - LOGO_CAP)}
          </span>
        )}
      </span>
    );
  }

  function PeoplePreview({ list, resolution, resolving }) {
    const PersonAvatar = T.UI.PersonAvatar;
    // TRA-1359 step 5: preview the people the list actually RESOLVES to —
    // pinned people plus the contacts its dynamic account members resolve to
    // (ranked, capped by each account's lever) — matching the drawer.
    const people = resolution?.recipients ?? [];
    if (people.length === 0) {
      // While the roster for account members is still in flight, a list with
      // account members is "resolving", not empty — don't flash "Empty".
      if (resolving && T.listComposition(list.members).accounts > 0) {
        return <span className="text-meta text-text-muted">Resolving…</span>;
      }
      return <span className="text-meta text-text-muted">Empty</span>;
    }
    return (
      <span className="hl-faces">
        {people.slice(0, FACE_CAP).map((p) => (
          <PersonAvatar
            key={p.personId}
            src={p.profileImageUrl}
            personId={p.personId}
            name={p.fullName}
            className="hdw-face size-[26px]"
          />
        ))}
        {people.length > FACE_CAP && (
          <span className="hl-chip relative z-[1] grid h-[26px] min-w-[26px] place-items-center rounded-full bg-surface-well px-1 text-2xs font-bold text-text-muted ring-1 ring-border-strong">
            +{T.compactCount(people.length - FACE_CAP)}
          </span>
        )}
      </span>
    );
  }

  /** Compose is not ported: every entry point takes the app's flag-off path. */
  const composeNotPorted = () =>
    T.toast('The sequence composer is not part of this prototype.');

  function UserLists() {
    T.useRegisterPageContext({ route: '/user/lists', entity: null });
    const {
      MobileSidebarTrigger,
      PageContainer,
      TableScrollRegion,
      Button,
      ConfirmDialog,
    } = T.UI;
    const {
      Bookmark,
      BookmarkFilled,
      ChevronDown,
      Download,
      ListFilter,
      Loader2,
      Mail,
      Sparkles,
      Trash2,
    } = T.Icons;
    const navigate = T.Router.useNavigate();
    const pb = T.useAudience();
    // useContextValidation / usePermissions are auth surfaces (CONVENTIONS
    // rule 8): the fixture tenant+user always satisfy hasRequiredContext, and
    // the fixture user is an admin → both enrich permissions hold. Ids match
    // the MainLayout fixtures so the scratch provider keys stay consistent.
    const currentCompany = { id: 'tenant-acme' };
    const currentUser = { id: 'user-alex' };
    const canEnrich = true;

    const useLists = T.useLists || T.Data.useLists;
    const { lists, loading, deleteList } = useLists();
    // Resolve each list's people preview + count from ONE batched roster
    // fetch, reusing the drawer's audience machinery so the row and the
    // drawer agree about dynamic account members (TRA-1359 step 5).
    const { byList: resolutions, resolving } = T.useListResolutions(lists);

    // The list open in the drawer. In the app a `stage` flag advances the
    // drawer to the ComposeDrawer; with the composer not ported the drawer
    // only ever shows the review stage (AudienceDrawer), and compose entry
    // points toast (flag-off behavior).
    const [openListId, setOpenListId] = useState(null);
    const [deleteTarget, setDeleteTarget] = useState(null);
    const [deleting, setDeleting] = useState(false);
    const [contactsTarget, setContactsTarget] = useState(null);

    // useComposeCampaign / useComposeReentry (TRA-1311/TRA-1437 OAuth-hop
    // resume) are composer surfaces — not ported.
    const startCompose = composeNotPorted;

    const openList = openListId
      ? (lists.find((l) => l.id === openListId) ?? null)
      : null;
    const closeDrawer = () => {
      setOpenListId(null);
    };

    // Focus lives in the audience context, not the URL, so View sets it and
    // then navigates to the plain People route.
    const focus = (l) => {
      // Scoping from the Lists page always lands on People, so the table is known.
      T.homeAnalytics.listFocused({ list_id: l.id, table: 'people' });
      pb.setFocusedList(l.id);
      void navigate('/user/home/people');
    };
    const view = (l) => {
      setOpenListId(l.id);
    };
    const startOutreach = (l) => {
      setOpenListId(l.id);
      // The same conversion moment as Home's. Sizes come off the LIST's
      // membership, not `pb`: the composer here runs against a nested scratch
      // audience seeded from this list.
      const members = l.members ?? [];
      T.homeAnalytics.composeStarted({
        audience_people: members.filter((m) => m.kind === 'person').length,
        audience_accounts: members.filter((m) => m.kind === 'account').length,
        from_list: true,
      });
      startCompose();
    };
    const startEnrich = (l) => setContactsTarget({ list: l, entry: 'enrich' });
    const startExport = (l) => setContactsTarget({ list: l, entry: 'auto' });
    const confirmDelete = async () => {
      if (!deleteTarget) return;
      setDeleting(true);
      try {
        await deleteList(deleteTarget.id);
        T.homeAnalytics.listDeleted({ list_id: deleteTarget.id });
        if (pb.linkedListId === deleteTarget.id) pb.unlink();
        if (openListId === deleteTarget.id) closeDrawer();
        T.toast('List deleted. People and accounts are untouched.');
        setDeleteTarget(null);
      } finally {
        setDeleting(false);
      }
    };

    const AudienceProvider = T.AudienceProvider;
    const AudienceDrawer = T.AudienceDrawer;
    const DrawerShell = T.DrawerShell;

    return (
      <PageContainer width="page" className="flex h-full min-h-0 flex-col">
        {/* Title, count and blurb share one baseline-aligned row. `flex-wrap`
            keeps the blurb from being squeezed on narrow viewports — it drops
            to its own line instead, which is what `gap-y-1` restores the old
            stacked spacing for. */}
        <div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
          {/* Mobile nav opener — see UserHome. Hand-rolled title row, so the
              burger is explicit rather than inherited from PageHeader. */}
          <MobileSidebarTrigger className="-my-1 self-center" />
          <h1 className="text-page-title text-text-primary">
            Lists
            {lists.length > 0 && <span className="hl-headct">{lists.length}</span>}
          </h1>
          <p className="text-body text-text-secondary">
            Lists you saved. Use one for outreach, or filter Home by it.
          </p>
        </div>

        {/* The card is the drawer's positioning context (like Home's
            htbl-card), so the list drawer spans the table, not the page. */}
        <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden htbl-card">
          {/* TRA-1428: see PeopleTab — one scroller for both axes (sticky
              header), affordance on the non-scrolling anchor. */}
          <TableScrollRegion
            rootClassName="flex min-h-0 flex-1 flex-col"
            className="min-h-0 flex-1 overflow-y-auto"
          >
            {loading && lists.length === 0 ? (
              <div className="flex items-center justify-center py-16 text-text-muted">
                <Loader2 className="mr-2 size-4 animate-spin" />
                Loading lists
              </div>
            ) : lists.length === 0 ? (
              <div className="flex flex-col items-center gap-3 px-6 py-20 text-center">
                <span className="grid size-14 place-items-center rounded-full border border-dashed border-border-strong text-text-muted">
                  <Bookmark className="size-7 **:stroke-1" />
                </span>
                <span className="text-section text-text-primary">No lists yet</span>
                <p className="max-w-sm text-body text-text-secondary">
                  Build a list on Home, then choose Save list in the
                  bar at the bottom.
                </p>
              </div>
            ) : (
              <table className="htbl hl-table htbl-w-lists">
                <colgroup>
                  <col className="htbl-c-listname" />
                  <col className="htbl-c-logos" />
                  <col className="htbl-c-people" />
                  <col className="htbl-c-acts" />
                  <col className="htbl-c-tw" />
                </colgroup>
                <thead>
                  <tr>
                    <th className="htbl-c-listname">List</th>
                    <th className="htbl-c-logos">Accounts</th>
                    <th className="htbl-c-people">People</th>
                    <th className="htbl-c-acts" />
                    <th className="htbl-c-tw" />
                  </tr>
                </thead>
                <tbody>
                  {lists.map((l) => (
                    <tr
                      key={l.id}
                      onClick={() => view(l)}
                      className={`htbl-row hl-row${openListId === l.id ? ' is-open' : ''}`}
                    >
                      <td>
                        <span className="htbl-acct">
                          {/* Filled, not outline: every row here IS a saved
                              list, matching the AudienceBar convention where
                              the solid glyph marks a draft that's linked to
                              one. */}
                          <span className="hl-glyph">
                            <BookmarkFilled />
                          </span>
                          <span className="htbl-acct-t">
                            <span className="htbl-acct-name">{l.name}</span>
                            {/* Composition with the RESOLVED people count: the
                                raw row count under-counts dynamic account
                                members (TRA-1359). */}
                            <span className="text-meta text-text-muted">
                              {T.compositionLabel(
                                T.listComposition(l.members).accounts,
                                resolutions.get(l.id)?.peopleCount ??
                                  T.listComposition(l.members).people,
                              )}
                            </span>
                          </span>
                        </span>
                      </td>
                      <td>
                        <AccountsPreview list={l} />
                      </td>
                      <td>
                        <PeoplePreview
                          list={l}
                          resolution={resolutions.get(l.id)}
                          resolving={resolving}
                        />
                      </td>
                      <td onClick={(e) => e.stopPropagation()}>
                        <span className="hl-acts">
                          {/* `size='xs'` (h-7, 28px) lines the circle up with
                              the `.hdw-cta.sm` pills beside it. `hl-del` is
                              only the hook for the reveal-on-row-hover rule in
                              drawer.css. */}
                          <Button
                            variant="destructive-quiet"
                            size="xs"
                            className="hl-del size-7 p-0"
                            onClick={() => setDeleteTarget(l)}
                            title="Delete list (never touches people or accounts)"
                          >
                            <span className="sr-only">Delete list</span>
                            <Trash2 />
                          </Button>
                          {canEnrich && (
                            <>
                              <button
                                type="button"
                                className="hdw-cta sm ghost"
                                onClick={() => startEnrich(l)}
                                title="Find missing emails and phones"
                              >
                                <Sparkles />
                                Enrich
                              </button>
                            </>
                          )}
                          <button
                            type="button"
                            className="hdw-cta sm ghost"
                            onClick={() => startExport(l)}
                            title="Export contacts as CSV"
                          >
                            <Download />
                            Export
                          </button>
                          <button
                            type="button"
                            className="hdw-cta sm second"
                            onClick={() => focus(l)}
                            title="View this list on Home"
                          >
                            <ListFilter />
                            View
                          </button>
                          <button
                            type="button"
                            className="hdw-cta sm"
                            onClick={() => startOutreach(l)}
                          >
                            <Mail />
                            Outreach
                          </button>
                        </span>
                      </td>
                      <td>
                        <span className="htbl-tw">
                          <ChevronDown />
                        </span>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            )}
          </TableScrollRegion>

          {openList && currentCompany && (
            <AudienceProvider
              /* keyed by list so switching lists reseeds the scratch state */
              key={openList.id}
              tenantId={currentCompany.id}
              userId={currentUser?.id ?? 'tenant'}
              scratch={{
                audience: T.membersToAudience(openList.members),
                linkedListId: openList.id,
              }}
            >
              <DrawerShell label={openList.name} onClose={closeDrawer}>
                {/* Review stage only — the compose stage is the ComposeDrawer,
                    not ported; `onStart` toasts (flag-off). */}
                <AudienceDrawer onClose={closeDrawer} onStart={startCompose} />
              </DrawerShell>
            </AudienceProvider>
          )}
        </div>

        <ConfirmDialog
          open={!!deleteTarget}
          onOpenChange={(o) => !o && setDeleteTarget(null)}
          title="Delete this list?"
          desc="This removes the list only. It never touches the people or accounts on it."
          confirmText="Delete list"
          destructive
          isLoading={deleting}
          handleConfirm={confirmDelete}
        />

        <ListContactsDialog
          key={contactsTarget ? `${contactsTarget.list.id}-${contactsTarget.entry}` : 'closed'}
          list={contactsTarget?.list ?? null}
          entry={contactsTarget?.entry ?? 'auto'}
          open={!!contactsTarget}
          onOpenChange={(o) => !o && setContactsTarget(null)}
        />
      </PageContainer>
    );
  }

  /* ======================================================================
   * UserList — /user/lists/:listId detail (synthesized; see header note).
   *
   * Table skeleton follows the app's standard list-page pattern (the file the
   * harness maps here): PageHeader + back link, TableChrome (search + pager)
   * around DataTable, OverflowMenu row actions, ConfirmDialog for delete.
   * Roster = the people the list RESOLVES to (same audience machinery as the
   * overview and the drawer, TRA-1359), fetched through the Home people query
   * scoped by resolvedPeopleScope. Row click opens the same Person/Account/
   * Signal drawers Home uses, on the shell's working AudienceProvider.
   * ==================================================================== */

  const DETAIL_PAGE_SIZE = 25;

  function UserList() {
    T.useRegisterPageContext({ route: '/user/lists', entity: null });
    const {
      PageContainer,
      PageHeader,
      PageHeaderBackLink,
      Button,
      Input,
      searchFieldClassName,
      LogoAvatar,
      getInitials,
      PersonAvatar,
      DataTable,
      TableChrome,
      OverflowMenu,
      OverflowMenuItem,
      ConfirmDialog,
    } = T.UI;
    const {
      ChevronLeft,
      Download,
      ExternalLink,
      ListFilter,
      Mail,
      Search,
      Sparkles,
      Trash2,
    } = T.Icons;
    const { Link } = T.Router;
    const navigate = T.Router.useNavigate();
    const { listId } = T.Router.useParams();
    const pb = T.useAudience();
    // Auth surfaces skipped (rule 8): fixture admin.
    const canEnrich = true;

    const useLists = T.useLists || T.Data.useLists;
    const { loading, updateList, deleteList } = useLists();
    const { list } = T.Data.useListDetail(listId);

    // Resolve THIS list with the same machinery as the overview so the roster
    // here equals the "M people" the drawer and the row label report.
    const listArr = useMemo(() => (list ? [list] : []), [list]);
    const { byList: resolutions, resolving } = T.useListResolutions(listArr);
    const resolution = list ? resolutions.get(list.id) : undefined;
    const recipientIds = useMemo(
      () => (resolution ? resolution.recipients.map((r) => r.personId) : []),
      [resolution],
    );
    const scopeOr = useMemo(
      () => T.resolvedPeopleScope(recipientIds),
      [recipientIds],
    );
    const { data: peopleData } = T.Data.useHomePeople({ scopeOr });
    // Keep the resolver's ranked order, not the query's.
    const roster = useMemo(() => {
      const byId = new Map();
      for (const p of peopleData?.people ?? []) byId.set(p.id, p);
      return recipientIds.map((id) => byId.get(id)).filter(Boolean);
    }, [peopleData, recipientIds]);

    const [search, setSearch] = useState('');
    const [page, setPage] = useState(1);
    // Reset to the first page whenever the filter changes. Setting state
    // during render (the React-recommended "adjust state when a value
    // changes" pattern) and kept above the guard returns.
    const [prevSearch, setPrevSearch] = useState(search);
    if (search !== prevSearch) {
      setPrevSearch(search);
      setPage(1);
    }

    const visibleRoster = useMemo(() => {
      const q = search.trim().toLowerCase();
      if (!q) return roster;
      return roster.filter(
        (p) =>
          (p.fullName ?? '').toLowerCase().includes(q) ||
          (p.title ?? '').toLowerCase().includes(q) ||
          (p.email ?? '').toLowerCase().includes(q) ||
          (p.account?.name ?? '').toLowerCase().includes(q),
      );
    }, [roster, search]);

    const [contactsTarget, setContactsTarget] = useState(null);
    const [deleteOpen, setDeleteOpen] = useState(false);
    const [deleting, setDeleting] = useState(false);

    // Cross-navigable detail drawer (person ↔ account ↔ signal), one level of
    // "back" per hop — the same DrawerShell + bodies Home mounts.
    const [detail, setDetail] = useState(null);
    const closeDetail = () => setDetail(null);
    const openPerson = (person) =>
      setDetail((cur) => ({ type: 'person', person, from: cur || null }));
    const openAccount = (account) =>
      setDetail((cur) => ({ type: 'account', account, from: cur || null }));
    const openSignal = (event) =>
      setDetail((cur) => ({ type: 'signal', event, from: cur || null }));
    const goBack = () => setDetail((cur) => (cur && cur.from) || null);
    const fromLabel = (from) =>
      !from
        ? undefined
        : from.type === 'person'
          ? from.person.fullName
          : from.type === 'account'
            ? from.account.name
            : 'Signal';
    const accountShape = (acct) => ({
      id: acct.id,
      name: acct.name,
      url: acct.url ?? null,
      logoUrl: acct.logoUrl ?? null,
      oneLiner: null,
      employeeCount: null,
      annualRevenueFrom: null,
      annualRevenueTo: null,
      ownership: null,
      hqLocation: null,
      timingScore: null,
      createdAt: '',
    });

    const focusOnHome = () => {
      if (!list) return;
      T.homeAnalytics.listFocused({ list_id: list.id, table: 'people' });
      pb.setFocusedList(list.id);
      void navigate('/user/home/people');
    };

    const startOutreach = () => {
      if (!list) return;
      const members = list.members ?? [];
      T.homeAnalytics.composeStarted({
        audience_people: members.filter((m) => m.kind === 'person').length,
        audience_accounts: members.filter((m) => m.kind === 'account').length,
        from_list: true,
      });
      composeNotPorted();
    };

    const confirmDelete = async () => {
      if (!list) return;
      setDeleting(true);
      try {
        await deleteList(list.id);
        T.homeAnalytics.listDeleted({ list_id: list.id });
        if (pb.linkedListId === list.id) pb.unlink();
        T.toast('List deleted. People and accounts are untouched.');
        setDeleteOpen(false);
        void navigate('/user/lists');
      } finally {
        setDeleting(false);
      }
    };

    // Membership edit: a pinned person is removed outright; a person resolved
    // from a dynamic account member gets an exclusion row (via: 'account'),
    // the same member shape SaveListControl writes.
    const memberInput = (m) => ({
      kind: m.kind,
      personId: m.person ? m.person.id : undefined,
      accountId: m.accountId ?? undefined,
      via: m.via,
      per: m.per ?? undefined,
      eventId: m.eventId ?? undefined,
      excluded: m.excluded,
    });
    const removePerson = async (p) => {
      if (!list) return;
      const pinned = list.members.some(
        (m) => m.kind === 'person' && m.person?.id === p.id && !m.excluded,
      );
      const next = list.members
        .filter((m) => !(m.kind === 'person' && m.person?.id === p.id))
        .map(memberInput);
      if (!pinned) {
        next.push({ kind: 'person', personId: p.id, accountId: p.accountId, via: 'account', excluded: true });
      }
      await updateList(list.id, { members: next });
      T.toast(pinned ? 'Removed from list.' : 'Excluded from this list.');
    };

    const backLink = (
      <PageHeaderBackLink asChild>
        <Link to="/user/lists">
          <ChevronLeft size={16} />
          <span>Back to lists</span>
        </Link>
      </PageHeaderBackLink>
    );

    if (!list) {
      if (loading) return null;
      return (
        <PageContainer width="page" className="space-y-4">
          <PageHeader
            title="List not found"
            subtitle="This list may have been deleted."
            back={backLink}
          />
          <Button variant="secondary" onClick={() => void navigate('/user/lists')}>
            <ChevronLeft size={16} />
            All lists
          </Button>
        </PageContainer>
      );
    }

    const composition = T.listComposition(list.members);
    const total = visibleRoster.length;
    const pageRows = visibleRoster.slice(
      (page - 1) * DETAIL_PAGE_SIZE,
      page * DETAIL_PAGE_SIZE,
    );

    const renderRowActions = (p) => (
      <OverflowMenu label="Person actions" contentClassName="min-w-[200px]">
        <OverflowMenuItem onSelect={() => openPerson(p)}>
          <ExternalLink size={14} />
          View person
        </OverflowMenuItem>
        <OverflowMenuItem
          variant="destructive"
          onSelect={() => void removePerson(p)}
        >
          <Trash2 size={14} />
          {list.members.some(
            (m) => m.kind === 'person' && m.person?.id === p.id && !m.excluded,
          )
            ? 'Remove from list'
            : 'Exclude from list'}
        </OverflowMenuItem>
      </OverflowMenu>
    );

    const columns = [
      {
        id: 'name',
        header: 'Name',
        width: '28%',
        accessor: (p) => (
          <span className="flex items-center gap-2.5">
            <PersonAvatar
              src={p.profileImageUrl}
              personId={p.id}
              name={p.fullName}
              className="size-7"
            />
            <button
              type="button"
              onClick={() => openPerson(p)}
              className="cursor-pointer text-left text-name-sm text-text-primary hover:text-accent-text transition-colors underline-offset-2 hover:underline"
              title={`View ${p.fullName}`}
            >
              {p.fullName}
            </button>
          </span>
        ),
      },
      {
        id: 'title',
        header: 'Title',
        width: '24%',
        accessor: (p) => p.title || '-',
      },
      {
        id: 'company',
        header: 'Company',
        width: '20%',
        accessor: (p) =>
          p.account ? (
            <span className="flex items-center gap-2">
              <LogoAvatar
                src={p.account.logoUrl}
                domain={p.account.url}
                alt={p.account.name}
                fallbackText={getInitials(p.account.name)}
                size="sm"
                className="size-[22px] rounded-[5px]"
              />
              <span className="truncate">{p.account.name}</span>
            </span>
          ) : (
            '-'
          ),
      },
      {
        id: 'email',
        header: 'Email',
        width: '18%',
        accessor: (p) =>
          p.email ? (
            p.email
          ) : T.emailStatus(p.email, p.enrichmentState) === 'pending' ? (
            <span className="text-text-muted">Finding…</span>
          ) : (
            '-'
          ),
      },
      {
        id: 'lastContacted',
        header: 'Last contacted',
        width: '13%',
        accessor: (p) =>
          p.lastContactedAt
            ? new Date(p.lastContactedAt).toLocaleDateString()
            : '-',
      },
      {
        id: 'actions',
        header: 'Actions',
        width: '5%',
        align: 'center',
        accessor: renderRowActions,
      },
    ];

    const DrawerShell = T.DrawerShell;
    const PersonDrawer = T.PersonDrawer;
    const AccountDrawer = T.AccountDrawer;
    const SignalDrawer = T.SignalDrawer;

    return (
      <PageContainer width="page" className="flex h-full min-h-0 flex-col space-y-4">
        <PageHeader
          title={list.name}
          subtitle={T.compositionLabel(
            composition.accounts,
            resolution?.peopleCount ?? composition.people,
          )}
          back={backLink}
          actions={
            <>
              {canEnrich && (
                <Button
                  variant="secondary"
                  onClick={() => setContactsTarget('enrich')}
                  title="Find missing emails and phones"
                >
                  <Sparkles />
                  Enrich
                </Button>
              )}
              <Button
                variant="secondary"
                onClick={() => setContactsTarget('auto')}
                title="Export contacts as CSV"
              >
                <Download />
                Export
              </Button>
              <Button onClick={startOutreach}>
                <Mail />
                Outreach
              </Button>
              <OverflowMenu label="List actions" contentClassName="min-w-[200px]">
                <OverflowMenuItem onSelect={focusOnHome}>
                  <ListFilter size={14} />
                  View on Home
                </OverflowMenuItem>
                <OverflowMenuItem
                  variant="destructive"
                  onSelect={() => setDeleteOpen(true)}
                >
                  <Trash2 size={14} />
                  Delete list
                </OverflowMenuItem>
              </OverflowMenu>
            </>
          }
        />

        {/* Positioning context for the detail drawer (spans the table, like
            Home's htbl-card). Menus/dialogs escape via portals. */}
        <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
          <TableChrome
            total={total}
            page={page}
            pageSize={DETAIL_PAGE_SIZE}
            onPageChange={setPage}
            search={
              <Input
                icon={<Search />}
                className={searchFieldClassName}
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder="Search people"
                aria-label="Search people"
              />
            }
          >
            <DataTable
              columns={columns}
              rows={pageRows}
              getRowKey={(p) => p.id}
              loading={resolving && roster.length === 0}
              empty={
                <div className="p-8 text-center text-text-muted">
                  {roster.length === 0
                    ? 'This list resolves to no people.'
                    : 'No people match your search.'}
                </div>
              }
            />
          </TableChrome>

          {detail && (
            <DrawerShell
              key="detail"
              label={
                detail.type === 'signal'
                  ? 'Signal'
                  : detail.type === 'person'
                    ? detail.person.fullName
                    : detail.account.name
              }
              onClose={closeDetail}
            >
              {detail.type === 'person' && (
                <PersonDrawer
                  person={detail.person}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenSignal={openSignal}
                  onOpenAccount={(acct) => openAccount(accountShape(acct))}
                />
              )}
              {detail.type === 'account' && (
                <AccountDrawer
                  account={detail.account}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenSignal={openSignal}
                  onOpenPerson={(person) => openPerson(person)}
                />
              )}
              {detail.type === 'signal' && (
                <SignalDrawer
                  event={detail.event}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenPerson={(person) => openPerson(person)}
                  onOpenAccount={(acct) => openAccount(accountShape(acct))}
                />
              )}
            </DrawerShell>
          )}
        </div>

        <ConfirmDialog
          open={deleteOpen}
          onOpenChange={(o) => !o && setDeleteOpen(false)}
          title="Delete this list?"
          desc="This removes the list only. It never touches the people or accounts on it."
          confirmText="Delete list"
          destructive
          isLoading={deleting}
          handleConfirm={confirmDelete}
        />

        <ListContactsDialog
          key={contactsTarget ? `${list.id}-${contactsTarget}` : 'closed'}
          list={contactsTarget ? list : null}
          entry={contactsTarget ?? 'auto'}
          open={!!contactsTarget}
          onOpenChange={(o) => !o && setContactsTarget(null)}
        />
      </PageContainer>
    );
  }

  Object.assign(window.T, {
    // pages
    UserLists,
    UserList,
    // ListContactsDialog.tsx
    ListContactsDialog,
    // list-contacts-model.ts
    initialContactsStep,
    resolveContactsStep,
    listCoverageLine,
    enrichCreditCost,
    remainingEnrichGaps,
    listContactsErrorMessage,
    EXPORT_FIELDS,
    // list-csv.ts
    listRowsToCsv,
    downloadListCsv,
    // useListEnrichJobProgress.ts
    projectListEnrichJob,
    registerListEnrichSim,
    useListEnrichJobProgress,
  });
})();
