/* app.jsx — Nita shell: device frame, per-tab navigation stacks with
 * iOS-style push/pop transitions + edge-swipe-back, floating glass dock,
 * overlays (composer / send / payment), onboarding gate.
 */
const { useState, useEffect, useMemo, useRef, useCallback } = React;
const Q = window.Q, LIB = window.LIB, Icons = window.Icons;
const { Icon } = Icons;
const { ToastHost, toast } = Q;
const S = () => window.QScreens || {};

const TAB_DEFS = [
  { id: "home", icon: "home", key: "nav.home" },
  { id: "invoices", icon: "invoice", key: "nav.invoices" },
  { id: "__new", icon: "plus", key: "nav.new" },
  { id: "clients", icon: "clients", key: "nav.clients" },
  { id: "settings", icon: "settings", key: "nav.settings" },
];

/* ── Floating glass dock ───────────────────────────────────────── */
function TabBar({ tab, onTab, onNew, onHold, hidden, inertAll }) {
  const idx = TAB_DEFS.findIndex(td => td.id === tab);
  /* Center-button long-press (F7 voice): time-based (350ms) rather than
     coordinate-based so the #stage scale never skews it. A fired hold sets
     `held`, and the click that follows the pointer-up is swallowed — same
     suppress trick as the edge-swipe guard below. */
  const hold = useRef({ t: null, held: false });
  useEffect(() => () => clearTimeout(hold.current.t), []);
  /* `hidden` (document-style routes, e.g. the A4 preview): the dock slides
     away so glass never floats over the paper. animation:none drops the
     q-dock-in keyframe fill so the inline transform wins; un-hiding replays
     the dock-in entrance. */
  return (
    <div className="q-dock q-glass-2" aria-hidden={hidden ? "true" : undefined} {...(inertAll ? { inert: "" } : {})} style={{
      /* Frameless (real phone): lift the dock above the real home indicator. */
      position: "absolute", left: 14, right: 14, height: 64, zIndex: 30,
      bottom: window.NITA_FRAMELESS ? "calc(16px + env(safe-area-inset-bottom))" : 16,
      borderRadius: 24, border: "1px solid var(--glass-border)",
      boxShadow: "var(--elev-3)", display: "flex", alignItems: "stretch", padding: "0 6px",
      animation: hidden ? "none" : undefined,
      transform: hidden ? "translateY(120px)" : undefined,
      opacity: hidden ? 0 : undefined,
      pointerEvents: hidden ? "none" : undefined,
      transition: "transform .32s var(--spring), opacity .26s",
    }}>
      {/* sliding highlight under the active tab */}
      {idx >= 0 && (
        <div aria-hidden="true" style={{
          position: "absolute", top: 7, bottom: 7, left: 6, width: "calc((100% - 12px) / 5)",
          transform: `translateX(${idx * 100}%)`, transition: "transform .32s var(--spring)",
          borderRadius: 17, background: "var(--color-surface-2)",
        }} />
      )}
      {TAB_DEFS.map(td => {
        if (td.id === "__new") {
          return (
            <div key="new" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
              <button
                onClick={(e) => { if (hold.current.held) { hold.current.held = false; e.preventDefault(); e.stopPropagation(); return; } onNew(); }}
                onPointerDown={() => { hold.current.held = false; clearTimeout(hold.current.t); hold.current.t = setTimeout(() => { hold.current.held = true; if (onHold) onHold(); }, 350); }}
                onPointerUp={() => clearTimeout(hold.current.t)}
                onPointerCancel={() => clearTimeout(hold.current.t)}
                aria-label={window.t("nav.new")} title={window.t("voice.hold.hint")} tabIndex={hidden ? -1 : undefined} className="q-tap" style={{
                position: "relative", top: -14, width: 54, height: 54, borderRadius: 19,
                border: "none", cursor: "pointer", background: "var(--color-primary)",
                color: "var(--color-primary-fg)", display: "flex", alignItems: "center",
                justifyContent: "center", boxShadow: "0 6px 18px color-mix(in oklab, var(--color-primary) 40%, transparent), 0 2px 5px color-mix(in oklab, var(--color-primary) 30%, transparent), inset 0 1px 0 hsl(40 28% 100% / 0.18)",
                transition: "filter .12s, transform .2s var(--spring-bouncy)",
              }}
                onMouseDown={e => e.currentTarget.style.transform = "scale(0.92)"}
                onMouseUp={e => e.currentTarget.style.transform = "scale(1)"}
                onPointerLeave={() => clearTimeout(hold.current.t)}
                onMouseLeave={e => e.currentTarget.style.transform = "scale(1)"}>
                <Q.Sparkle size={22} color="var(--color-primary-fg)" />
              </button>
            </div>
          );
        }
        const active = tab === td.id;
        return (
          <button key={td.id} onClick={() => onTab(td.id)} aria-label={window.t(td.key)} tabIndex={hidden ? -1 : undefined} className="q-tap" style={{
            flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3,
            border: "none", background: "transparent", cursor: "pointer", padding: 0, position: "relative",
            color: active ? "var(--color-text-1)" : "var(--color-text-3)", transition: "color .2s",
          }}>
            <Icon name={td.icon} size={22} strokeWidth={active ? 2 : 1.7} />
            <span style={{ fontSize: 10, fontWeight: active ? 700 : 500, letterSpacing: "-0.01em" }}>{window.t(td.key)}</span>
          </button>
        );
      })}
    </div>
  );
}

