/* Gravity CMS — app shell, state, actions, router.
 *
 * State now comes from useSheets() (app/useSheets.js) instead of localStorage —
 * see that file for the read/write/poll mechanics. Every action below does two
 * things with the same record(s): applyLocalRows appends them to the local raw
 * grids and re-runs the tested assembler (instant UI, converges with the poll
 * by construction — #57 retired the old clone-and-patch mutate()), and
 * writeRow sends them to the right Sheets tab.
 * IDs for operational rows (Exchanges/Alterations/Addons) follow SHEETS_SPEC.md
 * §4.1's <prefix>-<dancerId>-<...>-<timestamp> format rather than a local counter,
 * specifically so two devices creating a row at the same moment can't collide.
 */
const { useState, useEffect, useRef } = React;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accent": ["#7b4fc4", "#6638ad", "#f1eafb"],
  "density": "comfortable",
  "showUnitIds": true,
  "overdueDays": 12
}/*EDITMODE-END*/;

const NAV = [
  { view: "tryon", label: "Try-Ons", icon: "shirt" },
  { view: "exchanges", label: "Exchanges", icon: "repeat", count: (s) => GLOGIC.allOpenExchanges(s).length },
  { view: "alterations", label: "Alterations", icon: "scissors", count: (s) => GLOGIC.openAlterations(s).length },
  { view: "addons", label: "Add-ons", icon: "sparkles", count: (s) => GLOGIC.pendingAddons(s).length },
  // The fourth queue (issue #7): open drop cards nag until every keep/release
  // and flagged in-flight item is decided — warn tone, same as Manager.
  { view: "drops", label: "Drops", icon: "userX", count: (s) => GLOGIC.openDropCards(s).length, tone: "warn" },
  // Rollout (#51, ADR-0010): Receiving's mirror. Badge counts delivery-bin
  // items awaiting a desk sweep — quiet until arrivals routing (#52) fills bins.
  { view: "rollout", label: "Rollout", icon: "bag", count: (s) => Reconcile.countDeliveryBinUnits(s.units) },
  // Ordered-but-not-arrived units (issue #13): normal tone — outstanding
  // orders are a fact, not overdue work.
  { view: "receiving", label: "Receiving", icon: "package", count: (s) => Reconcile.buildReceivingGroups(s.units).reduce((n, g) => n + g.unitIds.length, 0) },
  // Read-only roster reference (issue #15): no count — nothing is ever "due".
  { view: "roster", label: "Roster", icon: "users" },
  // #67: every other badge here means "work waiting in this queue", so a bare
  // number next to Manager reads as an inbox count. It isn't — it's how many
  // assignments aren't ready yet. countLabel says so out loud; the Manager
  // screen's own subtitle (manager.jsx) says it in words for sighted staff.
  { view: "manager", label: "Manager", icon: "dashboard", count: (s) => GLOGIC.studioReadiness(s).notReady, tone: "warn", countLabel: (n) => `${n} not ready` },
  { view: "import", label: "Import", icon: "upload" },
];

// ADR-0009: the sign-in gate. Google Sign-In for everyone; the ADR-0004
// passcode field appears ONLY when api/auth.js says so (`vercel dev`), so
// agents can still drive the gate during local verification. Full-screen so
// it also covers a mounted app when the 12h session ends mid-day — the app
// stays mounted underneath, so in-progress work survives re-entry.
function SignInGate({ auth, onGoogleCredential, onPasscode, askName, rejected }) {
  const [code, setCode] = useState("");
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState("");
  const buttonRef = useRef(null);
  const clientId = auth && auth.clientId;

  // Google's script loads async (index.html); draw the button once both it
  // and the client id are here. `google.accounts.id` is a global from
  // https://accounts.google.com/gsi/client — no bundler, no package.
  useEffect(() => {
    if (!clientId || !buttonRef.current) return undefined;
    let cancelled = false;
    let timer = null;
    const draw = () => {
      const gsi = window.google && window.google.accounts && window.google.accounts.id;
      if (!gsi) { timer = setTimeout(draw, 150); return; }
      if (cancelled) return;
      gsi.initialize({
        client_id: clientId,
        ux_mode: "popup",
        callback: async (resp) => {
          setBusy(true); setMessage("");
          try { await onGoogleCredential(resp.credential); }
          catch (e) { setMessage(e.message || "Sign-in failed."); }
          finally { setBusy(false); }
        },
      });
      buttonRef.current.innerHTML = "";
      gsi.renderButton(buttonRef.current, { theme: "outline", size: "large", shape: "pill", text: "signin_with", width: 292 });
    };
    draw();
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
  }, [clientId, onGoogleCredential]);

  async function submitPasscode(e) {
    e.preventDefault();
    if (!code.trim() || busy) return;
    setBusy(true);
    try { await onPasscode(code, name); } finally { setBusy(false); }
  }

  const configError = auth && auth.configError;
  return (
    <div style={{ position: "fixed", inset: 0, zIndex: 1000, background: "var(--bg)", display: "grid", placeItems: "center" }}>
      <div style={{ maxWidth: 340, width: "100%", padding: 24, textAlign: "center" }}>
        <img src="app/assets/icons/logo-large.png" alt="" style={{ display: "block", width: 110, margin: "0 auto 12px" }} />
        <div style={{ fontWeight: 800, fontSize: 18, marginBottom: 4 }}>CostumeFlow</div>
        <div style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 16 }}>
          {rejected ? "Your sign-in ended. Sign in again to keep going." : "Sign in with your studio Google account."}
        </div>
        {clientId && <div ref={buttonRef} style={{ display: "flex", justifyContent: "center", minHeight: 44 }} />}
        {!auth && <div style={{ fontSize: 13, color: "var(--text-2)" }}>Loading sign-in…</div>}
        {configError && (
          <div role="alert" style={{ fontSize: 13, color: "var(--bad-text)", marginTop: 8 }}>Sign-in isn't set up yet: {configError}</div>
        )}
        {message && <div role="alert" style={{ fontSize: 13, color: "var(--bad-text)", marginTop: 10 }}>{message}</div>}
        {busy && !message && <div style={{ fontSize: 13, color: "var(--text-2)", marginTop: 10 }}>Checking…</div>}

        {auth && auth.devPasscode && (
          <form onSubmit={submitPasscode} style={{ marginTop: 22, paddingTop: 16, borderTop: "1px solid var(--line)", textAlign: "left" }}>
            <div style={{ fontSize: 12, color: "var(--text-2)", marginBottom: 8 }}>Local development only</div>
            {askName && (
              <Field label="Your name (shows on changes you make)">
                <input className="inp" value={name} placeholder={'e.g. "Front desk iPad"'} onChange={(e) => setName(e.target.value)} />
              </Field>
            )}
            <Field label="Dev passcode">
              <input className="inp" type="password" autoComplete="off" value={code} onChange={(e) => setCode(e.target.value)} />
            </Field>
            <div style={{ marginTop: 14 }}>
              <button type="submit" className="btn btn-accent btn-md" disabled={!code.trim() || busy} style={{ width: "100%" }}>
                {busy ? "Checking…" : "Unlock"}
              </button>
            </div>
          </form>
        )}
      </div>
    </div>
  );
}

