# UI (/product/building/ui)



## Before you style anything: does the primitive exist

The failure is not the wrong folder; it is hand-rolling a shape the primitives already ship. A primitive owns colour, radius, focus ring, hover, disabled state and its data slots. A class that re-derives any of those means the wrong element is being rendered. Keep `className` to what the primitive cannot know: geometry, a one-off spacing, a grid span.

| The shape you are about to write                             | Render this                                                             |
| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| A bordered, hoverable row or tile with icon, title, sub-line | `Item` with its media, content, title, description and actions parts    |
| A titled panel with header, body, footer                     | `Card`                                                                  |
| A tinted square holding one icon                             | `IconTile`                                                              |
| A labelled metric                                            | `StatTile`                                                              |
| "Nothing here yet"                                           | `Empty` with its header, title and description. Never a bare paragraph. |
| A loading placeholder                                        | `Skeleton`, geometry in the class only                                  |
| A spinner                                                    | `Spinner`                                                               |
| Buttons that touch                                           | `ButtonGroup`                                                           |
| An input with an affix, icon or inline button                | `InputGroup`                                                            |
| A labelled control with description and error                | `Field`                                                                 |
| A status pill                                                | `Badge`                                                                 |
| A dialog or sheet                                            | `Modal`, sized with its size prop, never a max-width class              |
| A confirm-then-act dialog                                    | `ConfirmDialog`                                                         |

`Item` is the one most often missed. An anchor or button carrying a border, a radius, padding and a hover fill is an `Item`. Read the primitive's file before composing; the variants sit at its top. Never guess a prop.

## The three homes under `components/`

* **`ui/`**: the generated primitives plus a few bespoke leaves, flat.
* **`ui-extended/`**: ports or extensions of a known archetype, and compounds built over `ui/`. The axis is archetype versus bespoke, not "extends a primitive". Nothing in `ui/` imports upward.
* **Block modules** such as chat, file, filter, form, grid and shell: a folder with a barrel, and `ui/` and `hooks/` only when non-empty, that wraps children with context or chrome, or owns state, actions and UI across several files.

Decide in that order. Nothing sits loose at the `components/` root.

## Admin forms

The form body is a client component rendering a form element. It takes no props for mode or data; it reads the form configuration from context.

```tsx
"use client";

export function OrganizationForm() {
  const t = useTranslations("organizations.form");
  const formToast = useFormToast();
  const config = useFormConfig();
  const schema = createSchema((key) => t(`validation.${key}`));

  const form = useForm<FormSchema>({
    resolver: zodResolver(schema),
    defaultValues: { ...defaultValues, ...(config.data ?? {}) },
    disabled: config.isView,
  });

  const onSubmit = (addAnother = false) =>
    form.handleSubmit((data) => submitAction(data, { addAnother }));

  return (
    <form onSubmit={onSubmit()}>
      <FieldGroup>
        <FieldSet>
          <FormInput
            control={form.control}
            name="name"
            label={t("fields.name")}
          />
        </FieldSet>
        <FormActions
          isSubmitting={form.formState.isSubmitting}
          isDirty={form.formState.isDirty}
          onAddAnother={onSubmit(true)}
        />
      </FieldGroup>
    </form>
  );
}
```

* The schema comes from a factory taking the translator, so validation messages are translated.
* Fields come from the form block: input, textarea, number, date-time, select, combobox and their async and multi variants, each taking the control and a name and rendering its own error or description.
* Layout comes from the field primitives: group, set, content, legend, separator.
* The server action verifies the session first and returns a result. The form branches on it: toast the error, or toast the success and call the configured completion.
* A wrapping mount supplies the configuration: a form page for a route, a form modal for a dialog, a grid form on a grid.

Two traps. A form body throws outside its page or modal, because the configuration accessor has no fallback. And a field-level refine on a nullable field breaks resolver parity and only the app build catches it; use an object-level refine with an explicit path.

Do not hand-roll a dialog. Follow this convention.

## Grid surfaces

Four files per entity:

1. **`<entity>-content.tsx`**, a server component. The page header, then the module settings server provider, the grid configuration provider with the persisted view state, and the grid. It awaits the grid's persisted cookies itself so first paint matches the last choice.
2. **`<entity>-grid-shared.tsx`**, a client hook returning the filter definitions and sorts as plain data.
3. **`<entity>-grid.tsx`**, the client router: form settings, view persistence, the form modal, then the filters provider, the filter sidebar, and the table or list variant.
4. **`<entity>-grid-table.tsx`*&#x2A; and &#x2A;*`-grid-list.tsx`**, the variants.

