/* eslint-disable */
// The Boardroom — FIRST-RUN ONBOARDING (ONBOARDING-V1)
//
// Built from the Claude Design handoff committed at docs/design/onboarding-2026-09-01/.
// Screens: 00 Face ID offer · 00b Face ID second ask · 01 Name · 02 Purpose · 03 Seat ·
// 04 Entering. Screens 05 (Ask first-land) and 06 (the name row in Settings) are amendments
// to surfaces that already exist and live in app.jsx / boards.jsx.
//
// WHAT THIS REPLACES — the old `OnboardingScreen` in screens.jsx: one grid of every member
// under "Choose your board", a subtitle AND a status box reporting the same seating state
// twice, a first board silently named "The full board", landing on the Board tab facing a
// Save button for the board you had just built. All of it is deleted, not hidden.
//
// THE VERB IS SEAT. One control per row whose LABEL STATES THE MEMBER'S CONDITION —
// "Seat" when they are not seated, "Seated" when they are — and whose tap changes it.
// Trimming a pre-seated member is therefore the same gesture, in the same slot, as adding
// one. No add/remove/choose anywhere.
//
// ALL SEATING RULES ARE PURE AND LIVE IN logic.js (BR.presetSeats / BR.seatGroups /
// BR.progressLine / BR.faceIdNext), so the Node suite and the real Chrome gate compute the
// same answers this component renders. Nothing about ordering, capping or copy is decided here.
(function () {
  const DS = window.TheBoardroomDesignSystem_4eda1b;
  const BR = window.BR;

  const onbCSS = `
/* ── ROUND 4 §5: onboarding 04b ────────────────────────────────────────────────
   §5.1 the two numbered steps; §5.2 the strip pinned under the status bar. */
.br-04b-steps { margin-top:18px; }
.br-04b-step { display:flex; gap:12px; align-items:flex-start; padding:16px 0;
               border-top:1px solid var(--line); font-family:var(--font-sans);
               font-size:15px; line-height:1.5; color:var(--ink-700); }
.br-04b-i { font-family:var(--font-mono); font-size:11px; letter-spacing:0.18em;
            color:var(--claret); flex:none; padding-top:3px; }
.br-04b-strip { position:fixed; top:calc(env(safe-area-inset-top, 0px) + 0px); left:10px; right:10px;
                z-index:60; background:var(--paper-inverse); border-radius:9px;
                box-shadow:0 14px 44px rgba(27,26,23,0.45); padding:14px 16px; }
.br-04b-strip-1 { display:flex; align-items:center; gap:9px; font-family:var(--font-mono);
                  font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:#d0a95e; }
.br-04b-ring { width:11px; height:11px; border-radius:50%; border:2px solid #d0a95e;
               box-sizing:border-box; flex:none; }
.br-04b-strip-2 { margin-top:8px; font-family:var(--font-serif); font-size:20px; line-height:1.3; color:#f3ede3; }
.br-04b-strip-3 { margin-top:6px; font-family:var(--font-sans); font-size:13.5px; line-height:1.5; color:#b9b2a6; }
/* The page behind stands down while the strip is armed — nothing else is the task. */
.br-04b-dimmed { opacity:0.5; pointer-events:none; }

/* IOS-SAFARI-PASS-V1: inset:0 on a fixed element resolves against the LAYOUT viewport,
   which on iOS Safari extends BEHIND the toolbar — so the footer holding Continue was drawn
   off the bottom of what the user can see. Anchored to the visual viewport instead, using the
   variables viewport.js already publishes app-wide. Both fall back to today's exact behaviour
   where there is no visualViewport reading. */
.br-onb { position:fixed; left:0; right:0; top:var(--vv-top, 0px); height:var(--vvh, 100dvh);
          z-index:40; display:flex; flex-direction:column;
          background:var(--paper); color:var(--ink-900); }
.br-onb-scroll { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; }
.br-onb-pad { padding:calc(28px + env(safe-area-inset-top,0px)) 22px 26px; }
.br-onb-h1 { font-family:var(--font-serif); font-size:30px; line-height:1.18; letter-spacing:-.02em;
             margin:12px 0 0; text-wrap:balance; }
.br-onb-h1--seat { font-size:28px; }
.br-onb-lede { font-family:var(--font-serif); font-size:17px; line-height:1.55; color:var(--ink-700);
               margin:10px 0 0; text-wrap:pretty; }
/* Footer — fixed, above the home indicator, holding the one forward control. */
.br-onb-foot { flex:none; background:var(--paper-raised); border-top:1px solid var(--line);
               padding:16px 22px max(16px, calc(env(safe-area-inset-bottom,0px) + 6px)); }

/* 02 — purpose cards */
.br-onb-cards { display:flex; flex-direction:column; gap:10px; margin-top:26px; }
.br-onb-card { display:block; width:100%; text-align:left; box-sizing:border-box; cursor:pointer;
               padding:16px 18px; border-radius:var(--radius-2); border:1px solid var(--line);
               background:var(--paper-raised); position:relative;
               transition:border-color var(--dur-fast) var(--ease-standard),
                          background var(--dur-fast) var(--ease-standard); }
.br-onb-card.sel { border-color:var(--claret); background:var(--claret-wash); }
.br-onb-card-n { font-family:var(--font-serif); font-size:20px; line-height:1.2; color:var(--ink-900);
                 padding-right:26px; }
.br-onb-card-s { font-family:var(--font-sans); font-size:13px; line-height:1.45; color:var(--ink-500);
                 margin-top:4px; }
.br-onb-card-c { position:absolute; top:17px; right:16px; display:flex; }
.br-onb-flabel { font-family:var(--font-mono); font-size:10px; letter-spacing:.16em;
                 text-transform:uppercase; color:var(--ink-500); margin-bottom:8px; }

/* 03 — the seat list */
.br-onb-head { flex:none; padding:calc(24px + env(safe-area-inset-top,0px)) 22px 14px;
               border-bottom:1px solid var(--line); background:var(--paper); }
.br-onb-prog { font-family:var(--font-sans); font-size:14px; line-height:1.45; margin-top:12px; }
.br-onb-prog.muted { color:var(--ink-500); }
.br-onb-prog.on    { color:var(--ink-700); }
.br-onb-prog.full  { color:var(--claret); }
.br-onb-grp { font-family:var(--font-mono); font-size:12px; letter-spacing:.18em; text-transform:uppercase;
              color:var(--ink-500); padding:18px 22px 10px; }
.br-onb-row { display:flex; align-items:flex-start; gap:13px; padding:14px 22px 15px;
              border-top:1px solid var(--line-faint); }
.br-onb-row-b { flex:1; min-width:0; }
.br-onb-row-n { font-family:var(--font-serif); font-size:18px; line-height:1.3; color:var(--ink-900); }
.br-onb-row-f { font-family:var(--font-mono); font-size:10px; letter-spacing:.1em; text-transform:uppercase;
                color:var(--ink-500); margin-top:4px; }
.br-onb-row-p { font-family:var(--font-serif); font-size:15px; line-height:1.5; color:var(--ink-700);
                margin:5px 0 0; text-wrap:pretty; }
.br-onb-row-c { flex:none; width:88px; }

/* 04 — the beat */
.br-onb-beat { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
               padding:0 34px; text-align:center; }
.br-onb-beat-r { width:44px; height:1px; background:var(--line-strong); margin:18px 0; }
.br-onb-beat-n { font-family:var(--font-serif); font-size:34px; line-height:1.15; letter-spacing:-.02em;
                 color:var(--ink-900); text-wrap:balance; }
.br-onb-beat-m { font-family:var(--font-sans); font-size:14px; color:var(--ink-500); margin-top:14px; }

/* 00 — Face ID, full screen on the inverse surface */
.br-fid { position:fixed; left:0; right:0; top:var(--vv-top, 0px); height:var(--vvh, 100dvh);
          z-index:70; background:var(--paper-inverse); color:#F6F4EF;
          display:flex; flex-direction:column;
          padding:calc(44px + env(safe-area-inset-top,0px)) 28px
                  max(40px, calc(env(safe-area-inset-bottom,0px) + 20px)); }
.br-fid-mid { flex:1; display:flex; flex-direction:column; justify-content:center; }
.br-fid-mark { width:52px; height:52px; border:1.5px solid rgba(246,244,239,.42); border-radius:var(--radius-3);
               display:flex; align-items:center; justify-content:center; }
.br-fid-h { font-family:var(--font-serif); font-size:30px; line-height:1.2; letter-spacing:-.02em;
            color:#F6F4EF; margin:30px 0 0; text-wrap:balance; }
.br-fid-b { font-family:var(--font-serif); font-size:17px; line-height:1.62; color:rgba(246,244,239,.62);
            margin:14px 0 0; text-wrap:pretty; }
.br-fid-mark-btn { background:none; padding:0; cursor:pointer; -webkit-appearance:none; appearance:none; }
.br-fid-acts { display:flex; flex-direction:column; gap:12px; }
.br-fid-note { font-family:var(--font-sans); font-size:12.5px; color:rgba(246,244,239,.52); margin:0 0 2px; }

/* 00b — the second ask, a sheet over the room */
.br-fid-scrim { position:fixed; left:0; right:0; top:var(--vv-top, 0px); height:var(--vvh, 100dvh);
                z-index:70; background:rgba(27,26,23,.5); }
.br-fid-sheet { position:fixed; left:0; right:0; z-index:71; background:var(--paper-raised);
                top:calc(var(--vv-top, 0px) + var(--vvh, 100dvh)); transform:translateY(-100%);
                border-top:1px solid var(--line); box-shadow:var(--shadow-3);
                padding:24px 22px max(32px, calc(env(safe-area-inset-bottom,0px) + 16px));
                animation:br-fid-up var(--dur) var(--ease-standard); }
@keyframes br-fid-up { from { transform:translateY(12px); opacity:0; } to { transform:none; opacity:1; } }
.br-fid-sheet-h { font-family:var(--font-serif); font-size:22px; line-height:1.25; margin:0;
                  color:var(--ink-900); text-wrap:balance; }
.br-fid-sheet-b { font-family:var(--font-serif); font-size:15px; line-height:1.55; color:var(--ink-700);
                  margin:8px 0 0; }
.br-fid-sheet-a { display:flex; gap:10px; margin-top:18px; }
.br-fid-sheet-a > * { flex:1; }
`;

  // The scan-face mark. Lucide's glyph at 1.5px stroke — the app's Icons module has no
  // face glyph, and the handoff names this one specifically.
  function ScanFace({ size, color }) {
    const s = size || 24;
    return (
      <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color || "#F6F4EF"}
        strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" />
        <path d="M21 17v2a2 2 0 0 1-2 2h-2" /><path d="M7 21H5a2 2 0 0 1-2-2v-2" />
        <path d="M9 9h.01" /><path d="M15 9h.01" /><path d="M8.5 14.5a4 4 0 0 0 7 0" />
      </svg>
    );
  }

  // ── THE PASSKEY CEREMONY, INSTRUMENTED (IOS-SAFARI-PASS-V1, 2026-09-01) ────────
  //
  // WHAT WAS MEASURED. Mike's iPhone 11 ran screen 00: Safari's sheet appeared, Face ID
  // succeeded, and the app came back to the SAME screen with no error. The server says the
  // ceremony WORKED — auth.webauthn_credentials holds a real "Apple Passwords" credential for
  // that account, created 11:38:40Z. So enrolment is not failing. Its OUTCOME is being lost.
  //
  // A promise cannot report into a page that no longer exists, and it cannot report at all if
  // it never settles. Both are invisible to the code that awaits it, and both look exactly
  // like the reported symptom. So three things changed, and none of them assume which it was:
  //
  //   1. THE MARKER IS WRITTEN BEFORE THE CALL. A ceremony that started is recorded in
  //      localStorage first, so it survives Safari tearing the page down. On the next mount
  //      an unresolved marker means "this ran and we did not see the end" — which is not the
  //      same as "never asked", and must not re-offer the same screen as if nothing happened.
  //   2. A DEADLINE. If the promise has not settled in CEREMONY_MS the screen stops waiting.
  //      It does NOT claim failure — the server evidence says the opposite is more likely.
  //   3. IT NEVER RETURNS TO ITSELF SILENTLY. Every exit from this screen either advances or
  //      says something. "Nothing happened" is not one of the outcomes.
  //
  // Every step is stamped into window.__BR_PASSKEY, which the ?debug=1 readout renders — Mike
  // has no Mac, so the phone has to be able to say what happened without a remote inspector.
  const CEREMONY_MS = 60000;      // Face ID + a sheet the user may read: generous on purpose
  const MARKER = "br.passkey.ceremony";

  function noteP(step, extra) {
    try {
      window.__BR_PASSKEY = window.__BR_PASSKEY || [];
      window.__BR_PASSKEY.push(Object.assign({ t: Date.now(), step: step }, extra || {}));
      if (window.__BR_PASSKEY.length > 12) window.__BR_PASSKEY.shift();
    } catch (e) {}
  }
  function markerRead() {
    try { return JSON.parse(window.localStorage.getItem(MARKER) || "null"); } catch (e) { return null; }
  }
  function markerWrite(v) {
    try {
      if (v) window.localStorage.setItem(MARKER, JSON.stringify(v));
      else window.localStorage.removeItem(MARKER);
    } catch (e) {}
  }

  // The one enrolment call, wrapped. `store.registerPasskey()` is untouched — this adds a
  // record and a deadline around it, and nothing else. Resolves {outcome, detail} rather than
  // rejecting, because every outcome here has a screen to go to.
  function runCeremony(store, where) {
    markerWrite({ startedAt: Date.now(), where: where });
    noteP("start", { where: where });
    let settled = false;
    return new Promise((resolve) => {
      const done = (outcome, detail) => {
        if (settled) return;
        settled = true;
        markerWrite(null);
        noteP(outcome, detail ? { detail: String(detail).slice(0, 180) } : null);
        resolve({ outcome: outcome, detail: detail ? String(detail).slice(0, 180) : "" });
      };
      const timer = setTimeout(() => done("timeout", CEREMONY_MS + "ms with no answer from registerPasskey()"), CEREMONY_MS);
      try {
        Promise.resolve(store.registerPasskey())
          .then((r) => { clearTimeout(timer); done("enrolled", r && r.id ? "id=" + r.id : ""); })
          .catch((e) => {
            clearTimeout(timer);
            const msg = String((e && e.message) || (e && e.error_description) || e || "");
            try { window.__BR_LASTERR = "registerPasskey: " + msg.slice(0, 220); } catch (x) {}
            // NotAllowedError is what Safari reports for BOTH a user cancel and a lost user
            // gesture, and it cannot tell us which. Named rather than guessed at.
            done("failed", (e && e.name ? e.name + ": " : "") + msg);
          });
      } catch (e) { clearTimeout(timer); done("failed", "threw: " + String(e && e.message || e)); }
    });
  }

  // ── 00 · Face ID offer ─────────────────────────────────────────────────────
  // ONE enrolment path in the whole app: `store.registerPasskey()`, the same call the
  // account menu's row makes. This screen is a new PLACE to reach it, never a second
  // implementation — the WebAuthn ceremony, its config gate and its failure copy are
  // untouched. `onDone` carries the outcome so the caller can advance BR.faceIdNext.
  function FaceIdOffer({ store, onDone, onAttempt }) {
    const { Button } = DS;
    // An unresolved marker at MOUNT means a ceremony ran and this page never saw it end —
    // the reported iPhone symptom exactly. The screen says so and moves on rather than
    // presenting itself again as though the tap had not happened.
    const [interrupted] = React.useState(() => {
      const stage = window.BR.passkeyStageFromMarker(markerRead(), Date.now());
      if (stage === "interrupted") { noteP("interrupted-on-mount"); markerWrite(null); return true; }
      if (stage === "stale" || stage === "unknown") markerWrite(null);
      return false;
    });
    const [state, setState] = React.useState("offer");   // offer | busy | error | timeout
    const [detail, setDetail] = React.useState("");
    const enrol = () => {
      // ⚠️ RECORD THE ATTEMPT BEFORE THE CEREMONY (RULED: Mike 2026-09-02c). iOS can tear down
      // and cold-relaunch a standalone app while a system sheet is up, and a relaunch that finds
      // the ask still at stage 0 puts this very screen back — the reported loop. Advancing first
      // means the ceremony can be interrupted any number of times and the user still moves on.
      // It costs nothing when the ceremony completes: enrolled/declined both land on 2 anyway.
      try { if (onAttempt) onAttempt(); } catch (e) {}
      setState("busy");
      runCeremony(store, "onboarding").then((r) => {
        if (r.outcome === "enrolled") { onDone("enrolled"); return; }
        setDetail(r.detail || "");
        setState(r.outcome === "timeout" ? "timeout" : "error");
      });
    };
    // THE INTERRUPTED SCREEN. Not an error, and deliberately not phrased as one: the server
    // evidence says the ceremony most likely SUCCEEDED and only the answer was lost. It states
    // what is actually known, offers the one useful action, and never re-runs the ceremony
    // behind the user's back.
    if (interrupted) {
      return (
        <div className="br-fid" data-br-faceid="interrupted">
          <style>{onbCSS}</style>
          <div className="br-fid-mid">
            <div className="br-fid-mark"><ScanFace size={24} /></div>
            <h1 className="br-fid-h">Face ID may already be set up.</h1>
            <p className="br-fid-b">
              Safari interrupted us before we heard back. If it asked for Face ID and you gave
              it, you're set — try it next time you sign in. Either way the code still works,
              and you can turn Face ID on any time from your account.
            </p>
          </div>
          <div className="br-fid-acts">
            <Button variant="primary" size="lg" fullWidth onClick={() => onDone("declined")}>Continue</Button>
          </div>
        </div>
      );
    }
    // A deadline is NOT a failure. Saying "that didn't work" here would contradict what the
    // server has already recorded on this very account.
    if (state === "timeout") {
      return (
        <div className="br-fid" data-br-faceid="timeout">
          <style>{onbCSS}</style>
          <div className="br-fid-mid">
            <div className="br-fid-mark"><ScanFace size={24} /></div>
            <h1 className="br-fid-h">We didn't hear back from Face ID.</h1>
            <p className="br-fid-b">
              It may well have worked — try it next time you sign in. Your email code always
              works, and Face ID is in your account settings whenever you want it.
            </p>
          </div>
          <div className="br-fid-acts">
            <Button variant="primary" size="lg" fullWidth onClick={() => onDone("declined")}>Continue</Button>
          </div>
        </div>
      );
    }
    return (
      <div className="br-fid" data-br-faceid={state}>
        <style>{onbCSS}</style>
        <div className="br-fid-mid">
          <div className="br-fid-mark"><ScanFace size={24} /></div>
          <h1 className="br-fid-h">Use Face ID next time instead of the code.</h1>
          <p className="br-fid-b">Sign in with a look. The code still works if Face ID fails.</p>
        </div>
        <div className="br-fid-acts">
          {state === "error" && (
            <p className="br-fid-note">
              That didn't complete — your email code always works.
              {detail ? <span style={{ display: "block", opacity: .7, marginTop: 3 }}>{detail}</span> : null}
            </p>
          )}
          <Button variant="primary" size="lg" fullWidth onClick={enrol} disabled={state === "busy"}>
            {state === "busy" ? "Waiting for Face ID…" : (state === "error" ? "Try Face ID again" : "Use Face ID")}
          </Button>
          {/* ALWAYS present, including while busy. The reported defect left the user with a
              disabled button and no way forward; there is now always a way out of this screen. */}
          <Button variant="secondary" size="lg" fullWidth
            onClick={() => onDone(state === "busy" ? "declined" : "skipped")}>
            {state === "busy" ? "Skip and carry on" : "Not now"}
          </Button>
        </div>
      </div>
    );
  }

  // ── 00b · Face ID second ask ───────────────────────────────────────────────
  // Offered once more after the first answer has arrived — the value is demonstrated by
  // then. "No thanks" is final. Enrolment stays in the account menu either way.
  function FaceIdSheet({ store, onDone, onAttempt }) {
    const { Button } = DS;
    const [state, setState] = React.useState("offer");
    // Same ceremony wrapper as screen 00 — one instrumented path, not two.
    const enrol = () => {
      // Same rule as screen 00: the attempt is recorded before the ceremony, so an interrupted
      // one can never bring the ask back (RULED: Mike 2026-09-02c).
      try { if (onAttempt) onAttempt(); } catch (e) {}
      setState("busy");
      runCeremony(store, "second-ask").then((r) => {
        if (r.outcome === "enrolled") { onDone("enrolled"); return; }
        // A deadline or a failure here ends the asking. This is the SECOND and final ask;
        // leaving it open would be a third.
        setState(r.outcome === "timeout" ? "timeout" : "error");
        if (r.outcome === "timeout") onDone("declined");
      });
    };
    return (
      <React.Fragment>
        <style>{onbCSS}</style>
        <div className="br-fid-scrim" />
        <div className="br-fid-sheet" role="dialog" aria-label="Use Face ID">
          <h2 className="br-fid-sheet-h">Use Face ID next time instead of the code.</h2>
          <p className="br-fid-sheet-b">
            {state === "error"
              ? "That didn't complete — your email code always works."
              : "Sign in with a look. The code still works if Face ID fails."}
          </p>
          <div className="br-fid-sheet-a">
            <Button variant="primary" size="lg" fullWidth onClick={enrol} disabled={state === "busy"}>
              {state === "busy" ? "Waiting…" : "Use Face ID"}
            </Button>
            <Button variant="secondary" size="lg" fullWidth onClick={() => onDone("declined")}
              disabled={state === "busy"}>No thanks</Button>
          </div>
        </div>
      </React.Fragment>
    );
  }

  // ── 01 · Name ──────────────────────────────────────────────────────────────
  // One field. NOT pre-filled from the email — that is an account detail, not what a
  // room calls you. No label, no body copy, no explanation of why it is asked.
  function NameStep({ value, onChange, onContinue }) {
    const { Button, Input, Kicker } = DS;
    const ok = !!String(value || "").trim();
    return (
      <div className="br-onb">
        <style>{onbCSS}</style>
        <div className="br-onb-scroll">
          <div className="br-onb-pad">
            <Kicker>Step 1 of 3</Kicker>
            <h1 className="br-onb-h1">What should the board call you?</h1>
            <div style={{ marginTop: 28 }}>
              {React.createElement(Input, {
                size: "lg", fullWidth: true, placeholder: "First name", value: value,
                autoComplete: "given-name", maxLength: 80, "aria-label": "First name",
                onChange: (e) => onChange(e.target.value.slice(0, 80)),
                onKeyDown: (e) => { if (e.key === "Enter" && ok) onContinue(); },
              })}
            </div>
          </div>
        </div>
        <div className="br-onb-foot">
          <Button variant="primary" size="lg" fullWidth disabled={!ok} onClick={onContinue}>Continue</Button>
        </div>
      </div>
    );
  }

  // ── 02 · Purpose ───────────────────────────────────────────────────────────
  // One tap sets the subject: it orders step 3's list, seats a starting lineup and fills
  // the board's name. FOUR cards, no fifth: the ceiling is 8 of a 22-member roster, so a
  // "whole board" option would name a state that cannot exist.
  function PurposeStep({ experts, purpose, boardName, onPick, onName, onContinue }) {
    const { Button, Input, Kicker } = DS;
    const presets = BR.onboardingPresets();
    const ok = !!String(boardName || "").trim() && !!purpose;
    return (
      <div className="br-onb">
        <style>{onbCSS}</style>
        <div className="br-onb-scroll">
          <div className="br-onb-pad">
            <Kicker>Step 2 of 3</Kicker>
            <h1 className="br-onb-h1">What is this board for?</h1>
            <div className="br-onb-cards">
              {presets.map((p) => (
                <button type="button" key={p.id} aria-pressed={purpose === p.id}
                  className={"br-onb-card" + (purpose === p.id ? " sel" : "")}
                  onClick={() => onPick(p.id)}>
                  <div className="br-onb-card-n">{p.name}</div>
                  <div className="br-onb-card-s">{BR.presetFirstNames(p.id, experts)}</div>
                  {purpose === p.id && (
                    <span className="br-onb-card-c">
                      <Icon name="check" size={17} stroke="var(--claret)" />
                    </span>
                  )}
                </button>
              ))}
            </div>
          </div>
        </div>
        {/* The board-name field sits BEHIND the footer's own rule so it can never read as
            a fifth option in the card list. */}
        <div className="br-onb-foot">
          <div className="br-onb-flabel">Board name</div>
          {React.createElement(Input, {
            size: "lg", fullWidth: true, value: boardName, maxLength: 60,
            "aria-label": "Board name", placeholder: "Board name",
            onChange: (e) => onName(e.target.value.slice(0, 60)),
          })}
          <div style={{ height: 16 }} />
          <Button variant="primary" size="lg" fullWidth disabled={!ok} onClick={onContinue}>Continue</Button>
        </div>
      </div>
    );
  }

  // ── 03 · Seat ──────────────────────────────────────────────────────────────
  // The header and footer are fixed; only the list scrolls. `flex:1` AND `min-height:0`
  // are both required on the scroller — a flex child defaults to min-height:auto and
  // will not shrink below its content, which silently pushes the footer off-screen.
  function SeatStep({ experts, seated, purpose, cap, onToggle, onEnter, busy }) {
    const { Button, Kicker, Avatar } = DS;
    const max = cap || 8;
    const seatedIds = seated;                      // array — SEATING order, not set order
    const isSeated = {}; seatedIds.forEach((id) => { isSeated[id] = true; });
    const atCap = seatedIds.length >= max;
    const groups = BR.seatGroups(seatedIds, experts, purpose, max);
    const prog = BR.progressLine(seatedIds, experts, max);

    // ⚠️ THE ROW MOVES UNDER THE FINGER (2026-09-01, iPhone retest).
    // REPORTED: tapping the "Thinking" card — which names four — ended up with six seated.
    // MEASURED FIRST: it is NOT the preset. `BR.presetSeats("thinking", …)` returns exactly
    // the four on the card, the card's own subtitle is built from the SAME array, and there
    // is no config override in production. Run and asserted in app/test/onboarding.test.js.
    // WHAT CAN produce it: seating a member lifts its row out of its subject group and into
    // the Seated group at the TOP of the list (the handoff specifies that, and it is good —
    // it is how the list stays readable). But the lift removes a ~100px row from above the
    // finger and inserts it further up, so everything below jumps UP by a row. A second tap
    // at the same screen position lands on whoever moved into that spot. Two quick taps to
    // add one member is an ordinary phone gesture; the second one adds a stranger.
    // THE FIX: keep the reorder, and hold the SCROLL so what was under the finger stays under
    // the finger. The list re-ranks; the pixels do not move.
    const scrollRef = React.useRef(null);
    const pendingRef = React.useRef(null);
    React.useLayoutEffect(() => {
      const el = scrollRef.current;
      const p = pendingRef.current;
      pendingRef.current = null;
      if (!el || !p) return;
      // The anchor is the row the user did NOT touch: whatever sat at the top of the
      // scrollport before the tap. Its new offset minus its old one is exactly how far the
      // content moved, and scrolling by that difference cancels the jump.
      const anchor = p.anchorId ? el.querySelector('[data-br-member="' + p.anchorId + '"]') : null;
      if (!anchor) { el.scrollTop = p.scrollTop; return; }
      const delta = anchor.offsetTop - p.anchorTop;
      el.scrollTop = p.scrollTop + delta;
    });
    // Record where things were BEFORE the state change, from the tap handler.
    const toggleHolding = (id) => {
      const el = scrollRef.current;
      if (el) {
        const rows = Array.prototype.slice.call(el.querySelectorAll("[data-br-member]"));
        // First row whose top is at or below the current scroll position — the one the user
        // is actually looking at. Deliberately NOT the tapped row: that one is about to move.
        const anchorEl = rows.filter((r) => r.offsetTop >= el.scrollTop
          && r.getAttribute("data-br-member") !== id)[0] || null;
        pendingRef.current = { scrollTop: el.scrollTop,
          anchorId: anchorEl ? anchorEl.getAttribute("data-br-member") : null,
          anchorTop: anchorEl ? anchorEl.offsetTop : 0 };
      }
      onToggle(id);
    };
    return (
      <div className="br-onb">
        <style>{onbCSS}</style>
        <div className="br-onb-head">
          <Kicker>Step 3 of 3</Kicker>
          <h1 className="br-onb-h1 br-onb-h1--seat">Seat the board</h1>
          <p className="br-onb-lede">We've seated a starting board for you — keep them, or swap anyone.</p>
          {/* THE single place seating state is reported. */}
          <div className={"br-onb-prog " + prog.tone} data-br-progress role="status">{prog.text}</div>
        </div>
        <div className="br-onb-scroll" ref={scrollRef}>
          {groups.map((g) => (
            <div key={g.key} data-br-group={g.key}>
              <div className="br-onb-grp">{g.label}</div>
              {g.members.map((m) => {
                const on = !!isSeated[m.id];
                // AT THE CAP, SEATED ROWS STAY LIVE — trimming must remain possible, or the
                // only way out of a full board is to abandon the screen.
                const dis = atCap && !on;
                return (
                  <div className="br-onb-row" key={m.id} data-br-member={m.id}>
                    {React.createElement(Avatar, { name: m.name, size: "sm", shape: "round" })}
                    <div className="br-onb-row-b">
                      <div className="br-onb-row-n">{m.name}</div>
                      <div className="br-onb-row-f">{BR.memberField(m)}</div>
                      <p className="br-onb-row-p">{BR.memberBlurb(m)}</p>
                    </div>
                    <div className="br-onb-row-c">
                      <Button size="md" fullWidth variant={on ? "primary" : "secondary"}
                        disabled={dis} aria-pressed={on} onClick={() => toggleHolding(m.id)}>
                        {on ? "Seated" : "Seat"}
                      </Button>
                    </div>
                  </div>
                );
              })}
            </div>
          ))}
          <div style={{ height: 12 }} />
        </div>
        <div className="br-onb-foot">
          <Button variant="primary" size="lg" fullWidth disabled={!seatedIds.length || busy}
            onClick={onEnter}>{busy ? "Seating…" : "Enter the boardroom"}</Button>
        </div>
      </div>
    );
  }

  // ── 04 · Entering ──────────────────────────────────────────────────────────
  // The only screen with nothing to tap. The board has already been written by the time
  // this renders; this is the held beat before Ask. No dialog, no toast, nothing to dismiss.
  function EnteringStep({ boardName, count }) {
    const { Kicker } = DS;
    return (
      <div className="br-onb" data-br-entering>
        <style>{onbCSS}</style>
        <div className="br-onb-beat">
          {React.createElement(Kicker, { rule: false, align: "center" }, "Seated")}
          <div className="br-onb-beat-r" />
          <div className="br-onb-beat-n">{boardName}</div>
          <div className="br-onb-beat-m">
            {count} {count === 1 ? "member" : "members"} · saved to your boards
          </div>
        </div>
      </div>
    );
  }


  // ── 04b · ADD TO HOME SCREEN (AMENDS-2-V1; reworked 2026-09-02, Mike's iPhone run) ──
  // Sits AFTER Entering (04) and BEFORE the Ask first-land (05). Full screen on the inverse
  // surface, the Face ID offer's proportions, so the two enrolment moments bracket setup and
  // look like each other.
  //
  // **[SUPERSEDED 2026-09-02 — Mike, from the phone]** The screen used to open with
  // ~~"I've done it" / "Later"~~ — a primary button that CLAIMED instead of ACTING, over an
  // instruction the user then had to carry out somewhere else, beside a glyph tile that looked
  // tappable and did nothing. Now the primary button ACTS: it calls `navigator.share()` from
  // inside the tap, which is what makes Safari open the system share sheet (the sheet is where
  // "Add to Home Screen" lives). Only AFTER that attempt settles — resolved, dismissed or
  // rejected, all three are "they have seen the sheet" — does the pair become the claim it
  // always was. **The claim now follows the attempt instead of replacing it.**
  //
  // ⚠️ STILL UNVERIFIABLE, and deliberately so. The app cannot see whether the icon was added,
  // so "I've done it" is taken at its word — and what it buys is SILENCE: the room hint (07) is
  // suppressed for good. "Later" leaves 07 as the backstop. Nothing here pretends to check.
  //
  // ⚠️ AND ONE THING ONLY A PHONE CAN SETTLE: whether iOS Safari's share sheet actually offers
  // "Add to Home Screen" for a page opened this way. If it does not, this screen degrades to
  // exactly what it was — the instruction, the glyph, and the claim pair — because the offer
  // phase is skipped wholesale wherever `navigator.share` is absent.
  const HOME_URL = "https://myboard.confidentlywrong.me";
  function HomeScreenStep({ onAnswer }) {
    const { Button } = DS;
    // Feature-detected ONCE at mount. No share API (desktop Chrome, an Android WebView, a
    // browser that hides it) means there is no action to offer, so the screen opens in the
    // claim phase rather than showing a primary button that would do nothing — a dead primary
    // is the very defect this change is fixing.
    const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
    const [phase, setPhase] = React.useState(canShare ? "offer" : "claim");
    const [sharing, setSharing] = React.useState(false);
    const shareGlyph = (size, color) => (
      <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color || "currentColor"}
        strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="M12 3v13" /><path d="m8 7 4-4 4 4" />
        <path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" />
      </svg>
    );
    // ⚠️ THE GESTURE RULE, and the reason this is not async/await. navigator.share() must be
    // called SYNCHRONOUSLY inside the tap handler: iOS treats the user activation as spent the
    // moment the call is deferred behind an await, a timer or another promise, and the sheet
    // silently never opens. Everything that can wait — the phase flip — happens after.
    // ⚠️ ROUND 4 §5.2 — THE PERSISTENT STRIP, and OVERRIDE A (RULED: Mike 2026-09-03).
    //
    // Design proposed demoting the primary to "I'm ready — show me" on the premise (§5.3) that
    // an app "cannot open the sheet". THAT PREMISE IS WRONG HERE: this button has called
    // navigator.share() from inside the tap gesture since 2026-08, and it works — the sheet
    // opens. So the demotion is REJECTED and the button keeps both its label and its action.
    // What Round 4 gets right, and what is adopted exactly as drawn, is that page copy is
    // useless once the sheet is up: it covers the bottom of the viewport and cannot be styled,
    // detected or scrolled. So the SAME TAP now does both — opens the sheet AND arms the strip
    // in the one band the sheet never reaches, under the status bar.
    //
    // Dismissal, per §5.2: on return (visibilitychange/focus), on appinstalled, or after 90s.
    // No close button — it is two lines and it is the task.
    const [armed, setArmed] = React.useState(false);
    React.useEffect(() => {
      if (!armed) return undefined;
      const off = () => setArmed(false);
      const onVis = () => { if (!document.hidden) off(); };
      const t = setTimeout(off, 90000);
      window.addEventListener("focus", off);
      document.addEventListener("visibilitychange", onVis);
      window.addEventListener("appinstalled", off);
      return () => {
        clearTimeout(t);
        window.removeEventListener("focus", off);
        document.removeEventListener("visibilitychange", onVis);
        window.removeEventListener("appinstalled", off);
      };
    }, [armed]);

    const share = () => {
      setArmed(true);   // armed by the user's own tap, alongside the sheet it opens
      if (sharing) return;
      if (!canShare) { setPhase("claim"); return; }
      setSharing(true);
      const settle = () => { setSharing(false); setPhase("claim"); };
      let p;
      try { p = navigator.share({ title: "The Boardroom", url: HOME_URL }); }
      catch (e) { settle(); return; }        // threw synchronously (unsupported/blocked)
      // A DISMISSED sheet rejects with AbortError. That is not a failure here: the user was
      // taken to the sheet, which is the whole job of this button, so both paths land in the
      // claim phase. Treating a dismissal as "try again" would trap them on the offer.
      Promise.resolve(p).then(settle, settle);
    };
    return (
      <div className="br-fid" data-br-homestep data-br-homestep-phase={phase}>
        <style>{onbCSS}</style>
        <div className="br-fid-mid">
          {phase === "offer" ? (
            <button type="button" className="br-fid-mark br-fid-mark-btn" onClick={share}
              data-br-homestep-glyph aria-label="Add to Home Screen">
              {shareGlyph(24, "#F6F4EF")}
            </button>
          ) : (
            <div className="br-fid-mark">{shareGlyph(24, "#F6F4EF")}</div>
          )}
          <h1 className="br-fid-h">Last thing — put The Boardroom on your Home Screen.</h1>
          {/* ⚠️ §5.1 FRONT-LOADED COPY. The scroll is named BEFORE anything is covered, because
              once the sheet is up this text is behind it. "Further down the list than you
              expect" is the sentence that does the work — the row is below the sheet's visible
              fold, and that is the step users miss. */}
          <p className="br-fid-b">
            Tap <span style={{ display: "inline-flex", verticalAlign: "-2px", margin: "0 2px" }}>{shareGlyph(15)}</span>
            {" "}to open the share sheet. "Add to Home Screen" is <strong>further down the list
            than you expect</strong> — you will need to scroll the sheet.
          </p>
          <div className="br-04b-steps">
            <div className="br-04b-step">
              <span className="br-04b-i">01</span>
              <span>Tap the share glyph <span style={{ display: "inline-flex", verticalAlign: "-3px" }}>{shareGlyph(18)}</span>.</span>
            </div>
            <div className="br-04b-step">
              <span className="br-04b-i">02</span>
              <span><strong>Scroll down</strong> past Copy and Add to Bookmarks to <strong>Add to Home Screen</strong>.</span>
            </div>
          </div>
        </div>
        {/* ⚠️ §5.2 THE STRIP — pinned under the status bar, the one band iOS's sheet never
            reaches. Armed by the user's own tap, so it says what to do rather than claiming to
            know what is happening; if they change their mind it is briefly wrong and harmless.
            Serif 20px because it must be read at a glance while attention is on someone else's
            UI. No close button: it is two lines and it is the task. */}
        {armed ? (
          <div className="br-04b-strip" role="status" data-br-04b-strip>
            <div className="br-04b-strip-1">
              <span className="br-04b-ring" aria-hidden="true" />
              <span>Step 2 of 2</span>
            </div>
            <div className="br-04b-strip-2">Scroll the grey list down to <strong>Add to Home Screen</strong>.</div>
            <div className="br-04b-strip-3">
              It sits below Copy, Add to Bookmarks and Add to Reading List. This note stays put
              while you look.
            </div>
          </div>
        ) : null}
        <div className={"br-fid-acts" + (armed ? " br-04b-dimmed" : "")}>
          {phase === "offer" ? (
            /* ⚠️ OVERRIDE A (RULED: Mike 2026-09-03) — the label and the ACTION both stand.
               Design's "I'm ready — show me" demotion rests on §5.3's claim that an app cannot
               open the sheet; this button has opened it from inside the tap gesture since
               2026-08. One tap: the sheet opens AND the strip arms. */
            <Button variant="primary" size="lg" fullWidth onClick={share} disabled={sharing}
              data-br-homestep-share>Add to Home Screen</Button>
          ) : (
            <Button variant="primary" size="lg" fullWidth onClick={() => onAnswer("done")}
              data-br-homestep-done>I've done it</Button>
          )}
          <Button variant="secondary" size="lg" fullWidth onClick={() => onAnswer("later")}>Later</Button>
        </div>
      </div>
    );
  }

  // ── The flow ───────────────────────────────────────────────────────────────
  // Owns steps 1-4 and nothing else. `onFinish(name, boardName, seatedIds)` does the
  // writing; this component only decides what is on screen. The beat is held for
  // BEAT_MS AFTER the write resolves, so the user never sees "saved to your boards"
  // over a save that then failed.
  const BEAT_MS = 700;

  function OnboardingFlow({ experts, cap, initialName, onFinish, onEntered, onError, onHomeStep }) {
    const max = cap || 8;
    const [step, setStep] = React.useState(1);
    const [name, setName] = React.useState(initialName || "");
    const [purpose, setPurpose] = React.useState(null);
    const [boardName, setBoardName] = React.useState("");
    const [seated, setSeated] = React.useState([]);      // ARRAY — seating order matters
    const [busy, setBusy] = React.useState(false);
    const [homeStepAnswer, setHomeStepAnswer] = React.useState("unanswered");
    const publishedIds = (experts || []).map((e) => e.id);

    // A purpose tap sets the subject, refills the board name and REPLACES the seated set
    // with that preset's lineup. Refilling the name on every tap is deliberate: the field
    // tracks the last tap until the user edits it themselves.
    const pickPurpose = (id) => {
      const p = BR.presetById(id);
      setPurpose(id);
      setBoardName(p ? p.name : "");
      setSeated(BR.presetSeats(id, publishedIds, max));
    };
    const toggle = (id) => setSeated((prev) => {
      if (prev.indexOf(id) >= 0) return prev.filter((x) => x !== id);
      if (prev.length >= max) return prev;                 // the cap holds; seated rows stay live
      return prev.concat([id]);
    });

    const enter = () => {
      if (busy || !seated.length) return;
      setBusy(true);
      Promise.resolve(onFinish(name.trim(), boardName.trim(), seated.slice()))
        .then(() => {
          setStep(4);
          return new Promise((res) => setTimeout(res, BEAT_MS));
        })
        .then(() => {
          // 04b — only where the instruction is TRUE (Safari on iPhone, not already a
          // home-screen app). Anywhere else the step does not exist: skipped, not shown
          // disabled, because "Add to Home Screen" is not a thing Chrome on Android or a
          // desktop browser offers from that menu.
          if (window.BR.homeStepShows(navigator.userAgent, {
            standalone: !!(window.navigator && window.navigator.standalone),
            answered: homeStepAnswer,
          })) { setStep(5); return; }
          if (onEntered) onEntered();
        })
        .catch((e) => { setBusy(false); if (onError) onError(e); });
      // NOTE: no setBusy(false) on success — the flow is unmounted by the caller once the
      // beat resolves, and re-enabling the button first would offer a second write.
    };

    if (step === 1) {
      return <NameStep value={name} onChange={setName} onContinue={() => setStep(2)} />;
    }
    if (step === 2) {
      return <PurposeStep experts={experts} purpose={purpose} boardName={boardName}
        onPick={pickPurpose} onName={setBoardName} onContinue={() => setStep(3)} />;
    }
    if (step === 3) {
      return <SeatStep experts={experts} seated={seated} purpose={purpose} cap={max}
        onToggle={toggle} onEnter={enter} busy={busy} />;
    }
    if (step === 5) {
      return <HomeScreenStep onAnswer={(a) => {
        const next = window.BR.homeStepNext(a);
        setHomeStepAnswer(next);
        if (onHomeStep) onHomeStep(next);
        if (onEntered) onEntered();
      }} />;
    }
    return <EnteringStep boardName={boardName} count={seated.length} />;
  }

  Object.assign(window, { OnboardingFlow, HomeScreenStep, FaceIdOffer, FaceIdSheet, NameStep, PurposeStep, SeatStep, EnteringStep });
})();
