// ============================================================================ // Imara — shared UI primitives, theme store, helpers // ============================================================================ const { useState, useEffect, useRef, useMemo, useCallback, createContext, useContext } = React; // ---- Icon (Lucide UMD) ------------------------------------------------------ function Icon({ name, size = 16, stroke = 1.75, className, style }) { const data = window.lucide && (window.lucide[name] || (window.lucide.icons && window.lucide.icons[name])); // lucide IconNode = ["svg", attrs, [ [tag, attrs], ... ] ]; children at [2] const kids = (data && Array.isArray(data[2]) ? data[2] : []); return ( {kids.map((c, i) => React.createElement(c[0], { key: i, ...c[1] }))} ); } // ---- formatting helpers ----------------------------------------------------- const fmt = (n) => (n == null ? "-" : n.toLocaleString()); // Ages on the Imara server's clock (terms.js serverNow), not the browser's. function ago(iso) { const now = window.ImaraTerms && window.ImaraTerms.serverNow ? window.ImaraTerms.serverNow() : Date.now(); const s = Math.max(0, Math.round((now - new Date(iso).getTime()) / 1000)); if (s < 60) return `${s}s`; const m = Math.round(s / 60); if (m < 60) return `${m}m`; const h = Math.round(m / 60); if (h < 24) return `${h}h`; return `${Math.round(h / 24)}d`; } function agoLong(iso) { return ago(iso) + " ago"; } const cx = (...a) => a.filter(Boolean).join(" "); // ---- theme store ------------------------------------------------------------ // Only the light / dark choice is kept (an older saved accent or density is // ignored). function loadStore() { try { return { theme: JSON.parse(localStorage.getItem("imara.ui") || "{}").theme === "light" ? "light" : "dark" }; } catch { return { theme: "dark" }; } } const StoreCtx = createContext(null); function useStore() { return useContext(StoreCtx); } function StoreProvider({ children }) { const [s, setS] = useState(loadStore); useEffect(() => { const r = document.documentElement; r.setAttribute("data-theme", s.theme); try { localStorage.setItem("imara.ui", JSON.stringify(s)); } catch (e) { /* storage blocked: the choice lasts this visit */ } }, [s]); const set = useCallback((patch) => setS((p) => ({ ...p, ...patch })), []); return React.createElement(StoreCtx.Provider, { value: { ...s, set } }, children); } // ---- small components ------------------------------------------------------- function Badge({ tone = "neutral", sq, dot, children, style }) { return {dot && }{children}; } function Meter({ value, max = 100, tone }) { const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0; const color = tone === "ok" ? "var(--ok)" : tone === "warn" ? "var(--warn)" : tone === "bad" ? "var(--bad)" : "var(--accent)"; return
; } function Stat({ label, value, foot, trend, icon, onClick, on }) { return (
{icon && }
{label}
{value}
{trend && {trend.text}}
{foot &&
{foot}
}
); } // area sparkline (data viz: polyline + gradient area) function Sparkline({ data, height = 120, accessor = (d) => d.count }) { const ref = useRef(null); const [w, setW] = useState(800); useEffect(() => { if (!ref.current) return; const ro = new ResizeObserver((e) => setW(e[0].contentRect.width)); ro.observe(ref.current); return () => ro.disconnect(); }, []); const id = useMemo(() => "sp" + Math.random().toString(36).slice(2), []); if (!data || data.length < 2) { return
No data
; } const vals = data.map(accessor); const max = Math.max(...vals, 1), min = Math.min(...vals, 0); const pad = 6, h = height - pad * 2; const x = (i) => (i / (data.length - 1)) * w; const y = (v) => pad + h - ((v - min) / (max - min || 1)) * h; const line = vals.map((v, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); const area = `${line} L${w},${height} L0,${height} Z`; return (
); } // ---- drawer shell ----------------------------------------------------------- function Drawer({ open, onClose, wide, children }) { useEffect(() => { if (!open) return; // Escape closes the panel only when no dialog is open on top of it (the // dialog handles its own Escape). const h = (e) => e.key === "Escape" && !document.querySelector(".cmdk-scrim") && onClose(); window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [open, onClose]); if (!open) return null; return (
); } // severity helpers const SEV = { critical: "bad", warn: "warn", info: "info" }; Object.assign(window, { Icon, fmt, ago, agoLong, cx, useStore, StoreProvider, Badge, Meter, Stat, Sparkline, Drawer, SEV, // expose hooks as globals so every Babel script (separate scope) can use them bare useState, useEffect, useRef, useMemo, useCallback, });