/* app/set-comms.jsx — Settings pages: Accounts + Calls.
   ============================================================================================
   OWNERSHIP: this file and app/set-comms.css are owned by ONE agent. Nothing else in the repo needs
   to change to build these pages — the <script>/<link> tags and the `?v=` bumps are already wired,
   and the router finds a page purely by its key in the map below.

   CONTRACT
     - Read docs/SETTINGS-REFERENCE.md for what each page must contain, then the root CLAUDE.md.
     - Build every page out of `window.E8Set` primitives. Do not hand-roll a row, card or control;
       if a primitive is missing, say so rather than inventing a local one — dimensional drift
       between pages is the single most visible defect on this surface.
     - All CSS goes in app/set-comms.css under a `.e8-set-comms-*` prefix. Never edit app/set-core.css.
     - ZERO inline `style={{…}}` touching fontSize/color/display/gap/flex/margin/padding: a new
       file's inline-style budget is 0 and one such object fails `npm run lint`.
     - Colours are `--ui-*` tokens or `color-mix()` on them. Text sizes are `--ui-text-*`
       (11.5 / 12.5 / 13.5 / 15 / 18 / 24 / 28 — there is no 14px). Icons are `--ui-icon-*`.

   WHY THE TWO PAGES SHARE A STORE
   Accounts and Calls are one dependency chain, not two pages that happen to live in one file. The
   note taker cannot join a meeting it cannot see, so `Enable note taker` on Calls is inert until a
   calendar is connected on Accounts — the same fact that dims the Accounts defaults card. Holding
   that fact in ONE module-level store means the two pages cannot disagree, and connecting a mailbox
   visibly unlocks both. React state would not survive the route change between them (the router
   mounts exactly one page), so the store lives at module scope with a subscriber set.

   IT IS DELIBERATELY NOT PERSISTED. This repo has a long history of a stale persisted preference
   silently contaminating a measurement (see the remediation rules in CLAUDE.md), and a prototype
   settings surface writing new localStorage keys adds to that surface for no product gain. Every
   control here holds and responds to state for the life of the tab and starts from a known
   fixture on reload: nothing connected, which is the state the reference's dimmed card documents.
   ============================================================================================ */

const E8SComms = window.E8Set;

/* A context that always exists, created ONCE. `React.useContext(window.E8Ctx || fallback)` keeps
   the hook call unconditional (rules of hooks) without minting a new context object per render. */
const E8SCommsCtx = React.createContext({});

/* ---------- the shared store ----------------------------------------------------------------
   ONE BUCKET PER PERSONA. Both pages say these are personal — the Accounts subtitle names the
   active person, the toast says "connected for <name>", and the copy promises nobody else can read
   a connected inbox. One module-level object made all three false: connect Google as Sarah Kim,
   switch to Renee Castellanos, and Renee's Accounts page showed Sarah's mailbox as her own, with
   the note-taker name and avatar to match. Keying the bucket by persona is what makes the sentence
   the page prints true. Still not persisted, for the reason in the header. */
function e8scDefaults() {
  return {
    mailbox: null,            /* null | 'google' | 'microsoft' — the whole dependency chain */
    messaging: false,
    sharing: 'job',
    fontFamily: 'Inter',
    fontSize: '11 pt',
    join: 'never',
    language: 'en-US',
    video: true,              /* the reference has this one ON */
    recorderName: '',
    avatar: 'default',
  };
}
const E8SC_BY_PERSONA = {};
/* Resolved at every read rather than captured once: `e8scPersona()` reads localStorage['e8-persona']
   on each call, so a switch made on another screen is already visible by the time Settings renders
   again. */
function e8scStore() {
  const key = String(e8scPersona().name || 'You');
  if (!E8SC_BY_PERSONA[key]) E8SC_BY_PERSONA[key] = e8scDefaults();
  return E8SC_BY_PERSONA[key];
}
const E8SC_SUBS = new Set();
function e8scSet(patch) {
  Object.assign(e8scStore(), patch);
  E8SC_SUBS.forEach((fn) => fn());
}
/* Subscribe-and-rerender. Both pages read the same bucket for the same persona, so a change on one
   is visible on the other the moment the router mounts it. */