// #68: the search box owns its text. When q lived on App, every keystroke
// re-rendered the entire tree — sidebar badge closures (studioReadiness and
// friends) included. Scoped here, typing re-renders only this dropdown; the
// route effect reproduces the old nav()-clears-the-search behavior.
// `variant="hero"` is the same search scaled up for the Welcome page (#72) —
// one component, one behavior, only the size differs. Welcome is the ONLY
// caller that passes it, and the header hides its own copy on that route so
// there is never more than one search box on screen.
function DancerSearch({ state, nav, route, variant }) {
  const [q, setQ] = React.useState("");
  React.useEffect(() => { setQ(""); }, [route]);
  // Shared search seam (#37): identical inputs to the Roster screen's search,
  // capped visible, so hiddenCount rides along for the "+N more" row below.
  const { results, hiddenCount } = Roster.searchAssignments(state.assignments, q, 6);
  return (
    <div className={"search" + (variant === "hero" ? " hero" : "")}>
      <Icon name="search" size={16} className="search-icon" />
      <input className="search-inp" placeholder="Search dancers…" value={q} onChange={(e) => setQ(e.target.value)} />
      {results.length > 0 && (
        <div className="search-results">
          {results.map((a) => {
            const rt = state.routines.find((r) => r.id === a.routineId);
            // Fully dropped dancers stay findable but read as inactive
            // (ADR-0007: gray-out, never delete — history intact).
            const isFullyDropped = state.drops.fullyDroppedDancerIds.has(a.dancerId);
            return (
              <button key={a.id} className={"search-row" + (isFullyDropped ? " dropped-gray" : "")} onClick={() => nav({ view: "dancer", assignmentId: a.id })}>
                <span className="avatar sm"><Icon name={isFullyDropped ? "userX" : "user"} size={13} /></span>
                <span><span className="sr-name">{a.dancerName}</span><span className="sr-sub">{isFullyDropped ? "Dropped · " : ""}{rt.team} · {rt.name}</span></span>
              </button>
            );
          })}
          {hiddenCount > 0 && (
            <div className="search-row search-more">+{hiddenCount} more match{hiddenCount === 1 ? "" : "es"} — keep typing to narrow</div>
          )}
        </div>
      )}
    </div>
  );
}

// #79: where the current route parks so a refresh can pick it back up.
// sessionStorage, not localStorage, deliberately — per tab, and gone when the
// tab closes, so tomorrow morning still opens on Welcome. Both sides swallow
// their errors the same way the passcode/staff-name helpers do: storage is
// unavailable in some private-browsing modes, and losing your place is not
// worth a crash. Validation happens in GLOGIC.restoreRoute, never here.
const ROUTE_KEY = "costumeflow-route";
function readSavedRoute() {
  try { return JSON.parse(sessionStorage.getItem(ROUTE_KEY)); } catch (e) { return null; }
}
function saveRoute(route) {
  try { sessionStorage.setItem(ROUTE_KEY, JSON.stringify(route)); } catch (e) {}
}

