Files

738 lines
31 KiB
JavaScript

// ============================================================
// vibn-ai-templates/components.jsx
// ------------------------------------------------------------
// The core component set. Every visual property is wired to a
// CSS variable from tokens.css — flipping `class="theme-glass"`
// (or any other theme class) reskins the whole library.
//
// Components export to `window` for use in script-tag HTML
// projects. In a real codebase, swap the bottom-of-file
// assignment for `export { … }`.
//
// Components included:
// Button, IconButton, Field, Input, Textarea, Select,
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
// FieldGroup, KBD, Spinner.
// ============================================================
// ─── Helpers ─────────────────────────────────────────────────
const cx = (...names) => names.filter(Boolean).join(" ");
const noop = () => {};
// ─── Button ──────────────────────────────────────────────────
// variant: primary (default), secondary, ghost, destructive
// size: sm | md (default) | lg
// leadingIcon / trailingIcon: <Icon name="…"/>
// loading: disables and shows a spinner
const Button = ({
children, variant = "primary", size = "md", full = false,
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
...rest
}) => {
const sizing = {
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
}[size];
const variants = {
primary: {
background: "var(--button-bg)",
color: "var(--button-fg)",
border: "1px solid var(--button-border)",
},
secondary: {
background: "var(--button-secondary-bg)",
color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)",
},
ghost: {
background: "transparent",
color: "var(--button-ghost-fg)",
border: "1px solid transparent",
},
destructive: {
background: "var(--danger)",
color: "#ffffff",
border: "1px solid var(--danger)",
},
}[variant];
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
style={{
...variants,
padding: `${sizing.padY}px ${sizing.padX}px`,
borderRadius: "var(--button-radius)",
fontFamily: "var(--font-sans)",
fontSize: sizing.font,
fontWeight: "var(--weight-medium)",
lineHeight: 1.2,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: full ? "100%" : "auto",
whiteSpace: "nowrap",
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
...style,
}}
{...rest}
>
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
<span>{children}</span>
{!loading && trailingIcon}
</button>
);
};
// ─── IconButton ──────────────────────────────────────────────
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
const dims = { sm: 28, md: 32, lg: 38 }[size];
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
const variants = {
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)" },
}[variant];
return (
<button
aria-label={label}
onClick={onClick}
style={{
...variants,
width: dims, height: dims, borderRadius: "var(--radius-sm)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", padding: 0,
...style,
}}
>
{icon ?? <Icon name={name} size={iconSize} />}
</button>
);
};
// ─── Spinner ─────────────────────────────────────────────────
const Spinner = ({ size = 14, stroke = 2 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
</path>
</svg>
);
// ─── Field (wraps a labelled input with hint / error) ────────
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
<div style={{ marginBottom: "var(--space-4)", ...style }}>
{label && (
<label htmlFor={htmlFor} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
color: "var(--text)", marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
</label>
)}
{children}
{(hint || error) && (
<div style={{
fontSize: "var(--text-xs)", marginTop: 5,
color: error ? "var(--danger)" : "var(--text-3)",
}}>{error || hint}</div>
)}
</div>
);
// ─── Input ───────────────────────────────────────────────────
// Bare input (use inside <Field>). leadingIcon / trailingIcon
// add an inner ornament. invalid red-rings the border.
const Input = ({
value, placeholder, type = "text", leadingIcon, trailingIcon,
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
}) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
fontSize: "var(--text-md)",
color: "var(--text)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
opacity: disabled ? 0.5 : 1,
transition: "border-color var(--duration), box-shadow var(--duration)",
...style,
}}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value, e)}
style={{
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
padding: 0,
}}
{...rest}
/>
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
</div>
);
// ─── Textarea ────────────────────────────────────────────────
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
<textarea
id={id}
value={value}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value, e)}
style={{
width: "100%", display: "block", padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
fontSize: "var(--text-md)", color: "var(--text)",
fontFamily: "var(--font-sans)", resize: "vertical",
outline: "none", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}}
{...rest}
/>
);
// ─── Select (presentation only — clicks the menu open visually) ─
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: "var(--field-radius)",
background: "var(--field-bg)", border: "1px solid var(--field-border)",
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
cursor: "pointer", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}} {...rest}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
</div>
);
// ─── Checkbox ────────────────────────────────────────────────
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="checkbox"
aria-checked={indeterminate ? "mixed" : !!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
color: "var(--text-on-accent)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "background var(--duration), border-color var(--duration)",
}}
>
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Radio ───────────────────────────────────────────────────
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="radio"
aria-checked={!!checked}
onClick={() => !disabled && onChange(true)}
style={{
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
background: "var(--surface)",
position: "relative",
}}
>
{checked && <span style={{
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
background: "var(--accent)", borderRadius: "50%",
}}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Switch ──────────────────────────────────────────────────
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
<label style={{
display: "flex", alignItems: "center", gap: 12,
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="switch"
aria-checked={!!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 34, height: 20, borderRadius: 999,
background: checked ? "var(--accent)" : "var(--surface-alt)",
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
position: "relative", flexShrink: 0,
transition: "background var(--duration), border-color var(--duration)",
}}
>
<span style={{
position: "absolute", top: 1, left: checked ? 15 : 1,
width: 16, height: 16, borderRadius: "50%",
background: checked ? "var(--text-on-accent)" : "var(--surface)",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
transition: "left var(--duration) var(--ease)",
}}/>
</span>
{(label || hint) && (
<span style={{ flex: 1, minWidth: 0 }}>
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Card / Surface ──────────────────────────────────────────
// Card paints a `surface` background with border + shadow.
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
const shadows = {
default: "var(--shadow-sm)",
raised: "var(--shadow)",
floating:"var(--shadow-lg)",
flat: "none",
};
return (
<div style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--card-radius)",
padding,
boxShadow: shadows[variant] || shadows.default,
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
color: "var(--text)",
...style,
}} {...rest}>{children}</div>
);
};
const CardHeader = ({ title, subtitle, action, style }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
marginBottom: "var(--space-4)", gap: 16, ...style,
}}>
<div style={{ minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
}}>{title}</div>}
{subtitle && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>}
</div>
{action}
</div>
);
// ─── Badge / Tag ─────────────────────────────────────────────
// tone: neutral | accent | success | warn | danger | info
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
const palette = {
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
}[tone] || {};
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "2px 8px", borderRadius: "var(--radius-pill)",
background: palette.bg, color: palette.fg,
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
whiteSpace: "nowrap", lineHeight: 1.4,
...style,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
{leadingIcon}
{children}
</span>
);
};
const Tag = Badge; // alias
// ─── Avatar ──────────────────────────────────────────────────
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
const hashName = (s = "") => {
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return avatarPalette[Math.abs(h) % avatarPalette.length];
};
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
return (
<span style={{
position: "relative", display: "inline-flex", flexShrink: 0,
width: size, height: size, borderRadius: "50%",
background: color || hashName(name), color: "#3a2820",
alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
overflow: "hidden", ...style,
}}>
{src
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
: initials}
{status && <span style={{
position: "absolute", bottom: 0, right: 0,
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
borderRadius: "50%", border: "2px solid var(--surface)",
background: status === "online" ? "var(--success)" :
status === "busy" ? "var(--danger)" : "var(--text-3)",
}}/>}
</span>
);
};
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
const shown = items.slice(0, max);
const remaining = items.length - shown.length;
return (
<div style={{ display: "inline-flex" }}>
{shown.map((p, i) => (
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
))}
{remaining > 0 && (
<span style={{
width: size, height: size, borderRadius: "50%",
background: "var(--surface-alt)", color: "var(--text-2)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
}}>+{remaining}</span>
)}
</div>
);
};
// ─── Tabs ────────────────────────────────────────────────────
// Controlled: pass `active` (label of active tab) + `onChange`.
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
return (
<div style={{
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
...style,
}}>
{items.map(t => {
const isActive = t.label === active || t.id === active;
if (variant === "pill") {
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "6px 12px", borderRadius: "var(--radius-pill)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
background: isActive ? "var(--accent)" : "transparent",
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", fontWeight: 500,
display: "inline-flex", alignItems: "center", gap: 6,
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{t.count}</span>
)}
</button>
);
}
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "10px 4px", margin: "0 12px 0 0",
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
fontWeight: "var(--weight-medium)",
background: "transparent", border: "none", cursor: "pointer",
color: isActive ? "var(--text)" : "var(--text-2)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
position: "relative", top: 1,
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
color: isActive ? "var(--accent)" : "var(--text-3)",
}}>{t.count}</span>
)}
</button>
);
})}
</div>
);
};
// ─── Table ───────────────────────────────────────────────────
// columns: [{ key, label, width?, align?, render? }]
// rows: [{ id, [key]: value, … }]
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
const padY = density === "compact" ? 8 : 12;
const allChecked = rows.length > 0 && selected.length === rows.length;
const someChecked = selected.length > 0 && !allChecked;
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
const toggleOne = (id) => onSelectionChange(
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
);
const headerCell = {
padding: `10px 12px`, fontSize: "var(--text-xs)",
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
borderBottom: "1px solid var(--border)", background: "var(--surface)",
};
const bodyCell = {
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
};
return (
<div style={{
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", overflow: "hidden",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}>
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
<thead>
<tr>
{selectable && (
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
</th>
)}
{columns.map(c => (
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>
{selectable && (
<td style={{ ...bodyCell, paddingRight: 0 }}>
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
</td>
)}
{columns.map(c => (
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
{c.render ? c.render(r) : r[c.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
// ─── Modal (presentational — wrap your own state) ────────────
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100,
background: "rgba(0,0,0,0.5)",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
background: "var(--surface)", color: "var(--text)",
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
boxShadow: "var(--shadow-modal)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}
>
<div style={{
padding: "var(--space-6)", display: "flex",
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
}}>
<div style={{ minWidth: 0 }}>
{title && <h2 style={{
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
}}>{title}</h2>}
{description && <p style={{
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
}}>{description}</p>}
</div>
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
</div>
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
{footer && (
<div style={{
padding: "var(--space-4) var(--space-6)",
borderTop: "1px solid var(--divider)",
display: "flex", justifyContent: "flex-end", gap: 8,
background: "var(--surface-2)",
}}>{footer}</div>
)}
</div>
</div>
);
};
// ─── Banner / Alert ──────────────────────────────────────────
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
const palette = {
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
}[tone];
return (
<div style={{
display: "flex", gap: 12, padding: "12px 16px",
borderRadius: "var(--radius)",
background: palette.bg, color: "var(--text)",
border: `1px solid ${palette.fg}33`,
alignItems: "flex-start",
...style,
}}>
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{title}</div>}
{children && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
}}>{children}</div>}
</div>
{action}
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
</div>
);
};
// ─── Divider ─────────────────────────────────────────────────
const Divider = ({ label, vertical, style }) => {
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
return (
<div style={{
display: "flex", alignItems: "center", gap: 12,
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
margin: "var(--space-4) 0", ...style,
}}>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
<span>{label}</span>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
</div>
);
};
// ─── FieldGroup — horizontal segmented control ───────────────
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
<div style={{
display: "inline-flex", padding: 3, gap: 2,
background: "var(--surface-alt)", border: "1px solid var(--border)",
borderRadius: "var(--radius)", ...style,
}}>
{options.map(o => {
const v = typeof o === "string" ? o : o.value;
const label = typeof o === "string" ? o : o.label;
const sel = v === value;
return (
<button key={v} onClick={() => onChange(v)} style={{
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
background: sel ? "var(--surface)" : "transparent",
color: sel ? "var(--text)" : "var(--text-2)",
border: "none", cursor: "pointer",
boxShadow: sel ? "var(--shadow-sm)" : "none",
fontWeight: sel ? 500 : 400,
}}>{label}</button>
);
})}
</div>
);
// ─── KBD ─────────────────────────────────────────────────────
const KBD = ({ children, style }) => (
<kbd style={{
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
padding: "1px 6px", borderRadius: 4,
background: "var(--surface-alt)", color: "var(--text-2)",
border: "1px solid var(--border)",
...style,
}}>{children}</kbd>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
Button, IconButton, Spinner,
Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch,
Card, CardHeader, Divider,
Badge, Tag, Avatar, AvatarStack,
Tabs, Table, Modal, Banner, KBD,
});