function useE8SCStore() {
  const [, bump] = React.useReducer((n) => n + 1, 0);
  React.useEffect(() => {
    E8SC_SUBS.add(bump);
    return () => { E8SC_SUBS.delete(bump); };
  }, []);
  return e8scStore();
}

/* ---------- real app data ---------------------------------------------------------------------
   Everything below reads from the live app rather than a placeholder, and every read is guarded:
   these pages must render in empty and scale data modes too, where a collection can be missing. */
function e8scPersona() {
  const p = window.e8ActivePersona ? window.e8ActivePersona() : null;
  return p || (window.E8DATA || {}).user || { name: 'You', role: '', initials: 'Y' };
}
function e8scRows(key) {
  const rows = (window.E8DATA || {})[key];
  return Array.isArray(rows) ? rows : [];
}
function e8scFirstName(person) {
  return String((person && person.name) || 'You').split(' ')[0];
}
/* Counts come from the live dataset, so they are 12 in demo mode, 0 in empty mode and something
   else again in scale mode. Every sentence built from one has to read correctly at 0 and at 1,
   not just at the number that happens to be there today. */
function e8scPlural(n, one, many) { return n === 1 ? '1 ' + one : n + ' ' + many; }
/* The extension row names the browser it is actually running in. navigator.userAgent is real
   environment data, so the row is true on every machine rather than assuming Chrome. */