```tsx
<AgGridInfiniteProvider
  selection={selection}
  pagination={ORGANIZATIONS_PAGINATION}
>
  <GridContainer className="min-w-0 flex-1">
    <InfiniteGridToolbar />
    <div className="min-h-0 flex-1">
      <AgGrid columnDefs={columnDefs} rowModelType="infinite" />
    </div>
  </GridContainer>
  <GridDeleteDialog action={batchDeleteOrganizationAction} />
  <GridForm namespace="organizations">
    <OrganizationForm />
  </GridForm>
</AgGridInfiniteProvider>
```

The table runs the infinite row model over the keyset feed; the list runs a query-backed pagination with card rendering. The seam between them is intrinsic; do not unify. After a delete remove the rows, after an edit refresh the row, after a create or a filter change refresh the grid. The grid needs an unbroken `min-h-0 flex-1` chain above it or it has zero height.

## Filters

Definitions are authored once, up front, as data, because the provider needs the complete set before first render to seed URL deserialisation and server rendering. The default renderer draws one collapsible row per definition; a bespoke row references a definition by name and supplies its runtime data source.

```tsx
<CollapsibleFilter filters={filterDefs} name="collection">
  <FilterSelect items={collections} />
</CollapsibleFilter>
```

The filters provider is the only entry to the filter context and requires a grid persistence provider above it. The controls carry no chrome, so the sidebar row and the pill bar compose the same ones, and single versus multi selection derives from the definition's type. When a table and a list both render the sidebar, mount the provider once in the parent that routes between them.

## Styling

* `cn()` merges class lists. Every chrome component takes `className` last and stamps a data slot.
* Tailwind v4, no config file. Each app's global stylesheet is self-contained and stays that way; a shared base was tried and reverted. It scans the shared package's source, declares the custom variants and the inline theme, then the literal light and dark token blocks.
* Tokens are the standard set plus info, success, warning and invert. The authoritative list is the inline theme block of the app's stylesheet; read it rather than assume.
* Dark mode is class-based. Write `dark:` variants. A component needing the resolved value reads the theme hook.
* Per-tenant colour injects at runtime inside the tenant gate, layered over the app's defaults.

## Translation

No raw English in JSX, server actions, UI-surfaced errors or page metadata. A client component calls `useTranslations(namespace)`, a server component awaits `getTranslations(namespace)`, and metadata reads the `meta` namespace. Namespaces are one JSON file per language and namespace, and the file's content is the namespace body. The shared form copy is folded under every module's form node, so a domain form inherits common field, validation and action labels for free. Rebuild the messages package after editing JSON.

The sellable blocks, form, grid and filter, read copy through a resolver over a context instead, so they depend on no translation library.

## House style

* Brace every control-flow body, including single statements.
* No nested conditionals in JSX and no local render closure. Render-state selection is an early return from a component, one guard per state in priority order, then the populated JSX inline. A section with states of its own is its own component.

```tsx
function GridLegendListBody({ data, loading, error, empty }) {
  if (loading) {
    return <SkeletonGrid />;
  }
  if (error && data.length === 0) {
    return <GridError />;
  }
  if (data.length === 0) {
    return empty ?? <GridEmpty />;
  }
  return <LegendList data={data} />;
}
```

* A loading placeholder is the skeleton primitive, never a pulsing div of your own. A fallback is named for what it draws.
* Zero comments by default. Only a one-sentence non-obvious why survives: a constraint, a gotcha, a workaround.
* No manual memoisation. The React Compiler is on in every app; build context values as plain object literals.
* Accessor hooks throw on a missing provider.

## Traps

| Symptom                          | Cause                                                                                                                   |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Form crashes on mount            | The body rendered outside its page or modal.                                                                            |
| Filter sidebar crashes           | A filters provider without a grid persistence provider above it.                                                        |
| Tab panel shows stale content    | The tabs primitive never unmounts a panel. Make the tabs controlled and render only the active one.                     |
| Tree renders empty with no error | The tree port breaks under the compiler; opt the consumer out of memoisation.                                           |
| Every toast fires twice, or none | The auth UI mounts its own toaster. Render one bare toaster per app root.                                               |
| Modal renders nothing            | The surface modal returns nothing in the page variant; the launcher must not mount under the route that is the surface. |
| Link inside a button misbehaves  | Render the button as the link with the native-button flag off.                                                          |
| Grid has zero height             | A broken `min-h-0 flex-1` chain above it.                                                                               |