// #95: the notice for someone else's changes held on this device. Over-12h
// ones (slice 4) never send on their own, so the sentence has to say so.
function heldElsewhereNotice({ name, count, stale }) {
  const waiting = `${count} ${count === 1 ? "change" : "changes"} from ${name} ${count === 1 ? "is" : "are"} waiting on this device.`;
  if (!stale) return `${waiting} ${count === 1 ? "It'll" : "They'll"} send when ${name} signs in here.`;
  if (stale === count) return `${waiting} ${count === 1 ? "It's" : "They're"} more than 12 hours old, so ${name} will be asked to send or discard ${count === 1 ? "it" : "each one"} after signing in here.`;
  return `${waiting} They'll send when ${name} signs in here, except ${stale} more than 12 hours old, which ${name} will be asked about first.`;
}

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const {
    state, loading, error, writeRow, writeRows, replaceTab, reload, getStaffLabel, fetchTabs,
    gateNeeded, submitPasscode, hasStoredStaffName,
    dropsTabMissing, applyLocalRows, auth, submitGoogleCredential, signOut, heldElsewhere,
    skippedNotices, dismissSkippedNotice, staleHeld, sendHeld, discardHeld, heldStatus,
    checkExchangeResolve,
  } = useSheets();
  // #44: Welcome is the default route on every open (July 24 mini-grill) —
  // staff start oriented; Try-Ons is one tap away. #79 keeps it the default
  // for a genuinely new tab, but no longer for a REFRESH: mid try-on, a
  // reload used to throw away where you were.
  const [route, setRoute] = useState({ view: "welcome" });
  const routeRestored = useRef(false);

  // Restore runs once, and only after state exists — the saved route names
  // data (an assignment, a routine) that has to be there to validate against.
  // Costs one frame of Welcome on refresh, which is the price of never
  // rendering a dancer page for an assignment a re-import removed.
  // #81: browser-history-backed navigation. Created once, right after the
  // restore, so the restored route becomes the current history entry (a
  // refresh keeps that entry's depth, so back still knows the way).
  const histRef = useRef(null);
  const stateRef = useRef(state);
  stateRef.current = state;
  useEffect(() => {
    if (routeRestored.current || !state) return;
    routeRestored.current = true;
    const restored = GLOGIC.restoreRoute(readSavedRoute(), state);
    setRoute(restored);
    histRef.current = GLOGIC.makeHistoryNav({ history: window.history, onRoute: setRoute });
    histRef.current.start(restored);
  }, [state]);
  useEffect(() => {
    const onPop = (e) => { if (histRef.current && stateRef.current) histRef.current.onPop(e.state, stateRef.current); };
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  // Guarded on the restore having happened, and NOT merely on `route`
  // changing. The first render sits at the gate with no state, so an
  // unguarded save writes the default Welcome over the saved route before the
  // restore above ever gets to read it — the feature would silently do
  // nothing, on every refresh, while looking correct in review.
  useEffect(() => { if (routeRestored.current) saveRoute(route); }, [route]);
  // #67: which view's hint the staffer has expanded. Storing the VIEW rather
  // than a boolean means navigating away re-clamps the next screen's hint for
  // free — no effect to keep in sync.
  const [hintOpenFor, setHintOpenFor] = useState(null);
  // #95 slice 4: the over-12h held change whose Discard is awaiting its second tap.
  const [confirmDiscardId, setConfirmDiscardId] = useState(null);
  const mainRef = useRef(null);

  useEffect(() => { if (mainRef.current) mainRef.current.scrollTop = 0; }, [route]);

  // search box clears itself on route change (#68). Before the restore has
  // run there is no history object yet; plain setRoute keeps the gate path safe.
  function nav(r) { if (histRef.current) histRef.current.navigate(r); else setRoute(r); }
  // #81: back = the previous history entry when there is one, else `fallback`.
  function goBack(fallback) { if (histRef.current) histRef.current.back(fallback); else setRoute(fallback); }
  const backLabel = histRef.current ? GLOGIC.routeLabel(histRef.current.previousRoute(), state) : null;

  // First load (or a load that failed outright) has no state to render the
  // shell with at all — unlike localStorage, a Sheets fetch can fail, so this
  // case didn't exist before and the shell can't render around a null state.
  if (!state) {
    if (gateNeeded) {
      return <SignInGate auth={auth} onGoogleCredential={submitGoogleCredential} onPasscode={submitPasscode} askName={!hasStoredStaffName} rejected={false} />;
    }
    return (
      <div className="shell" style={{ display: "grid", placeItems: "center", height: "100vh", textAlign: "center" }}>
        {error ? (
          <div style={{ maxWidth: 380 }}>
            <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6, color: "var(--bad-text)" }}>Couldn't load CostumeFlow</div>
            <div style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 16 }}>{error}</div>
            <Button variant="accent" onClick={reload}>Try again</Button>
          </div>
        ) : (
          <div style={{ color: "var(--text-2)", fontWeight: 600 }}>Loading studio data…</div>
        )}
      </div>
    );
  }

  // #55: every Units row carries who/when, like the other five tabs — the
  // Rollout sent-home date reads the WITH DANCER row's stamp. Stamp BEFORE
  // the optimistic apply so the date shows the moment it happens.
  //
  // #71: one stamp for all six tabs (it was stampUnits + an identical twin).
  // Every builder returns an UNSTAMPED row on purpose, so the ledger records
  // when the write actually happened rather than when the row was shaped.
  // The optional args are for decidePiece, where one clock read has to stamp
  // a Decisions row and its Exchange/Alteration twin identically.
  function stamp(record, updatedBy, nowIso) {
    return {
      ...record,
      UpdatedBy: updatedBy || getStaffLabel(),
      UpdatedAt: nowIso || new Date().toISOString(),
    };
  }

  const actions = {
    decidePiece(aid, code, decision, detail = {}) {
      const a = state.assignments.find((x) => x.id === aid);
      const p = a.pieces.find((x) => x.code === code);
      const routine = state.routines.find((r) => r.id === a.routineId);
      const label = routine.team + " · " + routine.name;
      const updatedBy = getStaffLabel();
      const nowIso = new Date().toISOString();
      const exchangeId = `EX-${a.dancerId}-${code}-${Date.now()}`;
      const alterationId = `AL-${a.dancerId}-${code}-${Date.now()}`;

      if (decision === "cleared") {
        // #38: tombstone row — the assembler reads a newest 'cleared' as
        // no-decision, so the piece is undecided again and readiness
        // recomputes. Open exchange/alteration rows are deliberately NOT
        // touched: the card flags them, the queue keeps them (ADR-0007
        // rule; in-app cancel is #30's own slice).
        const record = { DancerID: a.dancerId, RoutineID: a.routineId, PieceCode: code, Decision: "cleared", UpdatedBy: updatedBy, UpdatedAt: nowIso };
        applyLocalRows("Decisions", [record]); // #57: append-to-raw, like every other action
        writeRow("Decisions", record);
        return;
      }

      // #57: the old mutate() patch here was erased by any later applyLocalRows
      // re-assembly (Fits, then drop a class → the chip snapped back). Append
      // the same rows the write path sends; the tested assembler derives the
      // rest. Side effect worth naming: mutate() also HID this piece's open
      // queue rows locally, which the next poll always un-hid (nothing writes
      // them) — the raw path now shows the same truth every other device sees.
      const decisionRecord = { DancerID: a.dancerId, RoutineID: a.routineId, PieceCode: code, Decision: decision, UpdatedBy: updatedBy, UpdatedAt: nowIso };
      applyLocalRows("Decisions", [decisionRecord]);
      writeRow("Decisions", decisionRecord);
      if (decision === "exchange") {
        const exchangeRecord = stamp(Reconcile.makeExchangeRecord({
          id: exchangeId, dancerId: a.dancerId, routineId: a.routineId, pieceCode: code,
          pieceName: p.name, unitId: p.unitId, originalSize: p.preselectedSize,
          requestedSize: detail.requestedSize || "—", reason: detail.reason || "Exchange",
          notes: detail.notes, opened: state.today, status: "new",
        }), updatedBy, nowIso);
        applyLocalRows("Exchanges", [exchangeRecord]);
        writeRow("Exchanges", exchangeRecord);
      } else if (decision === "alter") {
        const alterationRecord = stamp(Reconcile.makeAlterationRecord({
          id: alterationId, dancerId: a.dancerId, routineId: a.routineId, pieceCode: code,
          pieceName: p.name, unitId: p.unitId, type: detail.type || "Alteration",
          notes: detail.notes, status: "open", returned: false,
        }), updatedBy, nowIso);
        applyLocalRows("Alterations", [alterationRecord]);
        writeRow("Alterations", alterationRecord);
      }
    },

    setExchangeStatus(id, status) {
      const e = state.exchanges.find((x) => x.id === id);
      const updatedBy = getStaffLabel();
      const record = stamp(Reconcile.makeExchangeRecord({ ...e, status }), updatedBy);
      applyLocalRows("Exchanges", [record]); // #57: append-to-raw, never mutate()
      writeRow("Exchanges", record);
    },

    // Resolving an exchange used to only ever remove the *replacement* unit from
    // spares — the dancer's *old* unit just disappeared from tracked inventory
    // (SHEETS_SPEC.md §10 finding 5). Fix: the old unit's AssignedDancerID gets
    // cleared (it becomes a spare again) in the same place the new one is claimed.
    // landingBin (#52, ADR-0010): the replacement in hand is an ARRIVAL — its
    // confirmed landing spot rides the assign row. Blank falls back to the
    // unit's recorded location (pre-#52 behavior, older call sites safe).
    // #91: async — it reads the Sheet fresh first and THROWS a refusal
    // (spare or exchange taken on another device, or no connection) before
    // anything is written. The caller shows the message inline.
    async resolveExchange(id, unit, via, landingBin) {
      const landingSpot = String(landingBin || "").trim();
      const e = state.exchanges.find((x) => x.id === id);
      if (!e) throw Object.assign(new Error("This exchange is no longer in the queue. Nothing was saved."), { refusal: true });
      const a = state.assignments.find((x) => x.id === e.assignmentId);
      const p = a ? a.pieces.find((x) => x.code === e.pieceCode) : null;
      const oldUnitId = p ? p.unitId : null;
      // The rows below come from the SAME fresh read the check passed.
      const freshUnits = await checkExchangeResolve({ exchangeId: id, unitId: unit, dancerId: e.dancerId, currentUnitId: oldUnitId });
      const unitRecord = freshUnits.find((u) => u.unitId === unit);
      const oldUnitRecord = freshUnits.find((u) => u.unitId === oldUnitId);
      const updatedBy = getStaffLabel();
      const nowIso = new Date().toISOString();
      const resolvedVia = via || "inventory";

      // #57: mutate() gone — the raw Units/Exchanges appends drive the
      // assembler, which derives the piece's new unit, the claimed spare's
      // disappearance, AND the freed old unit's return to spares (the old
      // hand-rolled spares bookkeeping here deleted with it).
      const exchangeRecord = stamp(Reconcile.makeExchangeRecord({
        ...e, status: "resolved", replacementUnit: unit, resolvedVia, resolvedDate: state.today,
      }), updatedBy, nowIso);
      const unitsRecords = [stamp(Reconcile.makeUnitsRecord({
        unitId: unit,
        routineId: unitRecord ? unitRecord.routineId : e.routineId,
        pieceCode: unitRecord ? unitRecord.pieceCode : e.pieceCode,
        size: unitRecord ? unitRecord.size : e.requestedSize,
        assignedDancerId: e.dancerId,
        location: landingSpot || (unitRecord ? unitRecord.location : ""),
        notes: `Assigned resolving ${e.id}`,
      }))];
      // Only free the old unit if the dancer still holds it; if another device
      // moved it meanwhile, that newer row stands.
      if (oldUnitId && oldUnitId !== unit && (!oldUnitRecord || oldUnitRecord.assignedDancerId === e.dancerId)) {
        unitsRecords.push(stamp(Reconcile.makeUnitsRecord({
          unitId: oldUnitId,
          routineId: oldUnitRecord ? oldUnitRecord.routineId : e.routineId,
          pieceCode: oldUnitRecord ? oldUnitRecord.pieceCode : e.pieceCode,
          size: oldUnitRecord ? oldUnitRecord.size : e.originalSize,
          assignedDancerId: "",
          location: oldUnitRecord ? oldUnitRecord.location : "",
          notes: `Freed resolving ${e.id}`,
        })));
      }
      applyLocalRows("Exchanges", [exchangeRecord]);
      applyLocalRows("Units", unitsRecords);
      writeRow("Exchanges", exchangeRecord);
      writeRows("Units", unitsRecords, { atomic: true }); // #95: new unit in, old unit out, or neither
    },

    setAlteration(id, patch) {
      const al = state.alterations.find((x) => x.id === id);
      const merged = { ...al, ...patch };
      const record = stamp(Reconcile.makeAlterationRecord(merged));
      // #29 pattern (found again by the #50 review): the Units apply below
      // re-assembles from the RAW grids, so a mutate()-only status patch would
      // snap back until the next poll — append the Alterations row locally too.
      applyLocalRows("Alterations", [record]);
      writeRow("Alterations", record);
      // #50 (ADR-0010): the transition INTO "With seamstress" moves the garment
      // AT SEAMSTRESS on the Units ledger — only that transition, so flipping
      // between other statuses never double-writes. An alteration whose piece
      // has no unit on record has nothing to track (the board already flags the
      // dancer as no-unit), so there is deliberately no write for it. Marking
      // Returned leaves the unit AT SEAMSTRESS until #52's arrivals routing
      // lands the landing-spot step — deliberate interim, noted on #50.
      if (patch.status === "in_progress" && al.status !== "in_progress") {
        const unit = state.units.find((u) => u.unitId === al.unitId);
        if (unit) {
          const unitsRecord = stamp(Reconcile.makeAtSeamstressUnitsRecord({ unit, alterationId: al.id }));
          applyLocalRows("Units", [unitsRecord]);
          writeRow("Units", unitsRecord);
        }
      }
    },

    // detail now optionally carries dancerId (null = whole-routine add-on, per
    // SHEETS_SPEC.md §10 finding 6) and routineId, alongside the display-only
    // dancerName/routineLabel tryon.jsx already passed.
    createAddon(detail) {
      const id = `AO-${detail.dancerId || detail.routineId}-${Date.now()}`;
      const record = stamp(Reconcile.makeAddonRecord({ ...detail, id, date: state.today, status: "requested" }));
      // #29: optimistic append to the RAW grid (drops pattern) — the old
      // mutate()-only patch died on the next poll's re-assembly, read as a
      // failed submit, and invited duplicates.
      applyLocalRows("Addons", [record]);
      writeRow("Addons", record);
    },

    setAddonStatus(id, status) {
      const ao = state.addons.find((x) => x.id === id);
      // #30 review find: a MintAddonUnitModal left open across a poll could
      // otherwise write 'ordered' over a cancel another device just made.
      // Same throw-before-write shape as cancelAddon; callers surface it.
      if (!ao) {
        throw new Error("This add-on is no longer in the queue — another device may have handled it. Nothing was written.");
      }
      if (ao.status === "cancelled") {
        throw new Error(`${ao.id} was cancelled on another device — un-cancel isn't a thing; file a fresh request if it's still wanted.`);
      }
      const record = stamp(Reconcile.makeAddonRecord({ ...ao, status }));
      applyLocalRows("Addons", [record]); // #29: same append-to-raw pattern
      writeRow("Addons", record);
    },

    // #30: cancel = one appended 'cancelled' Status row, never Sheet surgery.
    // The builder holds the guards (missing add-on, already cancelled) and
    // throws; the modal surfaces the message verbatim.
    cancelAddon(id, note) {
      const ao = state.addons.find((x) => x.id === id);
      const record = stamp(Reconcile.makeAddonCancelRecord({ addon: ao, note }));
      applyLocalRows("Addons", [record]);
      writeRow("Addons", record);
    },

    // Kept behaviorally as-is per SHEETS_SPEC.md §8 (the richer reassign/chain
    // workflow is deferred) — but now also writes both units' AssignedDancerID
    // to Sheets so a swap doesn't go stale relative to everything else that syncs.
    swapPieces(routineId, aid1, aid2, code) {
      const a1 = state.assignments.find((x) => x.id === aid1);
      const a2 = state.assignments.find((x) => x.id === aid2);
      const p1 = a1.pieces.find((x) => x.code === code);
      const p2 = a2.pieces.find((x) => x.code === code);
      const unit1 = p1.unitId, unit2 = p2.unitId;
      const unit1Record = state.units.find((u) => u.unitId === unit1);
      const unit2Record = state.units.find((u) => u.unitId === unit2);

      // #57: mutate() gone — the two Units appends drive the assembler, same
      // as a Units-board move. (The old mutate also swapped the two plan
      // lines' preselected sizes locally; nothing ever wrote that, so every
      // poll and every other device already showed the plan's own sizes —
      // the board's size-differs flag is the surface for a mismatch.)
      // A unit's physical size doesn't change just because it changed hands —
      // use its own recorded Size, not the assignment's (possibly different) preselected size.
      const swapRecords = [];
      if (unit1) {
        swapRecords.push(stamp(Reconcile.makeUnitsRecord({
          unitId: unit1, routineId, pieceCode: code,
          size: unit1Record ? unit1Record.size : p1.preselectedSize,
          assignedDancerId: a2.dancerId, location: unit1Record ? unit1Record.location : "",
          notes: `Swapped from ${a1.dancerName} to ${a2.dancerName}`,
        })));
      }
      if (unit2) {
        swapRecords.push(stamp(Reconcile.makeUnitsRecord({
          unitId: unit2, routineId, pieceCode: code,
          size: unit2Record ? unit2Record.size : p2.preselectedSize,
          assignedDancerId: a1.dancerId, location: unit2Record ? unit2Record.location : "",
          notes: `Swapped from ${a2.dancerName} to ${a1.dancerName}`,
        })));
      }
      if (swapRecords.length) applyLocalRows("Units", swapRecords);
      writeRows("Units", swapRecords, { atomic: true }); // #95: both sides of a swap, or neither
    },

    // ---- drops (issue #7, ADR-0007). Every write is an append-only event on
    // the Drops tab. Optimistic UI here appends the same records to the local
    // raw rows and re-runs the tested assembler (applyLocalRows) — hand-mutating
    // nested card state would duplicate assembleDrops' derivation logic.
    dropClasses(dancerId, classNames, note) {
      const records = Drops.makeEnrollmentRecords({
        dropId: Drops.deriveDropId(dancerId, Date.now()),
        dancerId, classNames, value: "dropped", note,
        updatedBy: getStaffLabel(), updatedAt: new Date().toISOString(),
      });
      applyLocalRows("Drops", records);
      writeRows("Drops", records);
    },

    // Un-drop = counter-event on the card that dropped the class. A class
    // line reads dropped only on the card owning its newest enrollment event
    // (see drops.js), so the match is unambiguous.
    undropClass(dancerId, className, note) {
      const card = state.drops.cards.find(
        (c) => c.dancerId === dancerId && c.classLines.some((l) => l.className === className && l.dropped)
      );
      if (!card) {
        // Restore buttons only render on dropped lines, so this is a real
        // bug (or a poll race) — say so instead of silently doing nothing.
        console.error(`undropClass: no active drop card holds "${className}" for ${dancerId}`);
        return;
      }
      const records = Drops.makeEnrollmentRecords({
        dropId: card.id, dancerId, classNames: [className], value: "restored", note,
        updatedBy: getStaffLabel(), updatedAt: new Date().toISOString(),
      });
      applyLocalRows("Drops", records);
      writeRows("Drops", records);
    },

    pickDropRoutines(card, routineIds) {
      const record = Drops.makeRoutinesRecord({
        dropId: card.id, dancerId: card.dancerId, routineIds,
        updatedBy: getStaffLabel(), updatedAt: new Date().toISOString(),
      });
      applyLocalRows("Drops", [record]);
      writeRow("Drops", record);
    },

    decideDropUnit(card, unitId, decision) {
      if (decision === "release") {
        // Releasing must write a complete Units row (newest-row-wins: a row
        // with blank RoutineID/PieceCode/Size would corrupt the ledger). If
        // the unit isn't in the ledger, refuse loudly and leave the line
        // undecided rather than guess.
        const u = state.units.find((x) => x.unitId === unitId);
        if (!u) {
          console.error(`decideDropUnit: unit ${unitId} not found in the Units ledger — release not recorded`);
          return;
        }
        // Same freeing mechanism resolveExchange already uses: append a Units
        // row with the assignment cleared — the unit becomes a normal spare.
        // Keep writes nothing to Units: the unit stays on the (dropped) dancer,
        // permanently out of the spares pool ("kept by family").
        const unitsRecord = stamp(Reconcile.makeUnitsRecord({
          unitId, routineId: u.routineId, pieceCode: u.pieceCode, size: u.size,
          assignedDancerId: "", location: u.location,
          notes: `Released by drop ${card.id}`,
        }));
        applyLocalRows("Units", [unitsRecord]);
        writeRow("Units", unitsRecord);
      }
      const record = Drops.makeUnitDecisionRecord({
        dropId: card.id, dancerId: card.dancerId, unitId, decision,
        updatedBy: getStaffLabel(), updatedAt: new Date().toISOString(),
      });
      applyLocalRows("Drops", [record]);
      writeRow("Drops", record);
    },

    // ---- reassignment (issue #10, SHEETS_SPEC.md §8). One move per call —
    // chains are N calls in the order they happened at the rack. The record
    // builder writes a COMPLETE Units row (newest-row-wins). The offered
    // exchange for whoever was left short goes through the normal
    // decidePiece path from the UI — never written automatically here.
    reassignUnit(unit, toDancerId) {
      const record = stamp(Reconcile.makeReassignUnitsRecord({ unit, toDancerId }));
      applyLocalRows("Units", [record]);
      writeRow("Units", record);
    },

    // Receiving day (issue #13, ADR-0008): records are built from LIVE units
    // at confirm — never from the group a screen rendered earlier (the
    // stale-snapshot rule) — and appended like every other Units write. The
    // builders throw on empty bin / over-count; callers surface the message.
    checkInUnits({ routineId, pieceCode, size, count, bin }) {
      const records = Reconcile.makeCheckInUnitsRecords({ routineId, pieceCode, size, count, bin, liveUnits: state.units }).map((r) => stamp(r));
      applyLocalRows("Units", records);
      writeRows("Units", records);
    },
    markBackordered({ routineId, pieceCode, size, count }) {
      const records = Reconcile.makeBackorderedUnitsRecords({ routineId, pieceCode, size, count, liveUnits: state.units }).map((r) => stamp(r));
      applyLocalRows("Units", records);
      writeRows("Units", records);
    },
    // #50 (ADR-0010): vendor return, record-only. The unit is re-resolved from
    // LIVE state at confirm (stale-snapshot rule) — if another device assigned
    // it mid-confirm, the builder refuses loudly instead of vanishing a
    // dancer's costume. Caller surfaces the message.
    returnUnitToVendor(unitId) {
      const unit = state.units.find((u) => u.unitId === unitId);
      const record = stamp(Reconcile.makeVendorReturnUnitsRecord({ unit }));
      applyLocalRows("Units", [record]);
      writeRow("Units", record);
    },
    // Hand-out (#51, ADR-0010): per-dancer WITH DANCER appends, re-resolved
    // from live units at confirm; the builder refuses stale selections loudly.
    rolloutUnits({ dancerId, unitIds }) {
      const records = Reconcile.makeRolloutUnitsRecords({ dancerId, unitIds, liveUnits: state.units }).map((r) => stamp(r));
      // Undo rows capture PRIOR state — built before the optimistic apply
      // rewrites it (#55: the receipt banner's safety net for mis-taps).
      const undoRecords = Reconcile.makeUndoUnitsRecords({ unitIds: records.map((r) => r.UnitID), priorUnits: state.units });
      applyLocalRows("Units", records);
      writeRows("Units", records);
      return { records, undoRecords }; // #55: the screen banners exactly what went home
    },
    // Arrival routing (#52, ADR-0010): land the item in its confirmed spot.
    // Callers run this BEFORE any companion status write, so a loud refusal
    // (stale unit) leaves everything untouched for a clean retry.
    routeArrival({ unitId, bin }) {
      const unit = state.units.find((u) => u.unitId === unitId);
      const record = stamp(Reconcile.makeArrivalRouteRecord({ unit, bin }));
      applyLocalRows("Units", [record]);
      writeRow("Units", record);
    },
    // Desk sweep (#52): everything in one delivery bin went home after class.
    sweepBin(bin) {
      const records = Reconcile.makeBinSweepRecords({ bin, liveUnits: state.units }).map((r) => stamp(r));
      const undoRecords = Reconcile.makeUndoUnitsRecords({ unitIds: records.map((r) => r.UnitID), priorUnits: state.units });
      applyLocalRows("Units", records);
      writeRows("Units", records);
      return { records, undoRecords }; // #55: the screen banners exactly what was picked up
    },
    // #55: the Undo on the receipt banner — appends each unit's prior row
    // back, the ledger way (never an edit). One shot per receipt.
    undoUnitsWrite(undoRecords) {
      const records = (undoRecords || []).map((r) => stamp(r));
      if (records.length === 0) return records;
      applyLocalRows("Units", records);
      writeRows("Units", records);
      return records;
    },
    // Add-on minting (#53, folds #31): the builder re-checks the id against
    // LIVE units and refuses a duplicate loudly. Callers mint FIRST, then
    // write the status, so a refusal leaves the add-on card untouched.
    mintAddonUnit({ unitId, routineId, pieceCode, size, dancerId }) {
      const record = stamp(Reconcile.makeAddonUnitRecord({ unitId, routineId, pieceCode, size, dancerId, liveUnits: state.units }));
      applyLocalRows("Units", [record]);
      writeRow("Units", record);
    },

    setDropFlagHandled(card, line, handled) {
      const record = Drops.makeFlagRecord({
        dropId: card.id, dancerId: card.dancerId, kind: line.kind, refId: line.refId,
        value: handled ? "handled" : "unhandled",
        updatedBy: getStaffLabel(), updatedAt: new Date().toISOString(),
      });
      applyLocalRows("Drops", [record]);
      writeRow("Drops", record);
    },
  };

  // #44/#17: the sidebar and the Welcome tiles consume the SAME grouping, so
  // they can never disagree; an unclaimed view throws here (welcome.js seam).
  const navSections = Welcome.groupNavSections(NAV);

  let body;
  if (route.view === "welcome") body = <WelcomeView state={state} nav={nav} route={route} />;
  else if (route.view === "guide") body = <GuideView nav={nav} />;
  else if (route.view === "tryon") body = <TryOnHome state={state} nav={nav} />;
  else if (route.view === "routine") body = <RoutineDetail state={state} routineId={route.routineId} nav={nav} goBack={goBack} backLabel={backLabel} actions={actions} />;
  else if (route.view === "dancer") body = <DancerTryOn state={state} assignmentId={route.assignmentId} nav={nav} goBack={goBack} backLabel={backLabel} actions={actions} dropsDisabled={dropsTabMissing} />;
  else if (route.view === "exchanges") body = <ExchangeQueue state={state} actions={actions} overdueDays={t.overdueDays} />;
  else if (route.view === "alterations") body = <AlterationsQueue state={state} actions={actions} />;
  else if (route.view === "addons") body = <AddonsQueue state={state} actions={actions} />;
  else if (route.view === "drops") body = <DropQueue state={state} actions={actions} dropsTabMissing={dropsTabMissing} />;
  else if (route.view === "rollout") body = <RolloutScreen state={state} actions={actions} />;
  else if (route.view === "receiving") body = <ReceivingScreen state={state} actions={actions} />;
  else if (route.view === "roster") body = <RosterView state={state} />;
  else if (route.view === "manager") body = <ManagerDashboard state={state} nav={nav} />;
  else if (route.view === "import") body = <ImportScreen state={state} replaceTab={replaceTab} reload={reload} fetchTabs={fetchTabs} />;

  const activeTop = ["routine", "dancer"].includes(route.view) ? "tryon" : route.view;

  const accent = Array.isArray(t.accent) ? t.accent : [t.accent, t.accent, "#f1eafb"];
  const shellStyle = { "--accent": accent[0], "--accent-2": accent[1] || accent[0], "--accent-soft": accent[2] || "#f1eafb" };
  const shellClass = "shell"
    + (t.density === "compact" ? " compact" : "")
    + (t.showUnitIds ? "" : " hide-units");

  return (
    <div className={shellClass} style={shellStyle}>
      <aside className="sidebar">
        <div className="brand">
          {/* Zone 1 — product identity: fixed, identical for every studio.
              Since #44 it's also the home link (logo-goes-home, Denise's
              comment on the issue) — visually unchanged per branding rules. */}
          <button className="brand-product" onClick={() => nav({ view: "welcome" })} title="Home">
            <span className="brand-logo">
              <img src="app/assets/brand/costumeflow-mark-white.svg" alt="CostumeFlow" />
            </span>
            <div className="brand-wordmark">CostumeFlow</div>
          </button>
          {/* Zone 2 (the tenant) moved to the top bar in #73 — see there. */}
        </div>
        <nav className="nav">
          {navSections.map((sec) => [
            // The caption ("nothing here affects try-on day") lost its only
            // renderer when #72 removed the Welcome tiles. It's reassurance
            // from the July 24 grill, not decoration, so the sidebar picks it
            // up — which is what makes #72's "the sidebar already carries the
            // identical grouping" actually true. Hidden on the phone rail,
            // where .nav-sec itself collapses to a divider.
            <div key={sec.id + "-head"} className="nav-sec">
              {sec.label}
              {sec.caption && <span className="nav-sec-caption">{sec.caption}</span>}
            </div>,
            ...sec.items.map((n) => {
            const c = n.count ? n.count(state) : 0;
            // #66: the name is spelled out on EVERY item, not just the ones
            // whose number needed explaining (#67). At phone width
            // `.nav-label` is display:none, so the button's only remaining
            // content is an icon and a chip — a screen reader got "4", or
            // nothing at all, for ten different destinations. `title` gives
            // sighted staff the same words on hover while they learn the
            // icons.
            const countPhrase = c > 0 ? (n.countLabel ? n.countLabel(c) : `${c} open`) : "";
            const navName = countPhrase ? `${n.label}, ${countPhrase}` : n.label;
            return (
              <button
                key={n.view} className={"nav-item" + (activeTop === n.view ? " active" : "")}
                onClick={() => nav({ view: n.view })}
                aria-label={navName}
                title={navName}
              >
                <Icon name={n.icon} size={18} />
                <span className="nav-label">{n.label}</span>
                {/* Hidden from the name: it is already inside `navName` as
                    words. Left exposed, the chip concatenated onto the label
                    and read as "Exchanges4". */}
                {c > 0 && <span aria-hidden="true" className={"nav-count" + (n.tone === "warn" ? " warn" : "")}>{c}</span>}
              </button>
            );
          }),
          ])}
        </nav>
        <div className="sidebar-foot">
          <button className="reset-btn" onClick={reload} title="Reload from Google Sheets"><Icon name="repeat" size={13} /><span>Reload data</span></button>
          {/* ADR-0009: who is signed in, and the way out — shared iPads
              change hands mid-day, and a sign-in lasts until end of day. */}
          {auth && auth.session && (
            <button className="reset-btn" onClick={signOut} title={`Signed in as ${auth.session.email}. Sign out.`}>
              <Icon name="user" size={13} /><span>{auth.session.name} · Sign out</span>
            </button>
          )}
        </div>
      </aside>

      <div className="content">
        <header className="topbar">
          {/* Zone 2 — the tenant workspace, rehomed here from the sidebar in
              #73. It used to sit under the product mark while this chip
              carried the season, so the season name rendered TWICE on every
              screen a few inches apart. One place now, and the sidebar is
              shorter for it. Studio #2 still lands here and nothing else
              moves. The chevrons-up-down went with it: Denise, July 28 —
              "it makes me think it is clickable when it is not." Multi-studio
              (#8) can add a real control when there's something to switch to.
              Zone 1 stays product-only per CLAUDE.md's branding rule. */}
          <div className="season-chip">
            <div className="studio-initial">G</div>
            <div className="studio-text">
              <div className="studio-name">Gravity Dance</div>
              <div className="season-range">
                {state.season.name}<span className="season-dates"> · {state.season.range}</span>
              </div>
            </div>
          </div>
          {/* Welcome renders its own hero-sized copy (#72), so the header
              stands down there — two identical search boxes a few rows apart
              is worse than one in the right place. */}
          {route.view !== "welcome" && <DancerSearch state={state} nav={nav} route={route} />}
          <div className="today-chip"><Icon name="clock" size={14} /> {fmtDate(state.today)}, 2026</div>
        </header>
        {error && (
          // Data already loaded once, but the most recent ~15s poll failed — keep
          // showing what we have (it's still useful) and say so, rather than hiding it.
          // Reuses the same warn tone as the nav badges/.badge-warn (index.html).
          <div style={{ background: "var(--warn-soft)", color: "var(--warn-text)", fontSize: 12.5, fontWeight: 600, padding: "6px 24px" }}>
            {error}
          </div>
        )}
        {heldStatus && (
          // #95 slice 5: "Sending 3 changes saved on this device..." then "3 held changes sent."
          <div role="status" style={{ background: `var(--${heldStatus.tone}-soft)`, color: `var(--${heldStatus.tone}-text)`, fontSize: 12.5, fontWeight: 600, padding: "6px 24px" }}>
            {heldStatus.msg}
          </div>
        )}
        {heldElsewhere.map((h) => (
          // #95 / ADR-0012: someone else's unsent changes are held on this
          // device and only go out under their own sign-in.
          <div key={h.key} style={{ background: "var(--warn-soft)", color: "var(--warn-text)", fontSize: 12.5, fontWeight: 600, padding: "6px 24px" }}>
            {heldElsewhereNotice(h)}
          </div>
        ))}
        {staleHeld.length > 0 && (
          // #95 slice 4 / ADR-0012: held changes over 12h old never send on
          // their own. Send still runs the newer-work check before it goes out.
          <div style={{ background: "var(--warn-soft)", color: "var(--warn-text)", fontSize: 12.5, fontWeight: 600, padding: "6px 24px" }}>
            <div>These changes are more than 12 hours old. Send or discard each one.</div>
            {staleHeld.map((h) => (
              <div key={h.id} style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 6, flexWrap: "wrap" }}>
                <span style={{ flex: 1, minWidth: 180 }}>
                  {h.label}
                  <span style={{ fontWeight: 400 }}> · made {new Date(h.madeAt).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</span>
                </span>
                {confirmDiscardId === h.id ? (
                  // Discard can't be undone, so it takes a second tap. Inline,
                  // never confirm(): that can throw in embedded browsers.
                  <>
                    <span>Discard this change?</span>
                    <button className="btn btn-default btn-sm" onClick={() => { setConfirmDiscardId(null); discardHeld(h.id); }}>Yes, discard</button>
                    <button className="btn btn-default btn-sm" onClick={() => setConfirmDiscardId(null)}>Keep</button>
                  </>
                ) : (
                  <>
                    <button className="btn btn-default btn-sm" onClick={() => sendHeld(h.id)}>Send</button>
                    <button className="btn btn-default btn-sm" onClick={() => setConfirmDiscardId(h.id)}>Discard</button>
                  </>
                )}
              </div>
            ))}
          </div>
        )}
        {skippedNotices.map((n) => (
          // #95 / ADR-0012: a held change someone else overrode was not sent.
          <div key={n.id} style={{ background: "var(--warn-soft)", color: "var(--warn-text)", fontSize: 12.5, fontWeight: 600, padding: "6px 24px", display: "flex", gap: 12, alignItems: "center" }}>
            <span style={{ flex: 1 }}>{n.msg}</span>
            <button className="btn btn-default btn-sm" onClick={() => dismissSkippedNotice(n.id)}>OK</button>
          </div>
        ))}
        {dropsTabMissing && (
          // The Sheet predates the Drops tab (issue #7) — everything else
          // works; drops stay disabled until the tab exists. Self-heals on the
          // poll after it's added, no redeploy needed.
          <div style={{ background: "var(--warn-soft)", color: "var(--warn-text)", fontSize: 12.5, fontWeight: 600, padding: "6px 24px" }}>
            The Google Sheet has no “Drops” tab yet, so the drop workflow is disabled. Add a tab named “Drops” with the issue #7 header row — the app picks it up within ~15s.
          </div>
        )}
        {Welcome.SCREEN_HINTS[route.view] && (
          // #47: the one-line "what happens next" per screen — guide language,
          // one mount for every view (same slot as the app banners, quieter
          // tone). #48's mascot docks here when it arrives.
          // #67: alignItems is flex-start, not center — once the hint clamps to
          // two lines on a phone, centring pushed the sparkle and the Guide link
          // to the middle of a two-line block.
          <div style={{ display: "flex", alignItems: "flex-start", gap: 8, background: "var(--surface-2)", color: "var(--text-2)", fontSize: 12.5, padding: "6px 24px", borderBottom: "1px solid var(--border)" }}>
            <Icon name="sparkles" size={13} className="hint-icon" />
            {/* #67: the longest hints ran to 5 lines at 375px, eating the top of
                every screen. Clamped to 2 on phones, tap to see the rest. The
                clamp is CSS-only and desktop never reaches 2 lines, so the
                toggle is a no-op there rather than a control with nothing to do. */}
            <button
              type="button"
              className={"hint-text" + (hintOpenFor === route.view ? " hint-open" : "")}
              aria-expanded={hintOpenFor === route.view}
              onClick={() => setHintOpenFor(hintOpenFor === route.view ? null : route.view)}
            >
              {/* The clamp lives on this span, not the button. Safari does not
                  honour -webkit-line-clamp on a <button> (button internals get
                  their own layout box), so the first build showed the hint in
                  full on a phone and the tap did nothing. */}
              <span className="hint-clamp">{Welcome.SCREEN_HINTS[route.view]}</span>
            </button>
            <button
              onClick={() => nav({ view: "guide" })}
              style={{ background: "none", border: 0, cursor: "pointer", color: "var(--accent)", font: "inherit", fontSize: 12.5, fontWeight: 600, whiteSpace: "nowrap" }}
            >
              Guide →
            </button>
          </div>
        )}
        <main className="main" ref={mainRef}>{body}</main>
      </div>

      {/* Mid-session 401 (session ended): gate on top, app stays mounted. */}
      {gateNeeded && (
        <SignInGate auth={auth} onGoogleCredential={submitGoogleCredential} onPasscode={submitPasscode} askName={!hasStoredStaffName} rejected />
      )}

      <TweaksPanel>
        <TweakSection label="Brand" />
        <TweakColor label="Accent" value={t.accent}
          options={[["#7b4fc4", "#6638ad", "#f1eafb"], ["#1d9488", "#147268", "#e3f4f1"], ["#d6477e", "#b83468", "#fbe7f0"], ["#4f5bd5", "#3d49bf", "#eaecfc"]]}
          onChange={(v) => setTweak("accent", v)} />
        <TweakSection label="Layout" />
        <TweakRadio label="Density" value={t.density} options={["comfortable", "compact"]}
          onChange={(v) => setTweak("density", v)} />
        <TweakToggle label="Show Unit IDs" value={t.showUnitIds}
          onChange={(v) => setTweak("showUnitIds", v)} />
        <TweakSection label="Exchange queue" />
        <TweakSlider label="Overdue after" value={t.overdueDays} min={5} max={21} step={1} unit="d"
          onChange={(v) => setTweak("overdueDays", v)} />
      </TweaksPanel>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