/* ── Screen layer: own scroll container, remembers scroll per route ── */
function ScreenLayer({ routeObj, className, style, ariaHidden, children }) {
  const ref = useRef(null);
  useEffect(() => { if (ref.current) ref.current.scrollTop = (routeObj && routeObj._scroll) || 0; }, [routeObj]);
  const onScroll = e => { if (routeObj) routeObj._scroll = e.currentTarget.scrollTop; };
  return (
    <div ref={ref} onScroll={onScroll} aria-hidden={ariaHidden}
      className={"q-screen" + (className ? " " + className : "")} style={style}>
      {children}
    </div>
  );
}

/* Fresh seed: blank the seed company's identity so a new user never
   inherits Mirador Studio. Sensible defaults stay (currency, terms,
   tax rate, numbering, payment toggles); onboarding answers merge
   over this in finishOnboarding. The seed spread keeps `clock` alive:
   a fresh run still has a pinned business "today" for the scheduler. */
function freshSeed() {
  const seed = window.DATA.seed();
  const pm = seed.COMPANY.payment || {};
  const COMPANY = {
    ...seed.COMPANY,
    name: "", owner: "", email: "", phone: "", address: "",
    vat: "", siret: "", iban: "", bic: "", accountName: "", logo: null,
    nextSeq: 1,
    payment: {
      ...pm,
      card: { ...(pm.card || {}), link: "" },
      nita: { ...(pm.nita || {}), handle: "", phone: "" },
    },
  };
  return window.LIB.ensureConfig({ ...seed, COMPANY, INVOICES: [], ACTIVITY: [], CLIENTS: [], CATALOG: [], EXPENSES: [], DISMISSED: [] });
}

/* ── Image separation (FIX 1) ──────────────────────────────────────
   Receipts are {id,name,src,...} where `src` is a multi-KB data:URL,
   referenced from EXPENSES[].receipt, INVOICES[].items[].receipt,
   INVOICES[].audit[].receipt; COMPANY.logo is a string|null data:URL.
   To keep the persisted JSON db lean we move each `src`/logo into the
   IDB `images` store keyed by a stable id and leave a light {__img:id}
   ref inline. On load the refs rehydrate back to data-URLs so every
   screen/paper/A4 keeps reading `.src`/COMPANY.logo unchanged. */
function forEachReceipt(db, fn) {
  if (!db) return;
  (db.EXPENSES || []).forEach(e => { if (e && e.receipt) fn(e.receipt); });
  (db.INVOICES || []).forEach(inv => {
    (inv.items || []).forEach(it => { if (it && it.receipt) fn(it.receipt); });
    (inv.audit || []).forEach(au => { if (au && au.receipt) fn(au.receipt); });
  });
}

/* Walk the image paths of a structural clone, hoisting each data-URL into
   `out` (deduped by id) and replacing the inline value with a {__img:id}
   ref. Returns { slimDb, images:[{id,dataUrl}] }. Already-ref'd or null
   values are skipped defensively (idempotent). */
function extractImages(db) {
  const slimDb = JSON.parse(JSON.stringify(db));
  const seen = {};
  const images = [];
  const hoist = (id, src) => {
    if (id == null || typeof src !== "string" || src.indexOf("data:") !== 0) return;
    if (!seen[id]) { seen[id] = true; images.push({ id, dataUrl: src }); }
  };
  forEachReceipt(slimDb, r => {
    if (r.id == null || typeof r.src !== "string" || r.src.indexOf("data:") !== 0) return;
    hoist(r.id, r.src);
    r.src = { __img: r.id };
  });
  const logo = slimDb.COMPANY && slimDb.COMPANY.logo;
  if (typeof logo === "string" && logo.indexOf("data:") === 0) {
    hoist("logo", logo);
    slimDb.COMPANY.logo = { __img: "logo" };
  }
  return { slimDb, images };
}

/* Inverse of extractImages: for each {__img:id} ref look up the data-URL
   via getImage(id) and restore `.src`/logo. Tolerates missing rows (leaves
   the ref so a later save re-hoists it) and already-inline values. */
async function rehydrateImages(db, getImage) {
  const cache = {};
  const lookup = async (id) => {
    if (!(id in cache)) { try { cache[id] = await getImage(id); } catch (e) { cache[id] = undefined; } }
    return cache[id];
  };
  const recs = [];
  forEachReceipt(db, r => { if (r && r.src && r.src.__img) recs.push(r); });
  for (const r of recs) { const v = await lookup(r.src.__img); if (typeof v === "string") r.src = v; }
  const logo = db.COMPANY && db.COMPANY.logo;
  if (logo && logo.__img) { const v = await lookup(logo.__img); if (typeof v === "string") db.COMPANY.logo = v; }
  return db;
}

