// Shared UI primitives const { useState, useEffect, useRef, useMemo, useCallback } = React; // Sparkline function Sparkline({ data, w = 76, h = 20, stroke = "var(--accent-500)", fill = "transparent", strokeWidth = 1.3 }) { if (!data || !data.length) return null; const max = Math.max(...data, 1); const min = Math.min(...data, 0); const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * (w - 2) + 1; const y = h - 1 - ((v - min) / (max - min || 1)) * (h - 2); return [x, y]; }); const d = pts.map((p,i) => (i ? "L" : "M") + p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" "); const area = d + ` L ${w-1},${h-1} L 1,${h-1} Z`; return ( ); } function StatusPill({ status }) { const map = { ACTIVE: { c: "var(--green-400)", bg: "color-mix(in oklab, var(--green-500) 18%, transparent)", t: "Active" }, PAUSED: { c: "var(--text-3)", bg: "var(--chip)", t: "Paused" }, ARCHIVED:{ c: "var(--text-4)", bg: "var(--chip)", t: "Archived" }, }; const s = map[status] || map.PAUSED; return ( {s.t} ); } function SevDot({ level }) { const c = level === "red" ? "var(--red-500)" : level === "amber" ? "var(--amber-500)" : "var(--green-500)"; return ; } function Ratio({ ratio }) { // ratio = current daily / 30d avg const pct = Math.round(ratio * 100); let color = "var(--text-2)"; let bg = "var(--chip)"; if (ratio > 3) { color = "var(--red-400)"; bg = "color-mix(in oklab, var(--red-500) 18%, transparent)"; } else if (ratio > 2) { color = "var(--amber-400)"; bg = "color-mix(in oklab, var(--amber-500) 18%, transparent)"; } else if (ratio > 1.2) { color = "var(--text-2)"; } else if (ratio > 0) { color = "var(--green-400)"; bg = "color-mix(in oklab, var(--green-500) 14%, transparent)"; } return ( {ratio > 0 ? pct : 0}% ); } function Button({ children, kind = "ghost", size="sm", icon, onClick, className="", ...rest }) { const sizes = { xs: "h-6 px-2 text-[11px]", sm: "h-8 px-2.5 text-[12px]", md: "h-9 px-3 text-[13px]", }; const kinds = { ghost: "border border-soft hover:bg-[var(--surface-2)]", subtle: "bg-[var(--chip)] hover:bg-[var(--surface-2)] border border-transparent", primary: "accent-bg hover:brightness-110 border border-transparent font-medium", danger: "bg-[color-mix(in_oklab,var(--red-500)_18%,transparent)] text-[var(--red-400)] hover:bg-[color-mix(in_oklab,var(--red-500)_26%,transparent)] border border-transparent", }; return ( ); } function SearchInput({ value, onChange, placeholder = "Search…", kbd, className="" }) { return (
onChange(e.target.value)} placeholder={placeholder} className="w-full h-8 pl-8 pr-12 rounded-md bg-[var(--surface)] border border-soft text-[12px] placeholder:text-[var(--text-3)]" /> {kbd && {kbd}}
); } function Select({ value, onChange, options, className="" }) { return (
); } function Chip({ children, tone="default", className="" }) { const tones = { default: { color:"var(--text-2)", bg:"var(--chip)" }, accent: { color:"var(--accent-400)", bg:"color-mix(in oklab, var(--accent-500) 14%, transparent)" }, red: { color:"var(--red-400)", bg:"color-mix(in oklab, var(--red-500) 16%, transparent)" }, amber: { color:"var(--amber-400)", bg:"color-mix(in oklab, var(--amber-500) 16%, transparent)" }, green: { color:"var(--green-400)", bg:"color-mix(in oklab, var(--green-500) 16%, transparent)" }, }; const t = tones[tone]; return ( {children} ); } function Tabs({ tabs, value, onChange }) { return (
{tabs.map(t => ( ))}
); } // Context menu function ContextMenu({ x, y, items, onClose }) { const ref = useRef(null); useEffect(() => { const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; const onEsc = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); }; }, [onClose]); return (
{items.map((it, i) => it.sep ?
: ( ))}
); } // Toast function Toast({ text, onDone }) { useEffect(() => { const t = setTimeout(onDone, 2400); return () => clearTimeout(t); }, []); return (
{text}
); } function Breadcrumbs({ items, onNav }) { return ( ); } // Diff view inline function Diff({ oldV, newV, money, ccy }) { const fmt = (v) => { if (money) return (ccy ? window.fmtCCY(v, ccy) : window.fmtHKD(v)); if (typeof v === "string" && v.length > 40) return v.slice(0, 40) + "…"; return String(v); }; return ( {fmt(oldV)} {fmt(newV)} ); } Object.assign(window, { Sparkline, StatusPill, SevDot, Ratio, Button, SearchInput, Select, Chip, Tabs, ContextMenu, Toast, Breadcrumbs, Diff, });