Shell UI primitives
Every primitive is exported from @geenius-mvp/shell (and the backend-free ./ui entrypoint) and styled by lab-* classes, so all designs theme them automatically. Real props below, from packages/shell/src/shell/ui.
Layout and structure
PageHeader({ title: string; subtitle?: string; actions?: ReactNode })
Card({ children } & React.HTMLAttributes<HTMLDivElement>)
Badge({ tone?: "default" | "ok" | "warn" | "danger"; children })
EmptyState({ icon?: string; title: string; message?: string; action?: ReactNode })
PageHeader is the standard top-of-page row: title + optional subtitle on the left, actions on the right. Card is a thin .lab-card div that forwards any div attributes. EmptyState is the rich empty screen (icon defaults to a glyph); there is also a minimal Empty({ children }) centered card.
Stats and progress
StatGrid({ children })
StatCard({ label: string; value: ReactNode; hint?: string; icon?: string;
tone?: "default" | "ok" | "warn" | "danger" | "accent"; to?: string })
ProgressBar({ pct: number; tone?: "accent" | "ok" })
Wrap StatCards in a StatGrid. Passing to renders the card as a router Link. ProgressBar clamps pct to 0-100.
Filtering and navigation
SearchInput({ value: string; onChange: (v: string) => void;
placeholder?: string; width?: number | string })
FilterChips({ options: ChipOption[]; value: string | null;
onChange: (v: string | null) => void; allLabel?: string | null })
// ChipOption: { value: string; label: string; icon?: string; count?: number }
Tabs({ tabs: { id: string; label: string }[]; active: string;
onChange: (id: string) => void })
SearchInput shows a clear button when non-empty. FilterChips uses null as the "All" value; pass allLabel={null} to drop the All chip. All three are controlled components.
Loading
Skeleton({ rows?: number }) // default 3
Spinner({ label?: string })
Loaded<T>({ data: T | undefined; loading?: ReactNode; children: (d: T) => ReactNode })
Loaded renders loading (default: a Skeleton) while data is undefined — exactly matching Convex useQuery, which returns undefined while loading — then calls children(data).
Modal
Modal({ title?: string; onClose: () => void; children;
footer?: ReactNode; width?: number /* default 440 */ })
There is no open prop — you conditionally render the Modal itself:
const [editing, setEditing] = useState<Row | null>(null);
// ...
{editing && (
<Modal title="Edit" onClose={() => setEditing(null)}>
<EntityDetail cfg={CONFIG} row={editing} onSave={save} onClose={() => setEditing(null)} />
</Modal>
)}
Accessibility is built in: Escape closes, Tab is trapped inside the dialog, focus starts on the first focusable element and returns to the trigger on close. Clicking the backdrop closes; clicks inside the dialog do not.
Confirm and toasts
const confirm = useConfirm();
if (await confirm({ title: "Delete item?", message: "This cannot be undone.",
confirmLabel: "Delete", danger: true })) { /* ... */ }
const toast = useToast();
toast.success("Saved");
toast.error("Something failed");
toast.toast("Neutral message"); // tone "default" | "ok" | "danger"
ConfirmProvider and ToastProvider are already mounted by the app factory; useConfirm/useToast throw if used outside it. Confirm resolves a Promise<boolean>; danger styles the confirm button destructively. Toasts auto-dismiss after 3.2 seconds.
Errors
ErrorBoundary (a class component wrapping the whole app inside createLabApp) catches render errors and shows a recoverable fallback with Reload and Dismiss buttons instead of a blank screen. NotFound is the default screen for unknown routes with a link back to the app root.