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({ 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.