function e8scBrowser() {
  const ua = (window.navigator && window.navigator.userAgent) || '';
  if (/Edg\//.test(ua)) return { name: 'Edge', store: 'https://microsoftedge.microsoft.com/addons' };
  if (/Firefox\//.test(ua)) return { name: 'Firefox', store: 'https://addons.mozilla.org/firefox/extensions/' };
  if (/OPR\//.test(ua)) return { name: 'Opera', store: 'https://addons.opera.com/extensions/' };
  if (/Chrome\//.test(ua)) return { name: 'Chrome', store: 'https://chromewebstore.google.com/' };
  if (/Safari\//.test(ua)) return { name: 'Safari', store: 'https://apps.apple.com/us/story/id1377753262' };
  return { name: 'your browser', store: 'https://chromewebstore.google.com/' };
}

const E8SC_PROVIDERS = [
  { id: 'google', label: 'Google Workspace', tone: 'is-a' },
  { id: 'microsoft', label: 'Microsoft 365', tone: 'is-b' },
];

/* The provider marks, drawn rather than lettered. A monogram in a box ("G", "M") is the placeholder
   shape: at the tile's ~450px width the mark is the ONLY thing distinguishing the two blocks, and a
   letter distinguishes nothing.

   They are inline <svg>, not <img src="data:…">, for two reasons. An inline path inherits
   `currentColor`, so the mark re-inks with the theme and with its category tint — a data URI bakes
   its fill in and would need a second copy for dark mode. And a data URI would have to carry the
   brand hexes, which are neither `--ui-*` tokens nor invertible: a raw hex is a lint failure here
   and a dark-mode bug everywhere. So the marks are MONOCHROME brand geometry on the category tints
   the tiles already use — the recognisable shape without a colour we cannot express in tokens.
   Geometry is the CC0 Simple Icons outline for each, at the 24×24 viewBox it is authored in. */
function E8SCMark({ id, tone }) {
  return (
    <span className={'e8-set-comms-mark ' + tone} aria-hidden="true">
      <svg className="e8-set-comms-marksvg" viewBox="0 0 24 24" focusable="false" role="presentation">
        {id === 'google' ? (
          <path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
        ) : (
          <path d="M0 0h11.377v11.372H0zM12.623 0H24v11.372H12.623zM0 12.628h11.377V24H0zM12.623 12.628H24V24H12.623z" />
        )}
      </svg>
    </span>
  );
}
const E8SC_SHARING = [
  { value: 'nobody', label: 'Nobody' },
  { value: 'job', label: 'People on the same job' },
  { value: 'workspace', label: 'Everyone in the workspace' },
];
const E8SC_FONTS = ['Inter', 'Arial', 'Georgia', 'Verdana', 'Courier New'];
const E8SC_SIZES = ['9 pt', '10 pt', '11 pt', '12 pt', '14 pt'];
const E8SC_JOIN = [
  { value: 'never', label: 'Do not join by default' },
  { value: 'external', label: 'Join meetings with an external guest' },
  { value: 'all', label: 'Join every meeting on the calendar' },
];
const E8SC_LANGS = [
  { value: 'en-US', label: 'American English', flag: '🇺🇸' },
  { value: 'en-GB', label: 'British English', flag: '🇬🇧' },
  { value: 'es-419', label: 'Spanish (Latin America)', flag: '🇲🇽' },
  { value: 'fr-FR', label: 'French', flag: '🇫🇷' },
  { value: 'de-DE', label: 'German', flag: '🇩🇪' },
  { value: 'pt-BR', label: 'Portuguese (Brazil)', flag: '🇧🇷' },
];

/* ================================================================================================
   P4 · Accounts
   The page the dimmed-dependent state is proven on. Nothing is connected on first load, so the
   defaults card carries ONE `is-locked` class (SetCard's `locked`) and the whole block — icons,
   labels, descriptions and controls — drops together. Greying only the controls is the named
   failure mode. The reference calls this card FULLY dimmed (the "one row stays live" variant is
   Features', not this page's), which is what core's opacity actually produces: opacity composites,
   so no descendant rule can lift a child back out of a 42% parent.
   ============================================================================================= */
function SetAccountsPage() {
  const { Page, Section, Card, Row, NavRow, Select, Btn, Pill, Banner, Icon } = E8SComms;
  const st = useE8SCStore();
  const ctx = React.useContext(window.E8Ctx || E8SCommsCtx);
  const persona = e8scPersona();
  const browser = e8scBrowser();
  const toast = (msg) => { if (ctx && ctx.showToast) ctx.showToast(msg); };

  const connected = E8SC_PROVIDERS.find((p) => p.id === st.mailbox) || null;
  const other = E8SC_PROVIDERS.find((p) => p.id !== st.mailbox) || null;
  const templateCount = e8scRows('messageTemplates').length;
  const chatThreads = e8scRows('conversations')
    .filter((c) => c.channel === 'whatsapp' || c.channel === 'sms').length;

  /* ONE ACCOUNT, AND SWAPPING IT IS NEVER A SIDE EFFECT. The section says "Connect one account" and
     used to enforce it by having the second tile silently overwrite the first: one click on
     Microsoft and a live Google mailbox was gone — no dialog, no toast naming what was lost, and
     nothing on screen to undo it. Destroying a connection is now reachable only from a control that
     SAYS disconnect — the banner's Disconnect button, or the connected tile itself, which is a
     pressed toggle whose visible label names the account it is holding.

     The store-level guard below is deliberately redundant with the disabled attribute on the other
     tile. The invariant belongs to the store, not to one render, and the next caller of `pick()`
     should not have to re-derive it from the markup. */
  const pick = (p) => {
    if (st.mailbox === p.id) { e8scSet({ mailbox: null }); toast('Disconnected ' + p.label); return; }
    if (st.mailbox) return;
    e8scSet({ mailbox: p.id });
    toast(p.label + ' connected for ' + persona.name);
  };

  return (
    <Page title="Accounts"
      subtitle={'Mailbox, calendar and messaging connections for ' + persona.name
        + '. These are personal: nobody else in the workspace can read a connected inbox.'}>

      {/* Section headings are TITLE CASE across both pages in this module — the surface-wide rule
          the critic asked for, and the casing the reference itself uses (§P4 "Email & Calendar",
          §P5 "Note Taker"). Row labels and card-header titles stay sentence case: those are a
          different level and the reference does not Title Case them. */}
      <Section title="Email and Calendar"
        desc="Connect one account. ELEV8 sends from it, files replies onto the record, and reads free/busy so the note taker knows which meetings exist. The defaults below apply once something is connected.">

        <div className="e8-set-comms-connectgrid">
          {E8SC_PROVIDERS.map((p) => {
            const on = st.mailbox === p.id;
            /* Not "the other one" — "the one that is not connected while something is". With
               nothing connected both tiles are live, which is the only state that offers a choice. */
            const blocked = !!connected && !on;
            return (
              <button key={p.id} type="button" aria-pressed={on} disabled={blocked}
                className={'e8-set-comms-connect' + (on ? ' is-on' : '')}
                /* No aria-label override: the accessible name has to contain the visible label
                   (WCAG 2.5.3), and `aria-pressed` already carries "this is a toggle and it is
                   on". `title` adds the consequence without renaming the control. */
                title={blocked
                  ? 'Disconnect ' + connected.label + ' first — ELEV8 connects one account at a time.'
                  : (on ? 'Disconnect ' + p.label : undefined)}
                onClick={() => pick(p)}>
                <E8SCMark id={p.id} tone={p.tone} />
                <span className="e8-set-comms-connect-t">
                  {on ? 'Connected to ' + p.label : 'Connect ' + p.label}
                </span>
                {on ? <Icon name="check_circle" className="e8-set-comms-connect-ok" /> : null}
              </button>
            );
          })}
        </div>

        {/* "HERE IS THE STATE OF THIS DEPENDENCY" IS ONE SHAPE, AND IT IS A BANNER. This page said
            it as a plain icon-and-text line while Calls said the same thing in a tinted banner one
            nav item away — two authors, one sentence.

            Both STATES are a banner here, not just the inert one, because Calls proves the
            convention in both: it draws `tone="warn"` with no calendar and `tone="info"` naming the
            provider once there is one, in the same slot. Accounts is the other end of that exact
            dependency, so a plain line for "connected" and a banner for "not connected" would put
            two grammars in one slot on one page — the defect one layer down from the one being
            fixed. Info in both states here: nothing is wrong when nothing is connected yet, it is
            the state the page opens in, and Calls owns the warning about the consequence. */}
        {connected ? (
          <Banner tone="info" icon="lock">
            <span className="e8-set-comms-noteline">
              <span>
                Signed in as {persona.name}. ELEV8 reads mail and free/busy only. Disconnect to move
                this desk to {other ? other.label : 'another provider'}.
              </span>
              <Btn kind="quiet" icon="link_off" onClick={() => pick(connected)}>Disconnect</Btn>
            </span>
          </Banner>
        ) : (
          <Banner tone="info">No account is connected, so the defaults below are inactive.</Banner>
        )}

        <Card locked={!st.mailbox}>
          {/* `locked` on the NAV ROW as well as the card, deliberately. The card's own dim makes
              the block read as inert, but opacity composites — it cannot stop a real <button>
              from staying in the tab order, so without this the row is invisible-ish and still
              focusable. Passing it here disables the button; core's
              `.e8-set-card.is-locked .e8-set-navrow { opacity: 1 }` (3 classes) outranks
              `.e8-set-row.is-locked` (2), so the row does NOT double-dim below its siblings. */}
          <NavRow icon="draw" label="Configure signatures" locked={!st.mailbox}
            desc="Blocks appended to email sent from ELEV8. They are managed with your other message templates."
            meta={e8scPlural(templateCount, 'template', 'templates')}
            onClick={() => (window.navigate ? window.navigate('#/settings/templates') : null)} />
          <Row icon="visibility" label="Default email and calendar sharing"
            desc="Who else can see the subject lines and meeting titles ELEV8 files onto a record. Message bodies and meeting recordings are never shared by this setting."
            control={<Select value={st.sharing} options={E8SC_SHARING}
              ariaLabel="Default email and calendar sharing" disabled={!st.mailbox}
              onChange={(v) => e8scSet({ sharing: v })} />} />
          <Row icon="font_download" label="Default font family"
            desc="Applied to new email composed in ELEV8."
            control={<Select value={st.fontFamily} options={E8SC_FONTS}
              ariaLabel="Default font family" disabled={!st.mailbox}
              onChange={(v) => e8scSet({ fontFamily: v })} />} />
          <Row icon="format_size" label="Default font size"
            desc="Applied to new email composed in ELEV8."
            control={<Select value={st.fontSize} options={E8SC_SIZES}
              ariaLabel="Default font size" disabled={!st.mailbox}
              onChange={(v) => e8scSet({ fontSize: v })} />} />
        </Card>
      </Section>

      <Section title="Messaging"
        desc="One business number for the channels candidates actually answer on.">
        <Card>
          <Row className="e8-set-comms-row-msg" icon="forum" label="WhatsApp Business"
            desc={'Messages already carries ' + e8scPlural(chatThreads, 'thread', 'threads')
              + ' on WhatsApp or SMS. Connect the business number to send and receive them inside ELEV8 instead of on someone’s phone.'}
            control={st.messaging ? (
              <React.Fragment>
                <Pill tone="ok">Connected</Pill>
                <Btn kind="quiet" icon="link_off"
                  onClick={() => { e8scSet({ messaging: false }); toast('WhatsApp Business disconnected'); }}>
                  Disconnect
                </Btn>
              </React.Fragment>
            ) : (
              <Btn kind="secondary"
                onClick={() => { e8scSet({ messaging: true }); toast('WhatsApp Business connected'); }}>
                Connect
              </Btn>
            )} />
        </Card>
      </Section>

      <Section title="Browser Extension"
        desc="Source without leaving the tab you are already in.">
        <Card>
          <Row icon="extension" label={'ELEV8 for ' + browser.name}
            desc="Save a profile from LinkedIn or a job board straight onto a candidate record, and see whether that person is already in the pool before you write to them."
            control={<Btn kind="secondary" href={browser.store}>
              Open {browser.name} add-ons
            </Btn>} />
        </Card>
      </Section>
    </Page>
  );
}

/* ================================================================================================
   P5 · Calls
   Warning banner, a toggle that is ON, radio cards, and an iconned card header — the four things
   this page exists to prove. The banner is tied to the real dependency rather than hardcoded: with
   no calendar connected it warns and offers the fix; connected, it becomes an info banner naming
   the provider. The settings card is locked by the SAME dependency, as a whole CARD through the
   primitive's own `locked` prop — never by greying one select and leaving its neighbours live.
   The banner and the card are two statements of one fact, so they cannot be allowed to disagree.
   ============================================================================================= */
function SetCallsPage() {
  const { Page, Section, Card, CardHead, Row, Select, Toggle, Input, RadioCard, Banner, Btn, Icon } = E8SComms;
  const st = useE8SCStore();
  const ctx = React.useContext(window.E8Ctx || E8SCommsCtx);
  const [upload, setUpload] = React.useState(null);   /* { name, url } once a valid JPEG lands */
  const [uploadErr, setUploadErr] = React.useState('');
  /* Revokes the PREVIOUS object URL when the picture changes, and the last one on unmount. */
  React.useEffect(() => () => { if (upload && upload.url) URL.revokeObjectURL(upload.url); }, [upload]);

  const persona = e8scPersona();
  const provider = E8SC_PROVIDERS.find((p) => p.id === st.mailbox) || null;
  const lang = E8SC_LANGS.find((l) => l.value === st.language) || E8SC_LANGS[0];
  const recorded = e8scRows('callResults').length;
  const defaultName = 'ELEV8 note taker (' + e8scFirstName(persona) + ')';
  const shownName = String(st.recorderName || '').trim() || defaultName;
  const toast = (msg) => { if (ctx && ctx.showToast) ctx.showToast(msg); };

  /* A real upload: the reference's constraint is exactly 1280 x 720, so measure it rather than
     printing the sentence and accepting anything. */
  const onPick = (e) => {
    const file = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!file) return;
    if (file.type !== 'image/jpeg') {
      setUpload(null);
      setUploadErr('That file is ' + (file.type || 'of an unknown type') + '. The avatar must be a .jpeg.');
      return;
    }
    const url = URL.createObjectURL(file);
    const probe = new Image();
    probe.onload = () => {
      if (probe.naturalWidth !== 1280 || probe.naturalHeight !== 720) {
        URL.revokeObjectURL(url);
        setUpload(null);
        setUploadErr(probe.naturalWidth + ' x ' + probe.naturalHeight + ' px. The avatar must be exactly 1280 x 720.');
        return;
      }
      setUploadErr('');
      setUpload({ name: file.name, url: url });
      toast('Avatar accepted');
    };
    probe.onerror = () => { URL.revokeObjectURL(url); setUpload(null); setUploadErr('That file could not be read as an image.'); };
    probe.src = url;
  };

  /* SetRadioCard is a real <button role="radio">, so Tab and Enter already work. A radiogroup is
     also expected to move with the arrow keys, and announcing the role without honouring that is
     worse than not grouping at all — with two options every arrow means "the other one". */

  /* ROVING TABINDEX. A radiogroup is ONE tab stop: Tab lands on the checked option, the arrows
     move inside the group, Tab leaves it. Two stops for two mutually exclusive options is the
     shape of a toolbar, not a radio group, and it makes every group a longer detour the more
     options it grows.

     The group sets this rather than the card because the group is the thing that knows which
     member is checked; that is also where the arrow keys already live, so all of this page's
     focus behaviour stays in one place. It is applied to the DOM rather than passed as a prop
     because SetRadioCard takes no `tabIndex` — and adding a local copy of the primitive to get
     one attribute is exactly the drift this surface is avoiding. React never wrote `tabindex` on
     these buttons, so it does not own the attribute and will not fight this on re-render. A prop
     passthrough in core is still the tidier home for it; noted in the report.

     No dependency array on purpose: `checked` is module-store state, so the render that flips it
     is the render that must move the stop. */
  const radiosRef = React.useRef(null);
  React.useEffect(() => {
    const host = radiosRef.current;
    if (!host) return;
    host.querySelectorAll('[role="radio"]').forEach((el) => {
      el.tabIndex = el.getAttribute('aria-checked') === 'true' ? 0 : -1;
    });
  });

  const onAvatarKey = (e) => {
    if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].indexOf(e.key) < 0) return;
    e.preventDefault();
    const next = st.avatar === 'default' ? 'custom' : 'default';
    e8scSet({ avatar: next });
    const radios = e.currentTarget.querySelectorAll('[role="radio"]');
    const target = radios[next === 'default' ? 0 : 1];
    if (target) target.focus();
  };

  return (
    <Page title="Calls">
      <Section title="Note Taker"
        desc={'ELEV8 can join a scheduled call, record it, and write the summary back to the record. '
          + 'This workspace already holds ' + e8scPlural(recorded, 'call recording', 'call recordings') + '.'}>

        {st.mailbox ? (
          <Banner tone="info">
            Connected to {provider ? provider.label : 'your calendar'}. The note taker follows the
            rules below for every meeting it can see.
          </Banner>
        ) : (
          <Banner tone="warn">
            No calendar is connected, so the note taker cannot see your meetings and nothing will
            be recorded.{' '}
            <button type="button" className="e8-set-comms-blink"
              onClick={() => (window.navigate ? window.navigate('#/settings/accounts') : null)}>
              Connect one on Accounts
            </button>.
          </Banner>
        )}

        {/* THE WHOLE CARD IS THE DEPENDENT BLOCK, not just its first row. §2.8 dims the group as a
            unit, and anything less makes the page contradict itself: the banner immediately above
            says nothing will be recorded, so a live `Record video` toggle sitting under it —
            accent track, aria-checked=true, clickable — tells the reader the opposite in the same
            breath. Language and video are note-taker settings; with no calendar there is no
            meeting to apply them to, so all three fall together.

            `locked` on the CARD dims glyph, label, description and control as one (opacity
            composites, so no descendant rule can climb back out of the 42% group) and kills
            pointer events. `disabled` on each control then does the half opacity cannot: it takes
            them out of the TAB ORDER. `pointer-events: none` never has — a dimmed block you can
            still Tab into and operate is the same defect wearing a lighter colour. */}
        <Card locked={!st.mailbox}>
          <Row icon="smart_toy" label="Enable note taker"
            desc="Which meetings ELEV8 joins without being asked. It announces itself on arrival and any participant can remove it."
            control={<Select value={st.join} options={E8SC_JOIN} disabled={!st.mailbox}
              ariaLabel="Enable note taker" onChange={(v) => e8scSet({ join: v })} />} />
          <Row icon="language" label="Fallback transcription language"
            desc="Used when automatic detection is not confident enough to pick a language on its own."
            control={<Select value={st.language} options={E8SC_LANGS} flag={lang.flag}
              ariaLabel="Fallback transcription language" disabled={!st.mailbox}
              onChange={(v) => e8scSet({ language: v })} />} />
          <Row icon="videocam" label="Record video"
            desc="Keeps the video track beside the transcript. Audio only uses roughly a tenth of the storage and transcribes just as well."
            control={<Toggle checked={st.video} label="Record video" disabled={!st.mailbox}
              onChange={(v) => e8scSet({ video: v })} />} />
        </Card>

        <Card>
          <CardHead icon="badge" title="Note taker appearance"
            desc="How ELEV8 shows up in the participant list." />
          <Row className="e8-set-comms-field" label="Recorder name"
            desc="Everyone in the meeting sees this, including people outside your company.">
            <Input size="full" value={st.recorderName} placeholder={defaultName}
              ariaLabel="Recorder name" onChange={(v) => e8scSet({ recorderName: v })} />
          </Row>
          <Row className="e8-set-comms-field" label="Avatar"
            desc="Shown beside the name while the note taker is in the call.">
            <div className="e8-set-comms-radios" role="radiogroup" aria-label="Note taker avatar"
              ref={radiosRef} onKeyDown={onAvatarKey}>
              <RadioCard name="e8-set-comms-avatar" title="Default"
                desc={'Name only. Participants see “' + shownName + '”.'}
                checked={st.avatar === 'default'} onChange={() => e8scSet({ avatar: 'default' })} />
              <RadioCard name="e8-set-comms-avatar" title="Custom"
                desc="Upload a .jpeg of exactly 1280 x 720 px."
                checked={st.avatar === 'custom'} onChange={() => e8scSet({ avatar: 'custom' })} />
            </div>
            {st.avatar === 'custom' ? (
              <div className="e8-set-comms-upload">
                {upload ? (
                  <span className="e8-set-comms-thumb" style={{ '--e8-sc-avatar': 'url(' + upload.url + ')' }} />
                ) : (
                  <span className="e8-set-comms-thumb is-empty"><Icon name="image" /></span>
                )}
                <span className="e8-set-comms-uploadtxt">
                  <input id="e8-set-comms-avatar-file" type="file" accept="image/jpeg" onChange={onPick} />
                  <label htmlFor="e8-set-comms-avatar-file">
                    <Icon name="upload" />{upload ? 'Replace image' : 'Choose image'}
                  </label>
                  {upload ? <span className="e8-set-comms-uploadname">{upload.name} · 1280 x 720</span> : null}
                  {uploadErr ? <span className="e8-set-comms-err" role="alert">{uploadErr}</span> : null}
                </span>
              </div>
            ) : null}
          </Row>
        </Card>

        <Card>
          <CardHead icon="smartphone" title="Download the mobile app"
            desc="Dial, record and dictate from the road. Notes land on the record before you are back at a desk." />
          <Row icon="phone_iphone" label="iOS"
            desc="iPhone and iPad, iOS 17 and later."
            control={<Btn kind="secondary" href="https://www.apple.com/app-store/">App Store</Btn>} />
          <Row icon="android" label="Android"
            desc="Phone and tablet, Android 12 and later."
            control={<Btn kind="secondary" href="https://play.google.com/store/apps">Google Play</Btn>} />
        </Card>
      </Section>
    </Page>
  );
}

window.E8SetPages = window.E8SetPages || {};
Object.assign(window.E8SetPages, { accounts: SetAccountsPage, calls: SetCallsPage });