/* Keep the synchronous window.CURRENCIES (used by fmtMoney at module scope)
   topped up with any user-added config.currencies so a custom currency gets a
   symbol/name everywhere, not just at db-aware read sites. */
function syncCurrencies(db) {
  try {
    const list = (db && db.config && db.config.currencies) || [];
    for (const c of list) { if (c && c.code && !window.CURRENCIES[c.code]) window.CURRENCIES[c.code] = { code: c.code, symbol: c.symbol || c.code, name: c.name || c.code }; }
  } catch (e) {}
}

function App() {
  /* Tester mode (same detection as app/feedback.jsx): the shipped tester
     build injects window.NITA_TESTER; ?tester=1 / #tester preview it.
     In tester mode the working db is persisted so a tester's clients,
     projects, invoices… survive closing/reloading the file. ?fresh=1 and
     ?demo=1 still force a clean state and drop the saved copy. Dev and
     audit runs (no tester flag) keep reload-resets-everything. */
  /* FIX 1: persistence is the DEFAULT now (no longer tester-gated). The
     heavy db + images live in IndexedDB (window.NITA_STORE); only the tiny
     first-paint GATES (mode / fresh / onboarded) stay in localStorage so
     they resolve synchronously before first render. */
  const persistDb = true;
  const DB_KEY = "db";          // NITA_STORE kv key for the slim db
  const MODE_KEY = "nita_mode"; // synchronous localStorage gate (first-run choice)
  /* Demo data is the default: the founder wants screens alive with data.
     The clean first-run arc stays reachable via ?fresh=1 (or the
     nita_fresh flag); ?demo=1 keeps meaning "seed + skip onboarding".
     In tester mode the first-run choice screen stores nita_mode, which
     selects the path on later launches (URL params still win). */
  const fresh = (() => {
    try {
      const url = new URLSearchParams(window.location.search);
      if (url.get("demo") === "1") return false;
      if (url.get("fresh") === "1" || localStorage.getItem("nita_fresh") === "1") return true;
      return persistDb && localStorage.getItem(MODE_KEY) === "fresh";
    } catch (e) { return false; }
  })();
  const isDemo = !fresh;
  /* Explicit ?fresh=1 / ?demo=1 override: the URL param fully wins, so the
     hydrate effect below must NOT swap in any saved IDB db — it wipes the
     store instead. Computed once, synchronously, for the first paint. */
  const urlOverride = (() => {
    try {
      const url = new URLSearchParams(window.location.search);
      return url.get("fresh") === "1" || url.get("demo") === "1";
    } catch (e) { return false; }
  })();
  /* FIX 1: seed React state SYNCHRONOUSLY so the first paint never blocks on
     IDB. A returning user's real db is swapped in by the async hydrate effect
     (see below) — seed renders for ~1 frame, then the saved db replaces it. */
  const [db, setDb] = useState(() =>
    window.LIB.ensureConfig(isDemo ? window.DATA.seed() : freshSeed())
  );
  /* True until the async hydrate attempt resolves/rejects. Gates the SAVE
     effect so the seed placeholder is never written over the real saved db
     (the single most important correctness invariant). No override → we may
     have a saved db to load, so start hydrating; override → nothing to load. */
  const [hydrating, setHydrating] = useState(() => persistDb && !urlOverride);
  const [saveState, setSaveState] = useState("idle"); // idle|saving|saved|full|error
  const hydrated = useRef(false);
  /* config.currencies → window.CURRENCIES so fmtMoney resolves user codes. */
  syncCurrencies(db);
  /* First-run choice (tester builds): a tester double-clicks the file with
     no URL params, so a brand-new profile gets an explicit demo-vs-fresh
     choice screen instead of silently landing in demo data. Skipped when a
     db is already saved, a mode was already chosen, or an explicit
     ?fresh=1/?demo=1 override is present (handled just above). */
  const [needChoice, setNeedChoice] = useState(() => {
    if (!persistDb) return false;
    try {
      if (urlOverride) return false;
      if (localStorage.getItem(MODE_KEY)) return false;
      /* The heavy db lives in IDB (async), but a synchronous mirror flag lets
         first paint know a saved db exists without awaiting the store. */
      if (localStorage.getItem("nita_has_db") === "1") return false;
      return true;
    } catch (e) { return false; }
  });
  const chooseMode = useCallback((mode) => {
    try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
    if (mode === "fresh") {
      setDb(freshSeed());
      let ob = false;
      try { ob = localStorage.getItem("nita_onboarded") === "1"; } catch (e) {}
      setOnboarded(ob);
    } else {
      setDb(window.LIB.ensureConfig(window.DATA.seed()));
      setOnboarded(true);
    }
    setNeedChoice(false);
  }, []);
  /* Pinned demo clock: db.clock is the single business "today" (only
     api.advanceClock writes it). Mirroring onto window.DATA.TODAY keeps
     LIB.nowISO() truthful for user-initiated writes after an advance. */
  const today = db.clock || window.DATA.TODAY;
  useEffect(() => { window.DATA.TODAY = today; }, [today]);
  /* ── Hydrate (FIX 1): swap the seed placeholder for the saved IDB db ──
     Runs once. With an explicit ?fresh/?demo override we instead WIPE the
     store (mirroring the old localStorage.removeItem path) so the param
     fully wins. The `hydrating` flag gates the save effect until this
     resolves — without that gate the debounced save could overwrite the
     real saved db with the seed placeholder. */
  useEffect(() => {
    if (!persistDb || hydrated.current) return;
    hydrated.current = true;
    let alive = true;
    (async () => {
      try {
        await window.NITA_STORE.ready();
        if (urlOverride) {
          await window.NITA_STORE.clearAll();
          try { localStorage.removeItem(MODE_KEY); localStorage.removeItem("nita_has_db"); } catch (e) {}
          return;
        }
        const saved = await window.NITA_STORE.getKV(DB_KEY);
        if (alive && saved && saved.COMPANY && Array.isArray(saved.INVOICES)) {
          await rehydrateImages(saved, (id) => window.NITA_STORE.getImage(id));
          if (alive) setDb(window.LIB.ensureConfig(saved));
        }
      } catch (e) {
        /* Load failure is non-fatal: the seed placeholder stays usable. */
        Q.toast(window.t("save.error") || "Couldn't load saved data", "save-load");
      } finally {
        if (alive) setHydrating(false);
      }
    })();
    return () => { alive = false; };
  }, []);

  /* ── Persistence (FIX 1): debounced save to IDB, save-state visible ──
     Skipped while hydrating (don't clobber the real db with the seed) or
     while the first-run choice screen is up. Splits images into the
     `images` store and writes the slim db to `kv`. The silent catch is
     gone: quota → 'saved'state 'full' + toast, other errors → 'error'. */
  useEffect(() => {
    if (!persistDb || hydrating || needChoice) return;
    const t = setTimeout(async () => {
      setSaveState("saving");
      try {
        const { slimDb, images } = extractImages(db);
        for (const img of images) {
          await window.NITA_STORE.putImage(img.id, img.dataUrl);
        }
        await window.NITA_STORE.setKV(DB_KEY, slimDb);
        try { localStorage.setItem("nita_has_db", "1"); } catch (e) {}
        setSaveState("saved");
      } catch (err) {
        if (err && err.quota) {
          setSaveState("full");
          Q.toast(window.t("save.full") || "Storage full — some changes weren't saved", "save-full");
        } else {
          setSaveState("error");
          Q.toast(window.t("save.error") || "Couldn't save changes", "save-err");
        }
      }
    }, 250);
    return () => clearTimeout(t);
  }, [db, hydrating, needChoice]);

  /* Auto-fade the transient 'saved' pill back to idle (calm, non-blocking). */
  useEffect(() => {
    if (saveState !== "saved") return;
    const t = setTimeout(() => setSaveState("idle"), 1200);
    return () => clearTimeout(t);
  }, [saveState]);
  window.setLocale(db.locale || "en");
  /* Palette (G9): apply the chosen theme to the document root. */
  const theme = (db.COMPANY && db.COMPANY.theme) || "light";
  useEffect(() => { try { document.documentElement.setAttribute("data-theme", theme); } catch (e) {} }, [theme]);
  const [onboarded, setOnboarded] = useState(() => {
    if (isDemo) return true;
    try { return localStorage.getItem("nita_onboarded") === "1"; } catch (e) { return false; }
  });

  const [tab, setTab] = useState("home");
  const [stacks, setStacks] = useState({
    home: [{ name: "home" }], invoices: [{ name: "invoices" }],
    clients: [{ name: "clients" }], settings: [{ name: "settings" }],
  });
  const route = stacks[tab][stacks[tab].length - 1];
  const depth = stacks[tab].length;

  /* ── Single active overlay (sheet-stacking fix) ──────────────────
     One reducer holds the at-most-one open overlay so a new sheet always
     REPLACES whatever was up (no payview-over-payment double-stack). Each
     entry is { kind, ...payload }; the per-slot locals below derive from it
     with their ORIGINAL shapes so every render branch + the hasOverlay /
     overlayUp derivations stay byte-for-byte the same. The public api.open* /
     close* methods keep their exact signatures — they just dispatch here.
     close(kind) is kind-guarded: a stale onClose for an already-replaced
     sheet is a no-op, so it can never tear down a newer overlay. */
  const sheetReducer = (state, action) => {
    if (action.type === "open") return { kind: action.kind, ...(action.payload || {}) };
    if (action.type === "close") {
      /* kind omitted → unconditional close (used by the "close all" sites). */
      if (action.kind && state && state.kind !== action.kind) return state;
      return null;
    }
    return state;
  };
  const [activeSheet, dispatchSheet] = React.useReducer(sheetReducer, null);
  const openSheet = useCallback((kind, payload) => dispatchSheet({ type: "open", kind, payload }), []);
  const closeSheet = useCallback((kind) => dispatchSheet({ type: "close", kind }), []);
  const sheetOf = (kind) => (activeSheet && activeSheet.kind === kind ? activeSheet : null);
  /* Derived per-slot locals — same names + shapes the render tree already uses. */
  const composer = sheetOf("composer");                          // {seed, listen, mode} | null
  const manual = sheetOf("manual");                              // {seed} | null
  const create = !!sheetOf("create");                            // boolean (create chooser)
  const send = sheetOf("send");                                  // {invoiceId, mode} | null
  const payment = sheetOf("payment");                            // {invoiceId} | null
  const expense = sheetOf("expense");                            // {seed} | null
  const payview = sheetOf("payview");                            // {invoiceId, preview} | null
  const quoteView = sheetOf("quoteView") ? sheetOf("quoteView").id : null; // quoteId (raw) | null
  const deposit = sheetOf("deposit") ? sheetOf("deposit").id : null;       // quoteId (raw) | null
  const notif = !!sheetOf("notif");                              // boolean (notifications drawer)
  const [printId, setPrintId] = useState(null);    // invoice id — print portal target
  const [dup, setDup] = useState(false);           // reuse/duplicate-picker mode on invoices list
  const mainRef = useRef(null);

  const go = useCallback((name, params) => {
    setStacks(s => ({ ...s, [tab]: [...s[tab], { name, params }] }));
  }, [tab]);
  const back = useCallback(() => {
    setStacks(s => s[tab].length > 1 ? ({ ...s, [tab]: s[tab].slice(0, -1) }) : s);
  }, [tab]);
  /* goTab(nt, name?, params?):
     • name given  → push a child route carrying params (unchanged behaviour).
     • name omitted but params given → params ride on the BASE tab route, so a
       screen can seed itself from route.params (e.g. the Home digest CTA calls
       goTab('invoices', undefined, {filter:'overdue'}) and screen-invoices
       reads route.params.filter). Without this the param was silently dropped.
     • neither → plain tab switch to a fresh base route. */
  const goTab = useCallback((nt, name, params) => {
    setTab(nt);
    setStacks(s => ({
      ...s,
      [nt]: name
        ? [{ name: nt }, { name, params }]
        : [params ? { name: nt, params } : { name: nt }],
    }));
  }, []);
  const switchTab = useCallback((nt) => {
    setDup(false);
    if (nt === tab) setStacks(s => ({ ...s, [nt]: [{ name: nt }] }));
    else setTab(nt);
  }, [tab]);

  /* ── Screen transition engine (iOS push/pop + tab cross-fade) ──── */
  const prevView = useRef(null);
  const skipNextTrans = useRef(false);
  const [trans, setTrans] = useState(null); // {type:'push'|'pop'|'tab', from:{tab,route}}
  const transTimer = useRef(null);
  const transSeq = useRef(0);
  useEffect(() => {
    const pv = prevView.current;
    prevView.current = { tab, route, depth };
    if (!pv) return;
    if (skipNextTrans.current) { skipNextTrans.current = false; return; }
    let type = null;
    if (pv.tab !== tab) type = "tab";
    else if (depth > pv.depth) type = "push";
    else if (depth < pv.depth) type = "pop";
    else if (pv.route !== route) type = "tab";
    if (!type) return;
    transSeq.current++;
    setTrans({ type, from: pv, seq: transSeq.current });
    clearTimeout(transTimer.current);
    transTimer.current = setTimeout(() => setTrans(null), 420);
  }, [tab, route, depth]);
  useEffect(() => () => clearTimeout(transTimer.current), []);

  /* ── Edge-swipe-back (live drag with parallax) ─────────────────── */
  const [dragBack, setDragBack] = useState(null); // {dx, releasing, commit}
  const eg = useRef({ active: false });
  const hasOverlay = !!(composer || manual || create || send || payment || expense || payview || quoteView || deposit || notif);
  const canSwipeBack = depth > 1 && !hasOverlay && !trans;
  const frameW = () => (mainRef.current ? mainRef.current.getBoundingClientRect().width : 402);
  const onEdgeDown = (e) => {
    if (!canSwipeBack) return;
    const r = mainRef.current.getBoundingClientRect();
    if (e.clientX - r.left > 26) return;
    eg.current = { active: true, sx: e.clientX, sy: e.clientY, lx: e.clientX, lt: performance.now(), vx: 0, captured: false, suppress: false };
  };
  const onEdgeMove = (e) => {
    const s = eg.current;
    if (!s.active) return;
    const dx = e.clientX - s.sx, dy = e.clientY - s.sy;
    if (!s.captured) {
      if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
      if (Math.abs(dy) > Math.abs(dx) || dx <= 0) { s.active = false; return; }
      try { mainRef.current.setPointerCapture(e.pointerId); } catch (err) {}
      s.captured = true; s.suppress = true;
    }
    const now = performance.now();
    s.vx = (e.clientX - s.lx) / Math.max(1, now - s.lt); s.lx = e.clientX; s.lt = now;
    setDragBack({ dx: Math.max(0, dx), releasing: false });
  };
  const onEdgeUp = () => {
    const s = eg.current;
    if (!s.active) return;
    s.active = false;
    if (!s.captured) return;
    const w = frameW();
    const cur = dragBack ? dragBack.dx : 0;
    const commit = cur > w * 0.35 || s.vx > 0.5;
    setDragBack({ dx: commit ? w : 0, releasing: true, commit });
    setTimeout(() => {
      if (commit) { skipNextTrans.current = true; back(); }
      setDragBack(null);
    }, 260);
  };
  const onMainClickCapture = (e) => {
    if (eg.current.suppress) { e.preventDefault(); e.stopPropagation(); eg.current.suppress = false; }
  };

  /* ── Print path ─────────────────────────────────────────────────
     #q-print-root is hidden on screen and the ONLY thing visible in
     print (index.html @media print). The dialog fires next frame so the
     portal has painted; afterprint clears the state, with a timer
     fallback for engines that never emit the event. */
  useEffect(() => {
    if (!printId) return;
    const raf = requestAnimationFrame(() => { try { window.print(); } catch (e) {} });
    const done = () => setPrintId(null);
    window.addEventListener("afterprint", done);
    const fb = setTimeout(done, 1500);
    return () => { cancelAnimationFrame(raf); window.removeEventListener("afterprint", done); clearTimeout(fb); };
  }, [printId]);

  const finishOnboarding = (profile) => {
    if (profile) {
      /* Merge only the answers the user actually gave - empty fields never
         overwrite the company (keeps ?demo=1 replay-onboarding intact). */
      const answers = {};
      Object.keys(profile).forEach(k => {
        const v = profile[k];
        if (v != null && String(v).trim() !== "") answers[k] = v;
      });
      setDb(d => ({ ...d, COMPANY: { ...d.COMPANY, ...answers } }));
    }
    try { localStorage.setItem("nita_onboarded", "1"); } catch (e) {}
    setOnboarded(true);
  };
  const replayOnboarding = () => {
    try { localStorage.removeItem("nita_onboarded"); } catch (e) {}
    setOnboarded(false);
  };

  /* FIX 1: one-tap pristine start. Wipe both IDB stores + the synchronous
     gates, then reload into demo seed — the guaranteed-clean image-store
     path (a non-reload setDb could leave orphaned image rows). */
  const resetToSeed = useCallback(async () => {
    try { await window.NITA_STORE.clearAll(); } catch (e) {}
    try {
      /* Land cleanly in demo (not the first-run choice gate) mid-pitch: pin
         the mode to "demo" and clear the per-run flags. */
      localStorage.setItem(MODE_KEY, "demo");
      localStorage.removeItem("nita_has_db");
      localStorage.removeItem("nita_fresh");
      localStorage.setItem("nita_onboarded", "1");
    } catch (e) {}
    try { window.location.reload(); } catch (e) {}
  }, []);

  const api = {
    db, setDb, today, route, tab, go, back, goTab, switchTab,
    saveState, resetToSeed,
    /* opts.listen (F7): the dock long-press opens the composer already
       listening; single-arg callers are unaffected. */
    openComposer: (seed, opts) => openSheet("composer", { seed: seed || null, listen: !!(opts && opts.listen), mode: (opts && opts.mode) || null }),
    openManual: (seed) => openSheet("manual", { seed: seed || null }),
    openCreate: () => openSheet("create"),
    closeCreate: () => closeSheet("create"),
    /* Export screen (replaces the old DownloadSheet): a full route, seeded
       from the optional filter shape { clientId, projectId, status, types,
       basis, from, to, currency, ids }; it closes via api.back(). */
    openExport: (seed) => go("export", seed || {}),
    /* openDownload({ids}) forwards to the Export screen, hand-picking the
       currently-shown invoice ids when present (ids override the filter). */
    openDownload: (opts) => api.openExport({ ids: opts && opts.ids }),
    closeManual: () => closeSheet("manual"),
    dup,
    beginReuse: () => { closeSheet(); goTab("invoices"); setDup(true); },
    endReuse: () => setDup(false),
    openSend: (invoiceId, mode = "send") => openSheet("send", { invoiceId, mode }),
    openPayment: (invoiceId) => openSheet("payment", { invoiceId }),
    openExpenseSheet: (seed) => openSheet("expense", { seed: seed || null }),
    closeExpenseSheet: () => closeSheet("expense"),
    /* V2-6: in-app opens are always the OWNER previewing the client page —
       flag them so the preview never fabricates a "viewed by client" event. */
    openPayView: (invoiceId) => openSheet("payview", { invoiceId, preview: true }),
    closePayView: () => closeSheet("payview"),
    openQuoteView: (quoteId) => openSheet("quoteView", { id: quoteId }),
    closeQuoteView: () => closeSheet("quoteView"),
    openDeposit: (quoteId) => openSheet("deposit", { id: quoteId }),
    closeDeposit: () => closeSheet("deposit"),
    openNotifications: () => openSheet("notif"),
    closeNotifications: () => closeSheet("notif"),
    printInvoice: (id) => setPrintId(id),
    markRead: (ids) => setDb(d => ({ ...d, ACTIVITY: LIB.markRead(d.ACTIVITY, ids) })),
    dismissNudge: (id) => setDb(d => ({ ...d, DISMISSED: [...(d.DISMISSED || []), id] })),
    setTheme: (t) => setDb(d => ({ ...d, COMPANY: { ...d.COMPANY, theme: t } })),
    toggleTheme: () => setDb(d => ({ ...d, COMPANY: { ...d.COMPANY, theme: (d.COMPANY.theme === "dark" ? "light" : "dark") } })),
    closeSend: () => closeSheet("send"),
    closePayment: () => closeSheet("payment"),
    closeComposer: () => closeSheet("composer"),
    /* Demo clock: ONE aggregated toast (single-slot ToastHost). Reading db
       from the render closure is fine here — dedicated Settings button,
       never concurrent. */
    advanceClock: (days) => {
      const res = LIB.advanceClock(db, days);
      window.DATA.TODAY = res.today;
      setDb(() => res.db);
      const n = res.report.length;
      Q.toast(n === 1 ? window.t("clock.advanced.one", { date: window.fmtDate(res.today) })
            : n ? window.t("clock.advanced.events", { date: window.fmtDate(res.today), n })
            : window.t("clock.advanced", { date: window.fmtDate(res.today) }), "clock");
    },
    replayOnboarding,
    toast: Q.toast,
    L: LIB, Q, Icons, t: window.t,
  };

  function renderScreen(r) {
    const Sc = S();
    const p = (r && r.params) || {};
    const map = {
      home: [Sc.Home, {}],
      invoices: [Sc.Invoices, {}],
      invoice: [Sc.InvoiceDetail, { id: p.id }],
      "invoice-preview": [Sc.InvoicePreview, { id: p.id, fresh: p.fresh }],
      clients: [Sc.Clients, {}],
      client: [Sc.ClientDetail, { id: p.id }],
      project: [Sc.ProjectDetail, { id: p.id }],
      expenses: [Sc.Expenses, {}],
      expense: [Sc.ExpenseDetail, { id: p.id }],
      catalog: [Sc.Catalog, {}],
      settings: [Sc.Settings, {}],
      taxes: [Sc.Taxes, {}],
      insights: [Sc.Insights, {}],
      export: [Sc.Export, { seed: p }],
    };
    const entry = map[r && r.name];
    if (!entry || !entry[0]) {
      return <div style={{ padding: 40, paddingTop: Q.SAFE_TOP + 40, color: "var(--color-text-3)", fontFamily: "var(--font-mono)", fontSize: 13 }}>{window.t("g.loading", { name: r && r.name })}</div>;
    }
    const C = entry[0];
    return <C api={api} {...entry[1]} />;
  }

  const Sc = S();
  const A4 = window.A4 || {};
  const printInv = printId ? (db.INVOICES || []).find(i => i.id === printId) : null;

  /* ── Compose the screen layers ─────────────────────────────────── */
  const activeKey = tab + ":" + depth + ":" + (route.name || "") + ":" + ((route.params && route.params.id) || "");
  let underLayer = null, overLayer = null, dim = null, activeClass = "", activeStyle;
  if (dragBack) {
    const w = frameW();
    const p = Math.min(1, dragBack.dx / w);
    const tr = dragBack.releasing ? "transform .26s var(--spring)" : "none";
    const prevRoute = stacks[tab][stacks[tab].length - 2];
    underLayer = (
      <ScreenLayer key={"drag-under"} routeObj={prevRoute} ariaHidden="true" className="q-still"
        style={{ transform: `translateX(${-26 * (1 - p)}%)`, transition: tr, pointerEvents: "none" }}>
        {renderScreen(prevRoute)}
      </ScreenLayer>
    );
    dim = <div key="drag-dim" className="q-screen-dim" style={{ opacity: 1 - p, transition: dragBack.releasing ? "opacity .26s" : "none" }} />;
    activeStyle = { transform: `translateX(${dragBack.dx}px)`, transition: tr, boxShadow: "-18px 0 48px hsl(35 16% 5% / 0.22)" };
  } else if (trans) {
    if (trans.type === "push") {
      activeClass = "q-anim-push-in";
      underLayer = (
        <ScreenLayer key={"u" + trans.seq} routeObj={trans.from.route} ariaHidden="true" className="q-anim-push-under q-still" style={{ pointerEvents: "none" }}>
          {renderScreen(trans.from.route)}
        </ScreenLayer>
      );
      dim = <div key={"d" + trans.seq} className="q-screen-dim" style={{ animation: "q-dim-in .38s var(--spring) both" }} />;
    } else if (trans.type === "pop") {
      activeClass = "q-anim-pop-under-back q-still";
      overLayer = (
        <ScreenLayer key={"o" + trans.seq} routeObj={trans.from.route} ariaHidden="true" className="q-anim-pop-out q-still" style={{ pointerEvents: "none" }}>
          {renderScreen(trans.from.route)}
        </ScreenLayer>
      );
      dim = <div key={"d" + trans.seq} className="q-screen-dim" style={{ animation: "q-dim-out .34s var(--spring) both" }} />;
    } else {
      activeClass = "q-anim-tab-in q-still";
    }
  }

  /* Fullscreen editors (composer / manual) cover main + dock entirely; mark
     the obscured background `inert` so Tab can't reach hidden controls.
     React 18 UMD doesn't know the `inert` prop, so it's passed as a string
     attribute via spread (D-01 keyboard a11y). */
  const overlayUp = !!(composer || manual);

  /* Frame: on real phones (window.NITA_FRAMELESS, set by the index.html boot
     script) the app fills the actual viewport — no IOSDevice mockup, no fake
     status bar / island / home indicator. Desktop keeps the framed device.
     NITA_FRAMELESS is fixed for the session, so the branch never remounts. */
  const frame = (
    <>
      {/* id=q-frame: portal target for overlays (Q.Sheet, viewers) so they
          always pin to the device frame, never to a scrolled screen. */}
      <div id="q-frame" style={{ height: "100%", position: "relative", overflow: "hidden", background: "var(--color-bg)", color: "var(--color-text-1)" }}>
        {needChoice ? (
          Sc.FirstRun ? <Sc.FirstRun onChoose={chooseMode} /> : null
        ) : !onboarded ? (
          Sc.Onboarding ? <Sc.Onboarding api={api} onFinish={finishOnboarding} /> : null
        ) : (
          <>
            <main ref={mainRef} onPointerDown={onEdgeDown} onPointerMove={onEdgeMove}
              onPointerUp={onEdgeUp} onPointerCancel={onEdgeUp} onClickCapture={onMainClickCapture}
              {...(overlayUp ? { inert: "" } : {})}
              style={{ position: "absolute", inset: 0, overflow: "hidden", touchAction: "pan-y" }}>
              {underLayer}
              {trans && trans.type === "push" ? dim : null}
              {dragBack ? dim : null}
              <ScreenLayer key={activeKey} routeObj={route} className={activeClass} style={activeStyle}>
                {renderScreen(route)}
              </ScreenLayer>
              {trans && trans.type === "pop" ? dim : null}
              {overLayer}
            </main>
            <TabBar tab={tab} onTab={switchTab} onNew={() => api.openComposer(null, { mode: "ask" })} onHold={() => api.openComposer(null, { listen: true })} hidden={route.name === "invoice-preview"} inertAll={overlayUp} />

            {create && Sc.CreateSheet && <Sc.CreateSheet api={api} onClose={() => closeSheet("create")} />}
            {composer && Sc.Composer && <Sc.Composer api={api} seed={composer.seed} autoVoice={composer.listen} mode={composer.mode} onClose={() => closeSheet("composer")} />}
            {manual && Sc.ManualEditor && <Sc.ManualEditor api={api} seed={manual.seed} onClose={() => closeSheet("manual")} />}
            {/* Subject-tied keys: remount a sheet when its target changes so a
                second open never shows the previous invoice/expense draft state. */}
            {send && Sc.SendSheet && <Sc.SendSheet key={send.invoiceId + ":" + send.mode} api={api} invoiceId={send.invoiceId} mode={send.mode} onClose={() => closeSheet("send")} />}
            {payment && Sc.PaymentSheet && <Sc.PaymentSheet key={payment.invoiceId} api={api} invoiceId={payment.invoiceId} onClose={() => closeSheet("payment")} />}
            {expense && Sc.ExpenseSheet && <Sc.ExpenseSheet key={expense.seed ? (expense.seed.edit ? "edit:" + expense.seed.edit.id : expense.seed.mode || "seed") : "new"} api={api} seed={expense.seed} onClose={() => closeSheet("expense")} />}
            {payview && Sc.ClientPayView && <Sc.ClientPayView key={payview.invoiceId} api={api} invoiceId={payview.invoiceId} preview={!!payview.preview} onClose={() => closeSheet("payview")} />}
            {quoteView && Sc.ClientQuoteView && <Sc.ClientQuoteView key={quoteView} api={api} invoiceId={quoteView} onClose={() => closeSheet("quoteView")} />}
            {deposit && Sc.DepositSheet && <Sc.DepositSheet key={deposit} api={api} quoteId={deposit} onClose={() => closeSheet("deposit")} />}
            {notif && Sc.NotificationsSheet && <Sc.NotificationsSheet key="notif" api={api} onClose={() => closeSheet("notif")} />}
          </>
        )}
        <ToastHost />
        {/* Print portal: lives on document.body (outside #stage) — the one
            sanctioned exception to the #q-frame rule, print-only. Renders
            the UNSCALED 820px Invoice, never Scaled. */}
        {printInv && A4.Invoice && ReactDOM.createPortal(
          <div id="q-print-root">
            <A4.Invoice api={api} inv={printInv} balance={LIB.totals(printInv).balance} />
          </div>,
          document.body
        )}
      </div>
    </>
  );
  if (window.NITA_FRAMELESS) {
    return <div className="q-frameless-root">{frame}</div>;
  }
  return (
    <IOSDevice width={402} height={870} dark={theme === "dark"}>
      {frame}
    </IOSDevice>
  );
}

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