/** * The design system's component vocabulary, in one place. Every other file * composes these rather than re-spelling the class strings. */ import type { ReactNode } from "react"; /** * Every control in the app is this tall. Width still grows with the label — * only the height is fixed, so a row of mixed buttons lines up. */ export const CONTROL_H = "h-[30px]"; /** Square version, for icon-only buttons. */ export const ICON_BTN = `grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` + "disabled:cursor-not-allowed disabled:opacity-40"; export const HEADING = "text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400"; export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400"; export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400"; export const PANEL = "bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-800"; export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800"; export const INPUT = `w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` + "placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500"; export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50"; const BTN_BASE = `inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` + "disabled:cursor-not-allowed"; export const BTN = `${BTN_BASE} border border-slate-300 px-2.5 font-medium ` + "hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " + "dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400"; export const BTN_PRIMARY = `${BTN_BASE} bg-sky-500 px-3 font-semibold text-white ` + "hover:bg-sky-400 disabled:opacity-40"; export const BTN_DANGER = `${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`; /** Header actions: quieter than a secondary button, still a real target. */ export const BTN_CHROME = `inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` + "hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " + "dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"; /** Reads as a link; sits at the edge of a group. */ export const BTN_QUIET = "text-[11px] text-slate-500 underline underline-offset-2 " + "hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"; /** The only spinning thing in the app; used where a wait has no known length. */ export function Spinner({ className = "size-4" }: { className?: string }) { return ( ); } export function SectionHeading({ step, children, }: { step?: number; children: ReactNode; }) { return (

{step !== undefined && ( {step} )} {children}

); } /** Bordered container, borderless children — the group's outline does the framing. */ export function Segmented({ options, value, onChange, }: { options: Array<{ value: T; label: string }>; value: T; onChange: (v: T) => void; }) { return (
{options.map((o) => { const active = o.value === value; return ( ); })}
); } /** * Success confirmation of something that already happened, so it never asks to * be dismissed. Making someone dismiss a box to be told it worked is a bug. */ export function Toast({ message }: { message: string | null }) { return (
{message && (
{message}
)}
); } /** A modal for anything needing a decision or reporting a failure. */ export function Dialog({ title, children, onCancel, confirmLabel, onConfirm, destructive, wide, }: { title: string; children: ReactNode; onCancel: () => void; confirmLabel?: string; onConfirm?: () => void; destructive?: boolean; wide?: boolean; }) { return (
e.stopPropagation()} className={`w-full ${wide ? "max-w-lg max-h-[82vh] overflow-y-auto" : "max-w-sm"} rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-900`} >

{title}

{children}
{onConfirm && ( )}
); }