// components.jsx — shared UI primitives for Haven
const { useState, useRef, useEffect } = React;
// ── Card ────────────────────────────────────────────────────────────
function Card({ children, style, pad = 22, className = '', hover, onClick, ...rest }) {
const [h, setH] = useState(false);
return (
setH(true)}
onMouseLeave={() => setH(false)}
style={{
background: 'var(--surface)', border: '1px solid var(--line)',
borderRadius: 'var(--radius)', padding: pad,
boxShadow: hover && h ? 'var(--shadow)' : 'var(--shadow-sm)',
transition: 'box-shadow .2s, transform .2s, border-color .2s',
transform: hover && h ? 'translateY(-2px)' : 'none',
cursor: onClick ? 'pointer' : 'default',
...style,
}}
{...rest}
>
{children}
);
}
// ── Button ──────────────────────────────────────────────────────────
function Button({ children, variant = 'primary', size = 'md', icon: Icon, iconRight, full, style, ...rest }) {
const [h, setH] = useState(false);
const sizes = {
sm: { padding: '7px 13px', fontSize: 13.5, radius: 10, gap: 6, ic: 16 },
md: { padding: '11px 18px', fontSize: 15, radius: 12, gap: 8, ic: 18 },
lg: { padding: '14px 24px', fontSize: 16, radius: 14, gap: 9, ic: 20 },
}[size];
const variants = {
primary: { bg: 'var(--primary)', color: '#fff', border: 'transparent', bgH: 'var(--primary-ink)' },
amber: { bg: 'var(--amber)', color: '#fff', border: 'transparent', bgH: 'var(--amber-ink)' },
soft: { bg: 'var(--primary-soft)', color: 'var(--primary-ink)', border: 'transparent', bgH: '#f3d9e4' },
ghost: { bg: 'transparent', color: 'var(--ink-2)', border: 'var(--line-2)', bgH: 'var(--surface-2)' },
white: { bg: 'var(--surface)', color: 'var(--ink)', border: 'var(--line-2)', bgH: 'var(--surface-2)' },
}[variant];
return (
);
}
// ── Pill / Badge ────────────────────────────────────────────────────
function Pill({ children, color = 'var(--ink-2)', bg = 'var(--surface-2)', style }) {
return (
{children}
);
}
const STATUS = {
draft: { label: 'Draft', color: 'var(--ink-2)', bg: 'var(--surface-2)', icon: 'doc' },
submitted: { label: 'Submitted', color: 'var(--amber-ink)', bg: 'var(--amber-soft)', icon: 'clock' },
paid: { label: 'Paid', color: 'var(--good)', bg: 'var(--good-soft)', icon: 'checkCircle' },
rejected: { label: 'Needs a fix', color: 'var(--alert)', bg: 'var(--alert-soft)', icon: 'alert' },
pending: { label: 'To approve', color: 'var(--amber-ink)', bg: 'var(--amber-soft)', icon: 'clock' },
};
function StatusPill({ status }) {
const s = STATUS[status] || STATUS.draft;
const I = Icons[s.icon];
return {s.label};
}
// ── Progress bar ────────────────────────────────────────────────────
function Bar({ pct, color = 'var(--primary)', track = 'var(--surface-2)', height = 9, marker }) {
const p = Math.min(1, Math.max(0, pct));
return (
);
}
// ── Donut ring ──────────────────────────────────────────────────────
function Ring({ pct, size = 120, stroke = 12, color = 'var(--primary)', track = 'var(--surface-2)', children }) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const p = Math.min(1, Math.max(0, pct));
return (
);
}
// ── Avatar ──────────────────────────────────────────────────────────
function Avatar({ initials, size = 38, bg = 'var(--primary-soft)', color = 'var(--primary-ink)' }) {
return (
{initials}
);
}
// ── Plain-English helper (jargon tooltip) ───────────────────────────
let PLAIN_ON = true;
function PlainTip({ term, children }) {
const [open, setOpen] = useState(false);
const text = GLOSSARY[term] || children;
if (!PLAIN_ON) return {term};
return (
setOpen(true)} onMouseLeave={() => setOpen(false)}>
{term}
{open && (
In plain English
{text}
)}
);
}
// ── Section heading ─────────────────────────────────────────────────
function SectionTitle({ children, sub, action }) {
return (
{children}
{sub &&
{sub}
}
{action}
);
}
// ── Modal ───────────────────────────────────────────────────────────
function Modal({ open, onClose, children, width = 560, title, sub }) {
useEffect(() => {
if (!open) return;
const h = (e) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()} style={{
background: 'var(--surface)', borderRadius: 'var(--radius-lg)', width, maxWidth: '100%',
maxHeight: '90vh', overflow: 'auto', boxShadow: 'var(--shadow-lg)', animation: 'pop .2s ease both',
}}>
{title && (
)}
{children}
);
}
// ── Empty / info note ───────────────────────────────────────────────
function Note({ children, tone = 'info', icon }) {
const tones = {
info: { bg: 'var(--surface-2)', color: 'var(--ink-2)', ic: 'var(--ink-3)' },
good: { bg: 'var(--good-soft)', color: '#3a7264', ic: 'var(--good)' },
amber: { bg: 'var(--amber-soft)', color: 'var(--amber-ink)', ic: 'var(--amber-ink)' },
primary: { bg: 'var(--primary-soft)', color: 'var(--primary-ink)', ic: 'var(--primary)' },
}[tone];
const I = Icons[icon || 'info'];
return (
{children}
);
}
// number ticker
function useCountUp(target, dur = 700) {
const [v, setV] = useState(0);
useEffect(() => {
let raf, start;
const tick = (t) => {
if (!start) start = t;
const p = Math.min(1, (t - start) / dur);
const e = 1 - Math.pow(1 - p, 3);
setV(target * e);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, dur]);
return v;
}
function setPlain(on) { PLAIN_ON = on; }
Object.assign(window, {
Card, Button, Pill, StatusPill, STATUS, Bar, Ring, Avatar, PlainTip, SectionTitle, Modal, Note, useCountUp, setPlain,
});