/* primitives.jsx — Quill UI atoms (Gamma DS, mobile-tuned) → window.Q.
 * Load after icons.jsx. const { Button, Badge, Sheet, ... } = window.Q;
 */
(function () {
  const { useState, useEffect, useRef, useMemo, useCallback } = React;
  const { Icon, NitaMark } = window.Icons;
  const REDUCE = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* Framed (desktop mockup): fixed offsets clear the fake status bar /
     island / home indicator. Frameless (real phone, window.NITA_FRAMELESS
     from the index.html boot script): fold the measured real safe-area
     insets (window.NITA_SAFE) in instead — no fake chrome to clear. */
  const NSAFE = window.NITA_SAFE || { top: 0, bottom: 0 };
  const SAFE_TOP = window.NITA_FRAMELESS
    ? Math.max(20, Math.round(NSAFE.top) + 8)  // clears the REAL status bar (standalone) or just breathes (browser)
    : 56;                                      // clears fake status bar + dynamic island
  const TABBAR_H = window.NITA_FRAMELESS
    ? 76 + Math.round(NSAFE.bottom)            // dock height + real home-indicator inset
    : 76;                                      // bottom tab bar height incl. fake home indicator

  /* ── Background scroll lock (overlays) ───────────────────────────
     While any sheet / fullscreen viewer is mounted the screens behind it
     must not scroll. Counter-based so stacked overlays (sheet over sheet)
     nest correctly and the lock always releases on unmount; the actual
     freeze is html.q-locked .q-screen { overflow-y: hidden } (index.html). */
  let scrollLocks = 0;
  const useScrollLock = (active) => {
    useEffect(() => {
      if (!active) return;
      scrollLocks++;
      document.documentElement.classList.add("q-locked");
      /* An overlay taking the stage also dismisses any open swipe-row
         actions beneath it, so they never linger under the scrim. */
      if (closeOpenSwipe) closeOpenSwipe();
      return () => {
        scrollLocks = Math.max(0, scrollLocks - 1);
        if (scrollLocks === 0) document.documentElement.classList.remove("q-locked");
      };
    }, [active]);
  };

  /* ── Status-bar tint (dark fullscreen overlays) ───────────────────
     The device status bar (ios-frame.jsx) floats above every app layer and
     reads --ios-status-color. Dark overlays (A4 viewer, receipt viewer)
     flip it white while mounted; counter-based like useScrollLock so
     stacked overlays release correctly. */
  let statusTints = 0;
  const useStatusBarLight = (active) => {
    useEffect(() => {
      if (!active) return;
      statusTints++;
      document.documentElement.style.setProperty("--ios-status-color", "#fff");
      return () => {
        statusTints = Math.max(0, statusTints - 1);
        if (statusTints === 0) document.documentElement.style.removeProperty("--ios-status-color");
      };
    }, [active]);
  };

  /* ── Sparkle (AI signal) ─────────────────────────────────────── */
  const Sparkle = ({ size = 13, color = "var(--color-primary)", style }) => (
    <Icon name="sparkle" size={size} color={color} strokeWidth={1.6} style={style} />
  );

  /* ── Button ──────────────────────────────────────────────────── */
  const BTN = {
    /* primary = warm ink: the ONE human CTA voice (sage stays AI-only,
       gold stays money-only — there is no gold button) */
    primary: { backgroundColor: "var(--color-text-1)", color: "var(--color-bg)", border: "none" },
    secondary: { background: "var(--color-surface-1)", color: "var(--color-text-1)", border: "1.4px solid var(--color-border-strong)" },
    ghost: { background: "transparent", color: "var(--color-text-2)", border: "none" },
    outline: { background: "transparent", color: "var(--color-text-1)", border: "1.4px solid var(--color-border-strong)" },
    danger: { backgroundColor: "var(--color-error)", color: "var(--color-error-fg)", border: "none" },
    ai: { backgroundColor: "var(--color-primary)", color: "var(--color-primary-fg)", border: "none" },
  };
  const RAISED = { primary: 1, danger: 1, ai: 1 };
  const Button = ({ variant = "secondary", size = "md", full, children, onClick, style = {}, disabled, icon, type }) => {
    const h = { sm: 32, md: 40, lg: 48 }[size];
    const fs = { sm: 13, md: 14, lg: 15 }[size];
    const px = { sm: 12, md: 16, lg: 20 }[size];
    return (
      <button type={type} disabled={disabled} onClick={onClick}
        className={"q-btn" + (RAISED[variant] ? " q-btn-raised" : "")}
        style={{
          display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8,
          height: h, padding: `0 ${px}px`, fontSize: fs, width: full ? "100%" : undefined,
          fontFamily: "var(--font-sans)", fontWeight: 600, borderRadius: "var(--radius-md)",
          cursor: disabled ? "not-allowed" : "pointer", whiteSpace: "nowrap",
          opacity: disabled ? 0.4 : 1,
          transition: "filter .12s, background .12s, transform .2s cubic-bezier(.32,.72,0,1), box-shadow .2s cubic-bezier(.32,.72,0,1)",
          letterSpacing: "-0.01em", ...BTN[variant], ...style,
        }}>
        {variant === "ai" && <Sparkle size={14} color="var(--color-primary-fg)" />}
        {icon && <Icon name={icon} size={fs + 3} />}
        {children}
      </button>
    );
  };

  const IconButton = ({ name, size = 40, iconSize = 19, onClick, label, style = {}, active, color }) => (
    <button aria-label={label} onClick={onClick} className="q-tap q-iconbtn" style={{
      width: size, height: size, borderRadius: 999, border: "none", cursor: "pointer",
      background: active ? "var(--color-surface-2)" : "transparent",
      color: color || "var(--color-text-2)", display: "flex", alignItems: "center",
      justifyContent: "center", flexShrink: 0, ...style,
    }}>
      <Icon name={name} size={iconSize} />
    </button>
  );

  /* ── Icon button with an unread-count chip ───────────────────────
     Terracotta chip = attention (never gold); identical to IconButton
     at count 0. The chip is decorative — the count lives in the
     computed aria-label so screen readers hear one announcement. */
  const BadgedIconButton = ({ name, label, count, onClick, size }) => {
    const n = Number(count) || 0;
    const aria = n > 0 ? window.t("notif.bell.aria", { count: n }) : label;
    return (
      <span style={{ position: "relative", display: "inline-flex" }}>
        <IconButton name={name} size={size} onClick={onClick} label={aria} />
        {n > 0 && (
          <span aria-hidden="true" style={{
            position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
            padding: "0 4px", boxSizing: "border-box", borderRadius: 999,
            background: "var(--color-accent)", color: "var(--color-error-fg)",
            fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
            display: "inline-flex", alignItems: "center", justifyContent: "center",
            pointerEvents: "none",
          }}>{n > 9 ? "9+" : n}</span>
        )}
      </span>
    );
  };

  /* ── File download (CSV/FEC exports) — mechanics live in app/download.js
     (window.NITA_SAVE); the tester build swaps that file for a
     clipboard-copy variant so the shipped HTML never contains the
     blob-download pattern mail scanners flag. ─────── */
  const downloadFile = (name, content, mime) => {
    try { if (window.NITA_SAVE) window.NITA_SAVE(name, content, mime); } catch (e) {}
  };

  /* ── QR code (real, scannable) ───────────────────────────────── */
  const QR = ({ value, size = 120, color = "hsl(35 20% 12%)", bg = "#fff", quiet = 2, style }) => {
    const model = useMemo(() => {
      try { const q = window.qrcode(0, "M"); q.addData(value || " "); q.make(); return q; }
      catch (e) { return null; }
    }, [value]);
    if (!model) return <div style={{ width: size, height: size, borderRadius: 8, background: bg, border: "1px solid var(--color-border)", ...style }} />;
    const count = model.getModuleCount();
    const total = count + quiet * 2;
    const rects = [];
    for (let r = 0; r < count; r++) for (let c = 0; c < count; c++) if (model.isDark(r, c)) rects.push(`M${c + quiet} ${r + quiet}h1v1h-1z`);
    return (
      <svg width={size} height={size} viewBox={`0 0 ${total} ${total}`} shapeRendering="crispEdges" style={{ display: "block", borderRadius: 6, ...style }}>
        <rect width={total} height={total} fill={bg} />
        <path d={rects.join("")} fill={color} />
      </svg>
    );
  };

  /* ── Toggle / switch ─────────────────────────────────────────── */
  const Toggle = ({ checked, onChange, label }) => (
    /* 40px-tall hit target (negative margin keeps the 28px layout footprint);
       the visible 46x28 pill is unchanged. */
    <button role="switch" aria-checked={!!checked} aria-label={label} onClick={() => onChange(!checked)} style={{
      width: 46, height: 40, margin: "-6px 0", borderRadius: 999, border: "none", cursor: "pointer", flexShrink: 0, padding: 0,
      background: "transparent", display: "flex", alignItems: "center", justifyContent: "center",
    }}>
      <span style={{
        position: "relative", display: "block", width: 46, height: 28, borderRadius: 999,
        background: checked ? "var(--color-primary)" : "var(--color-surface-2)", transition: "background .15s",
      }}>
        <span style={{
          position: "absolute", top: 3, left: 3, width: 22, height: 22, borderRadius: "50%",
          transform: checked ? "translateX(18px)" : "none",
          background: "#fff", boxShadow: "0 1px 3px rgb(0 0 0 / 0.25)", transition: "transform .26s var(--spring-bouncy)",
        }} />
      </span>
    </button>
  );

  /* ── Status pill / badge ─────────────────────────────────────── */
  const STATUS = {
    draft: { c: "var(--color-text-3)", key: "st.draft" },
    sent: { c: "var(--color-info)", key: "st.sent" },
    paid: { c: "var(--color-success)", key: "st.paid" },
    overdue: { c: "var(--color-accent)", key: "st.overdue" },
    partial: { c: "var(--color-info)", key: "st.partial" },
    quote: { c: "var(--color-text-2)", key: "st.quote" },
    accepted: { c: "var(--color-success)", key: "st.accepted" },
    declined: { c: "var(--color-error)", key: "st.declined" },
    expired: { c: "var(--color-warning)", key: "st.expired" },
    issued: { c: "var(--color-text-3)", key: "st.issued" },
    /* viewed is derived, never stored; label reuses det.t.viewed */
    viewed: { c: "var(--color-info)", key: "det.t.viewed" },
  };
  const StatusPill = ({ status, label, solid }) => {
    const s = STATUS[status] || STATUS.draft;
    const txt = label || window.t(s.key);
    if (solid) {
      return (
        <span style={{
          display: "inline-flex", alignItems: "center", gap: 5, height: 20, padding: "0 8px",
          borderRadius: 999, fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
          background: `color-mix(in oklab, ${s.c} 16%, transparent)`, color: s.c, whiteSpace: "nowrap",
        }}>
          <span style={{ width: 5, height: 5, borderRadius: "50%", background: "currentColor" }} />{txt}
        </span>
      );
    }
    return (
      <span style={{
        display: "inline-flex", alignItems: "center", gap: 5, height: 20, padding: "0 8px",
        borderRadius: 999, fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
        border: `1px solid ${s.c}`, color: s.c, whiteSpace: "nowrap",
      }}>
        <span style={{ width: 5, height: 5, borderRadius: "50%", background: "currentColor" }} />{txt}
      </span>
    );
  };

  const Badge = ({ tone = "neutral", children, ai, style }) => {
    const T = {
      neutral: { color: "var(--color-text-3)", borderColor: "var(--color-border-strong)" },
      primary: { color: "var(--color-primary)", borderColor: "var(--color-primary)" },
      accent: { color: "var(--color-accent)", borderColor: "var(--color-accent)" },
      gold: { color: "var(--color-gold)", borderColor: "var(--color-gold)" },
      success: { color: "var(--color-success)", borderColor: "var(--color-success)" },
      info: { color: "var(--color-info)", borderColor: "var(--color-info)" },
    }[tone];
    return (
      <span style={{
        display: "inline-flex", alignItems: "center", gap: 5, height: 20, padding: "0 8px",
        borderRadius: 999, fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
        border: "1px solid", whiteSpace: "nowrap", ...T, ...style,
      }}>
        {ai && <Sparkle size={9} />}{children}
      </span>
    );
  };

  /* ── Avatar (initials) ───────────────────────────────────────── */
  const TONES = ["155 26% 46%", "30 58% 50%", "205 55% 45%", "38 60% 42%", "280 30% 50%"];
  const Avatar = ({ name = "", size = 36, square }) => {
    /* Initials come only from tokens that start with a letter or digit, so
       "Hudson & Gray" -> "HG" (never "H&"). */
    const init = name.split(" ").filter(w => /^[\p{L}\p{N}]/u.test(w)).slice(0, 2).map(n => n[0]).join("").toUpperCase();
    const tone = TONES[(name.charCodeAt(0) || 0) % TONES.length];
    return (
      <div style={{
        width: size, height: size, borderRadius: square ? Math.round(size * 0.28) : "50%",
        /* Initials mixed toward text-1 (darker in light theme, lighter in
           dark) so they hold >= 4.5:1 on the 16% tint behind them. */
        background: `hsl(${tone} / 0.16)`, color: `color-mix(in oklab, hsl(${tone}) 55%, var(--color-text-1))`,
        display: "flex", alignItems: "center", justifyContent: "center",
        fontSize: size * 0.38, fontWeight: 700, flexShrink: 0, fontFamily: "var(--font-sans)",
      }}>{init}</div>
    );
  };

  /* ── Money ───────────────────────────────────────────────────── */
  const Money = ({ amount, currency = "EUR", size = 14, weight = 600, color = "var(--color-gold)", cents = true, style }) => (
    <span style={{
      fontFamily: "var(--font-mono)", fontSize: size, fontWeight: weight, color,
      fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", letterSpacing: "-0.01em",
      display: "inline-block", minWidth: 0, maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", ...style,
    }}>{window.fmtMoney(amount, currency, { cents })}</span>
  );

  /* ── Display money (Fraunces serif gold — the single voice for all
     amounts rendered at 22px or larger; no tabular figures in Fraunces,
     so no fontFeatureSettings) ─────────────────────────────────── */
  const MoneyDisplay = ({ amount, currency = "EUR", size = 24, weight = 600, color = "var(--color-gold)", cents = true, style }) => (
    <span style={{
      fontFamily: "var(--font-serif)", fontSize: size, fontWeight: weight, color,
      letterSpacing: "-0.02em", lineHeight: 1.05, whiteSpace: "nowrap",
      display: "inline-block", minWidth: 0, maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", ...style,
    }}>{window.fmtMoney(amount, currency, { cents })}</span>
  );

  /* ── Animated number (eased count-up; money-formatted) ─────────
     animKey gives the count-up session memory (MF-1c): once a keyed
     amount has played, remounting at the same value renders it still —
     real value changes (mark paid) animate as before. */
  const seenAmounts = new Map();
  const AnimatedNumber = ({ amount, currency = "EUR", size = 14, weight = 600, color = "var(--color-gold)", cents = true, duration = 750, animKey, style }) => {
    const seen = animKey != null && seenAmounts.get(animKey) === amount;
    const [disp, setDisp] = useState(amount);
    const prevRef = useRef(seen ? amount : null); /* null => first mount, count up from 0 */
    const rafRef = useRef(null);
    useEffect(() => {
      const from = prevRef.current == null ? 0 : prevRef.current;
      prevRef.current = amount;
      if (animKey != null) seenAmounts.set(animKey, amount);
      if (REDUCE() || from === amount) { setDisp(amount); return; }
      const t0 = performance.now();
      const tick = (now) => {
        const p = Math.min(1, (now - t0) / duration);
        const e = 1 - Math.pow(1 - p, 3);
        setDisp(from + (amount - from) * e);
        if (p < 1) rafRef.current = requestAnimationFrame(tick);
      };
      rafRef.current = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(rafRef.current);
    }, [amount, duration]);
    return <Money amount={disp} currency={currency} size={size} weight={weight} color={color} cents={cents} style={style} />;
  };

  /* ── Paid celebration burst (one-shot gold/sage particles) ───── */
  const PaidBurst = ({ size = 130 }) => (
    <div aria-hidden="true" style={{ position: "absolute", left: "50%", top: "50%", width: 0, height: 0, pointerEvents: "none", zIndex: 5 }}>
      {Array.from({ length: 14 }).map((_, i) => {
        const a = (i / 14) * Math.PI * 2;
        const r = size * (0.5 + (i % 3) * 0.17);
        return <span key={i} style={{
          position: "absolute", width: i % 2 ? 6 : 9, height: i % 2 ? 6 : 9,
          borderRadius: i % 3 ? "50%" : 2,
          background: i % 2 ? "var(--color-success)" : "var(--color-gold)",
          "--dx": Math.cos(a) * r + "px", "--dy": Math.sin(a) * r + "px",
          animation: `q-burst .8s var(--spring) ${(i % 5) * 0.035}s both`,
        }} />;
      })}
    </div>
  );

  /* ── SwipeRow (drag left to reveal actions; touch + mouse) ────
     actions: [{ icon, label, tone: success|error|accent|info|neutral, onPress }]
     Only one row open at a time. Tap anywhere while open closes it. */
  let closeOpenSwipe = null;
  /* iOS convention: when a tap lands on row B while row A's actions are
     revealed, that tap only dismisses A. onDown sets this module flag when
     it closes a sibling; the row's next click is swallowed instead of
     navigating. */
  let swallowNextRowClick = false;
  /* fg is theme-aware: --color-primary-fg is dark ink in the dark theme
     (where these tone bgs are light) and near-white in the light theme,
     keeping labels >= 4.5:1 on every tone in both themes. */
  const SWIPE_TONES = {
    success: { bg: "var(--color-success)", fg: "var(--color-primary-fg)" },
    error: { bg: "var(--color-error)", fg: "var(--color-primary-fg)" },
    accent: { bg: "var(--color-accent)", fg: "var(--color-primary-fg)" },
    info: { bg: "var(--color-info)", fg: "var(--color-primary-fg)" },
    neutral: { bg: "var(--color-surface-3)", fg: "var(--color-text-1)" },
  };
  const SwipeRow = ({ actions = [], children, onClick, last, style }) => {
    const ACTION_W = 74;
    const W = actions.length * ACTION_W;
    const [x, setX] = useState(0);
    const [animated, setAnimated] = useState(true);
    const d = useRef({ down: false, captured: false, sx: 0, sy: 0, base: 0, axis: null, moved: false, lx: 0, lt: 0, vx: 0 });
    const xRef = useRef(0); xRef.current = x;
    const close = useCallback(() => {
      setAnimated(true); setX(0);
      /* Deregister: a stale closeOpenSwipe would make every later onDown
         think a sibling is still open (and swallow that tap's click). */
      if (closeOpenSwipe === close) closeOpenSwipe = null;
    }, []);
    useEffect(() => () => { if (closeOpenSwipe === close) closeOpenSwipe = null; }, [close]);
    const onDown = (e) => {
      if (!W) return;
      /* Yield the left edge to the shell's swipe-back gesture. */
      const r = e.currentTarget.getBoundingClientRect();
      if (e.clientX - r.left < 28) return;
      d.current = { down: true, captured: false, sx: e.clientX, sy: e.clientY, base: xRef.current, axis: null, moved: false, lx: e.clientX, lt: performance.now(), vx: 0 };
      if (closeOpenSwipe && closeOpenSwipe !== close) { closeOpenSwipe(); swallowNextRowClick = true; }
      else swallowNextRowClick = false; /* clear any unconsumed flag from a cancelled tap */
    };
    const onMove = (e) => {
      const s = d.current;
      if (!s.down) return;
      const dx = e.clientX - s.sx, dy = e.clientY - s.sy;
      if (!s.axis && (Math.abs(dx) > 7 || Math.abs(dy) > 7)) s.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
      if (s.axis !== "x") return;
      if (!s.captured) { try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {} s.captured = true; setAnimated(false); }
      s.moved = true;
      const now = performance.now();
      s.vx = (e.clientX - s.lx) / Math.max(1, now - s.lt); s.lx = e.clientX; s.lt = now;
      let nx = s.base + dx;
      if (nx > 0) nx = nx * 0.18;                       /* rubber right */
      if (nx < -W) nx = -W + (nx + W) * 0.18;           /* rubber past actions */
      setX(nx);
    };
    const onUp = () => {
      const s = d.current;
      if (!s.down) return;
      s.down = false;
      if (s.axis === "x") {
        const open = xRef.current < -W / 2 || s.vx < -0.4;
        setAnimated(true);
        if (open && !(s.vx > 0.4)) { setX(-W); closeOpenSwipe = close; }
        else { setX(0); if (closeOpenSwipe === close) closeOpenSwipe = null; }
      }
    };
    const onRowClick = (e) => {
      if (swallowNextRowClick) { swallowNextRowClick = false; e.preventDefault(); e.stopPropagation(); d.current.moved = false; return; }
      if (d.current.moved) { e.preventDefault(); e.stopPropagation(); d.current.moved = false; return; }
      if (xRef.current !== 0) { e.stopPropagation(); close(); return; }
      if (onClick) onClick(e);
    };
    return (
      <div style={{ position: "relative", overflow: "hidden", borderBottom: last ? "none" : "1px solid var(--color-border)" }}>
        <div aria-hidden={x === 0 ? "true" : undefined} style={{ position: "absolute", inset: 0, display: "flex", justifyContent: "flex-end" }}>
          {actions.map((a, i) => {
            const tn = SWIPE_TONES[a.tone] || SWIPE_TONES.neutral;
            return (
              <button key={i} tabIndex={x === 0 ? -1 : 0} onClick={(e) => { e.stopPropagation(); close(); a.onPress && a.onPress(); }}
                aria-label={a.label} className="q-tap" style={{
                  width: ACTION_W, border: "none", cursor: "pointer", background: tn.bg, color: tn.fg,
                  display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4,
                }}>
                {a.icon && <Icon name={a.icon} size={18} />}
                <span style={{ fontSize: 11, fontWeight: 600, fontFamily: "var(--font-sans)" }}>{a.label}</span>
              </button>
            );
          })}
        </div>
        <div className="q-row" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
          onClickCapture={d.current.moved ? onRowClick : undefined} onClick={onRowClick}
          role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined}
          onKeyDown={onClick ? (e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(e); } }) : undefined}
          style={{
            display: "flex", alignItems: "center", gap: 12, padding: "12px 16px",
            background: "var(--color-bg)", touchAction: "pan-y", position: "relative",
            transform: `translateX(${x}px)`, transition: animated ? "transform .3s var(--spring)" : "none",
            cursor: onClick ? "pointer" : "default", ...style,
          }}>{children}</div>
      </div>
    );
  };

  /* ── Kicker / section label ──────────────────────────────────── */
  const Kicker = ({ children, style, color = "var(--color-text-3)" }) => (
    <div style={{
      fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 600, textTransform: "uppercase",
      letterSpacing: "0.10em", color, ...style,
    }}>{children}</div>
  );

  /* ── AI kicker (sparkle + sage mono label) ──────────────────── */
  const AiKicker = ({ children, style }) => (
    <div style={{ display: "flex", alignItems: "center", gap: 7, ...style }}>
      <Sparkle size={13} />
      <span style={{
        fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 600, letterSpacing: "0.10em",
        textTransform: "uppercase", color: "var(--color-primary)",
      }}>{children}</span>
    </div>
  );

  /* ── Card ────────────────────────────────────────────────────── */
  const Card = ({ children, ai, dashed, onClick, style = {}, pad = 16 }) => (
    <div onClick={onClick}
      className={onClick ? "q-tap " + (ai ? "q-hover-raise" : "q-hover-lift") : undefined}
      role={onClick ? "button" : undefined}
      tabIndex={onClick ? 0 : undefined}
      onKeyDown={onClick ? (e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(e); } }) : undefined}
      style={{
      background: "var(--color-surface-0)", borderRadius: "var(--radius-lg)",
      border: ai ? "1.4px solid var(--color-primary)"
        : dashed ? "1.4px dashed var(--color-border-strong)"
        : "1.4px solid var(--color-border-strong)",
      boxShadow: ai ? "0 8px 24px color-mix(in oklab, var(--color-primary) 10%, transparent)" : dashed ? "none" : "var(--elev-1)",
      padding: pad, cursor: onClick ? "pointer" : "default",
      transition: "transform .2s cubic-bezier(.32,.72,0,1), box-shadow .2s cubic-bezier(.32,.72,0,1)", ...style,
    }}>{children}</div>
  );

  /* ── Field (label + control) ─────────────────────────────────── */
  const Field = ({ label, children, hint, style }) => (
    <label style={{ display: "block", ...style }}>
      {label && <div style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-2)", marginBottom: 7 }}>{label}</div>}
      {children}
      {hint && <div style={{ fontSize: 12, color: "var(--color-text-3)", marginTop: 6 }}>{hint}</div>}
    </label>
  );

  /* outline stays on so :focus-visible (gamma.css) draws the standardized
     terracotta keyboard-focus ring — only the resting border is styled here.
     The onFocus/onBlur handlers paint a pointer-focus border tint; the ring
     is what keyboard users see. */
  const inputBase = {
    width: "100%", height: 44, padding: "0 14px", fontSize: 15,
    fontFamily: "var(--font-sans)", color: "var(--color-text-1)",
    background: "var(--color-surface-0)", border: "1.4px solid var(--color-border-strong)",
    borderRadius: "var(--radius-md)", transition: "border-color .15s",
  };
  /* Input: explicitly pull the props we OWN (style/border behaviour) and the
     non-DOM/handled ones (mono, right) out of ...rest, plus inputMode — which
     is re-applied explicitly so it reaches the element while NEVER leaking an
     unstyled raw box or tripping React's attribute warning. Caller onFocus/
     onBlur compose with (don't clobber) the border-tint handlers. */
  const Input = ({ value, onChange, placeholder, type = "text", mono, right, inputMode, onFocus, onBlur, style = {}, ...rest }) => (
    <input value={value} onChange={onChange} placeholder={placeholder} type={type} inputMode={inputMode}
      onFocus={e => { e.target.style.borderColor = "var(--color-primary)"; if (onFocus) onFocus(e); }}
      onBlur={e => { e.target.style.borderColor = "var(--color-border-strong)"; if (onBlur) onBlur(e); }}
      style={{ ...inputBase, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", textAlign: right ? "right" : "left", ...style }}
      {...rest} />
  );
  const Textarea = ({ value, onChange, placeholder, rows = 3, onFocus, onBlur, style = {}, ...rest }) => (
    <textarea value={value} onChange={onChange} placeholder={placeholder} rows={rows}
      onFocus={e => { e.target.style.borderColor = "var(--color-primary)"; if (onFocus) onFocus(e); }}
      onBlur={e => { e.target.style.borderColor = "var(--color-border-strong)"; if (onBlur) onBlur(e); }}
      style={{ ...inputBase, height: "auto", padding: "11px 14px", lineHeight: 1.5, resize: "none", ...style }}
      {...rest} />
  );
  const Select = ({ value, onChange, options, onFocus, onBlur, style = {}, ...rest }) => (
    <div style={{ position: "relative" }}>
      <select value={value} onChange={onChange}
        onFocus={e => { e.target.style.borderColor = "var(--color-primary)"; if (onFocus) onFocus(e); }}
        onBlur={e => { e.target.style.borderColor = "var(--color-border-strong)"; if (onBlur) onBlur(e); }}
        style={{ ...inputBase, appearance: "none", paddingRight: 38, cursor: "pointer", ...style }}
        {...rest}>
        {options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
      <div style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", pointerEvents: "none", color: "var(--color-text-3)" }}>
        <Icon name="chevronDown" size={16} />
      </div>
    </div>
  );

  /* ── Segmented control (sliding thumb — same language as the dock
     highlight) ─────────────────────────────────────────────────── */
  const Segmented = ({ options, value, onChange, style }) => {
    const n = options.length;
    const idx = Math.max(0, options.findIndex(o => o.value === value));
    return (
      <div style={{
        position: "relative", display: "flex", background: "var(--color-surface-1)", borderRadius: 999, padding: 3, gap: 2,
        border: "1px solid var(--color-border)", ...style,
      }}>
        <div aria-hidden style={{
          position: "absolute", top: 3, bottom: 3, left: 3,
          width: `calc((100% - 6px - ${(n - 1) * 2}px) / ${n})`,
          transform: `translateX(calc(${idx} * (100% + 2px)))`,
          borderRadius: 999, background: "var(--color-surface-0)",
          boxShadow: "0 1px 2px rgb(0 0 0 / 0.08)",
          transition: "transform .3s var(--spring)",
        }} />
        {options.map(o => {
          const active = value === o.value;
          return (
            <button key={o.value} onClick={() => onChange(o.value)} aria-pressed={active} style={{
              flex: 1, height: 34, borderRadius: 999, border: "none", cursor: "pointer",
              fontSize: 12.5, fontWeight: 600, fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
              display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "0 10px",
              background: "transparent", position: "relative",
              color: active ? "var(--color-text-1)" : "var(--color-text-2)",
              boxShadow: "none", transition: "color .12s",
            }}>
              {o.label}
              {o.count != null && <span style={{
                fontFamily: "var(--font-mono)", fontSize: 10, padding: "1px 5px", borderRadius: 999,
                background: o.danger ? "var(--color-accent-muted)" : "var(--color-surface-2)",
                color: o.danger ? "var(--color-accent)" : "var(--color-text-3)",
              }}>{o.count}</span>}
            </button>
          );
        })}
      </div>
    );
  };

  /* ── Bottom sheet ────────────────────────────────────────────── */
  const Sheet = ({ open, onClose, children, title, full, height, footer, hideClose }) => {
    const [mounted, setMounted] = useState(open);
    const [shown, setShown] = useState(false);
    const panelRef = useRef(null);
    /* Drag-to-dismiss (grabber/header zone): panel follows the finger,
       releases past 120px or with downward velocity dismiss; else springs back. */
    const [dragY, setDragY] = useState(0);
    const dragYRef = useRef(0); dragYRef.current = dragY;
    const dragging = useRef(false);
    const dragS = useRef({});
    const onHDown = (e) => {
      if (full) return;
      dragging.current = true;
      dragS.current = { sy: e.clientY, ly: e.clientY, lt: performance.now(), vy: 0, moved: false, captured: false, el: e.currentTarget, id: e.pointerId };
    };
    const onHMove = (e) => {
      if (!dragging.current) return;
      const s = dragS.current;
      const dy = e.clientY - s.sy;
      if (!s.captured && Math.abs(dy) > 6) { try { s.el.setPointerCapture(s.id); } catch (err) {} s.captured = true; }
      if (!s.captured) return;
      s.moved = true;
      const now = performance.now();
      s.vy = (e.clientY - s.ly) / Math.max(1, now - s.lt); s.ly = e.clientY; s.lt = now;
      setDragY(Math.max(0, dy));
    };
    const onHUp = () => {
      if (!dragging.current) return;
      dragging.current = false;
      const commit = dragS.current.moved && (dragYRef.current > 120 || dragS.current.vy > 0.55);
      if (commit && onClose) onClose(); /* keep dragY: the close transition continues downward */
      else setDragY(0);
    };
    useEffect(() => {
      if (open) { setMounted(true); setDragY(0); const r = setTimeout(() => setShown(true), 20); return () => clearTimeout(r); }
      else { setShown(false); const t = setTimeout(() => setMounted(false), 280); return () => clearTimeout(t); }
    }, [open]);
    /* Freeze the screens behind the sheet for as long as it is mounted. */
    useScrollLock(mounted);
    /* Focus trap (a11y): move focus into the sheet on open and give it back
       on close. Children that autoFocus keep their focus; the panel only
       takes it when focus is still outside. */
    useEffect(() => {
      if (!mounted) return;
      const prev = document.activeElement;
      const root = panelRef.current;
      if (root && !root.contains(prev)) { try { root.focus({ preventScroll: true }); } catch (e) {} }
      return () => { if (prev && prev.focus && document.contains(prev)) { try { prev.focus({ preventScroll: true }); } catch (e) {} } };
    }, [mounted]);
    const trapKeys = (e) => {
      if (e.key === "Escape") { e.stopPropagation(); if (onClose) onClose(); return; }
      if (e.key !== "Tab") return;
      const root = panelRef.current;
      if (!root) return;
      const focusables = Array.prototype.filter.call(
        root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
        el => !el.disabled && el.offsetParent !== null
      );
      if (!focusables.length) { e.preventDefault(); return; }
      const first = focusables[0], last = focusables[focusables.length - 1];
      const active = document.activeElement;
      if (e.shiftKey) {
        if (active === first || active === root || !root.contains(active)) { e.preventDefault(); last.focus(); }
      } else if (active === last || !root.contains(active)) {
        e.preventDefault(); first.focus();
      }
    };
    if (!mounted) return null;
    /* Render into the app frame (portal): a sheet opened from deep inside a
       scrolled screen must still cover the whole frame, and its scrim must
       never live inside the screen's scroll chain. */
    const overlay = (
      /* z 135: above fullscreen editor/composer overlays (130) so pickers
         opened from inside them stack correctly; below viewers (140). */
      <div onKeyDown={trapKeys} style={{ position: "absolute", inset: 0, zIndex: 135, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
        <div onClick={onClose} className="q-scrim" style={{
          position: "absolute", inset: 0, background: "rgb(20 16 10 / 0.50)",
          opacity: shown ? Math.max(0, 1 - dragY / 420) : 0,
          transition: dragging.current ? "none" : "opacity .28s", backdropFilter: "blur(2px)",
        }} />
        <div ref={panelRef} tabIndex={-1} role="dialog" aria-modal="true"
          aria-label={typeof title === "string" ? title : undefined}
          className={full ? undefined : "q-glass-sheet"}
          style={{
          outline: "none",
          position: "relative", background: full ? "var(--color-bg)" : undefined,
          borderRadius: full ? 0 : "var(--radius-2xl) var(--radius-2xl) 0 0",
          height: full ? "100%" : height, maxHeight: "100%",
          boxShadow: "var(--elev-3), inset 0 1px 0 var(--glass-border)",
          transform: shown ? `translateY(${dragY}px)` : "translateY(101%)",
          transition: dragging.current ? "none" : "transform .32s var(--spring)",
          display: "flex", flexDirection: "column", overflow: "hidden",
          borderTop: full ? "none" : "1px solid var(--glass-border)",
        }}>
          <div onPointerDown={onHDown} onPointerMove={onHMove} onPointerUp={onHUp} onPointerCancel={onHUp}
            style={{ touchAction: full ? undefined : "none", cursor: full ? undefined : "grab" }}>
            {!full && <div style={{ display: "flex", justifyContent: "center", paddingTop: 10 }}>
              <div style={{ width: 38, height: 5, borderRadius: 999, background: "var(--color-border-strong)" }} />
            </div>}
            {(title || !hideClose) && (
              <div style={{
                display: "flex", alignItems: "center", gap: 10, padding: full ? `${SAFE_TOP}px 16px 12px` : "12px 16px 10px",
              }}>
                {title && <div style={{ fontFamily: "var(--font-serif)", fontWeight: 600, fontSize: 21, letterSpacing: "-0.01em", flex: 1, color: "var(--color-text-1)" }}>{title}</div>}
                {!title && <div style={{ flex: 1 }} />}
                {!hideClose && <IconButton name="x" onClick={onClose} label={window.t("g.close")} style={{ background: "var(--color-surface-1)" }} />}
              </div>
            )}
          </div>
          <div style={{ flex: 1, overflowY: "auto", overflowX: "hidden", WebkitOverflowScrolling: "touch" }}>{children}</div>
          {footer && <div style={{ padding: "12px 16px", paddingBottom: full ? 34 : 22, borderTop: "1px solid var(--color-border)", background: "var(--color-surface-0)" }}>{footer}</div>}
        </div>
      </div>
    );
    const frame = document.getElementById("q-frame");
    return frame ? ReactDOM.createPortal(overlay, frame) : overlay;
  };

  /* ── Toast (springs in, springs out; keyed by seq so back-to-back
     toasts replay the entrance) ────────────────────────────────── */
  let toastSetter = null;
  const ToastHost = () => {
    const [state, setState] = useState({ toast: null, leaving: false, seq: 0 });
    useEffect(() => {
      toastSetter = (t) => setState(s => ({ toast: t, leaving: false, seq: s.seq + 1 }));
      return () => { toastSetter = null; };
    }, []);
    /* Keyed by seq only: both timers are scheduled once per toast, so the
       `leaving` flip at 3760ms can't cancel the 4000ms removal timer. The
       seq guard makes a stale timer a no-op if a newer toast took over. */
    const seqRef = useRef(state.seq);
    seqRef.current = state.seq;
    useEffect(() => {
      if (!state.toast) return;
      const mySeq = state.seq;
      const a = setTimeout(() => { if (seqRef.current === mySeq) setState(s => ({ ...s, leaving: true })); }, 3760);
      const b = setTimeout(() => { if (seqRef.current === mySeq) setState(s => ({ ...s, toast: null, leaving: false })); }, 4000);
      return () => { clearTimeout(a); clearTimeout(b); };
    }, [state.seq]);
    const { toast, leaving, seq } = state;
    /* The live region is always mounted so screen readers announce toasts. */
    return (
      <div role="status" aria-live="polite" style={{
        position: "absolute", left: 0, right: 0, bottom: TABBAR_H + 14, zIndex: 200,
        display: "flex", justifyContent: "center", pointerEvents: "none", padding: "0 20px",
      }}>
        {toast && <div key={seq} style={{
          display: "inline-flex", alignItems: "center", gap: 9, padding: "11px 16px",
          background: "var(--color-text-1)", color: "var(--color-bg)", borderRadius: 999,
          fontSize: 13.5, fontWeight: 600, boxShadow: "var(--elev-2)", maxWidth: "100%",
          animation: leaving ? "q-toast-out .24s var(--spring) both" : "q-toast-in .3s var(--spring) both",
        }}>
          {toast.icon && <Icon name={toast.icon} size={16} color={toast.iconColor || "var(--color-primary)"} />}
          <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{toast.msg}</span>
        </div>}
      </div>
    );
  };
  const toast = (msg, icon = "check", iconColor) => { if (toastSetter) toastSetter({ msg, icon, iconColor }); };

  /* ── App bar (contextual top bar; turns to glass once content
     scrolls beneath it — finds its own scroll parent) ───────────── */
  const AppBar = ({ title, onBack, right, kicker, large, transparent }) => {
    const ref = useRef(null);
    const [scrolled, setScrolled] = useState(false);
    useEffect(() => {
      const el = ref.current; if (!el) return;
      let sp = el.parentElement;
      while (sp && sp !== document.body && !/(auto|scroll)/.test(getComputedStyle(sp).overflowY)) sp = sp.parentElement;
      if (!sp || sp === document.body) return;
      const on = () => setScrolled(sp.scrollTop > 8);
      on(); sp.addEventListener("scroll", on, { passive: true });
      return () => sp.removeEventListener("scroll", on);
    }, []);
    const glass = scrolled;
    return (
      <div ref={ref} className={glass ? "q-glass-1" : undefined} style={{
        paddingTop: SAFE_TOP, paddingLeft: 16, paddingRight: 12, paddingBottom: large ? 4 : 10,
        background: glass ? undefined : (transparent ? "transparent" : "var(--color-bg)"),
        boxShadow: glass ? "0 1px 0 var(--color-border)" : "none",
        transition: "background .25s, box-shadow .25s",
        position: "sticky", top: 0, zIndex: 20,
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 6, minHeight: 40 }}>
          {onBack && <IconButton name="chevronLeft" onClick={onBack} label={window.t("g.back")} style={{ marginLeft: -8 }} />}
          {!large && <div style={{ fontFamily: "var(--font-sans)", fontWeight: 700, fontSize: 17, letterSpacing: "-0.01em", flex: 1, color: "var(--color-text-1)" }}>{title}</div>}
          {!large && <div style={{ display: "flex", alignItems: "center", gap: 4 }}>{right}</div>}
          {large && <div style={{ flex: 1 }} />}
          {large && <div style={{ display: "flex", alignItems: "center", gap: 4 }}>{right}</div>}
        </div>
        {large && (
          <div style={{ marginTop: 6 }}>
            {kicker && <Kicker style={{ marginBottom: 6 }}>{kicker}</Kicker>}
            <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 600, fontSize: 32, letterSpacing: "-0.02em", lineHeight: 1.05, color: "var(--color-text-1)", margin: 0 }}>{title}</h1>
          </div>
        )}
      </div>
    );
  };

  /* ── Empty state ─────────────────────────────────────────────── */
  const EmptyState = ({ icon = "file", title, body, action }) => (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", textAlign: "center", padding: "48px 32px", gap: 6, minHeight: 340 }}>
      <div style={{ width: 64, height: 64, borderRadius: 18, background: "var(--color-surface-1)", border: "1.4px dashed var(--color-border-strong)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--color-text-3)", marginBottom: 8 }}>
        <Icon name={icon} size={26} />
      </div>
      <div style={{ fontFamily: "var(--font-serif)", fontWeight: 600, fontSize: 19, color: "var(--color-text-1)" }}>{title}</div>
      <div style={{ fontSize: 14, color: "var(--color-text-2)", lineHeight: 1.5, maxWidth: 280 }}>{body}</div>
      {action && <div style={{ marginTop: 12 }}>{action}</div>}
    </div>
  );

  /* ── List row (tappable) ─────────────────────────────────────── */
  const Row = ({ children, onClick, style = {}, last }) => (
    <div onClick={onClick} className="q-row"
      role={onClick ? "button" : undefined}
      tabIndex={onClick ? 0 : undefined}
      onKeyDown={onClick ? (e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(e); } }) : undefined}
      style={{
      display: "flex", alignItems: "center", gap: 12, padding: "12px 16px",
      borderBottom: last ? "none" : "1px solid var(--color-border)",
      cursor: onClick ? "pointer" : "default", transition: "background .12s", ...style,
    }}>{children}</div>
  );

  /* ── Progress bar ────────────────────────────────────────────── */
  const Progress = ({ value, max = 100, tone = "primary", height = 6 }) => {
    const C = { primary: "var(--color-primary)", success: "var(--color-success)", gold: "var(--color-gold)", accent: "var(--color-accent)", info: "var(--color-info)" };
    const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
    return (
      <div style={{ width: "100%", height, background: "var(--color-surface-2)", borderRadius: 999, overflow: "hidden" }}>
        <div style={{ height: "100%", width: `${pct}%`, background: C[tone] || C.primary, borderRadius: 999, transition: "width 320ms ease" }} />
      </div>
    );
  };

  /* ── EntityLink (G7: every entity reference is a tappable link) ──
     One atom for clients, projects, invoices, expenses, catalog items,
     team members, receipts. Neutral chrome (icon stays text-3); never
     decorative color. Renders a plain span when not tappable. */
  const ENTITY_ICON = { client: "clients", project: "briefcase", invoice: "file", expense: "receipt", catalog: "package", receipt: "receipt", member: "user" };
  const EntityLink = ({ kind, name, onClick, icon, mono, sub, size = 14, color, style }) => {
    const ic = icon !== undefined ? icon : ENTITY_ICON[kind];
    const inner = (
      <>
        {ic && <Icon name={ic} size={size + 1} color="var(--color-text-3)" style={{ flexShrink: 0 }} />}
        <span style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
          <span style={{ fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", fontSize: size, fontWeight: 600, color: color || (onClick ? "var(--color-text-1)" : "var(--color-text-1)"), overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</span>
          {sub && <span style={{ fontSize: size - 2.5, color: "var(--color-text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sub}</span>}
        </span>
      </>
    );
    if (!onClick) return <span style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0, ...style }}>{inner}</span>;
    return (
      <button onClick={e => { if (e && e.stopPropagation) e.stopPropagation(); onClick(e); }} className="q-tap" style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0, background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left", ...style }}>
        {inner}
      </button>
    );
  };

  /* ── Receipt thumbnail ───────────────────────────────────────── */
  const ReceiptThumb = ({ receipt, size = 54, onClick, ariaLabel, style }) => {
    if (!receipt || !receipt.src) return null;
    return (
      <button onClick={onClick} disabled={!onClick} aria-label={ariaLabel || window.t("receipt.view")} className="q-tap" style={{
        width: size, height: Math.round(size * 1.3), borderRadius: "var(--radius-lg)", flexShrink: 0,
        border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-1)",
        overflow: "hidden", padding: 0, cursor: onClick ? "pointer" : "default", display: "block", ...style,
      }}>
        <img src={receipt.src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top", display: "block" }} />
      </button>
    );
  };

  /* ── Receipt viewer (fullscreen overlay, like the A4 Viewer) ──── */
  const ReceiptViewer = ({ open, receipt, onClose, title, meta }) => {
    const [shown, setShown] = useState(false);
    useEffect(() => { if (open) { const r = setTimeout(() => setShown(true), 20); return () => clearTimeout(r); } else setShown(false); }, [open]);
    useScrollLock(!!(open && receipt));
    useStatusBarLight(!!(open && receipt));
    if (!open || !receipt) return null;
    const overlay = (
      <div style={{ position: "absolute", inset: 0, zIndex: 140, display: "flex", flexDirection: "column", background: "rgb(20 16 10 / 0.92)", opacity: shown ? 1 : 0, transition: "opacity .2s" }}>
        <div style={{ paddingTop: SAFE_TOP, paddingLeft: 16, paddingRight: 12, paddingBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 15, fontWeight: 600, color: "#fff", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title || receipt.name || window.t("receipt.title")}</div>
            {meta && <div style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "rgb(255 255 255 / 0.6)", marginTop: 2 }}>{meta}</div>}
          </div>
          <button onClick={() => toast(window.t("receipt.downloaded"), "download")} aria-label={window.t("prev.download")} style={{ width: 40, height: 40, borderRadius: 999, border: "none", background: "rgb(255 255 255 / 0.14)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}><Icon name="download" size={19} /></button>
          <button onClick={onClose} aria-label={window.t("g.close")} style={{ width: 40, height: 40, borderRadius: 999, border: "none", background: "rgb(255 255 255 / 0.14)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}><Icon name="x" size={19} /></button>
        </div>
        <div style={{ flex: 1, overflow: "auto", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "8px 20px 30px" }}>
          <div style={{
            transform: shown ? "none" : "scale(0.82) translateY(36px)", opacity: shown ? 1 : 0,
            transition: "transform .38s var(--spring), opacity .24s", transformOrigin: "50% 30%",
          }}>
            <img src={receipt.src} alt={receipt.name || ""} style={{ maxWidth: "100%", borderRadius: 10, boxShadow: "0 12px 40px rgb(0 0 0 / 0.4)" }} />
          </div>
        </div>
      </div>
    );
    const frame = document.getElementById("q-frame");
    return frame ? ReactDOM.createPortal(overlay, frame) : overlay;
  };

  /* ── Stepper (qty for catalog / stock) ───────────────────────── */
  const STEP_BTN = { width: 34, height: 34, borderRadius: 8, border: "1.4px solid var(--color-border-strong)", background: "var(--color-surface-0)", color: "var(--color-text-1)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };
  const Stepper = ({ value, onChange, min = 0, step = 1 }) => {
    const v = Number(value) || 0;
    const set = nv => onChange(Math.max(min, nv));
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <button onClick={() => set(v - step)} aria-label={window.t("g.decrease")} className="q-tap" style={STEP_BTN}><span style={{ fontSize: 18, lineHeight: 1 }}>{"−"}</span></button>
        <span style={{ minWidth: 34, textAlign: "center", fontFamily: "var(--font-mono)", fontSize: 15, fontWeight: 600, fontVariantNumeric: "tabular-nums", color: "var(--color-text-1)" }}>{v}</span>
        <button onClick={() => set(v + step)} aria-label={window.t("g.increase")} className="q-tap" style={STEP_BTN}><Icon name="plus" size={16} /></button>
      </div>
    );
  };

  /* ── Collapsible (disclosure section) ────────────────────────
     Card-wrapped by default (pad 0, rows render flush); flat=true renders
     a bordered div so it can embed inside an existing Card (taxes drill-downs). */
  const Collapsible = ({ title, sub, count, defaultOpen = false, flat = false, children, style }) => {
    const [open, setOpen] = useState(defaultOpen);
    const inner = (
      <React.Fragment>
        <button
          className="q-tap"
          aria-expanded={open}
          onClick={() => setOpen(o => !o)}
          style={{
            width: "100%", minHeight: 48, padding: "13px 16px",
            display: "flex", alignItems: "center", gap: 10,
            background: "none", border: "none", cursor: "pointer", textAlign: "left",
          }}
        >
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontFamily: "var(--font-sans)", fontSize: 14, fontWeight: 600, color: "var(--color-text-1)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
            {sub != null && (
              <div style={{ fontSize: 12, color: "var(--color-text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sub}</div>
            )}
          </div>
          {count != null && (
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-3)", fontVariantNumeric: "tabular-nums" }}>{count}</span>
          )}
          <Icon name="chevronRight" size={16} color="var(--color-text-3)" style={{
            flexShrink: 0,
            transform: open ? "rotate(90deg)" : "none",
            transition: REDUCE() ? "none" : "transform .2s var(--spring)",
          }} />
        </button>
        <div style={{
          display: "grid", gridTemplateRows: open ? "1fr" : "0fr",
          transition: REDUCE() ? "none" : "grid-template-rows .32s var(--spring)",
        }}>
          <div aria-hidden={!open} {...(open ? {} : { inert: "" })} style={{
            overflow: "hidden", minHeight: 0, opacity: open ? 1 : 0, transition: "opacity .22s",
            borderTop: open ? "1px solid var(--color-border)" : "1px solid transparent",
          }}>{children}</div>
        </div>
      </React.Fragment>
    );
    return flat
      ? <div style={{ borderTop: "1px solid var(--color-border)", ...style }}>{inner}</div>
      : <Card pad={0} style={style}>{inner}</Card>;
  };

  /* ── Type scale (4px-grid snapped; the only font sizes atoms use) ── */
  const FS = { micro: 10, caption: 11, label: 12, body: 13, ui: 14, lg: 16 };

  window.Q = {
    SAFE_TOP, TABBAR_H, FS, Sparkle, Button, IconButton, StatusPill, Badge, Avatar, Money, MoneyDisplay,
    Kicker, AiKicker, Card, Field, Input, Textarea, Select, Segmented, Sheet, ToastHost, toast,
    AppBar, EmptyState, Row, Progress, QR, Toggle, NitaMark, QuillMark: NitaMark, STATUS,
    EntityLink, ReceiptThumb, ReceiptViewer, Stepper, Collapsible,
    AnimatedNumber, PaidBurst, SwipeRow, useScrollLock, useStatusBarLight,
    BadgedIconButton, downloadFile,
  };
})();
