# UI system (`@probo/ui` v2 kit) Shared React UI for Probo apps lives in the **`@probo/ui`** workspace package ([`packages/ui`](../../packages/ui)). The **v2 kit** ([`packages/ui/src/v2`](../../packages/ui/src/v2)) is the target system: a flat set of components styled on top of a headless primitive library, consuming the Radix-scale v2 theme. This document describes how to build and style those components. These rules are the **source of truth**. The legacy tree (`Atoms/`, `Molecules/`, `Layouts/`, `clsx`-mixed `className`, imperative `DialogRef`) is non-compliant code to migrate, not precedent. ## Related guides | Topic | Guide | |-------|--------| | Component shape, props, naming/suffixes | [`contrib/claude/react-components.md`](react-components.md) | | v2 design tokens (color, type, radius, shadow, z-index, spacing) | [`contrib/claude/v2-tokens.md`](v2-tokens.md) | | App folder layout and special folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) | | Error boundaries and error/fallback props | [`contrib/claude/error-handling.md`](error-handling.md) | | Relay data loading | [`contrib/claude/relay.md`](relay.md) | ## Package and tooling | Item | Convention | |------|------------| | Package | **`@probo/ui`** — v2 components under `src/v2`. Apps opt into v2 by importing the v2 theme (see [`v2-tokens.md`](v2-tokens.md)). | | Styling | **Tailwind v4** with the Radix-scale tokens (`bg-sand-3`, `text-sand-12`, `rounded-3`, `text-4`, …). | | Variants API | **`tailwind-variants/lite`** only — `import { tv } from "tailwind-variants/lite"`. The `/lite` entrypoint ships **without `tailwind-merge`**, which is required: the numbered scales (`text-1…9`, `rounded-1…6`, `shadow-1…6`, `z-1…6`) collide with the color/utility namespaces and tailwind-merge would silently drop the scale class (e.g. `text-3` next to `text-sand-11`). The legacy v1 kit stays on `tailwind-variants` (with merge). | | Class composition | **Do not use `clsx` or `tailwind-merge`.** All conditional styling goes through `tv` variants and slots. | | Headless primitives | **Base UI** (`@base-ui/react`). We **style** these primitives; we do not re-implement their behavior. | Preview components with Storybook from `packages/ui`: `npm run dev` (Storybook on port 6006). > Base UI is published as `@base-ui/react` (the earlier `@base-ui-components/react` name is frozen at an RC — do not use it). Import parts from per-component subpaths, e.g. `@base-ui/react/dialog`. ## Headless primitives: style, don't re-implement Interactive components (dialogs, popovers, menus, selects, tabs, tooltips) are **Base UI primitives with our styling applied** — nothing more. The job of a v2 component is to bind `tv` classes to the primitive's parts. Do **not** add a custom behavior layer on top. Rules: - **Use the primitive's controlled API as-is.** A dialog is controlled with `open` / `onOpenChange` — the exact same API Base UI exposes. Opening *our* dialog is opening *the lib's* dialog. - **No custom imperative ref API.** Never invent `useDialogRef()` / `ref.current.open()` / `ref.current.close()`. If imperative control is genuinely needed, use the primitive's own mechanism (e.g. Base UI's `Dialog.createHandle()` / `actionsRef`), never a hand-rolled `useRef` + `useEffect` shim. - **No `cloneElement` / `Children.map` plumbing.** Compose with the primitive's parts and `asChild`-style props the library provides, not by cloning children to inject className/handlers. - **No local mirror state.** Don't copy `open` into `useState` and sync it with `useEffect`; pass `open`/`onOpenChange` straight through, or let the primitive stay uncontrolled. ### Do / don't: dialog wrapper ```tsx // Bad — hand-rolled imperative ref, mirrored open state, cloneElement plumbing export const useDialogRef = () => useRef(null); export function Dialog({ trigger, ref, children }: Props) { const [open, setOpen] = useState(false); useEffect(() => { if (ref) ref.current = { open: () => setOpen(true), close: () => setOpen(false) }; }); // ... Children.map / cloneElement to inject classes ... return {/* … */}; } ``` ```tsx // Good — thin styling over Base UI; consumers use the lib's open/onOpenChange directly import { Dialog as BaseDialog } from "@base-ui/react/dialog"; import { tv } from "tailwind-variants/lite"; const dialog = tv({ slots: { backdrop: "fixed inset-0 bg-sand-12/40", popup: "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-4 bg-sand-2 p-6 shadow-4", title: "text-4 font-medium text-sand-12", }, }); export type DialogProps = ComponentProps; export function Dialog(props: DialogProps) { return ; } export function DialogPopup({ children, ...props }: ComponentProps) { const { backdrop, popup } = dialog(); return ( {children} ); } ``` ```tsx // Good — consumer controls it the same way they'd control the Base UI dialog const [open, setOpen] = useState(false); Open Delete third party {/* … */} ``` ## `tailwind-variants` and `className` In a **single component file**, do **not** mix arbitrary Tailwind utility strings on `className` with `tailwind-variants` for the same styling concern. Layout and look live in **`tv` slots and variants** (and the override APIs `tv` exposes). Extensibility is exposed through variant props or documented slot/class hooks — never by sprinkling raw utilities (or `clsx`) beside `tv()` output. For **compound / multi-slot** components, define `tv` in a **dedicated `variants.ts` module** (see [Variants file](#variants-file)) so loading-only code paths can import styles without pulling the full interactive implementation. ### Do / don't: `tv` vs raw `className` ```tsx // Bad — same file mixes tv() output with ad-hoc Tailwind / clsx on className import { clsx } from "clsx"; import { tv } from "tailwind-variants/lite"; const row = tv({ base: "flex items-center gap-2" }); export function Row({ children }: { children: ReactNode }) { return
{children}
; } ``` ```tsx // Good — layout and look live in tv import { tv } from "tailwind-variants/lite"; const row = tv({ base: "flex items-center gap-2 rounded-3 border border-sand-6", }); export function Row({ children }: { children: ReactNode }) { return
{children}
; } ``` ```tsx // Good — optional styling toggles use tv variants, not extra className strings import { tv } from "tailwind-variants/lite"; const row = tv({ base: "flex items-center gap-2", variants: { bordered: { true: "rounded-3 border border-sand-6", false: "" }, }, defaultVariants: { bordered: true }, }); export function Row({ bordered, children }: { bordered?: boolean; children: ReactNode }) { return
{children}
; } ``` ## No structure-changing variants Variants tune **look** (size, tone, density) — they must not change a component's **structure, semantics, or prop contract**. When a "variant" would render a different element, accept different props, or fork the behavior, build a **separate component** instead. This keeps each component's typing simple and its rendered element predictable. The clearest case is navigation vs action: a clickable action, button-looking navigation, and underlined text links are separate components — not one `Button` with an `as`/`href`/`to` union, and not `Link`/`Anchor` that secretly look like buttons. | Component | Element | Look | |---|---|---| | `Button` | `