/* Ported from apps/web/src/features/home/audience/
 *   audience-types.ts, audience-merge.ts, ranking.ts, recent-outreach.ts,
 *   AudienceProvider.tsx, AudienceBar.tsx, AudienceDrawer.tsx
 * (formatContactedDate inlined from apps/web/src/components/ContactedStatus.tsx).
 * Data substitution: GET_AUDIENCE_PEOPLE → T.Data.useListPeople,
 * GET_AUDIENCE_EVENTS → T.Data.useListEvents, GET_HOME_ACCOUNT_COUNTS →
 * T.Data.useHomeAccountCounts, GET_ACCOUNTS_BY_IDS → T.Data.raw.acctLite.
 * Audience state persists to localStorage `t-skeleton-audience` (rule 6). */
(() => {
  const T = window.T;
  const {
    useState,
    useEffect,
    useMemo,
    useRef,
    useCallback,
    createContext,
    useContext,
  } = React;
  const cn = T.cn;
  const homeAnalytics = T.homeAnalytics;
  /* analytics.ts `asVia` narrows to the known via union — identity in the port. */
  const asVia = (via) => via;

  /* ==================== audience-types.ts ==================== */
  // Client-side audience/list builder state (TRA-1298). IDs are the mock
  // dataset's real accountId / personId / eventId.

  const EMPTY_AUDIENCE = {
    accounts: {},
    people: {},
    excl: {},
    fresh: {},
    meta: {},
    peopleMeta: {},
    selectedEventIds: {},
    linkedListId: null,
    savedSnapshot: null,
    savedMembership: null,
  };

  /** The membership half of an audience — what a List persists, and the only
   *  part Reset restores. */
  function audienceMembership(a) {
    return { accounts: a.accounts, people: a.people, excl: a.excl };
  }

  /**
   * Remove only the whole-account membership owned by an account-only signal.
   * Explicit people in the same account are independent and must remain. When a
   * sibling account-only signal is still selected, ownership transfers to it.
   */
  function releaseSignalAccountOwnership(
    audience,
    accountId,
    eventId,
    replacementEventId,
  ) {
    const entry = audience.accounts[accountId];
    const signalOwned = entry?.via === 'signal-table' || entry?.via === 'signal';
    if (!entry || !signalOwned || entry.eventId !== eventId) return audience;
    const accounts = { ...audience.accounts };
    if (replacementEventId) {
      accounts[accountId] = { ...entry, eventId: replacementEventId };
    } else {
      delete accounts[accountId];
    }
    return { ...audience, accounts };
  }

  function releaseSignalPersonOwnership(
    audience,
    personId,
    eventId,
    replacementEventId,
  ) {
    const entry = audience.people[personId];
    const signalOwned = entry?.via === 'signal-table' || entry?.via === 'signal';
    if (!entry || !signalOwned || entry.eventId !== eventId) return audience;
    const people = { ...audience.people };
    if (replacementEventId) {
      people[personId] = { ...entry, eventId: replacementEventId };
    } else {
      delete people[personId];
    }
    return { ...audience, people };
  }

  /** How many contacts Trayo picks per whole-account entry. */
  const LEVER_PER = { best: 1, key3: 3 };
  const leverPer = (lever) => LEVER_PER[lever];

  /** Which cohort-wide contacts option is "on" in the audience drawer's seg:
   *  1 / 3 / 99 when EVERY whole-account entry carries that per (any per >= 99
   *  reads as "All"), 'mixed' when entries disagree, null when there are no
   *  whole-account entries (seg hidden). */
  function cohortPerState(accounts) {
    const pers = Object.values(accounts).map((e) => (e.per >= 99 ? 99 : e.per));
    if (pers.length === 0) return null;
    const first = pers[0];
    if (pers.some((p) => p !== first)) return 'mixed';
    return first === 1 || first === 3 || first === 99 ? first : 'mixed';
  }

  /** Stable JSON of the audience MEMBERSHIP (accounts/people/excl) - the link
   *  fields are excluded so dirty-tracking compares content, not the link.
   *  Normalized: sorted by id; seq AND provenance (via/eventId) omitted. */
  function audienceSnapshot(a) {
    const accounts = Object.entries(a.accounts)
      .map(([id, e]) => [id, e.per])
      .sort((x, y) => x[0].localeCompare(y[0]));
    const people = Object.entries(a.people)
      .map(([id, e]) => [id, e.accountId])
      .sort((x, y) => x[0].localeCompare(y[0]));
    const excl = Object.keys(a.excl).sort();
    return JSON.stringify({ accounts, people, excl });
  }

  /* ==================== audience-merge.ts ==================== */
  // Pure bulk-merge for the audience state (the "Add all N to list" primitive).

  /** Merge an audience slice into `prev` in ONE pass: union accounts/people
   *  (skip already-present), adopt display meta, and carry exclusions
   *  faithfully — without ever vetoing a pick the user already made. */
  function mergeAudienceState(prev, next, source, defaultPer) {
    const accounts = { ...prev.accounts };
    const people = { ...prev.people };
    const meta = { ...prev.meta };
    const peopleMeta = { ...(prev.peopleMeta ?? {}) };
    const excl = { ...prev.excl };
    const selectedEventIds = { ...(prev.selectedEventIds ?? {}) };
    let highestSeq = 0;
    for (const entry of Object.values(accounts)) {
      highestSeq = Math.max(highestSeq, entry.seq ?? 0);
    }
    for (const entry of Object.values(people)) {
      highestSeq = Math.max(highestSeq, entry.seq ?? 0);
    }
    let seq = highestSeq + 1;
    let added = 0;
    let changed = false;

    for (const [id, entry] of Object.entries(next.accounts ?? {})) {
      const held = accounts[id];
      if (held) {
        continue;
      }
      accounts[id] = {
        via: source,
        per: entry?.per ?? defaultPer,
        seq: seq++,
        ...(entry?.eventId ? { eventId: entry.eventId } : {}),
      };
      added++;
      changed = true;
    }
    for (const [id, m] of Object.entries(next.meta ?? {})) {
      if (!meta[id]) meta[id] = m; // first-paint display meta, don't clobber
    }

    const nextExcl = next.excl ?? {};
    for (const [pid, entry] of Object.entries(next.people ?? {})) {
      const positive = !nextExcl[pid];
      const held = people[pid];
      if (!held) {
        people[pid] = {
          via: source,
          accountId: entry.accountId ?? '',
          ...(entry.eventId ? { eventId: entry.eventId } : {}),
          seq: seq++,
        };
        if (positive) added++;
        changed = true;
      }
      // A positive add clears a prior bar exclusion of the same person.
      if (positive && excl[pid]) {
        delete excl[pid];
        changed = true;
      }
    }
    for (const [pid, m] of Object.entries(next.peopleMeta ?? {})) {
      if (!peopleMeta[pid]) peopleMeta[pid] = m;
    }
    // Carry incoming exclusions, but never veto a pick the user already made.
    for (const pid of Object.keys(nextExcl)) {
      if (prev.people[pid] && !prev.excl[pid]) continue;
      if (!excl[pid]) changed = true;
      excl[pid] = true;
    }
    for (const eventId of Object.keys(next.selectedEventIds ?? {})) {
      if (selectedEventIds[eventId]) continue;
      selectedEventIds[eventId] = true;
      changed = true;
    }

    return {
      state: {
        ...prev,
        accounts,
        people,
        meta,
        peopleMeta,
        excl,
        selectedEventIds,
      },
      added,
      changed,
    };
  }

  /* ==================== ranking.ts ==================== */
  // Pure audience resolution (TRA-1298). No data hooks here so it stays a
  // plain function: the provider fetches roster + recent events and hands
  // plain data to resolveAudience().

  const NO_ACCOUNT_GROUP_ID = '__no_account__';

  /** The outreach rollup slice of a person, spread into each ResolvedPerson. */
  const outreachOf = (p) => ({
    lastContactedAt: p.lastContactedAt ?? null,
    lastContactedByName: p.lastContactedByName ?? null,
    contactedByMe: p.contactedByMe ?? false,
  });

  // ---- seniority heuristic (ported verbatim from the mock's seniorityOf) ----

  const SENIORITIES = ['Exec', 'VP / Head', 'Director', 'Other'];

  function seniorityOf(title) {
    const role = title ?? '';
    if (/chief|c[a-z]?o\b|svp|president/i.test(role)) return 'Exec';
    if (/vp|head of|head,/i.test(role)) return 'VP / Head';
    if (/dir\.?|director/i.test(role)) return 'Director';
    return 'Other';
  }

  const seniorityRank = (title) => SENIORITIES.indexOf(seniorityOf(title));

  // ---- event context: recency ordering + per-person reasoning ----

  /** Build the ranking/why context from recent events (already sorted newest
   *  first by the caller). */
  function buildEventContext(events) {
    const eventOrderByAccount = new Map();
    const personEventRank = new Map();
    const reasoningByKey = new Map();
    const firstReasoningByPerson = new Map();

    events.forEach((ev) => {
      if (ev.accountId) {
        const list = eventOrderByAccount.get(ev.accountId) ?? [];
        list.push(ev.id);
        eventOrderByAccount.set(ev.accountId, list);
      }
      // Rank within the account: index of this event in the account's list.
      const rank = ev.accountId
        ? eventOrderByAccount.get(ev.accountId).length - 1
        : Number.MAX_SAFE_INTEGER;
      ev.people.forEach((ep) => {
        const prev = personEventRank.get(ep.personId);
        if (prev === undefined || rank < prev) personEventRank.set(ep.personId, rank);
        if (ep.reasoning) {
          reasoningByKey.set(`${ev.id}:${ep.personId}`, ep.reasoning);
          if (!firstReasoningByPerson.has(ep.personId)) {
            firstReasoningByPerson.set(ep.personId, ep.reasoning);
          }
        }
      });
    });

    return { eventOrderByAccount, personEventRank, reasoningByKey, firstReasoningByPerson };
  }

  /** Rank an account's people: linked to the most recent event first, then by
   *  the seniority heuristic, then name (stable, deterministic). */
  function rankAccountPeople(people, ctx) {
    const rankOf = (p) => ctx.personEventRank.get(p.id) ?? Number.MAX_SAFE_INTEGER;
    return [...people].sort(
      (a, b) =>
        rankOf(a) - rankOf(b) ||
        seniorityRank(a.title) - seniorityRank(b.title) ||
        a.fullName.localeCompare(b.fullName),
    );
  }

  /** Per-person "why": EventPerson.reasoning when the person maps to an event,
   *  else a title-template fallback (no LLM, no invented copy, no em-dash). */
  function whyFor(person, _accountName, ctx, eventId) {
    if (eventId) {
      const r = ctx.reasoningByKey.get(`${eventId}:${person.id}`);
      if (r) return r;
    }
    const linked = ctx.firstReasoningByPerson.get(person.id);
    if (linked) return linked;
    // No real per-event reasoning: just the title (or nothing).
    return person.title ?? '';
  }

  // ---- resolution ----

  function resolveAudience(input) {
    const { audience, lever, roster, accountsMeta, eventCtx } = input;
    const per = leverPer(lever);
    // Display meta captured at add time (guarded for back-compat). Used only
    // as the roster-miss fallback below.
    const peopleMeta = audience.peopleMeta ?? {};

    const personIndex = new Map();
    const peopleByAccount = new Map();
    for (const p of roster) {
      personIndex.set(p.id, p);
      const list = peopleByAccount.get(p.accountId) ?? [];
      list.push(p);
      peopleByAccount.set(p.accountId, list);
    }

    // Display meta captured at add time (add-time meta, or a fresh account's
    // own name/host), so the logo can render before any server resolve lands.
    const captured = (id) => {
      const m = audience.meta[id];
      if (m) return { id, name: m.name, url: m.url, logoUrl: m.logoUrl };
      const f = audience.fresh[id];
      if (f) return { id, name: f.name, url: f.host, logoUrl: null };
      return null;
    };

    const metaFor = (id) => {
      // Live query meta is freshest - prefer it, but backfill blanks from the
      // domain captured at add time so the logo renders immediately (TRA-1359).
      const known = accountsMeta.get(id);
      const fallback = captured(id);
      if (known) {
        return {
          id,
          name: known.name || fallback?.name || id,
          url: known.url ?? fallback?.url ?? null,
          logoUrl: known.logoUrl ?? fallback?.logoUrl ?? null,
        };
      }
      if (id === NO_ACCOUNT_GROUP_ID) {
        return { id, name: 'No account', url: null, logoUrl: null };
      }
      // No query row yet (account with no roster and no recent events): use
      // the captured meta so it never renders as a bare id.
      if (fallback) return fallback;
      return { id, name: id, url: null, logoUrl: null };
    };

    const groups = [];
    const groupFor = (accountId, whole) => {
      let g = groups.find((x) => x.account.id === accountId);
      if (!g) {
        g = { account: metaFor(accountId), whole, people: [] };
        groups.push(g);
      }
      if (whole) g.whole = true;
      return g;
    };
    // Group display order follows add order (min seq across the account's
    // entries), like the bar's strips.
    const groupSeq = new Map();
    const noteSeq = (accountId, seq) => {
      if (seq === undefined) return;
      const prev = groupSeq.get(accountId);
      if (prev === undefined || seq < prev) groupSeq.set(accountId, seq);
    };
    const seen = new Set();

    // Explicit person adds first - they carry signal context.
    for (const [personId, entry] of Object.entries(audience.people)) {
      if (seen.has(personId)) continue;
      const person = personIndex.get(personId);
      // Accountless people still render, grouped under a sentinel.
      const accountId = person?.accountId || entry.accountId || NO_ACCOUNT_GROUP_ID;
      const account = metaFor(accountId);
      const g = groupFor(accountId, false);
      noteSeq(accountId, entry.seq);
      // A person we couldn't hydrate (roster miss) still counts: fall back to
      // the display meta captured at add time (TRA-1359) over the bare id. A
      // roster HIT still wins.
      const pMeta = peopleMeta[personId];
      const lite = person ?? {
        id: personId,
        fullName: pMeta?.fullName ?? personId,
        title: pMeta?.title ?? null,
        profileImageUrl: pMeta?.profileImageUrl ?? null,
        email: null,
        enrichmentState: null,
        accountId,
      };
      g.people.push({
        personId,
        fullName: lite.fullName,
        title: lite.title,
        profileImageUrl: lite.profileImageUrl,
        email: lite.email,
        enrichmentState: lite.enrichmentState,
        accountId,
        ...outreachOf(lite),
        via: entry.via,
        why: whyFor(lite, account.name, eventCtx, entry.eventId),
        on: !audience.excl[personId],
        eventId: entry.eventId,
      });
      seen.add(personId);
    }

    // Whole accounts - Trayo resolves people via the lever (or the per
    // override). The lever decides HOW MANY, and Trayo picks by RANK POSITION:
    // the top `aper` ranks are the quota slots. An exclusion FORFEITS its slot;
    // it does NOT hand it down the ranking (TRA-1431) — the quota is refilled
    // deliberately by setAcctPer / setAllAcctPer, which drop exclusions.
    // Explicit adds (already rendered ON above, hence in `seen`) hold nothing:
    // a pin INSIDE the rank window IS its slot, a pin OUTSIDE it is additive
    // (TRA-1359 FIX #5).
    for (const [accountId, entry] of Object.entries(audience.accounts)) {
      const account = metaFor(accountId);
      const g = groupFor(accountId, true);
      noteSeq(accountId, entry.seq);
      const rosterForAccount = peopleByAccount.get(accountId) ?? [];
      // A fresh account with no roster yet is still researching its people.
      if (audience.fresh[accountId] && rosterForAccount.length === 0) {
        g.researching = true;
      }
      const aper = entry.per || per;
      const ranked = rankAccountPeople(rosterForAccount, eventCtx);
      ranked.forEach((person, rank) => {
        // Explicit pin already emitted above: it holds its slot in rank order
        // without adding a duplicate row, so move on.
        if (seen.has(person.id)) return;
        seen.add(person.id);
        const trayoPick = !audience.excl[person.id] && rank < aper;
        const on = trayoPick;
        g.people.push({
          personId: person.id,
          fullName: person.fullName,
          title: person.title,
          profileImageUrl: person.profileImageUrl,
          email: person.email,
          enrichmentState: person.enrichmentState,
          accountId,
          ...outreachOf(person),
          via: 'trayo',
          why: whyFor(person, account.name, eventCtx),
          on,
          pool: true,
          trayoPick,
        });
      });
    }

    // Every real-account group exposes the rest of its roster as togglable
    // pool people (off by default); then pin the group's display order to the
    // ranked roster so toggling a pool person on doesn't jump its row.
    for (const g of groups) {
      const accountId = g.account.id;
      if (accountId === NO_ACCOUNT_GROUP_ID) continue;
      const ranked = rankAccountPeople(peopleByAccount.get(accountId) ?? [], eventCtx);
      if (!g.whole) {
        for (const person of ranked) {
          if (seen.has(person.id)) continue;
          seen.add(person.id);
          g.people.push({
            personId: person.id,
            fullName: person.fullName,
            title: person.title,
            profileImageUrl: person.profileImageUrl,
            email: person.email,
            enrichmentState: person.enrichmentState,
            accountId,
            ...outreachOf(person),
            via: 'roster',
            why: whyFor(person, g.account.name, eventCtx),
            on: false,
            pool: true,
          });
        }
      }
      const rankIndex = new Map(ranked.map((p, i) => [p.id, i]));
      g.people.sort(
        (a, b) => (rankIndex.get(a.personId) ?? -1) - (rankIndex.get(b.personId) ?? -1),
      );
    }

    groups.sort(
      (a, b) =>
        (groupSeq.get(a.account.id) ?? Number.MAX_SAFE_INTEGER) -
        (groupSeq.get(b.account.id) ?? Number.MAX_SAFE_INTEGER),
    );

    const onPeople = groups.flatMap((g) => g.people.filter((p) => p.on));
    return {
      groups,
      counts: {
        people: onPeople.length,
        accounts: groups.length,
        noEmail: onPeople.filter((p) => !p.email).length,
        researching: groups.filter((g) => g.researching).length,
      },
      recipients: onPeople.map((p) => ({
        ...p,
        account: metaFor(p.accountId),
      })),
    };
  }

  /* ==================== recent-outreach.ts ==================== */
  // TRA-1359 step 8: last-outreach surfaced at decision points. Inform, never
  // block. Date wording rides the shared helpers (T.daysAgo / T.relativeWhen,
  // ported in home-shared.jsx) so all surfaces agree.

  /** Row markers (drawer, cards): anything inside two weeks is worth a glance. */
  const RECENT_ROW_DAYS = 14;
  /** The composer nudge: only same-week touches justify interrupting a send. */
  const RECENT_NUDGE_DAYS = 7;

  /** "May 3" — short month + day (components/ContactedStatus.tsx). */
  function formatContactedDate(value) {
    return new Date(value).toLocaleDateString('en-US', {
      month: 'short',
      day: 'numeric',
    });
  }

  function isRecentlyContacted(lastContactedAt, days, now = Date.now()) {
    if (!lastContactedAt) return false;
    const t = new Date(lastContactedAt).getTime();
    if (Number.isNaN(t) || t > now) return false;
    return T.daysAgo(lastContactedAt, now) <= days;
  }

  /** Recipients contacted within `days`, for the composer nudge + bulk exclude. */
  function recentlyContacted(people, days, now = Date.now()) {
    return people.filter((p) => isRecentlyContacted(p.lastContactedAt, days, now));
  }

  /** "Emailed today" / "Emailed yesterday" / "Emailed 3d ago" / "Emailed Jul 2",
   *  plus "by <name>" when SOMEONE ELSE in the tenant sent it. */
  function outreachMarker(p, now = Date.now()) {
    if (!p.lastContactedAt) return null;
    const t = new Date(p.lastContactedAt).getTime();
    if (Number.isNaN(t)) return null;
    const rel = T.relativeWhen(p.lastContactedAt, now);
    if (!rel) return null;
    // relativeWhen capitalizes ("Today"); mid-sentence it reads better lowered.
    const when = rel === 'Today' ? 'today' : rel === 'Yesterday' ? 'yesterday' : rel;
    const by =
      !p.contactedByMe && p.lastContactedByName ? ` by ${p.lastContactedByName}` : '';
    return `Emailed ${when}${by}`;
  }

  /** People-table cell format: the app-wide contacted short date. */
  function formatOutreach(date) {
    if (!date) return '';
    const t = new Date(date).getTime();
    if (Number.isNaN(t)) return '';
    return formatContactedDate(date);
  }

  /* ==================== AudienceProvider.tsx ==================== */

  const AudienceContext = createContext(null);

  /* App key: `home:audience:${tenantId}:${userId}` → t-skeleton-audience
     (CONVENTIONS rule 6; the prototype has one fixture tenant + user). */
  function storageKey(_tenantId, _userId) {
    return 't-skeleton-audience';
  }

  function loadPersisted(key) {
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return { audience: EMPTY_AUDIENCE, lever: 'best' };
      const parsed = JSON.parse(raw);
      return {
        audience: { ...EMPTY_AUDIENCE, ...(parsed.audience ?? {}) },
        lever: parsed.lever === 'key3' ? 'key3' : 'best',
      };
    } catch (e) {
      return { audience: EMPTY_AUDIENCE, lever: 'best' };
    }
  }

  // Empty query results, hoisted so the skip guards keep stable references.
  const NO_PEOPLE = { people: [] };
  const NO_EVENTS = { events: [] };

  /* Simulated research window (CONVENTIONS rule 7): the app polls discovery
     status server-side; the port holds a fresh (researching) account for a few
     seconds, then settles — with the roster's people when they landed, or with
     zero contacts. */
  const RESEARCH_GRACE_MS = 6000;

  function AudienceProvider({ tenantId, userId, scratch, children }) {
    const key = storageKey(tenantId, userId);
    // Ephemeral list-editing mode: seed from a list's members and never touch
    // localStorage. The Lists page nests one of these around the list drawer.
    const isScratch = !!scratch;
    // Lazy init: scratch seeds from the list (clean snapshot, so dirty-tracking
    // starts at Saved); otherwise localStorage restores the working audience.
    const [{ audience, lever }, setPersisted] = useState(() => {
      if (scratch) {
        const seeded = {
          ...EMPTY_AUDIENCE,
          ...scratch.audience,
          linkedListId: scratch.linkedListId,
        };
        return {
          audience: {
            ...seeded,
            savedSnapshot: audienceSnapshot(seeded),
            savedMembership: audienceMembership(seeded),
          },
          lever: 'best',
        };
      }
      return loadPersisted(key);
    });

    // Single writer: every state mutation goes through here so the
    // localStorage write and the React update stay in lockstep.
    const keyRef = useRef(key);
    useEffect(() => {
      keyRef.current = key;
    }, [key]);
    const commit = useCallback(
      (next) => {
        setPersisted((prev) => {
          const value = typeof next === 'function' ? next(prev) : next;
          if (!isScratch) {
            try {
              localStorage.setItem(keyRef.current, JSON.stringify(value));
            } catch (e) {
              // best-effort persistence
            }
          }
          return value;
        });
      },
      [isScratch],
    );

    const setAudience = useCallback(
      (updater) => {
        commit((prev) => ({ ...prev, audience: updater(prev.audience) }));
      },
      [commit],
    );

    const setLever = useCallback(
      (next) => commit((prev) => ({ ...prev, lever: next })),
      [commit],
    );

    // Scope filter over the three Home tables. In-memory (see the app's
    // AudienceApi doc): a hard refresh starts unfiltered.
    const [focusedListId, setFocusedListId] = useState(null);
    // Focusing a list scopes the tables; it does NOT select its members. The
    // one exception is a hand-off that opts in (the CSV import).
    const [seedFocusedList, setSeedFocusedList] = useState(false);
    const setFocusedList = useCallback((listId, options) => {
      setSeedFocusedList(listId !== null && options?.seedBar === true);
      setFocusedListId(listId);
    }, []);

    // "<Account> added" note above the bar: set on every whole-account add,
    // offers Best/Top 3/All + Undo, auto-dismissed by the bar. createdAt lets
    // the bar show only notes raised after it mounted.
    const [acctNote, setAcctNote] = useState(null);

    // Accounts to fetch = whole-account entries ∪ the accounts of explicit
    // adds ∪ fresh accounts (polled so their people surface once research
    // lands).
    const accountIds = useMemo(() => {
      const ids = new Set(Object.keys(audience.accounts));
      for (const entry of Object.values(audience.people)) {
        if (entry.accountId) ids.add(entry.accountId);
      }
      for (const id of Object.keys(audience.fresh)) ids.add(id);
      return [...ids];
    }, [audience.accounts, audience.people, audience.fresh]);

    const skip = accountIds.length === 0;

    // GET_AUDIENCE_PEOPLE → useListPeople (the mock returns each account's
    // FULL roster, so the app's 50-cap poll + on-demand "All N" full fetch
    // collapse into this one query). Guard the skip case by hand: the mock
    // filter treats an empty accountIds as "no filter".
    const { data: peopleDataRaw, loading: peopleLoading } = T.Data.useListPeople({
      accountIds,
    });
    const peopleData = skip ? NO_PEOPLE : peopleDataRaw;
    // GET_AUDIENCE_EVENTS → useListEvents (ranking + per-person "why").
    const { data: eventsDataRaw, loading: eventsLoading } = T.Data.useListEvents({
      accountIds,
    });
    const eventsData = skip ? NO_EVENTS : eventsDataRaw;
    // GET_HOME_ACCOUNT_COUNTS → authoritative per-account contact totals (the
    // same count the drawer + right sidebar show).
    const { data: audCountsData } = T.Data.useHomeAccountCounts({ ids: accountIds });

    // Fresh accounts' settle (replaces the app's discovery/status polls; the
    // caps and settle windows guard network races the mock doesn't have): a
    // fresh entry clears once the roster covers it — raising the "<Account>
    // added" note so its Best/Top 3/All picker appears at the moment the
    // choice becomes real — or settles-with-zero after the research window.
    const freshAddedAtRef = useRef({});
    const [settleTick, setSettleTick] = useState(0);
    useEffect(() => {
      const freshIds = Object.keys(audience.fresh);
      if (freshIds.length === 0) return undefined;
      const rosterAccountIds = new Set(
        (peopleData?.people ?? [])
          .map((p) => p.accountId ?? p.account?.id)
          .filter(Boolean),
      );
      const now = Date.now();
      const stamps = freshAddedAtRef.current;
      // A restored audience predates this mount: start its window now.
      for (const id of freshIds) if (stamps[id] === undefined) stamps[id] = now;
      const toClear = freshIds.filter(
        (id) => rosterAccountIds.has(id) || now - stamps[id] >= RESEARCH_GRACE_MS,
      );
      if (toClear.length === 0) {
        // Re-check when the earliest research window elapses.
        const wait = Math.max(
          250,
          Math.min(...freshIds.map((id) => RESEARCH_GRACE_MS - (now - stamps[id]))) + 50,
        );
        const t = setTimeout(() => setSettleTick((n) => n + 1), wait);
        return () => clearTimeout(t);
      }
      setAudience((a) => {
        const fresh = { ...a.fresh };
        let changed = false;
        for (const id of toClear) {
          if (fresh[id]) {
            delete fresh[id];
            changed = true;
          }
        }
        return changed ? { ...a, fresh } : a;
      });
      for (const id of toClear) delete stamps[id];
      // Contacts just LANDED for a freshly-added account → raise the
      // "<Account> added" note now. Single note slot: most recent add wins.
      const landed = toClear.filter(
        (id) => rosterAccountIds.has(id) && audience.accounts[id],
      );
      if (landed.length > 0) {
        const target = landed
          .slice()
          .sort(
            (a, b) =>
              (audience.accounts[b]?.seq ?? 0) - (audience.accounts[a]?.seq ?? 0),
          )[0];
        setAcctNote({
          accountId: target,
          per: audience.accounts[target]?.per ?? 1,
          createdAt: Date.now(),
        });
      }
      return undefined;
    }, [peopleData, audience.fresh, audience.accounts, setAudience, settleTick]);

    const res = useMemo(() => {
      const toLite = (p) => ({
        id: p.id,
        fullName: p.fullName,
        title: p.title ?? null,
        profileImageUrl: p.profileImageUrl ?? null,
        email: p.email ?? null,
        enrichmentState: p.enrichmentState ?? null,
        accountId: p.accountId ?? p.account?.id ?? '',
        lastContactedAt: p.lastContactedAt ?? null,
        lastContactedByName: p.lastContactedByName ?? null,
        contactedByMe: p.contactedByMe ?? false,
      });
      const rosterById = new Map();
      (peopleData?.people ?? []).forEach((p) => rosterById.set(p.id, toLite(p)));
      const roster = [...rosterById.values()];

      const accountsMeta = new Map();
      const noteAccount = (a) => {
        if (!a?.id) return;
        accountsMeta.set(a.id, {
          id: a.id,
          name: a.name,
          url: a.url ?? null,
          logoUrl: a.logoUrl ?? null,
        });
      };
      (peopleData?.people ?? []).forEach((p) => noteAccount(p.account));
      (eventsData?.events ?? []).forEach((e) => noteAccount(e.account));
      // Backfill any account the two above missed (no people, no events) —
      // the app's GET_ACCOUNTS_BY_IDS meta query, read from the mock dataset.
      accountIds.forEach((id) => {
        if (!accountsMeta.has(id)) noteAccount(T.Data.raw.acctLite(id));
      });

      const events = (eventsData?.events ?? []).map((e) => ({
        id: e.id,
        accountId: e.accountId ?? e.account?.id ?? null,
        eventDate: e.eventDate ?? null,
        people: (e.people ?? [])
          .filter((ep) => !!ep.person)
          .map((ep) => ({
            personId: ep.person.id,
            reasoning: ep.reasoning ?? null,
          })),
      }));

      return resolveAudience({
        audience,
        lever,
        roster,
        accountsMeta,
        eventCtx: buildEventContext(events),
      });
    }, [audience, lever, peopleData, eventsData, accountIds]);

    // On-people id set + per-account picked ids, derived from `res` UNIONED
    // with the synchronous explicit adds - indicators (checkbox tri-state,
    // "N of M") must never wait on the roster fetch settling.
    const { onKeys, pickedIdsByAccount } = useMemo(() => {
      const keys = new Set();
      const byAccount = new Map();
      const mark = (accountId, personId) => {
        keys.add(personId);
        const set = byAccount.get(accountId) ?? new Set();
        set.add(personId);
        byAccount.set(accountId, set);
      };
      res.groups.forEach((g) =>
        g.people.forEach((p) => {
          if (p.on) mark(p.accountId, p.personId);
        }),
      );
      for (const [personId, entry] of Object.entries(audience.people)) {
        if (!audience.excl[personId] && entry.accountId) {
          mark(entry.accountId, personId);
        }
      }
      return { onKeys: keys, pickedIdsByAccount: byAccount };
    }, [res, audience.people, audience.excl]);

    const rosterCountByAccount = useMemo(() => {
      const counts = new Map();
      (peopleData?.people ?? []).forEach((p) => {
        const id = p.accountId ?? p.account?.id;
        if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
      });
      return counts;
    }, [peopleData]);

    // Authoritative contact total per account (server COUNT in the app).
    const peopleCountByAccount = useMemo(() => {
      const counts = new Map();
      const rows = skip ? [] : (audCountsData?.accountCounts ?? []);
      rows.forEach((c) => {
        if (c.accountId) counts.set(c.accountId, c.peopleCount ?? 0);
      });
      return counts;
    }, [audCountsData, skip]);

    // Running audience size for analytics, reseeded from the RESOLVED audience
    // after every commit (see the app's rationale; T.homeAnalytics is a no-op
    // in the port but every call site is kept).
    const sizeRef = useRef({ people: 0, accounts: 0 });
    useEffect(() => {
      sizeRef.current = { people: res.counts.people, accounts: res.counts.accounts };
    }, [res.counts.people, res.counts.accounts]);
    /** Last list reported by `list_opened`; see the guard in `loadList`. */
    const openedListRef = useRef(null);

    const api = useMemo(() => {
      const perNow = () => LEVER_PER[lever];
      // Audience analytics are emitted HERE, not at the call sites — every way
      // into the bar funnels through this API (instrument the chokepoint).
      const bump = (dPeople, dAccounts) => {
        sizeRef.current = {
          people: Math.max(0, sizeRef.current.people + dPeople),
          accounts: Math.max(0, sizeRef.current.accounts + dAccounts),
        };
        return {
          audience_people: sizeRef.current.people,
          audience_accounts: sizeRef.current.accounts,
        };
      };
      const hasAccount = (accountId) => !!audience.accounts[accountId];
      // Explicit adds are known synchronously from the audience state; onKeys
      // (the resolved roster) covers Trayo picks.
      const hasPerson = (personId) =>
        (!!audience.people[personId] && !audience.excl[personId]) ||
        onKeys.has(personId);
      // Next add-order stamp: one past the highest seq in the audience.
      const nextSeq = (a) => {
        let highest = 0;
        for (const entry of Object.values(a.accounts)) {
          highest = Math.max(highest, entry.seq ?? 0);
        }
        for (const entry of Object.values(a.people)) {
          highest = Math.max(highest, entry.seq ?? 0);
        }
        return highest + 1;
      };
      const savedListAudience = (listId, seed) => {
        const snap = audienceSnapshot(seed);
        return {
          ...EMPTY_AUDIENCE,
          ...seed,
          // A saved list is membership, not a record of which signals built
          // it, so its signal rows do not start selected.
          selectedEventIds: {},
          linkedListId: listId,
          savedSnapshot: snap,
          savedMembership: audienceMembership(seed),
        };
      };

      const setAcctPer = (accountId, per) => {
        homeAnalytics.audienceLeverChanged({ per, scope: 'account' });
        return setAudience((a) => {
          // Clearing this account's excl re-syncs the picks to the new count.
          const excl = { ...a.excl };
          res.groups
            .find((g) => g.account.id === accountId)
            ?.people.forEach((p) => delete excl[p.personId]);
          return {
            ...a,
            excl,
            accounts: {
              ...a.accounts,
              [accountId]: {
                ...(a.accounts[accountId] ?? { via: 'manual' }),
                per,
              },
            },
          };
        });
      };

      // setAcctPer for the whole cohort, in ONE commit.
      const setAllAcctPer = (per) => {
        setAudience((a) => {
          const excl = { ...a.excl };
          const accounts = { ...a.accounts };
          for (const accountId of Object.keys(accounts)) {
            res.groups
              .find((g) => g.account.id === accountId)
              ?.people.forEach((p) => delete excl[p.personId]);
            accounts[accountId] = { ...accounts[accountId], per };
          }
          return { ...a, excl, accounts };
        });
        homeAnalytics.audienceLeverChanged({ per, scope: 'all' });
        // The cohort seg doubles as a lever shortcut, like the note's seg.
        if (per === 1) setLever('best');
        if (per === 3) setLever('key3');
      };

      const addPerson = (accountId, personId, via, eventId, accountMeta, personMeta) => {
        // Re-adding someone already picked is a no-op for size, so only a
        // genuinely new member steps the counter.
        const isNew = !audience.people[personId] && !hasPerson(personId);
        homeAnalytics.audiencePersonAdded({
          via: asVia(via),
          account_id: accountId,
          ...bump(isNew ? 1 : 0, 0),
        });
        setAudience((a) => {
          const excl = { ...a.excl };
          delete excl[personId];
          return {
            ...a,
            excl,
            people: {
              ...a.people,
              [personId]: { via, accountId, eventId, seq: nextSeq(a) },
            },
            // A signal-added person can be the FIRST thing forming its account
            // group — capture display meta so it never renders a bare id.
            meta:
              accountMeta && accountId
                ? { ...a.meta, [accountId]: accountMeta }
                : a.meta,
            // Person display meta captured at add time — the ranking
            // roster-miss fallback prefers it (TRA-1359).
            peopleMeta: personMeta
              ? {
                  ...(a.peopleMeta ?? {}),
                  [personId]: {
                    fullName: personMeta.fullName,
                    title: personMeta.title ?? null,
                    profileImageUrl: personMeta.profileImageUrl ?? null,
                  },
                }
              : a.peopleMeta,
          };
        });
      };

      const removePerson = (personId, via = 'manual') => {
        // Both branches take one person out of the resolved audience: an
        // explicit entry is deleted, a Trayo pick is excluded.
        homeAnalytics.audiencePersonRemoved({
          via: asVia(via),
          ...bump(hasPerson(personId) ? -1 : 0, 0),
        });
        setAudience((a) => {
          if (a.people[personId]) {
            const people = { ...a.people };
            delete people[personId];
            return { ...a, people };
          }
          // A Trayo pick from a whole-account entry: exclude it.
          return { ...a, excl: { ...a.excl, [personId]: true } };
        });
      };

      return {
        audience,
        lever,
        res,
        loading: peopleLoading || eventsLoading,
        hasAccount,
        hasPerson,
        pickedCount: (accountId) => pickedIdsByAccount.get(accountId)?.size ?? 0,
        accountPeopleTotal: (accountId) =>
          Math.max(
            peopleCountByAccount.get(accountId) ?? 0,
            rosterCountByAccount.get(accountId) ?? 0,
          ),
        addAccount: (accountId, via, meta, eventId) => {
          const isNew = !audience.accounts[accountId];
          homeAnalytics.audienceAccountAdded({
            via: asVia(via),
            account_id: accountId,
            // A whole account resolves to `per` contacts, so the people count
            // moves too — that is the number the bar shows.
            ...bump(isNew ? perNow() : 0, isNew ? 1 : 0),
          });
          setAudience((a) => ({
            ...a,
            accounts: {
              ...a.accounts,
              [accountId]: {
                via,
                per: perNow(),
                seq: nextSeq(a),
                ...(eventId ? { eventId } : {}),
              },
            },
            meta: meta ? { ...a.meta, [accountId]: meta } : a.meta,
          }));
          setAcctNote({ accountId, per: perNow(), createdAt: Date.now() });
        },
        addFreshAccount: (accountId, name, host, via = 'search') => {
          const isNew = !audience.accounts[accountId];
          homeAnalytics.audienceAccountAdded({
            via: asVia(via),
            account_id: accountId,
            // A brand-new account has no roster yet, so it contributes an
            // account but no resolvable people until discovery lands.
            ...bump(0, isNew ? 1 : 0),
          });
          // Start the simulated research window (rule 7).
          freshAddedAtRef.current[accountId] = Date.now();
          return setAudience((a) => ({
            ...a,
            accounts: {
              ...a.accounts,
              [accountId]: { via, per: perNow(), seq: nextSeq(a) },
            },
            fresh: { ...a.fresh, [accountId]: { name, host } },
            // Persist the domain we already know as durable display meta, so
            // the logo keeps rendering after the fresh entry clears.
            meta: { ...a.meta, [accountId]: { name, url: host, logoUrl: null } },
          }));
        },
        removeGroup: (accountId) => {
          const had = !!audience.accounts[accountId];
          homeAnalytics.audienceAccountRemoved(
            bump(had ? -(pickedIdsByAccount.get(accountId)?.size ?? 0) : 0, had ? -1 : 0),
          );
          setAcctNote((n) => (n?.accountId === accountId ? null : n));
          setAudience((a) => {
            const accounts = { ...a.accounts };
            delete accounts[accountId];
            const fresh = { ...a.fresh };
            delete fresh[accountId];
            const meta = { ...a.meta };
            delete meta[accountId];
            const people = { ...a.people };
            for (const [pid, entry] of Object.entries(people)) {
              if (entry.accountId === accountId) delete people[pid];
            }
            // Drop excl entries for that account's roster (best-effort - the
            // roster ids come from the resolved groups).
            const excl = { ...a.excl };
            res.groups
              .find((g) => g.account.id === accountId)
              ?.people.forEach((p) => delete excl[p.personId]);
            return { ...a, accounts, people, excl, fresh, meta };
          });
        },
        releaseSignalAccount: (accountId, eventId, replacementEventId) => {
          setAudience((a) =>
            releaseSignalAccountOwnership(a, accountId, eventId, replacementEventId),
          );
        },
        releaseSignalPerson: (personId, eventId, replacementEventId) => {
          setAudience((a) =>
            releaseSignalPersonOwnership(a, personId, eventId, replacementEventId),
          );
        },
        addPerson,
        removePerson,
        mergeAudience: (next, source) => {
          // Count via a pure read of the current snapshot — do NOT smuggle the
          // value out of the state-updater (concurrent updates would defer it
          // and return a stale 0, mislabeling the toast).
          const { added } = mergeAudienceState(audience, next, source, perNow());
          // Commit against `prev`, not the closed-over `audience`. When
          // nothing changed, return `prev` unchanged — no commit, no dirtying.
          setAudience((prev) => {
            const merged = mergeAudienceState(prev, next, source, perNow());
            // `changed`, not `added`: a bulk add over rows whose people are
            // ALL already in the bar still moves provenance (the eventId that
            // decides which signal row renders checked).
            return merged.changed ? merged.state : prev;
          });
          // No analytics event here — the call site emits `bulkAdded` (the one
          // documented exception to the chokepoint rule).
          return added;
        },
        togglePerson: (accountId, personId, isOn) => {
          if (isOn) {
            // Exclude rather than remove: an explicit entry stays in the
            // audience state, so the drawer keeps the row rendered (unchecked,
            // in place) instead of dropping it from its group.
            homeAnalytics.audiencePersonRemoved({
              via: 'person-drawer',
              ...bump(-1, 0),
            });
            setAudience((a) => ({
              ...a,
              excl: { ...a.excl, [personId]: true },
            }));
          } else if (audience.excl[personId]) {
            homeAnalytics.audiencePersonAdded({
              via: 'person-drawer',
              account_id: accountId,
              ...bump(1, 0),
            });
            setAudience((a) => {
              const excl = { ...a.excl };
              delete excl[personId];
              return { ...a, excl };
            });
          } else {
            addPerson(accountId, personId, 'person-drawer');
          }
        },
        getAcctPer: (accountId) => audience.accounts[accountId]?.per ?? perNow(),
        setAcctPer,
        setAllAcctPer,
        acctNote,
        noteContactsAdded: (accountId, personIds) =>
          setAcctNote({ accountId, per: 0, personIds, createdAt: Date.now() }),
        noteSetPer: (accountId, per) => {
          setAcctPer(accountId, per);
          // The note's seg doubles as a lever shortcut, like the mock.
          if (per === 1) setLever('best');
          if (per === 3) setLever('key3');
          // Keep the original raise time so tweaking the segment doesn't reset
          // the note's lifetime or re-trigger its entrance.
          setAcctNote((n) =>
            n?.accountId === accountId
              ? { ...n, per }
              : { accountId, per, createdAt: Date.now() },
          );
        },
        dismissNote: () => setAcctNote(null),
        setLever,
        clear: (reason = 'reset') => {
          // `reason` separates a user reset from the bar being spent by a
          // successful send or emptied by unfocusing a list.
          homeAnalytics.audienceCleared({
            reason,
            audience_people: sizeRef.current.people,
            audience_accounts: sizeRef.current.accounts,
          });
          setAcctNote(null);
          // An emptied bar stays empty — a live seeded focus must not re-seed.
          setSeedFocusedList(false);
          // EMPTY_AUDIENCE carries an empty selection set, so clearing the bar
          // also unlights every signal row.
          commit((prev) => ({ ...prev, audience: EMPTY_AUDIENCE }));
        },
        resetToSaved: () => {
          const { linkedListId, savedMembership } = audience;
          // No baseline, nothing to go back to (TRA-1443).
          if (!linkedListId || !savedMembership) return;
          homeAnalytics.audienceReset({
            list_id: linkedListId,
            audience_people: sizeRef.current.people,
            audience_accounts: sizeRef.current.accounts,
          });
          setAcctNote(null);
          commit((prev) => ({
            ...prev,
            audience: {
              ...prev.audience,
              ...savedMembership,
              // Restoring the membership makes the snapshot match again, so
              // the audience goes clean on its own. Signal selections are the
              // path taken, not the membership — dropped.
              selectedEventIds: {},
            },
          }));
        },
        setSignalSelected: (eventId, on) => {
          commit((prev) => {
            const cur = prev.audience.selectedEventIds ?? {};
            if (!!cur[eventId] === on) return prev;
            const next = { ...cur };
            if (on) next[eventId] = true;
            else delete next[eventId];
            return {
              ...prev,
              audience: { ...prev.audience, selectedEventIds: next },
            };
          });
        },
        focusedListId,
        setFocusedList,
        linkedListId: audience.linkedListId,
        isDirty:
          audience.linkedListId != null &&
          audienceSnapshot(audience) !== audience.savedSnapshot,
        // The saved baseline describes the membership that was actually
        // persisted (`sent`, captured before the round-trip); an in-flight
        // edit then correctly stays dirty.
        markSaved: (listId, sent) =>
          setAudience((a) => ({
            ...a,
            linkedListId: listId,
            savedSnapshot: audienceSnapshot(sent ?? a),
            savedMembership: audienceMembership(sent ?? a),
          })),
        unlink: () =>
          setAudience((a) => ({
            ...a,
            linkedListId: null,
            savedSnapshot: null,
            savedMembership: null,
          })),
        loadList: (listId, seed) => {
          // Focus alone never fills the bar — only a hand-off that asked for it.
          if (!seedFocusedList) return;
          const hasContent =
            Object.keys(audience.accounts).length > 0 ||
            Object.keys(audience.people).length > 0;
          const draftWithContent = hasContent && audience.linkedListId == null;
          const dirtyLinked =
            audience.linkedListId != null &&
            audienceSnapshot(audience) !== audience.savedSnapshot;
          // Never overwrite unsaved work. The user keeps their bar; focus just
          // scopes the tables.
          if (draftWithContent || dirtyLinked) return;
          const snap = audienceSnapshot(seed);
          // Already showing exactly this saved list — nothing to do.
          if (audience.linkedListId === listId && snap === audience.savedSnapshot) {
            return;
          }
          // `openedListRef` guards the import hand-off's chunked re-seeds from
          // emitting list_opened repeatedly with partial sizes.
          if (openedListRef.current !== listId) {
            openedListRef.current = listId;
            homeAnalytics.listOpened({
              list_id: listId,
              audience_accounts: Object.keys(seed.accounts ?? {}).length,
              audience_people: Object.keys(seed.people ?? {}).length,
            });
          }
          commit((prev) => ({
            ...prev,
            audience: savedListAudience(listId, seed),
          }));
        },
      };
    }, [
      audience,
      lever,
      res,
      acctNote,
      focusedListId,
      seedFocusedList,
      setFocusedList,
      peopleLoading,
      eventsLoading,
      onKeys,
      pickedIdsByAccount,
      rosterCountByAccount,
      peopleCountByAccount,
      setAudience,
      setLever,
      commit,
    ]);

    return <AudienceContext.Provider value={api}>{children}</AudienceContext.Provider>;
  }

  function useAudience() {
    const ctx = useContext(AudienceContext);
    if (!ctx) {
      throw new Error('useAudience must be used within an AudienceProvider');
    }
    return ctx;
  }

  /** Resolved audience only - groups, counts, recipients. */
  function useResolvedAudience() {
    return useAudience().res;
  }

  /* ==================== AudienceBar.tsx ==================== */

  /* lib/bottom-bar-presence (chat-launcher coordination, TRA-1532) — the
     prototype has no chat launcher; keep the call site as a no-op. */
  const useBottomBarPresence = T.useBottomBarPresence || (() => {});

  /** Width-aware caps so the bar never overflows the space it has. Measures
   *  the wrap's PARENT column, not the wrap itself (see the app's rationale:
   *  the docked wrap collapses to fit-content until the dock width applies). */
  function useCaps(barRef, visible) {
    const [caps, setCaps] = useState({ logos: 6, faces: 8 });
    useEffect(() => {
      const bar = barRef.current;
      if (!bar) return undefined;
      const wrap = bar.closest('.hdw-ab-wrap');
      const column = wrap?.parentElement ?? null;
      const measure = () => {
        if (!column) return;
        // The column is the reference, but the bar never gets all of it: the
        // in-card wrap's left/right insets and the wrap padding are room the
        // pill can never occupy.
        let avail = column.clientWidth;
        if (wrap) {
          const wrapStyle = getComputedStyle(wrap);
          if (!wrap.classList.contains('docked')) {
            avail -=
              (parseFloat(wrapStyle.left) || 0) + (parseFloat(wrapStyle.right) || 0);
          }
          avail -=
            (parseFloat(wrapStyle.paddingLeft) || 0) +
            (parseFloat(wrapStyle.paddingRight) || 0);
        }
        // Fixed chrome = every direct child EXCEPT the two avatar strips,
        // measured live.
        const kids = bar.children;
        let chrome = 0;
        let laidOut = 0;
        for (let i = 0; i < kids.length; i++) {
          const el = kids[i];
          // A `display: none` child costs no width AND no gap.
          if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
          laidOut++;
          if (el.classList.contains('hdw-ab-strip')) continue;
          chrome += el.offsetWidth;
        }
        // Read the real gap and padding rather than assuming `gap-3` + `px-3`.
        const barStyle = getComputedStyle(bar);
        const gap = parseFloat(barStyle.columnGap) || 0;
        const gaps =
          Math.max(0, laidOut - 1) * gap +
          (parseFloat(barStyle.paddingLeft) || 0) +
          (parseFloat(barStyle.paddingRight) || 0);
        // Each stacked 26px avatar costs ~20px (6px overlap); 52 = the two
        // strips' first-tile full widths + two possible `+N` chips.
        const fit = Math.max(0, Math.floor((avail - chrome - gaps - 52) / 20));
        const logos = Math.min(6, Math.max(1, Math.floor(fit * 0.45)));
        const faces = Math.min(8, Math.max(1, fit - logos));
        setCaps({ logos, faces });
      };
      measure();
      const ro = new ResizeObserver(measure);
      ro.observe(bar);
      if (column) ro.observe(column);
      return () => ro.disconnect();
    }, [barRef, visible]);
    return caps;
  }

  /** "<Account> added" note that hangs off the bar's top edge (mock
   *  ab-notebar): confirms the add, offers Best / Top 3 / All N, and an Undo.
   *  Auto-dismisses. */
  function AccountAddedNote() {
    const { Check } = T.Icons;
    const { SegmentedSwitch } = T.UI;
    const pb = useAudience();
    const note = pb.acctNote;
    // Keep the last note rendered through its 0.2s exit slide.
    const [kept, setKept] = useState(note);
    const [leaving, setLeaving] = useState(false);
    // Staged entrance (mock): on the very first add the BAR lands first, then
    // the note rises from behind it 450ms after the bar mounted.
    const mountedAt = useRef(Date.now());
    const [shown, setShown] = useState(false);
    useEffect(() => {
      // Raise a note that belongs to THIS mount: created after the bar
      // mounted, or within a short grace window just before it (the very
      // first add creates the note one render BEFORE the bar mounts).
      const MOUNT_GRACE_MS = 400;
      if (note && note.createdAt >= mountedAt.current - MOUNT_GRACE_MS) {
        setKept(note);
        setLeaving(false);
        const sinceMount = Date.now() - mountedAt.current;
        const wait = sinceMount < 450 ? 450 - sinceMount : 0;
        const show = setTimeout(() => setShown(true), wait);
        const t = setTimeout(() => pb.dismissNote(), 6000 + wait);
        return () => {
          clearTimeout(show);
          clearTimeout(t);
        };
      }
      setLeaving(true);
      const t = setTimeout(() => {
        setKept(null);
        setLeaving(false);
        setShown(false);
      }, 200);
      return () => clearTimeout(t);
      // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on note only
    }, [note]);

    // Contacts variant (per-signal adds): the account has no whole-account
    // entry, so presence is judged by the added people instead.
    const contactIds = kept?.personIds ?? [];
    const isContacts = contactIds.length > 0;
    const stillOn = isContacts
      ? contactIds.some((id) => pb.audience.people[id] && !pb.audience.excl[id])
      : kept
        ? pb.hasAccount(kept.accountId)
        : false;
    if (!kept || !shown || !stillOn) return null;
    const group = pb.res.groups.find((g) => g.account.id === kept.accountId);
    if (!group) return null;
    // A group whose display meta hasn't landed resolves name === id. Never
    // flash that: hold the note until the name is real.
    if (group.account.name === group.account.id) return null;
    if (isContacts) {
      return (
        <div
          className={cn('hdw-ab-note', leaving && 'out')}
          onClick={(e) => e.stopPropagation()}
        >
          <Check className="hdw-ab-note-check" />
          <span className="hdw-ab-note-t">
            <b>{group.account.name}</b>&nbsp;·&nbsp;
            {contactIds.length === 1
              ? '1 contact added'
              : `${contactIds.length} contacts added`}
          </span>
          <button
            type="button"
            className="hdw-abn-undo"
            onClick={() => contactIds.forEach((id) => pb.removePerson(id))}
          >
            Undo
          </button>
        </div>
      );
    }
    const n = pb.accountPeopleTotal(kept.accountId);
    const opts =
      n <= 3
        ? [
            { per: 1, label: 'Best contact' },
            { per: 99, label: `All ${n} contacts` },
          ]
        : [
            { per: 1, label: 'Best contact' },
            { per: 3, label: 'Top 3 contacts' },
            { per: 99, label: `All ${n} contacts` },
          ];
    const isOn = (per) =>
      per === 1
        ? kept.per === 1
        : per === 3
          ? kept.per === 3
          : n <= 3
            ? kept.per > 1
            : kept.per >= 99;
    // `isOn` is deliberately NOT equality — on a <=3 cohort the "All" option
    // is on for any kept.per > 1 — so derive the active option rather than
    // letting SegmentedSwitch compare. '' is the no-selection sentinel.
    const segOpts = opts.map((o) => ({
      value: String(o.per),
      label: o.label,
    }));
    const segValue = String(opts.find((o) => isOn(o.per))?.per ?? '');

    return (
      <div
        className={cn('hdw-ab-note', leaving && 'out')}
        onClick={(e) => e.stopPropagation()}
      >
        <Check className="hdw-ab-note-check" />
        <span className="hdw-ab-note-t">
          <b>{group.account.name}</b>&nbsp;added
        </span>
        {n > 1 && (
          // The note's own copy of the cohort seg — same shared primitive,
          // same warning tint over tokens.
          <SegmentedSwitch
            aria-label={`Contacts to keep for ${group.account.name}`}
            value={segValue}
            options={segOpts}
            onChange={(v) => pb.noteSetPer(kept.accountId, Number(v))}
            className="border-warning-line bg-warning-soft"
          />
        )}
        <button
          type="button"
          className="hdw-abn-undo"
          onClick={() => pb.removeGroup(kept.accountId)}
        >
          Undo
        </button>
      </div>
    );
  }

  /** Floating audience bar, docked to the bottom of the Home content card.
   *  Shown whenever the audience is non-empty (`shifted` slides it left beside
   *  a detail drawer; the audience/message drawers hide it — they ARE the
   *  audience). `docked` (TRA-1359) pins it to the viewport bottom on pages
   *  without a full-height relative card; `lift` raises it above a visible
   *  floating bulk-selection bar. */
  function AudienceBar({
    onReview,
    onReset,
    shifted = false,
    docked = false,
    lift = false,
  }) {
    const { Bookmark, BookmarkFilled, Loader2, Mail, X } = T.Icons;
    const { Button, LogoAvatar, PersonAvatar, getInitials } = T.UI;
    const SaveListControl = T.SaveListControl;
    const pb = useAudience();
    const { res, audience } = pb;
    // The bar IS the working list (TRA-1359 naming): loaded from a saved list
    // -> show its name; fresh draft -> "New list".
    const linkedName = T.Data.useLinkedListName(pb.linkedListId);
    const wrapRef = useRef(null);
    const barRef = useRef(null);

    // Whether the bar renders at all this pass (the empty-audience early
    // return below) — computed BEFORE the measure effects so their observers
    // attach when the bar first appears. A LINKED list keeps the bar alive
    // even when it resolves to nothing (TRA-1431): hiding it would hide the
    // only Update control.
    const visible =
      res.groups.length > 0 ||
      res.counts.people > 0 ||
      res.counts.researching > 0 ||
      pb.linkedListId !== null;

    const caps = useCaps(barRef, visible);

    // While visible, tell the bottom-bar registry (TRA-1532).
    useBottomBarPresence(visible);

    // Docked mode: pin to the content column (the wrap's parent) so the fixed
    // bar centers on the content, never the viewport. The BOTTOM offset is
    // derived from the layout's <main> box so the docked bar lands at exactly
    // the same viewport position as the in-card bar on Home.
    const [dockRect, setDockRect] = useState(null);
    // Docked entrance: the bar stays hidden until measured, and the transition
    // is armed one frame later so the first visible paint is already in place.
    const [dockAnim, setDockAnim] = useState(false);
    const measureRef = useRef(null);
    useEffect(() => {
      if (!docked) {
        setDockAnim(false);
        return undefined;
      }
      if (!dockRect || dockAnim) return undefined;
      // Confirm the position before revealing: re-measure on the next frame,
      // then reveal the frame after. Any settle happens while hidden.
      let raf2 = 0;
      const raf1 = requestAnimationFrame(() => {
        measureRef.current?.();
        raf2 = requestAnimationFrame(() => setDockAnim(true));
      });
      return () => {
        cancelAnimationFrame(raf1);
        cancelAnimationFrame(raf2);
      };
      // eslint-disable-next-line react-hooks/exhaustive-deps -- dockAnim guard only
    }, [docked, dockRect]);
    useEffect(() => {
      if (!docked || !visible) return undefined;
      const column = wrapRef.current?.parentElement;
      if (!column) return undefined;
      const main = column.closest('main');
      const measure = () => {
        const r = column.getBoundingClientRect();
        if (r.width === 0) return;
        let bottom = 14;
        if (main) {
          const mr = main.getBoundingClientRect();
          const pad = parseFloat(getComputedStyle(main).paddingBottom) || 0;
          // main is the scroll container, so its rect is its viewport box:
          // this mirrors Home's card-bottom geometry.
          bottom = Math.max(14, window.innerHeight - mr.bottom + pad + 14);
        }
        // Dead-band: rects come back fractional; only commit real moves.
        setDockRect((prev) =>
          prev &&
          Math.abs(prev.left - r.left) < 1 &&
          Math.abs(prev.width - r.width) < 1 &&
          Math.abs(prev.bottom - bottom) < 1
            ? prev
            : { left: r.left, width: r.width, bottom },
        );
      };
      measureRef.current = measure;
      measure();
      const ro = new ResizeObserver(measure);
      ro.observe(column);
      if (main) ro.observe(main);
      window.addEventListener('resize', measure);
      return () => {
        measureRef.current = null;
        ro.disconnect();
        window.removeEventListener('resize', measure);
      };
    }, [docked, visible]);

    // Stable display order: entities keep their add position (append at the
    // end, never reshuffle). Trayo picks inherit their account's add position.
    const accounts = useMemo(() => {
      const seqOf = (id) => {
        const accountSeq = audience.accounts[id]?.seq;
        if (accountSeq !== undefined) return accountSeq;
        let earliest = Number.MAX_SAFE_INTEGER;
        for (const person of Object.values(audience.people)) {
          if (person.accountId === id) {
            earliest = Math.min(earliest, person.seq ?? 0);
          }
        }
        return earliest;
      };
      return res.groups
        .map((g) => g.account)
        .slice()
        .sort((a, b) => seqOf(a.id) - seqOf(b.id));
    }, [res.groups, audience.accounts, audience.people]);
    const faces = useMemo(() => {
      const seqOf = (personId, accountId) =>
        audience.people[personId]?.seq ?? audience.accounts[accountId]?.seq ?? 0;
      return res.recipients
        .slice()
        .sort(
          (a, b) => seqOf(a.personId, a.accountId) - seqOf(b.personId, b.accountId),
        );
    }, [res.recipients, audience.people, audience.accounts]);
    const { people, accounts: acctCount, researching } = res.counts;

    // Only present when the audience is non-empty (a lone researching account
    // still counts) or linked to a saved list (an emptied list is still
    // editable). Keep in sync with `visible` above.
    if (!visible) return null;

    return (
      <div
        ref={wrapRef}
        className={cn(
          'hdw-ab-wrap',
          shifted && 'shifted',
          docked && 'docked',
          docked && dockAnim && 'dock-anim',
          docked && lift && 'lifted',
        )}
        style={
          docked
            ? dockRect
              ? {
                  left: dockRect.left,
                  width: dockRect.width,
                  // lift raises the bar above a visible bulk-selection bar by
                  // the same 78px delta the in-card lifted rule uses.
                  bottom: dockRect.bottom + (lift ? 78 : 0),
                  // Hidden until the position is CONFIRMED.
                  ...(dockAnim ? {} : { visibility: 'hidden' }),
                }
              : // Pre-measure frame: keep the layout box but paint nothing.
                { visibility: 'hidden' }
            : undefined
        }
      >
        <div className="hdw-ab-stack">
          <AccountAddedNote />
          <div
            ref={barRef}
            className="hdw-ab flex items-center gap-3 rounded-full border border-border-strong bg-surface-card px-3 py-2"
          >
            {/* The glyph stays at every width — filled vs outline is the
                saved-vs-draft signal. Only the NAME goes when the bar
                narrows. */}
            <span className="hdw-ab-lbl">
              {pb.linkedListId ? <BookmarkFilled /> : <Bookmark />}
              <span className="hdw-ab-lbl-t">{linkedName}</span>
            </span>

            {accounts.length > 0 && (
              <span className="hdw-ab-strip flex items-center -space-x-1.5">
                {accounts.slice(0, caps.logos).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 > caps.logos && (
                  <span className="hdw-ab-more relative z-[1] grid size-6.5 place-items-center rounded-xs bg-surface-well text-2xs font-bold text-text-muted">
                    +{accounts.length - caps.logos}
                  </span>
                )}
              </span>
            )}
            <span className="hdw-ab-count">
              <b>{acctCount}</b>
              {acctCount === 1 ? 'account' : 'accounts'}
            </span>

            <span className="hdw-ab-rule h-5 w-px bg-border-subtle" aria-hidden />

            {faces.length > 0 && (
              <span className="hdw-ab-strip flex items-center -space-x-1.5">
                {faces.slice(0, caps.faces).map((r) => (
                  <PersonAvatar
                    key={r.personId}
                    src={r.profileImageUrl}
                    personId={r.personId}
                    name={r.fullName}
                    className="hdw-face size-[26px]"
                  />
                ))}
                {people > caps.faces && (
                  <span className="hdw-ab-more relative z-[1] grid size-6.5 place-items-center rounded-full bg-surface-well text-2xs font-bold text-text-muted">
                    +{people - caps.faces}
                  </span>
                )}
              </span>
            )}
            {/* Discovery reads as a spinner ON the people count (spinner +
              "people" = finding people). The count keeps updating as
              stakeholders land; the spinner clears when discovery finishes. */}
            <span
              className="hdw-ab-count"
              {...(researching > 0
                ? {
                    title:
                      'Trayo is finding the key contacts at the accounts you just added.',
                  }
                : {})}
            >
              <b>{people}</b>
              {people === 1 ? 'person' : 'people'}
              {researching > 0 && (
                <Loader2
                  className="ml-0.5 inline size-2.5 shrink-0 animate-spin align-middle text-text-muted"
                  aria-label="Finding contacts"
                />
              )}
            </span>

            {/* Actions cluster: clear belongs with Save/Outreach. ml-auto
              replaces a flex spacer so a collapsed spacer can't invent extra
              gap-3. */}
            <div className="hdw-ab-acts ml-auto flex items-center gap-1.5">
              <span
                className="hdw-ab-rule h-5 w-px shrink-0 bg-border-subtle"
                aria-hidden
              />
              {/* Same control as the drawer header's close (DrawerShell) — a
                bare 28px quiet circle. */}
              <Button
                variant="quiet"
                size="xs"
                className="size-7 p-0"
                onClick={() => (onReset ? onReset() : pb.clear())}
                aria-label="Clear this list"
                title="Clear this list and start over"
              >
                <X />
              </Button>
              <SaveListControl variant="bar" />
              <button
                type="button"
                className="hdw-cta sm"
                disabled={!onReview || people === 0}
                title="Review this list and start outreach"
                onClick={onReview}
              >
                <Mail />
                Outreach
              </button>
            </div>
          </div>
        </div>
      </div>
    );
  }

  /* ==================== AudienceDrawer.tsx ==================== */

  /** The cohort seg's options - a per is "on" only when EVERY whole-account
   *  entry carries it (cohortPerState); hand-tuned mixes highlight nothing. */
  const COHORT_OPTS = [
    { value: '1', label: 'Best contact' },
    { value: '3', label: 'Top 3' },
    { value: '99', label: 'All' },
  ];

  function PersonRow({ person }) {
    const { Checkbox, PersonAvatar } = T.UI;
    const pb = useAudience();
    // Secondary line under the name: a real event reason when there is one,
    // otherwise the title (the fallback "why" restates the title).
    const whyEchoesTitle =
      !!person.title &&
      person.why.trim().toLowerCase().startsWith(person.title.trim().toLowerCase());
    const secondary = whyEchoesTitle ? person.title : person.why;
    return (
      <label className={`hdw-au-p${person.on ? '' : ' off'}`}>
        <Checkbox
          className="mt-1 size-[15px] rounded-[4px]"
          checked={person.on}
          onCheckedChange={() =>
            pb.togglePerson(person.accountId, person.personId, person.on)
          }
          aria-label={person.on ? 'Remove from list' : 'Add to list'}
        />
        <PersonAvatar
          src={person.profileImageUrl}
          personId={person.personId}
          name={person.fullName}
          className="size-[26px] shrink-0"
        />
        <span className="hdw-au-t">
          <span className="hdw-au-n">
            <b>{person.fullName}</b>
          </span>
          {secondary && (
            <span className="hdw-au-w" title={secondary}>
              {secondary}
            </span>
          )}
          {isRecentlyContacted(person.lastContactedAt, RECENT_ROW_DAYS) && (
            <span
              className="hdw-au-w text-text-muted"
              data-testid="recent-outreach-marker"
            >
              {outreachMarker(person)}
            </span>
          )}
        </span>
      </label>
    );
  }

  function GroupBlock({ group }) {
    const { ChevronDown, Loader2, Trash2 } = T.Icons;
    const { Button, LogoAvatar, getInitials } = T.UI;
    const pb = useAudience();
    const [expanded, setExpanded] = useState(false);
    const on = group.people.filter((p) => p.on);
    // Sticky rows: anyone rendered while checked keeps their row for this
    // drawer session, so unchecking leaves them in place (unchecked) instead
    // of folding them into "show more". useState (not a ref) so render-time
    // mutation is allowed; the set only grows and adds are idempotent.
    const [sticky] = useState(() => new Set());
    for (const p of group.people) if (p.on) sticky.add(p.personId);
    const hidden = group.people.filter((p) => !p.on && !sticky.has(p.personId));
    // Per-session display order: a row's slot is pinned the first time it
    // renders, and expansion appends the revealed people BELOW the existing
    // rows. Toggling a row keeps its slot.
    const [order] = useState(() => new Map());
    const pin = (id) => {
      if (!order.has(id)) order.set(id, order.size);
    };
    for (const p of group.people) if (p.on || sticky.has(p.personId)) pin(p.personId);
    if (expanded) for (const p of group.people) pin(p.personId);
    const visible = (
      expanded
        ? [...group.people]
        : group.people.filter((p) => p.on || sticky.has(p.personId))
    ).sort(
      (a, b) =>
        (order.get(a.personId) ?? Number.MAX_SAFE_INTEGER) -
        (order.get(b.personId) ?? Number.MAX_SAFE_INTEGER),
    );
    const total = Math.max(pb.accountPeopleTotal(group.account.id), group.people.length);

    return (
      <div className="hdw-au-group">
        <div className="hdw-au-ghead">
          {/* 20px matches the account tile beside a person's name in PeopleTab;
              same 6px corner (`rounded-xs`), so the two read as the same
              object at the same scale. */}
          <LogoAvatar
            src={group.account.logoUrl}
            domain={group.account.url}
            alt={group.account.name}
            fallbackText={getInitials(group.account.name)}
            size="sm"
            className="size-[20px] rounded-xs"
          />
          <b>{group.account.name}</b>
          {group.researching ? (
            <span className="hdw-au-ct">
              <Loader2 />
              researching · ~2 min
            </span>
          ) : (
            <span className="hdw-au-ct">
              {total === 0 ? 'No contacts' : `${on.length} of ${total} contacts`}
            </span>
          )}
          <span className="flex-1" />
          {/* Matches the Lists row delete: neutral circle at rest, Signals'
              danger treatment on hover. */}
          <Button
            variant="destructive-quiet"
            size="xs"
            className="size-7 p-0"
            onClick={() => pb.removeGroup(group.account.id)}
            title={`Remove ${group.account.name}`}
          >
            <span className="sr-only">Remove {group.account.name} from the list</span>
            <Trash2 />
          </Button>
        </div>
        {group.researching ? (
          <p className="hdw-au-freshnote">
            Monitoring started. People join here as research lands. Nothing waits
            on it.
          </p>
        ) : visible.length === 0 && hidden.length === 0 && !expanded ? (
          // A people-less account (e.g. added with no contacts yet) shows just
          // its bar - no empty body strip under it.
          null
        ) : (
          <div className="hdw-au-people">
            {visible.map((p) => (
              <PersonRow key={p.personId} person={p} />
            ))}
            {(hidden.length > 0 || expanded) && (
              <button
                type="button"
                className="hdw-more"
                onClick={() => setExpanded((e) => !e)}
              >
                {expanded
                  ? 'Show fewer'
                  : `Show ${hidden.length} more ${
                      hidden.length === 1 ? 'contact' : 'contacts'
                    } at ${group.account.name}`}
                <span className={`hdw-twist${expanded ? ' rot' : ''}`}>
                  <ChevronDown />
                </span>
              </button>
            )}
          </div>
        )}
      </div>
    );
  }

  /**
   * The header's one undo-ish affordance (TRA-1443). Home is a DRAFT bar: the
   * only sensible undo is "empty it", and the host owns that (onClearAll must
   * drop the list focus first). Lists is the list EDITOR: the useful undo is
   * "put it back" (Reset), gated on `isDirty`. The last arm (bare pb.clear())
   * is DEFENSIVE — no current host reaches it.
   */
  function headerActionFor(pb, empty, onClearAll) {
    const clearAll = (run) =>
      empty
        ? null
        : {
            label: 'Clear all',
            hint: 'Clear this list and start over',
            run,
            closes: true,
          };
    if (onClearAll) return clearAll(onClearAll);
    if (pb.linkedListId) {
      return pb.isDirty
        ? {
            label: 'Reset',
            hint: 'Undo unsaved changes and go back to the saved list',
            run: pb.resetToSaved,
            closes: false,
          }
        : null;
    }
    return clearAll(() => pb.clear());
  }

  // Header lives in its own child so it sits INSIDE DrawerShell's close
  // context and the close paths ease out like the header X everywhere else.
  function AudienceDrawerHeader({ title, empty, linked, action, onClose }) {
    const { RotateCcw, X } = T.Icons;
    const { Button } = T.UI;
    const SaveListControl = T.SaveListControl;
    /* drawers.jsx owns useDrawerClose; fall back to the raw handler when the
       header is rendered outside a DrawerShell. */
    const useDrawerClose = T.useDrawerClose || ((fallback) => fallback);
    const close = useDrawerClose(onClose);
    return (
      <div className="hdw-head">
        <span className="hdw-title">
          <span className="hdw-title-name">{title}</span>
        </span>
        <span className="flex-1" />
        {/* A LINKED list keeps its save affordance even with nothing left in
            it (TRA-1431): dropping the Update control there means closing the
            drawer silently discards the removal. */}
        {(!empty || linked) && <SaveListControl variant="drawer" />}
        {action && (
          <button
            type="button"
            className="hdw-quiet"
            onClick={() => {
              action.run();
              if (action.closes) close();
            }}
            title={action.hint}
          >
            <RotateCcw />
            {action.label}
          </button>
        )}
        {/* Neutral twin of this drawer's remove control — same bare 28px
            circle, `quiet` rather than `destructive-quiet`. */}
        <Button
          variant="quiet"
          size="xs"
          className="size-7 p-0"
          onClick={close}
          title="Close"
        >
          <span className="sr-only">Close</span>
          <X />
        </Button>
      </div>
    );
  }

  function AudienceDrawer({ onClose, onStart, onClearAll }) {
    const { Mail } = T.Icons;
    const { SegmentedSwitch } = T.UI;
    /* drawers.jsx owns DrawerBody; the fallback mirrors its exact markup so
       the drawer still lays out if it ever renders outside a DrawerShell. */
    const DrawerBody =
      T.DrawerBody ||
      (({ children, className }) => (
        <div className={cn('min-h-0 flex-1 overflow-y-auto px-4 py-3.5', className)}>
          {children}
        </div>
      ));
    const pb = useAudience();
    const { res } = pb;
    const c = res.counts;
    const empty = res.groups.length === 0;
    // Linked to a list (loaded from Lists, or saved from here): the header
    // carries the list's name so the drawer reads as that list.
    const linkedName = T.Data.useLinkedListName(pb.linkedListId);
    // Cohort-wide contacts lever: only meaningful when the audience has at
    // least one whole-account entry (Trayo picks the contacts for those).
    const hasWhole = Object.keys(pb.audience.accounts).length > 0;
    const cohort = cohortPerState(pb.audience.accounts);
    const headerAction = headerActionFor(pb, empty, onClearAll);

    return (
      <>
        <AudienceDrawerHeader
          title={linkedName}
          empty={empty}
          linked={!!pb.linkedListId}
          action={headerAction}
          onClose={onClose}
        />

        {hasWhole && (
          <div
            className="flex items-center gap-2 border-b border-border-subtle bg-surface-card px-4 py-2"
            title="How many contacts Trayo picks per account: applies to all accounts in this list."
          >
            <span className="text-meta text-text-secondary">
              Contacts per account
            </span>
            <span className="flex-1" />
            {/* A 'mixed' or absent cohort matches no option, so nothing
                highlights. '' is the no-selection sentinel. */}
            <SegmentedSwitch
              aria-label="Contacts per account"
              value={cohort === null || cohort === 'mixed' ? '' : String(cohort)}
              options={COHORT_OPTS}
              onChange={(v) => pb.setAllAcctPer(Number(v))}
              className="border-warning-line bg-warning-soft"
            />
          </div>
        )}

        <DrawerBody>
          {empty ? (
            <div className="flex flex-col items-center gap-2 py-16 text-center">
              <span className="text-name text-text-primary">
                Your audience is empty
              </span>
              <p className="max-w-xs text-body-sm text-text-muted">
                Check signals, accounts, or people in the tables. Everyone you
                pick collects here.
              </p>
            </div>
          ) : (
            <div className="hdw-au-groups">
              {res.groups.map((group) => (
                <GroupBlock key={group.account.id} group={group} />
              ))}
            </div>
          )}
        </DrawerBody>

        <div className="flex flex-col gap-2 border-t border-border-subtle bg-surface-card px-4 py-3">
          <span className="text-center text-meta text-text-secondary">
            {empty
              ? 'Nothing picked yet'
              : `${c.people} ${c.people === 1 ? 'person' : 'people'} · ${
                  c.accounts
                } ${c.accounts === 1 ? 'account' : 'accounts'}`}
          </span>
          <button
            type="button"
            className="hdw-cta w-full"
            disabled={empty || c.people === 0}
            onClick={onStart}
          >
            <Mail />
            Outreach
          </button>
        </div>
      </>
    );
  }

  Object.assign(T, {
    // audience-types.ts
    EMPTY_AUDIENCE,
    audienceMembership,
    releaseSignalAccountOwnership,
    releaseSignalPersonOwnership,
    LEVER_PER,
    leverPer,
    cohortPerState,
    audienceSnapshot,
    // audience-merge.ts
    mergeAudienceState,
    // ranking.ts
    SENIORITIES,
    seniorityOf,
    buildEventContext,
    rankAccountPeople,
    whyFor,
    resolveAudience,
    // recent-outreach.ts
    RECENT_ROW_DAYS,
    RECENT_NUDGE_DAYS,
    isRecentlyContacted,
    recentlyContacted,
    outreachMarker,
    formatOutreach,
    // AudienceProvider.tsx
    AudienceProvider,
    useAudience,
    useResolvedAudience,
    // AudienceBar.tsx
    AudienceBar,
    // AudienceDrawer.tsx
    AudienceDrawer,
  });
})();
