/* composer.jsx — the AI invoice composer (HERO) → window.QScreens.Composer
 * Prompt → (focused follow-up questions when incomplete) → live editable draft
 * (the SAME EditorBody the manual path uses) → confirm → PDF. Errors offer the
 * manual editor as a fallback so the user is never stuck.
 */
(function () {
  const { useState, useEffect, useRef } = React;
  const Q = window.Q, LIB = window.LIB, Icons = window.Icons, AIPARSE = window.AIPARSE;
  const { Icon } = Icons;
  const { Button, IconButton, Sparkle, Badge, Money, MoneyDisplay, Kicker, AiKicker, EntityLink } = Q;
  const t = window.t;
  /* t() with a literal fallback: shows the fallback if the key is undefined
   * (t returns the raw key in that case) so micro-labels never leak a key. */
  const tx = (key, fallback, vars) => { const s = t(key, vars); return s === key ? fallback : s; };

  let _uid = 8000;
  const uid = () => "x" + (++_uid);
  const addDays = (iso, n) => { const d = new Date(iso); d.setDate(d.getDate() + n); return d.toISOString(); };

  /* Notes prefill: the business default from Settings when set, else the
     localized stock line (contract: COMPANY.notesDefault may be ""). */
  const defaultNotes = (co, days) => {
    const s = (co && co.notesDefault ? String(co.notesDefault) : "").trim();
    return s ? s.replace(/\{days\}/g, days) : t("inv.notes.default", { days });
  };

  /* ── Thinking bubble ─────────────────────────────────────────── */
  function Thinking({ mini }) {
    const msgs = [t("ai.thinking"), t("ai.thinking2"), t("ai.thinking3")];
    const [i, setI] = useState(0);
    useEffect(() => { const id = setInterval(() => setI(p => (p + 1) % msgs.length), 620); return () => clearInterval(id); }, []);
    return (
      <div className="q-fade" style={{ display: "flex", gap: 10, alignItems: "center", padding: mini ? "4px 2px" : "10px 2px" }}>
        <div style={{ width: 30, height: 30, borderRadius: 8, background: "var(--color-primary-muted)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
          <span style={{ display: "inline-flex", animation: "q-spin 2.4s linear infinite" }}><Sparkle size={15} /></span>
        </div>
        <span className="q-shimmer-text" style={{ fontSize: 14, fontWeight: 600 }}>{mini ? t("ai.thinking") : msgs[i]}</span>
        {/* universal "AI is composing" tell — three dots blink in sequence */}
        <span aria-hidden="true" style={{ display: "inline-flex", gap: 3, alignItems: "center", marginLeft: -2 }}>
          {[0, 1, 2].map(d => (
            <span key={d} style={{ width: 4, height: 4, borderRadius: "50%", background: "var(--color-primary)", animation: `q-blink 1.1s ease-in-out ${d * 0.16}s infinite` }} />
          ))}
        </span>
      </div>
    );
  }

  /* ── Chat bubble ─────────────────────────────────────────────── */
  function Bubble({ m, api, onClose, onFollowup }) {
    if (m.role === "user") {
      return (
        <div className="q-fade-up" data-msg={m.id} style={{ display: "flex", justifyContent: "flex-end" }}>
          <div style={{ maxWidth: "82%", background: "var(--color-surface-2)", color: "var(--color-text-1)", padding: "10px 14px", borderRadius: "16px 16px 6px 16px", fontSize: 14, lineHeight: 1.4 }}>{m.text}</div>
        </div>
      );
    }
    if (m.answer) {
      /* Ask-Nita answer bubble (F8). Navigation = the confirm() pattern:
         the Composer is a fullscreen overlay, so onClose() FIRST, then goTab. */
      const a = m.answer;
      const navTo = (tab, route, params) => {
        onClose();
        /* chaseX: the composer is a fullscreen overlay, so close FIRST, then
           open the send sheet in 'remind' mode rather than pushing a route. */
        if (route === "send") { api.openSend(params && params.id, "remind"); return; }
        api.goTab(tab, route, params);
      };
      /* whoOwes overflow goes to the (balance-sorted) Clients tab; its goto
         carries no route, so label it from the generic "view all" string
         rather than the per-route ans.goto.* table. */
      const more = Number(a.more) || 0;
      const goRef = (r) => {
        if (r.kind === "client") navTo("clients", "client", { id: r.id });
        else if (r.kind === "project") navTo("clients", "project", { id: r.id });
        else if (r.kind === "expense") navTo("home", "expense", { id: r.id });
        else if (r.kind === "catalog") navTo("home", "catalog");
        else navTo("invoices", "invoice", { id: r.id });
      };
      return (
        <div className="q-fade-up" data-msg={m.id} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
          <div style={{ width: 30, height: 30, borderRadius: 8, background: "var(--color-primary-muted)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, marginTop: 1 }}>
            <Sparkle size={15} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            {a.money && (
              <div style={{ marginBottom: 3 }}>
                <MoneyDisplay amount={a.money.amount} currency={a.money.currency} size={22} />
              </div>
            )}
            <div style={{ fontSize: 14, color: "var(--color-text-1)", lineHeight: 1.45 }}>{t(a.key, a.params)}</div>
            {((a.refs && a.refs.length > 0) || more > 0) && (
              <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "6px 16px", marginTop: 8 }}>
                {(a.refs || []).map((r, i) => (
                  <EntityLink key={r.kind + ":" + r.id + ":" + i} kind={r.kind} name={r.name} mono={r.kind === "invoice"} size={13} onClick={() => goRef(r)} />
                ))}
                {more > 0 && (
                  <button onClick={() => navTo("clients", undefined, {})} className="q-tap" style={{ display: "inline-flex", alignItems: "center", gap: 5, height: 26, padding: "0 10px", borderRadius: 999, border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-0)", color: "var(--color-text-2)", fontSize: 12, fontWeight: 600, cursor: "pointer" }}>
                    {tx("ans.whoOwes.more", "+" + more + " more", { count: more })}
                    <Icon name="arrowUpRight" size={12} color="var(--color-text-3)" />
                  </button>
                )}
              </div>
            )}
            {/* The generic goto button only applies to ROUTED gotos; the
                clients-overflow goto (no route) is the "+N more" pill above. */}
            {a.goto && a.goto.route && (
              <button onClick={() => navTo(a.goto.tab, a.goto.route, a.goto.params)} className="q-tap" style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 30, padding: "0 12px", borderRadius: 999, border: "1.4px solid var(--color-primary)", background: "var(--color-primary-muted)", color: "var(--color-primary)", fontSize: 12, fontWeight: 600, cursor: "pointer", marginTop: 8 }}>
                {t("ans.goto." + a.goto.route)}
                <Icon name="arrowUpRight" size={13} />
              </button>
            )}
            {/* contextual follow-up chips (no-suggested-followups-after-answer) */}
            {a.followups && a.followups.length > 0 && onFollowup && (
              <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
                {a.followups.map((q, i) => (
                  <button key={i} onClick={() => onFollowup(q)} className="q-tap"
                    style={{ height: 30, padding: "0 12px", borderRadius: 999, border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-0)", color: "var(--color-text-2)", fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap" }}>{q}</button>
                ))}
              </div>
            )}
          </div>
        </div>
      );
    }
    const err = m.kind === "error";
    return (
      <div className="q-fade-up" data-msg={m.id} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
        <div style={{ width: 30, height: 30, borderRadius: 8, background: err ? "var(--color-accent-muted)" : "var(--color-primary-muted)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, marginTop: 1 }}>
          {err ? <Icon name="alert" size={15} color="var(--color-accent)" /> : <Sparkle size={15} />}
        </div>
        <div style={{ flex: 1 }}>
          {m.title && <div style={{ fontSize: 14, fontWeight: 700, color: "var(--color-text-1)", marginBottom: 2 }}>{m.title}</div>}
          <div style={{ fontSize: 14, color: err ? "var(--color-text-2)" : "var(--color-text-1)", lineHeight: 1.45 }}>{m.text}</div>
        </div>
      </div>
    );
  }

  /* ── Voice listening overlay ─────────────────────────────────── */
  function Listening({ transcript, onStop }) {
    /* Tie the waveform to what was actually heard: each time a new word lands
       the bars get a brief amplitude bump so the meter reacts to speech rather
       than looping decoratively (reduced-motion neutralizes the transition). */
    const [amp, setAmp] = useState(false);
    const wordCount = transcript ? transcript.trim().split(/\s+/).filter(Boolean).length : 0;
    const prevWords = useRef(0);
    useEffect(() => {
      if (wordCount > prevWords.current) {
        setAmp(true);
        const id = setTimeout(() => setAmp(false), 260);
        prevWords.current = wordCount;
        return () => clearTimeout(id);
      }
      prevWords.current = wordCount;
    }, [wordCount]);
    return (
      <div className="q-fade" style={{ position: "absolute", inset: 0, background: "var(--color-bg)", zIndex: 5, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: 32, textAlign: "center" }}>
        <div style={{ width: 96, height: 96, borderRadius: "50%", background: "var(--color-primary-muted)", display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
          <div style={{ position: "absolute", inset: 0, borderRadius: "50%", border: "2px solid var(--color-primary)", animation: "q-pulse 1.4s ease-in-out infinite" }} />
          <Icon name="mic" size={34} color="var(--color-primary)" />
        </div>
        <div style={{ display: "flex", gap: 4, alignItems: "center", height: 36, marginTop: 26 }}>
          {[0, 1, 2, 3, 4, 5, 6].map(i => (
            <span key={i} style={{ width: 4, height: 28, borderRadius: 2, background: "var(--color-primary)", transformOrigin: "center", transform: amp ? "scaleY(1.32)" : "scaleY(1)", transition: "transform .26s var(--spring-bouncy)", animation: `q-wave 0.9s ease-in-out ${i * 0.1}s infinite` }} />
          ))}
        </div>
        <div style={{ fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 600, letterSpacing: "0.10em", textTransform: "uppercase", color: "var(--color-primary)", marginTop: 22 }}>{t("ai.listening")}</div>
        <div style={{ fontSize: 16, color: "var(--color-text-1)", marginTop: 10, minHeight: 24, maxWidth: 300 }}>{transcript}</div>
        <Button variant="ghost" size="sm" onClick={onStop} style={{ marginTop: 18 }}>{t("voice.stop")}</Button>
      </div>
    );
  }

  /* ── Composer root ───────────────────────────────────────────── */
  function Composer({ api, seed, autoVoice, mode, onClose }) {
    const { db, setDb, today } = api;
    const co = db.COMPANY;
    /* 'ask' (dock green button → money Q&A first) vs 'invoice' (create
       chooser / home CTA → invoice draft first). Default 'invoice' for
       backward compatibility; the dock passes 'ask'. */
    const ask = mode === "ask";
    const [shown, setShown] = useState(false);
    const [messages, setMessages] = useState([]);
    const [phase, setPhase] = useState("empty"); // empty | thinking | gather | extra | draft | error | mini
    const [draft, setDraft] = useState(seed || null);
    const [pending, setPending] = useState(null);
    const [extra, setExtra] = useState(null); // current skippable follow-up { kind, key, param }
    const [asked, setAsked] = useState([]); // extra kinds already offered
    const [tries, setTries] = useState(0);
    const [input, setInput] = useState("");
    const [listening, setListening] = useState(false);
    const [transcript, setTranscript] = useState("");
    const [pickClient, setPickClient] = useState(false);
    const [pickProject, setPickProject] = useState(false);
    const [pickCatalog, setPickCatalog] = useState(false);
    const [pickExpense, setPickExpense] = useState(false);
    const [viewReceipt, setViewReceipt] = useState(null);
    const scrollRef = useRef(null);
    const timers = useRef([]);
    const recRef = useRef(null); // live SpeechRecognition instance (aborted on unmount / Stop)
    const voiceGen = useRef(0); // bumped by stopVoice so a cancelled replay never auto-submits
    const saidUnsupported = useRef(false); // voice.unsupported is pushed at most once
    const saidLocalFail = useRef(false); // ai.local.fail toast fires at most once per session
    const alive = useRef(true); // local-AI promises resolve after unmount: ignore them
    useEffect(() => () => { alive.current = false; }, []);

    /* Pluggable backend (window.NITA_AI): "local" routes drafting through
       the user's own Ollama; anything else stays on the scripted demo. */
    const aiCfg = (window.NITA_AI && window.NITA_AI.getConfig()) || { mode: "demo", model: "" };
    const aiLocal = aiCfg.mode === "local";
    /* V5-1: when the local endpoint fails and we serve the scripted demo
       draft, the banner must say so. A later successful local call clears it. */
    const [fellBack, setFellBack] = useState(false);
    /* Bumped each time a refine actually changes the draft so the card can
       replay a one-shot sage glow — "watch Nita edit it" made visible. The
       value is the changed field's code (or "" ) so it remounts the glow node. */
    const [refinePulse, setRefinePulse] = useState(0);

    useEffect(() => {
      const r = setTimeout(() => setShown(true), 20);
      if (seed) setPhase("draft");
      else if (autoVoice) after(420, startVoice);
      return () => {
        clearTimeout(r); timers.current.forEach(clearTimeout);
        const rec = recRef.current;
        if (rec) { recRef.current = null; try { rec.abort(); } catch (e) {} }
      };
    }, []);

    useEffect(() => {
      const el = scrollRef.current;
      if (!el) return;
      /* anchored bubbles (validation errors) render ABOVE the tall draft
         card, so a plain scroll-to-bottom would hide them off-screen:
         scroll the bubble itself into view instead */
      const last = messages[messages.length - 1];
      if (last && last.anchor) {
        const node = el.querySelector('[data-msg="' + last.id + '"]');
        if (node) { node.scrollIntoView({ behavior: "smooth", block: "center" }); return; }
      }
      el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
    }, [messages, phase, draft && draft.items.length]);

    const after = (ms, fn) => { const id = setTimeout(fn, ms); timers.current.push(id); };
    const pushUser = txt => setMessages(m => [...m, { id: uid(), role: "user", text: txt }]);
    const pushAI = (txt, extra) => setMessages(m => [...m, { id: uid(), role: "ai", text: txt, ...extra }]);
    /* Refine/gather replies render ABOVE the tall draft card, so the plain
       scroll-to-bottom would hide them off-screen (refine-reply-hidden-behind-
       draft). When a draft is already present, anchor the reply so the same
       scroll-into-view path the answer/validation bubbles use brings it on
       screen — Nita's edit is always visible. */
    const pushAIAnchored = (txt, extra) => pushAI(txt, { anchor: true, ...extra });

    /* Items run through LIB.cleanItem so any provenance the parser attaches
     * (source / catalogId / expenseId / receipt) survives into the draft. */
    const buildDraft = (res) => ({
      kind: "invoice", clientId: res.client.clientId, newClientName: res.client.isNew ? res.client.name : null, projectId: res.projectId || null,
      issued: today, terms: res.terms != null ? res.terms : co.terms, due: addDays(today, res.terms != null ? res.terms : co.terms), currency: res.currency,
      items: res.items.map(it => LIB.cleanItem({ ...it, id: uid(), discountPct: it.discountPct || 0, taxRate: it.taxRate != null ? it.taxRate : (res.taxRate != null ? res.taxRate : co.taxRate) }, null)),
      discountPct: res.discountPct || 0, taxRate: res.taxRate != null ? res.taxRate : co.taxRate, paid: 0, notes: res.notes || "", legal: "", status: "draft", vatMode: "standard",
      recurrence: res.recurrence || null, // dormant hook: parse never sets it this wave; EditorBody's Repeats control edits it
    });

    /* WP2 contract: parse()/refine() may report amounts they saw but chose
     * NOT to treat as money ("net 30", bare numbers). Mention them calmly so
     * nothing is dropped in silence. No-op when the surface is absent. */
    const noteUnparsed = (res, anchored) => {
      const u = res && res.unparsed;
      if (!Array.isArray(u) || !u.length) return;
      const list = u.map(x => (x && typeof x === "object") ? (x.text || x.raw || x.value || "") : String(x)).filter(Boolean).join(", ");
      if (!list) return;
      (anchored ? pushAIAnchored : pushAI)(tx("ai.reply.unparsed", "I read " + list + " as context, not as an amount. Tell me if I should bill it.", { amounts: list }));
    };

    const showDraft = (res, introKey) => {
      const d = buildDraft(res);
      setDraft(d);
      if (introKey) pushAI(introKey === "new"
        ? t("ai.reply.newclient", { client: res.client.name })
        : t("ai.reply.intro", { client: res.client.name, terms: `net-${d.terms}` }));
      setExtra(null);
      setPhase("draft");
    };

    /* After client + amount are resolved, optionally surface skippable
     * confirmations (pick a project, confirm default VAT). Each can be
     * skipped and still reach the draft. `introKey` is shown with the draft. */
    const proceedAfterResolved = (res, introKey, askedSoFar) => {
      const next = AIPARSE.extraAsk(res, db, co, askedSoFar);
      if (next) {
        setPending(res);
        setExtra({ ...next, introKey });
        setAsked([...askedSoFar, next.kind]);
        pushAI(t(next.key, next.param));
        setPhase("extra");
        return;
      }
      showDraft(res, introKey);
    };

    /* Shared tail of the initial parse: the scripted path and the local-AI
       fallback both land here with a parse()-shaped result. */
    const handleParsed = (p) => {
      const hasClient = !!p.client, hasAmount = p.items.some(it => it.rate > 0), hasItem = p.items.length > 0;
      if (!hasClient && !hasAmount && !hasItem) {
        setPhase("error");
        pushAI(t("ai.error.body"), { kind: "error", title: t("ai.error.title") });
        return;
      }
      if (p.ok) { proceedAfterResolved(p, p.client.isNew ? "new" : "intro", []); noteUnparsed(p); return; }
      // partial → ask focused follow-up
      setPending(p);
      pushAI(t(AIPARSE.askFor(p.missing)));
      setPhase("gather");
    };

    /* Map the local model's strict-JSON draft ({clientName, items:[{description,
       qty, unitPrice}], notes, currency}) onto the parse() result shape the
       rest of the composer consumes. Returns null when unusable → demo fallback. */
    const mapLocalDraft = (j) => {
      if (!j || typeof j !== "object") return null;
      const name = String(j.clientName || "").trim();
      const items = (Array.isArray(j.items) ? j.items : []).map(it => ({
        description: String((it && it.description) || "").trim(),
        qty: Number(it && it.qty) > 0 ? Number(it.qty) : 1,
        rate: Number(it && (it.unitPrice != null ? it.unitPrice : it.rate)) || 0,
        unit: "item",
      })).filter(it => it.description || it.rate > 0);
      if (!name || !items.some(it => it.rate > 0)) return null;
      const lcn = name.toLowerCase();
      const match = db.CLIENTS.find(c => c.name.toLowerCase() === lcn)
        || db.CLIENTS.find(c => c.name.toLowerCase().includes(lcn) || lcn.includes(c.name.toLowerCase()));
      const client = match
        ? { clientId: match.id, name: match.name, isNew: false }
        : { clientId: null, name, isNew: true };
      const cur = j.currency && window.CURRENCIES[String(j.currency).toUpperCase()]
        ? String(j.currency).toUpperCase() : co.currency;
      const tr = Number(j.taxRate);
      return {
        ok: true, missing: [], unparsed: [], client, items,
        terms: null, taxRate: isFinite(tr) && tr >= 0 ? tr : null, discountPct: 0, currency: cur,
        notes: typeof j.notes === "string" ? j.notes.trim() : "",
      };
    };

    const submitInitial = (text) => {
      pushUser(text); setInput(""); setPhase("thinking"); setTries(0); setAsked([]); setExtra(null);
      if (aiLocal && window.NITA_AI) {
        window.NITA_AI.draftInvoice(text, { currency: co.currency, clients: db.CLIENTS.map(c => c.name) })
          .then(j => {
            if (!alive.current) return;
            const res = mapLocalDraft(j);
            if (res) { setFellBack(false); handleParsed(res); return; }
            // unreachable / unusable → scripted demo draft, one calm toast
            setFellBack(true); // banner flips to the demo wording (V5-1)
            if (!saidLocalFail.current) { saidLocalFail.current = true; Q.toast(t("ai.local.fail"), "alert", "var(--color-accent)"); }
            handleParsed(AIPARSE.parse(text, db, co));
          });
        return;
      }
      /* thinking-delay-fixed: a parse is instant, so the bubble only needs to
         read as "Nita is working", not stall — ~900ms, not 1.6s. */
      after(900, () => handleParsed(AIPARSE.parse(text, db, co)));
    };

    const submitGather = (text) => {
      pushUser(text); setInput(""); setPhase("thinking");
      after(650, () => {
        const g = AIPARSE.gather(text, pending, db, co);
        setPending(g.pending);
        if (g.pending.ok) {
          pushAI(t("ai.gathered"));
          proceedAfterResolved(g.pending, g.pending.client.isNew ? "new" : null, asked);
          return;
        }
        if (g.filled.length === 0) {
          const nt = tries + 1; setTries(nt);
          if (nt >= 2) {
            setPhase("error");
            pushAI(t("ai.error.body"), { kind: "error", title: t("ai.error.title") });
            return;
          }
        }
        pushAI(t(AIPARSE.askFor(g.missing)));
        setPhase("gather");
      });
    };

    /* Apply a local-model corrected draft (mapLocalDraft parse-shape) ONTO the
       current draft, preserving the wiring the model never sees: line ids,
       provenance (source/catalogId/expenseId/receipt), per-line discount,
       issued date, terms, projectId. Items are matched to the existing draft
       lines positionally so unchanged lines keep their identity; new lines get
       a fresh id. Returns the merged draft, or null when the result is unusable. */
    const mergeRefinedDraft = (res) => {
      if (!res || !Array.isArray(res.items) || !res.items.some(it => it.rate > 0)) return null;
      const prev = draft.items;
      const items = res.items.map((it, i) => {
        const base = prev[i] || {};
        return LIB.cleanItem({
          ...base,
          id: base.id || uid(),
          description: it.description || base.description || "",
          /* prefer the existing line's unit for a positionally-matched line —
             mapLocalDraft flattens unit to "item", which would otherwise erase
             a day/hour rate the correction never touched. */
          qty: it.qty, rate: it.rate, unit: base.unit || it.unit || "item",
          discountPct: base.discountPct || 0,
          taxRate: base.taxRate != null ? base.taxRate : draft.taxRate,
        }, null);
      });
      return {
        ...draft, items,
        taxRate: res.taxRate != null ? res.taxRate : draft.taxRate,
        currency: res.currency || draft.currency,
      };
    };

    /* Apply a successfully-resolved refine (regex OR model): swap the draft in,
       confirm in Nita's voice, replay the sage glow, surface any unparsed money.
       `msg` is the full confirmation line already resolved by the caller. */
    const applyRefined = (nd, msg, hadUnparsed) => {
      nd.due = addDays(nd.issued, nd.terms);
      setDraft(nd);
      pushAIAnchored(msg);
      setRefinePulse(p => p + 1);
      if (hadUnparsed) noteUnparsed(hadUnparsed, true);
      setPhase("draft");
    };

    /* Refine result `change` is `{ code, params }` (localized through the
     * ai.change.* table); a plain string is still accepted as a legacy
     * fallback. Unknown codes degrade to the generic "draft updated" line.
     * In local mode, a correction the (now-broad) regex does NOT confidently
     * catch is routed through the user's own model (refineDraft) before we
     * ever answer "didn't catch that" — the regex stays the fast, deterministic
     * first pass; the model is the fallback, and an honest miss is the last
     * resort only when BOTH miss. */
    const submitRefine = (text) => {
      pushUser(text); setInput(""); setPhase("mini");
      after(560, () => {
        const res = AIPARSE.refine(text, draft);
        const ch = res.change;
        if (ch) {
          const changeText = typeof ch === "string" ? ch : tx("ai.change." + ch.code, null, ch.params);
          /* anchor: the reply renders ABOVE the tall draft card, so scroll it
             into view rather than to the bottom (refine-reply-hidden-behind-draft). */
          applyRefined(res.draft, changeText ? t("ai.reply.updated", { change: changeText }) : t("ai.reply.draftUpdated"), res);
          return;
        }
        /* regex missed. Local mode: ask the model to apply the correction. */
        if (aiLocal && window.NITA_AI && window.NITA_AI.refineDraft) {
          const snapshot = {
            clientName: client ? client.name : (draft.newClientName || ""),
            items: draft.items.map(it => ({ description: it.description, qty: Number(it.qty) || 0, unitPrice: Number(it.rate) || 0 })),
            notes: draft.notes || "", currency: draft.currency, taxRate: draft.taxRate,
          };
          window.NITA_AI.refineDraft(text, snapshot).then(j => {
            if (!alive.current) return;
            const mapped = mapLocalDraft(j);
            const nd = mapped && mergeRefinedDraft(mapped);
            if (nd) { setFellBack(false); applyRefined(nd, t("ai.reply.draftUpdated"), null); return; }
            /* model unreachable / unusable → honest miss (no false toast spam). */
            pushAIAnchored(t("ai.reply.notcaught"));
            setPhase("draft");
          });
          return;
        }
        /* demo mode, regex missed → honest "didn't catch that". */
        pushAIAnchored(t("ai.reply.notcaught"));
        setPhase("draft");
      });
    };

    /* Ask-Nita intercept (F8): runs BEFORE phase dispatch so a question
       asked mid-draft never reaches submitRefine. AIPARSE.answer returns
       null aggressively, so commands still fall through to the parse flow. */
    /* no-suggested-followups-after-answer: each answer offers 1-2 contextual
       next questions so the conversation keeps moving (re-uses existing
       question strings — no new copy). Keyed by intent; the generic tail is a
       sensible default. The current question is filtered out so we never
       suggest what was just asked. */
    const followupsFor = (a) => {
      const Q = {
        whoOwesMe: [t("ans.ex4"), t("ans.ex2")],
        clientBalanceX: [t("ans.ex2"), t("ans.ex1")],
        worstPayer: [t("ans.ex1"), t("ans.ex2")],
        outstandingTotal: [t("ans.ex1"), t("ans.ex4")],
        paidThisMonth: [t("ans.ex1"), t("ans.ex4")],
        overdueList: [t("ans.ex4"), t("ans.ex1")],
        spendThisMonth: [t("ans.ex2"), t("ans.ex1")],
        lastBilledX: [t("ans.ex1"), t("ans.ex2")],
        fallback: [t("ans.ex1"), t("ans.ex2"), t("ans.ex4")],
        capabilities: [t("ans.ex1"), t("ans.ex2"), t("ai.ex1")],
      };
      const list = Q[a && a.intent] || [t("ans.ex1"), t("ans.ex2")];
      // de-dupe + cap at 2 (3 for fallback/capabilities so the menu reads as guidance)
      const cap = a && (a.intent === "fallback" || a.intent === "capabilities") ? 3 : 2;
      return list.filter((x, i) => x && list.indexOf(x) === i).slice(0, cap);
    };

    /* Render a resolved AIPARSE.answer as a bubble (pushes the user line first). */
    const showAnswerBubble = (a, text) => {
      pushUser(text); setInput("");
      setPhase("thinking");
      /* thinking-delay-fixed: an answer is a lookup, not heavy work — ~700ms
         keeps the "thinking" tell without an artificial stall. */
      after(700, () => {
        /* anchor: mid-draft the answer renders ABOVE the tall draft card, so
           the plain scroll-to-bottom would hide it; the anchored-scroll path
           (same as validation errors) scrolls the bubble itself into view. */
        setMessages(m => [...m, { id: uid(), role: "ai", answer: a, anchor: true, followups: followupsFor(a) }]);
        setPhase(draft ? "draft" : "empty");
      });
    };
    const askAnswer = (text) => {
      const a = AIPARSE.answer(text, db, today);
      if (!a) return false;
      showAnswerBubble(a, text);
      return true;
    };

    /* Tell an invoice-draft request apart from general conversation. A draft
       carries an amount or an explicit billing word; a bare question (no
       amount) is conversation, even if it name-drops "invoice". Grounded money
       questions are already intercepted by answer() before this runs. */
    const looksLikeInvoice = (text) => {
      const s = (text || "").trim(), lc = s.toLowerCase();
      const amount = /[€$£]\s*\d|\d[\d.,]*\s*(?:€|eur|usd|gbp|dollars?|pounds?|euros?)\b|\b\d{2,}\b/.test(lc);
      const billing = /\b(invoice|bill|charge|quote|estimate|facture[rz]?|facturer|devis)\b/.test(lc);
      const question = /\?\s*$|^(what|how|who|why|can|could|do|does|is|are|when|which|where|que|qu['’]|qui|comment|combien|pourquoi|est-ce|quel)/i.test(s);
      if (question && !amount) return false;
      return amount || billing;
    };

    /* Compact, read-only book snapshot handed to the local model so it can
       answer light data questions (never mutates anything). */
    const buildAiContext = () => {
      let s = null;
      try { s = LIB.summary(db, today); } catch (e) {}
      return {
        company: co.name, currency: co.currency, today,
        clients: (db.CLIENTS || []).map(c => c.name).slice(0, 40),
        summary: s ? { outstanding: window.fmtMoney(s.outstanding, co.currency), overdue: s.outCount } : null,
      };
    };

    /* Deterministic capabilities orientation — demo mode, or the fallback when
       a live local chat call fails. Renders the same answer-bubble shape. */
    const showCapabilities = () => {
      setPhase("thinking");
      after(500, () => {
        setMessages(m => [...m, { id: uid(), role: "ai", answer: { intent: "capabilities", key: "ans.capabilities", params: {}, refs: [] }, anchor: true, followups: followupsFor({ intent: "capabilities" }) }]);
        setPhase(draft ? "draft" : "empty");
      });
    };

    /* General conversation: neither a money lookup nor an invoice request.
       Local mode routes to the user's Ollama; demo mode (or a failed local
       call) shows the capabilities orientation. (ai-not-conversational fix) */
    const submitChat = (text) => {
      pushUser(text); setInput(""); setPhase("thinking");
      if (aiLocal && window.NITA_AI && window.NITA_AI.chatAssistant) {
        window.NITA_AI.chatAssistant(text, buildAiContext()).then(reply => {
          if (!alive.current) return;
          if (reply && String(reply).trim()) {
            setFellBack(false);
            pushAI(String(reply).trim());
            setPhase(draft ? "draft" : "empty");
            return;
          }
          setFellBack(true);
          if (!saidLocalFail.current) { saidLocalFail.current = true; Q.toast(t("ai.local.fail"), "alert", "var(--color-accent)"); }
          showCapabilities();
        });
        return;
      }
      showCapabilities();
    };

    const onSubmit = () => {
      const text = input.trim();
      if (!text) return;
      /* Grounded money/finance answers win first, in BOTH modes — they read
         the live book and must stay deterministic. capabilities/fallback are
         NOT grounded, so they fall through (to the live model in local mode). */
      const a = AIPARSE.answer(text, db, today);
      const grounded = a && a.intent !== "capabilities" && a.intent !== "fallback";
      if (grounded) { showAnswerBubble(a, text); return; }
      if (phase === "gather" || phase === "extra") return submitGather(text);
      if (draft && phase !== "error") return submitRefine(text);
      // fresh input, no draft yet:
      if (aiLocal) {
        if (looksLikeInvoice(text)) return submitInitial(text);
        return submitChat(text);
      }
      // demo mode: capabilities/fallback orientation if we have one, else parse
      if (a) { showAnswerBubble(a, text); return; }
      if (!looksLikeInvoice(text)) return submitChat(text);
      return submitInitial(text);
    };

    /* F7 voice. Live path: webkitSpeechRecognition with interim transcript;
       fallback path: type out a rotating AIPARSE.voiceSample utterance via
       after() so unmount cleanup clears every timer. */
    const locale = () => (typeof window.getLocale === "function" ? window.getLocale() : "en");

    const replayVoice = () => {
      const gen = voiceGen.current;
      const phrase = AIPARSE.voiceSample(locale());
      const words = phrase.split(" ");
      words.forEach((_, i) => after(180 + i * 95, () => { if (voiceGen.current === gen) setTranscript(words.slice(0, i + 1).join(" ")); }));
      after(180 + words.length * 95 + 520, () => {
        if (voiceGen.current !== gen) return;
        setListening(false); submitInitial(phrase);
      });
    };

    const startVoice = () => {
      setListening(true); setTranscript("");
      const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
      if (SR) {
        let rec = null;
        try { rec = new SR(); } catch (e) { rec = null; }
        if (rec) {
          recRef.current = rec;
          rec.lang = locale() === "fr" ? "fr-FR" : "en-US";
          rec.interimResults = true;
          rec.continuous = false;
          rec.onresult = (e) => {
            if (recRef.current !== rec) return;
            let interim = "", finalText = "";
            for (let i = 0; i < e.results.length; i++) {
              const res = e.results[i];
              if (res.isFinal) finalText += res[0].transcript;
              else interim += res[0].transcript;
            }
            setTranscript((finalText + " " + interim).trim());
            const fin = finalText.trim();
            if (fin) {
              recRef.current = null;
              try { rec.stop(); } catch (err) {}
              setListening(false);
              submitInitial(fin);
            }
          };
          rec.onerror = (e) => {
            if (recRef.current !== rec) return;
            recRef.current = null;
            const code = e && e.error;
            if (code === "not-allowed" || code === "service-not-allowed") {
              pushAI(t("voice.denied"));
              replayVoice(); // mic blocked: show how it sounds instead
            } else if (code === "no-speech") {
              pushAI(t("voice.nospeech"));
              setListening(false);
            } else {
              setListening(false);
            }
          };
          rec.onend = () => {
            if (recRef.current !== rec) return;
            recRef.current = null;
            setListening(false);
          };
          try { rec.start(); return; } catch (e) { recRef.current = null; }
        }
      }
      // No recognizer here: say so once, then replay a sample utterance.
      if (!saidUnsupported.current) { saidUnsupported.current = true; pushAI(t("voice.unsupported")); }
      replayVoice();
    };

    const stopVoice = () => {
      voiceGen.current++;
      const rec = recRef.current;
      recRef.current = null;
      if (rec) { try { rec.abort(); } catch (e) {} }
      setListening(false);
    };

    /* client picker handler shared with EditorBody */
    const onPickClient = (clientId) => {
      setDraft(dr => ({ ...dr, clientId, newClientName: null, projectId: null }));
      setPickClient(false);
    };
    /* prefill from catalog / billable expenses (drops a blank starter row) */
    const realItems = items => items.filter(x => (x.description && String(x.description).trim()) || Number(x.rate) > 0);
    const addCatalogLine = (item) => {
      setDraft(dr => ({ ...dr, items: [...realItems(dr.items), LIB.catalogToLine(item, 1)] }));
      setPickCatalog(false);
      Q.toast(t("toast.addedToInvoice"), "check");
    };
    const addExpenseLine = (e) => {
      setDraft(dr => ({ ...dr, items: [...realItems(dr.items), LIB.expenseToLine(e)] }));
      setPickExpense(false);
      Q.toast(t("toast.addedToInvoice"), "check");
    };

    /* Resolve the current skippable follow-up, optionally patching the
     * pending draft, then chain to the next extra (or the live draft). */
    const resolveExtra = (patch) => {
      const res = { ...pending, ...(patch || {}) };
      setPending(res);
      setExtra(null);
      proceedAfterResolved(res, extra ? extra.introKey : null, asked);
    };
    const skipExtra = () => resolveExtra(null);
    /* project picker handler for the extra flow (no draft built yet) */
    const onPickExtraProject = (projectId) => {
      setPickProject(false);
      resolveExtra({ projectId });
    };

    const confirm = () => {
      /* validation parity with the manual editor: never emit an empty invoice */
      const priced = draft.items.filter(it => (Number(it.qty) || 0) > 0 && (Number(it.rate) || 0) > 0);
      if (!priced.length) { pushAI(t("man.valid.items"), { kind: "error", anchor: true }); return; }
      let d = { ...draft };
      let working = db;
      if (!d.clientId && d.newClientName) {
        const id = "c" + Date.now().toString(36);
        const nc = { id, name: d.newClientName, contact: "", email: "", phone: "", address: "", vat: "", reg: "", sector: "", currency: d.currency, projects: [] };
        working = { ...working, CLIENTS: [...working.CLIENTS, nc] };
        d.clientId = id; d.newClientName = null;
      }
      const number = LIB.nextNumber(working.COMPANY, d.kind);
      const clientRec = d.clientId ? LIB.getClient(working, d.clientId) : null;
      /* freeze seller/buyer identifiers onto the invoice (FacturX posture) */
      const parties = LIB.snapshotParties(working.COMPANY, clientRec, d.newClientName);
      let inv = {
        id: number, kind: d.kind, clientId: d.clientId, projectId: d.projectId || null, issued: d.issued, due: d.due,
        currency: d.currency, status: "draft",
        /* cleanItem preserves provenance (source / catalogId / expenseId / receipt) */
        items: d.items.filter(it => it.description || Number(it.rate) > 0).map(it => LIB.cleanItem(it, d)),
        discountPct: Number(d.discountPct) || 0, taxRate: d.taxRate || 0, paid: 0,
        notes: d.notes || defaultNotes(working.COMPANY, d.terms), legal: d.legal || "",
        seller: parties.seller, buyer: parties.buyer, vatMode: d.vatMode || "standard",
        recurrence: d.recurrence || null,
        audit: [LIB.auditEvent("created")],
      };
      /* flip the expenses behind expense lines to billed + audit them */
      const bill = window.QEditor && window.QEditor.billExpenses;
      if (bill) { const b = bill(working, inv); working = b.working; inv = b.inv; }
      /* persist any off-list rate (typed 10%) into config so it stays selectable */
      [{ rate: d.taxRate, name: d.taxName }, ...d.items.map(it => ({ rate: it.taxRate, name: it.taxName }))]
        .forEach(tx => { if (tx && Number(tx.rate) > 0) working = LIB.ensureConfigTax(working, { rate: Number(tx.rate), name: tx.name || "VAT" }); });
      const act = { ...LIB.activityEvent("ai.drafted", { id: number, client: clientRec ? clientRec.name : "" }, number), type: "ai" };
      setDb({
        ...working,
        COMPANY: { ...working.COMPANY, ...(d.kind === "quote"
          ? { nextQuoteSeq: (working.COMPANY.nextQuoteSeq != null ? working.COMPANY.nextQuoteSeq : working.COMPANY.nextSeq) + 1 }
          : { nextSeq: working.COMPANY.nextSeq + 1 }) },
        INVOICES: [inv, ...working.INVOICES],
        ACTIVITY: [act, ...working.ACTIVITY],
      });
      onClose();
      api.goTab("invoices", "invoice-preview", { id: number, fresh: true });
    };

    const goManual = () => { const seedDraft = draft ? { ...draft } : null; onClose(); api.openManual(seedDraft); };

    const examples = [t("ai.ex1"), t("ai.ex2"), t("ai.ex3")];
    /* ans.ex4 ("Chase …") only earns a starter chip in ask mode, where the
       money Q&A leads — it would distract from the invoice-draft examples. */
    const questions = ask
      ? [t("ans.ex1"), t("ans.ex2"), t("ans.ex3"), t("ans.ex4")]
      : [t("ans.ex1"), t("ans.ex2"), t("ans.ex3")];
    const refineChips = [t("ai.chip.vat"), t("ai.chip.net15"), t("ai.chip.discount"), t("ai.chip.travel")];
    const close = () => { setShown(false); after(280, onClose); };
    const client = draft && draft.clientId ? LIB.getClient(db, draft.clientId) : null;
    // client used by the project picker during the skippable extra flow (no draft yet)
    const extraClient = phase === "extra" && pending && pending.client && pending.client.clientId
      ? LIB.getClient(db, pending.client.clientId) : client;
    const EditorBody = window.QEditor && window.QEditor.EditorBody;
    const ClientPicker = window.QEditor && window.QEditor.ClientPicker;
    const ProjectPicker = window.QEditor && window.QEditor.ProjectPicker;
    const CatalogPicker = window.QEditor && window.QEditor.CatalogPicker;
    const ExpensePicker = window.QEditor && window.QEditor.ExpensePicker;

    return (
      <div style={{ position: "absolute", inset: 0, zIndex: 130, background: "var(--color-bg)", display: "flex", flexDirection: "column", transform: shown ? "translateY(0)" : "translateY(100%)", transition: "transform .34s cubic-bezier(.32,.72,0,1)", overflow: "hidden" }}>
        {/* header */}
        <div style={{ paddingTop: Q.SAFE_TOP, paddingLeft: 16, paddingRight: 12, paddingBottom: 10, display: "flex", alignItems: "center", gap: 8, borderBottom: "1px solid var(--color-border)" }}>
          <Q.Sparkle size={16} />
          <div style={{ fontFamily: "var(--font-serif)", fontWeight: 600, fontSize: 19, flex: 1, color: "var(--color-text-1)" }}>{t(ask ? "ask.title" : "ai.title")}</div>
          {/* two-ai-front-doors-inconsistent: the "switch to manual editor" pill
              presumes an invoice context, so it belongs to the invoice door or
              once a draft exists — in the Ask (Q&A) door's empty state it would
              contradict the header voice. The empty-state link still offers the
              manual path there, so nothing is lost. */}
          {(!ask || draft) && (
            <button onClick={goManual} className="q-tap" style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 32, padding: "0 12px", borderRadius: 999, border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-0)", color: "var(--color-text-2)", fontSize: 12, fontWeight: 600, cursor: "pointer" }}>
              <Icon name="keyboard" size={14} />{t("ai.switch.manual")}
            </button>
          )}
          <IconButton name="x" onClick={close} label={t("g.close")} style={{ background: "var(--color-surface-1)" }} />
        </div>

        {/* backend banner — announces a genuine LOCAL backend only (P1-4).
            In demo mode Nita stays silent rather than captioning its best
            feature as fake; the "Demo mode: scripted, not live AI" line is
            gone. A live local→demo fallback already raises its own one-shot
            toast (ai.local.fail), so the switch is still acknowledged. */}
        {aiLocal && !fellBack && (
          <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 16px 6px", background: "var(--color-primary-muted)", borderBottom: "1px solid var(--color-border)", flexShrink: 0 }}>
            <Sparkle size={11} />
            <span className="q-fade" style={{ fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 600, letterSpacing: "0.04em", color: "var(--color-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {t("ai.banner.local", { model: (aiCfg.model || "Ollama").split(":")[0] })}
            </span>
          </div>
        )}

        {/* body */}
        <div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: "18px 16px", position: "relative" }}>
          {messages.length === 0 && phase === "empty" && (
            <div className="q-fade" style={{ paddingTop: 14 }}>
              <div style={{ width: 52, height: 52, borderRadius: 16, background: "var(--color-primary-muted)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 16 }}>
                <Sparkle size={24} />
              </div>
              <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 600, fontSize: 27, letterSpacing: "-0.02em", color: "var(--color-text-1)", margin: 0, lineHeight: 1.1 }}>{t(ask ? "ask.empty.title" : "ai.empty.title")}</h2>
              <p style={{ fontSize: 14, color: "var(--color-text-2)", lineHeight: 1.5, marginTop: 10 }}>{t(ask ? "ask.empty.body" : "ai.empty.body")}</p>
              {/* one-thumb mic: this whole surface IS the AI, so the sage fill is correct */}
              <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, marginTop: 24 }}>
                <button onClick={startVoice} aria-label={t("ai.tap_to_speak")} className="q-tap" style={{ width: 56, height: 56, borderRadius: "50%", border: "none", background: "var(--color-primary)", color: "var(--color-primary-fg)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
                  <Icon name="mic" size={24} />
                </button>
                <span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-2)" }}>{t("ai.tap_to_speak")}</span>
              </div>
              {/* The money Q&A chips and the invoice-draft examples. Order
                  flips by mode: ask mode (dock green button) leads with the
                  money questions — Nita's front door — then offers invoice
                  drafting under a quieter kicker; invoice mode keeps the
                  original examples-first layout. */}
              {(() => {
                const questionsBlock = (
                  <React.Fragment key="qs">
                    <AiKicker style={{ marginTop: 24, marginBottom: 10 }}>{t("ans.kicker")}</AiKicker>
                    <div className="q-stagger" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
                      {questions.map((q, i) => (
                        <button key={i} onClick={() => { if (!askAnswer(q)) submitInitial(q); }} className="q-tap"
                          onPointerDown={e => { e.currentTarget.style.transform = "scale(0.96)"; }}
                          onPointerUp={e => { e.currentTarget.style.transform = ""; }}
                          onPointerLeave={e => { e.currentTarget.style.transform = ""; }}
                          style={{ height: 32, padding: "0 12px", borderRadius: 999, border: "1.4px solid var(--color-primary)", background: "var(--color-primary-muted)", color: "var(--color-primary)", fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", transition: "transform .14s var(--spring)" }}>{q}</button>
                      ))}
                    </div>
                  </React.Fragment>
                );
                const examplesBlock = (
                  <React.Fragment key="ex">
                    <Kicker style={{ marginTop: 24, marginBottom: 10 }}>{t(ask ? "ask.examples.invoice" : "ai.example.kicker")}</Kicker>
                    <div className="q-stagger" style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                      {examples.map((ex, i) => (
                        <button key={i} onClick={() => submitInitial(ex)} className="q-tap"
                          onPointerDown={e => { e.currentTarget.style.transform = "scale(0.98)"; }}
                          onPointerUp={e => { e.currentTarget.style.transform = ""; }}
                          onPointerLeave={e => { e.currentTarget.style.transform = ""; }}
                          style={{ textAlign: "left", border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-0)", borderRadius: 12, padding: "12px 14px", fontSize: 13, color: "var(--color-text-1)", cursor: "pointer", lineHeight: 1.4, display: "flex", gap: 8, alignItems: "flex-start", transition: "transform .14s var(--spring), border-color .15s" }}>
                          <Icon name="arrowUpRight" size={16} color="var(--color-text-3)" style={{ marginTop: 1, flexShrink: 0 }} />
                          <span>{ex}</span>
                        </button>
                      ))}
                    </div>
                  </React.Fragment>
                );
                return ask ? [questionsBlock, examplesBlock] : [examplesBlock, questionsBlock];
              })()}
              <button onClick={goManual} style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 20, background: "none", border: "none", color: "var(--color-text-2)", fontSize: 13, fontWeight: 600, cursor: "pointer", padding: 0 }}>
                <Icon name="keyboard" size={16} />{t("ai.manual.link")}
              </button>
            </div>
          )}

          <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
            {messages.map(m => <Bubble key={m.id} m={m} api={api} onClose={onClose} onFollowup={q => { if (!askAnswer(q)) submitInitial(q); }} />)}
            {phase === "thinking" && <Thinking />}
            {phase === "mini" && <Thinking mini />}
            {phase === "error" && (
              <div className="q-fade-up" style={{ display: "flex", gap: 8 }}>
                <Button variant="ai" size="sm" icon="keyboard" onClick={goManual}>{t("ai.error.manual")}</Button>
                <Button variant="outline" size="sm" icon="refresh" onClick={() => { setPhase("empty"); setMessages([]); }}>{t("ai.error.retry")}</Button>
              </div>
            )}
            {phase === "extra" && extra && (
              <div className="q-fade-up" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
                {/* Answer chips are the HUMAN's voice — not sage. */}
                {extra.kind === "project" && (
                  <Button variant="secondary" size="sm" onClick={() => setPickProject(true)}>{t("ai.extra.project.cta")}</Button>
                )}
                {extra.kind === "vat" && (
                  <React.Fragment>
                    <Button variant="secondary" size="sm" onClick={() => resolveExtra(null)}>{t("ai.extra.vat.keep", extra.param || { rate: co.taxRate })}</Button>
                    <Button variant="outline" size="sm" onClick={() => resolveExtra({ taxRate: 0 })}>{t("ai.extra.vat.none")}</Button>
                  </React.Fragment>
                )}
                <Button variant="ghost" size="sm" onClick={skipExtra}>{t("ai.extra.skip")}</Button>
              </div>
            )}
            {draft && (phase === "draft" || phase === "mini") && EditorBody && (
              <div className="q-ai-materialize" style={{ position: "relative", marginTop: 6, border: "1.4px solid var(--color-primary)", borderRadius: 16, background: "var(--color-surface-0)", overflow: "hidden" }}>
                {/* one-shot sage glow replayed when a refine edits the draft;
                    keyed on refinePulse so React remounts it and re-fires.
                    pointer-events:none keeps the editor fully interactive. */}
                {refinePulse > 0 && (
                  <span key={refinePulse} aria-hidden="true" style={{ position: "absolute", inset: 0, borderRadius: 16, pointerEvents: "none", zIndex: 2, animation: "q-ai-glow .55s cubic-bezier(.32,.72,0,1) both" }} />
                )}
                <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--color-border)", display: "flex", alignItems: "center", gap: 8 }}>
                  <Badge tone="primary" ai>{t("ai.draft.badge")}</Badge>
                  <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-3)", marginLeft: "auto" }}>{LIB.nextNumber(db.COMPANY, draft.kind)}</span>
                </div>
                <div style={{ padding: 16 }}>
                  <EditorBody draft={draft} setDraft={setDraft} db={db}
                    onPickClient={() => setPickClient(true)} onPickProject={() => setPickProject(true)}
                    onAddCatalog={() => setPickCatalog(true)} onAddExpense={() => setPickExpense(true)}
                    onViewReceipt={r => setViewReceipt(r)}
                    onAddTax={tx => setDb(d => LIB.ensureConfigTax(d, tx))} />
                </div>
              </div>
            )}
          </div>
        </div>

        {/* bottom controls */}
        <div style={{ borderTop: "1px solid var(--color-border)", background: "var(--color-surface-0)", padding: "10px 12px", paddingBottom: draft ? 12 : 26 }}>
          {phase === "draft" && (
            <div style={{ display: "flex", gap: 8, overflowX: "auto", paddingBottom: 8, marginBottom: 2 }}>
              {refineChips.map((c, i) => (
                <button key={i} onClick={() => submitRefine(c)} className="q-tap"
                  onPointerDown={e => { e.currentTarget.style.filter = "brightness(0.95)"; }}
                  onPointerUp={e => { e.currentTarget.style.filter = ""; }}
                  onPointerLeave={e => { e.currentTarget.style.filter = ""; }}
                  style={{ flexShrink: 0, height: 30, padding: "0 12px", borderRadius: 999, border: "1.4px solid var(--color-primary)", background: "var(--color-primary-muted)", color: "var(--color-primary)", fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", transition: "transform .18s var(--spring), filter .12s" }}>{c}</button>
              ))}
            </div>
          )}
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 6, height: 44, padding: "0 6px 0 14px", background: "var(--color-bg)", border: "1.4px solid var(--color-border-strong)", borderRadius: 999 }}>
              <input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => { if (e.key === "Enter") onSubmit(); }}
                placeholder={(phase === "gather" || phase === "extra") ? t("comp.answer.placeholder") : draft ? t("ai.refine.kicker") + "…" : t("ai.placeholder.ask")}
                onFocus={e => e.target.parentNode.style.borderColor = "var(--color-primary)"}
                onBlur={e => e.target.parentNode.style.borderColor = "var(--color-border-strong)"}
                style={{ flex: 1, border: "none", background: "transparent", outline: "none", fontSize: 15, color: "var(--color-text-1)", fontFamily: "var(--font-sans)", minWidth: 0 }} />
              {!input && !draft && phase !== "gather" && phase !== "extra" && (
                <button onClick={startVoice} aria-label={t("ai.tap_to_speak")} className="q-tap" style={{ width: 32, height: 32, borderRadius: 999, border: "none", background: "transparent", color: "var(--color-primary)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flexShrink: 0, transition: "transform .18s var(--spring), filter .12s" }}>
                  <Icon name="mic" size={19} />
                </button>
              )}
              <button onClick={onSubmit} disabled={!input.trim()} aria-label={t("ai.aria.send")} style={{ width: 34, height: 34, borderRadius: 999, border: "none", background: input.trim() ? "var(--color-primary)" : "var(--color-surface-2)", color: input.trim() ? "var(--color-primary-fg)" : "var(--color-text-3)", display: "flex", alignItems: "center", justifyContent: "center", cursor: input.trim() ? "pointer" : "default", flexShrink: 0, transition: "background .12s" }}>
                <Icon name="arrowRight" size={18} />
              </button>
            </div>
          </div>
          {draft && (
            <div style={{ marginTop: 10, paddingBottom: 14 }}>
              {/* Human commit, not Nita speaking: warm-ink primary per the
                  three-voice contract (sage stays Nita-only). */}
              <Button variant="primary" size="lg" full onClick={confirm}>{t("ai.confirm")}</Button>
            </div>
          )}
        </div>

        {listening && <Listening transcript={transcript} onStop={stopVoice} />}
        {ClientPicker && <ClientPicker api={api} open={pickClient} onClose={() => setPickClient(false)} onPick={onPickClient} currency={draft && draft.currency} />}
        {ProjectPicker && <ProjectPicker api={api} open={pickProject} client={extraClient} currency={(draft && draft.currency) || (pending && pending.currency)}
          onClose={() => setPickProject(false)}
          onPick={phase === "extra"
            ? onPickExtraProject
            : (pid) => { setDraft(dr => ({ ...dr, projectId: pid })); setPickProject(false); }} />}
        {CatalogPicker && <CatalogPicker api={api} open={pickCatalog} onClose={() => setPickCatalog(false)} onAdd={addCatalogLine} />}
        {ExpensePicker && <ExpensePicker api={api} open={pickExpense} onClose={() => setPickExpense(false)} onAdd={addExpenseLine} clientId={draft && draft.clientId} />}
        <Q.ReceiptViewer open={!!viewReceipt} receipt={viewReceipt} onClose={() => setViewReceipt(null)} />
      </div>
    );
  }

  window.QScreens = Object.assign(window.QScreens || {}, { Composer });
})();
