Fill frontend rule gaps and broaden v2 tokens

Add the frontend guides the v2 UI kit and compliance-portal need but
that the first rework left uncovered: forms, routing, client state, and
permission-gated UI.

forms.md documents a tiered approach on Base UI Field/Form -- native
constraints, then a validate function, then zod parsed in onSubmit, and
react-hook-form only for large or dynamic forms -- and drops the custom
useFormWithSchema wrapper. routing.md covers @probo/routes, navigation,
typed params, URL-as-state, redirects, auth/protected routes, and the
folded-in no-outlet-context rule. state-management.md gives a decision
order across Relay, URL, local state, context, and zustand.
permissions.md gates UI on the canUpdate/canDelete permission(action:)
fields without re-encoding authorization in the client.

Rename v2-colors.md to v2-tokens.md and add the typography, radius,
shadow, and native-spacing scales alongside color. Extend ui.md with
user feedback, empty-state, and accessibility sections; standardize
toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy
useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an
Intl formatting section to i18n.md and a non-Relay HTTP / file
upload-download section to ts-style.md. Update the AGENTS.md index and
the v2-color-scale cursor rule for the new and renamed guides.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-23 19:01:38 +02:00
parent c158eb9be4
commit 393c538de1
15 changed files with 836 additions and 32 deletions

View File

@@ -16,4 +16,4 @@ Use the Radix 12-step numbered scale for all colors in v2 components: `sand`, `g
- Do not mix v1 and v2 color classes in the same component
- Respect step ranges: 12 backgrounds, 35 component bg, 68 borders, 910 solid bg, 1112 text
Full guide: `contrib/claude/v2-colors.md`
Full guide: `contrib/claude/v2-tokens.md`

View File

@@ -5,7 +5,7 @@ Detailed guides for specific subsystems live in `contrib/claude/`:
- [`contrib/claude/make.md`](contrib/claude/make.md) — GNUmakefile targets, codegen, overridable variables
- [`contrib/claude/api-surface.md`](contrib/claude/api-surface.md) — GraphQL / MCP / CLI / n8n sync rules
- [`contrib/claude/go-style.md`](contrib/claude/go-style.md) — Go project deps, style (declarations, calls, imports, errors, naming, logging, safe URL construction)
- [`contrib/claude/ts-style.md`](contrib/claude/ts-style.md) — TypeScript style (safe URL construction)
- [`contrib/claude/ts-style.md`](contrib/claude/ts-style.md) — TypeScript style (safe URL construction, non-Relay HTTP, file upload/download)
- [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)
- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation)
- [`contrib/claude/go-worker.md`](contrib/claude/go-worker.md) — Go worker pattern (poll-based, bounded concurrency, FOR UPDATE SKIP LOCKED)
@@ -24,9 +24,13 @@ Detailed guides for specific subsystems live in `contrib/claude/`:
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
- [`contrib/claude/react-components.md`](contrib/claude/react-components.md) — React component shape (file/export, props, configure vs data via hooks, naming/suffix taxonomy, error props)
- [`contrib/claude/ui.md`](contrib/claude/ui.md) — @probo/ui v2 kit (Base UI headless, Tailwind, tailwind-variants, flat folders, bundle-safe skeletons)
- [`contrib/claude/v2-colors.md`](contrib/claude/v2-colors.md) — v2 color system (Radix scale, step usage, do/don't)
- [`contrib/claude/v2-tokens.md`](contrib/claude/v2-tokens.md) — v2 design tokens (color, typography, radius, shadow scales; native spacing)
- [`contrib/claude/forms.md`](contrib/claude/forms.md) — Frontend forms (Base UI Field/Form tiers, native vs zod vs react-hook-form, server errors)
- [`contrib/claude/routing.md`](contrib/claude/routing.md) — Frontend routing (@probo/routes, navigation, typed params, URL state, auth/protected routes)
- [`contrib/claude/state-management.md`](contrib/claude/state-management.md) — Client state decision order (Relay, URL, local, context, zustand)
- [`contrib/claude/permissions.md`](contrib/claude/permissions.md) — Permission-gated UI (canUpdate/canDelete permission(action:) fields)
- [`contrib/claude/error-handling.md`](contrib/claude/error-handling.md) — Frontend error handling (ErrorBoundary at any level, error/fallback props, async try/catch)
- [`contrib/claude/i18n.md`](contrib/claude/i18n.md) — Frontend i18next (key-based translations, _locales/<locale>.json per routes.ts)
- [`contrib/claude/i18n.md`](contrib/claude/i18n.md) — Frontend i18next (key-based translations, _locales/<locale>.json per routes.ts, Intl formatting)
- [`contrib/claude/config.md`](contrib/claude/config.md) — Configuration propagation (all files to update when config changes)
- [`contrib/claude/file-naming.md`](contrib/claude/file-naming.md) — File naming conventions (template files, extensions)
- [`contrib/claude/prompt-style.md`](contrib/claude/prompt-style.md) — Agent prompt template structure (role/task/instructions XML style)

View File

@@ -120,27 +120,27 @@ Boundaries will not catch a rejected promise in a submit handler. Wrap the risky
```tsx
// Good — async event handler guards itself; the boundary above can't help here
function PublishButton() {
const { __ } = useTranslate();
const { toast } = useToast();
const { t } = useTranslation();
const toast = Toast.useToastManager();
const [isPublishing, setIsPublishing] = useState(false);
async function onPublish() {
setIsPublishing(true);
try {
await publishReport();
toast({ title: __("Published"), variant: "success" });
toast.add({ title: t("reports.published"), type: "success" });
} catch (error) {
toast({
title: __("Publish failed"),
description: error instanceof Error ? error.message : __("Unknown error"),
variant: "error",
toast.add({
title: t("reports.publishFailed"),
description: error instanceof Error ? error.message : t("common.unknownError"),
type: "error",
});
} finally {
setIsPublishing(false);
}
}
return <Button disabled={isPublishing} onClick={onPublish}>{__("Publish")}</Button>;
return <Button disabled={isPublishing} onClick={onPublish}>{t("reports.publish")}</Button>;
}
```

205
contrib/claude/forms.md Normal file
View File

@@ -0,0 +1,205 @@
# Forms and validation
Forms in the v2 apps are built on **Base UI** `Form` + `Field`, which extend the native [Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation). The guiding rule matches the rest of the UI kit: **reach for the lightest tier that does the job**, and don't pile a form abstraction on top of the headless library when native validation already covers the case.
There is **no custom form hook.** The legacy `useFormWithSchema` (react-hook-form + zod wrapper) is not used in v2 — it forces every form, however trivial, onto react-hook-form + zod and hides which validation tier you are actually in.
## Related guides
| Topic | Guide |
|-------|--------|
| Component shape, `*Form` / `*Field` suffixes | [`contrib/claude/react-components.md`](react-components.md) |
| Styling primitives, Base UI | [`contrib/claude/ui.md`](ui.md) |
| Mutations, server errors, toasts | [`contrib/claude/relay.md`](relay.md) |
| Error/fallback handling | [`contrib/claude/error-handling.md`](error-handling.md) |
| Translating labels and messages | [`contrib/claude/i18n.md`](i18n.md) |
## The four tiers
Pick the **lowest** tier that expresses your validation. Do **not** mix tiers within one form.
| Tier | Tool | Use when |
|------|------|----------|
| 1 | Base UI `Field` + **native constraints** | The default. Validation is expressible as `required`, `type`, `minLength`, `maxLength`, `min`, `max`, `pattern`. |
| 2 | Base UI `Field` + **`validate` function** | A rule native constraints can't express: cross-field (confirm password), conditional, async uniqueness checks. |
| 3 | **zod** schema parsed in `onSubmit`, mapped to `Form` `errors` | Validation is genuinely complex, needs specific custom messages, or you want one typed schema as the source of truth (possibly shared). **No react-hook-form.** |
| 4 | **react-hook-form** + `zodResolver` | Large/dynamic forms: many fields, field arrays, wizards, heavy conditional logic, or perf with many controlled inputs. |
Server-side errors map onto the `Form` `errors` prop in **every** tier (see [Server errors](#server-errors)).
## Tier 1 — native constraints (default)
Most forms need nothing more. Base UI validates native HTML constraints, and `<Field.Error>` renders the message. Use `match` to supply your own copy (and i18n) per validity state.
```tsx
import { Form } from "@base-ui-components/react/form";
import { Field } from "@base-ui-components/react/field";
import { useTranslation } from "react-i18next";
export function CreateMeasureForm({ onSubmit }: CreateMeasureFormProps) {
const { t } = useTranslation();
return (
<Form onSubmit={onSubmit}>
<Field.Root name="name">
<Field.Label>{t("measures.form.name")}</Field.Label>
<Field.Control required maxLength={120} />
<Field.Error match="valueMissing">{t("measures.form.nameRequired")}</Field.Error>
<Field.Error match="tooLong">{t("measures.form.nameTooLong")}</Field.Error>
</Field.Root>
<Button type="submit">{t("common.save")}</Button>
</Form>
);
}
```
> `Field.Control` renders a native input by default; pass a Base UI input/select/checkbox via `render` (or nest one) when you need a styled control. The kit's styled inputs live under `form/` (see [`ui.md`](ui.md)).
## Tier 2 — custom `validate`
When the rule isn't a native constraint, add a sync/async `validate` on `Field.Root`. It returns a message string (or array) when invalid, or `null` when valid, and runs after native constraints pass. Stay in Base UI — do not introduce a library for this.
```tsx
<Field.Root
name="confirmPassword"
validate={(value, formValues) =>
value === formValues.password ? null : t("auth.passwordsDoNotMatch")
}
>
<Field.Label>{t("auth.confirmPassword")}</Field.Label>
<Field.Control type="password" required />
<Field.Error />
</Field.Root>
```
Async `validate` (e.g. a uniqueness check) is supported; note Base UI does not block submit on a pending async validation in `validationMode="onSubmit"`.
## Tier 3 — zod, without react-hook-form
When validation is complex or needs specific messages, parse a zod schema in `onFormSubmit` and feed the flattened field errors to `Form`'s `errors` prop. This keeps a single typed schema as the source of truth **without** pulling in react-hook-form.
```tsx
import { Form } from "@base-ui-components/react/form";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "measures.form.nameRequired"),
weight: z.coerce.number().min(0).max(100),
});
export function CreateMeasureForm({ onValid }: CreateMeasureFormProps) {
const [errors, setErrors] = useState<Record<string, string | string[]>>({});
return (
<Form
errors={errors}
onClearErrors={setErrors}
onFormSubmit={(formData) => {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
setErrors(z.flattenError(result.error).fieldErrors);
return;
}
onValid(result.data);
}}
>
<Field.Root name="name">
<Field.Label>{t("measures.form.name")}</Field.Label>
<Field.Control />
<Field.Error />
</Field.Root>
{/* … */}
</Form>
);
}
```
Reserve zod for the cases above — a `z.object({ name: z.string().min(1) })` that only restates a `required` attribute belongs in tier 1.
## Tier 4 — react-hook-form
Only when the form is large or dynamic enough that react-hook-form's machinery pays for itself. Use `useForm` + `zodResolver` **directly** (no project wrapper hook), and wire Base UI `Field.Control` through `register` / `Controller`.
```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
```
If you find yourself reaching for tier 4 for an ordinary CRUD dialog, step back — it is almost always tier 1 or 3.
## Server errors
Validation that only the backend can perform (uniqueness, business rules — see [`validation.md`](validation.md) for the error codes the API returns) comes back from the mutation. Map it onto the `Form` `errors` prop keyed by field `name`; Base UI clears each entry when the field changes. Use `formatError` from `@probo/helpers` to turn a GraphQL error into a message.
```tsx
const toast = Toast.useToastManager();
const [createMeasure, isCreating] = useMutation<CreateMeasureMutation>(createMeasureMutation);
const [errors, setErrors] = useState<Record<string, string | string[]>>({});
function onValid(input: MeasureInput) {
createMeasure({
variables: { input, connections: [connectionId] },
onCompleted() {
toast.add({ title: t("measures.created"), type: "success" });
},
onError(error) {
// Field-specific server errors → the form; otherwise → a toast.
const fieldErrors = toFieldErrors(error);
if (fieldErrors) {
setErrors(fieldErrors);
} else {
toast.add({
title: t("common.error"),
description: formatError(t("measures.createFailed"), error as GraphQLError),
type: "error",
});
}
},
});
}
```
- **Field-level** server errors → `Form` `errors`.
- **Whole-form / unexpected** errors → a toast (see [`error-handling.md`](error-handling.md) and [`ui.md`](ui.md)).
- Never rely on a page reload to surface a failed submit.
## Submit, loading, and disabled state
- Disable the submit control while the mutation is in flight (`isCreating` / `isUpdating` — see the mutation naming in [`relay.md`](relay.md)).
- Keep the form responsive: do not unmount fields on submit; let Base UI manage validity.
- For destructive submits, wrap in `useConfirm` (see [`relay.md`](relay.md)).
## Dialogs
Forms in dialogs use the Base UI dialog directly — controlled with `open` / `onOpenChange`. **Do not** use the legacy `useDialogRef` / imperative `open()` pattern (see [`ui.md`](ui.md#headless-primitives-style-dont-re-implement)).
```tsx
// Bad — imperative ref to open a form dialog (legacy console pattern)
const ref = useDialogRef();
ref.current?.open();
// Good — controlled open state, same API as the Base UI dialog
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}>{/* … <CreateMeasureForm /> … */}</Dialog>
```
## Do / don't summary
```text
// Bad
- Custom useFormWithSchema wrapper around every form
- zod schema that only restates `required` / `maxLength`
- react-hook-form for a two-field dialog
- Mixing native constraints and a zod parse in the same form
- Opening a form dialog through an imperative ref
// Good
- Native Base UI constraints by default
- `validate` for cross-field / async rules
- zod (no RHF) when validation is genuinely complex
- react-hook-form only for large / dynamic forms
- Server errors mapped onto the Form `errors` prop
```

View File

@@ -96,6 +96,28 @@ Never assemble translated sentences by concatenation — it breaks word order in
t("measures.count", { count });
```
## Formatting dates, numbers, and currency
Locale-aware formatting is **presentation** and must follow the active locale — never hand-format with string templates or hardcoded separators. Use i18next's [`Intl`-based formatting](https://www.i18next.com/translation-function/formatting) (which wraps `Intl.DateTimeFormat` / `Intl.NumberFormat` with the current language) or `Intl` directly when outside a translation string.
```tsx
// i18next interpolation formatters — locale comes from the active language
t("measures.updatedAt", { date: updatedAt, formatParams: { date: { dateStyle: "medium" } } });
t("invoice.total", { amount, val: amount, formatParams: { val: { style: "currency", currency: "EUR" } } });
// Outside a translation string — Intl directly with the active locale
const { i18n } = useTranslation();
new Intl.NumberFormat(i18n.language, { style: "percent" }).format(ratio);
```
```tsx
// Bad — hand-rolled, locale-blind formatting
`${(ratio * 100).toFixed(0)}%`;
`${day}/${month}/${year}`;
```
Reuse `@probo/helpers` only for its **non-presentational** date utilities — parsing and `<input type="date">` shaping (`parseDate`, `toDateInput`, `todayAsDateInput`). Do not use its v1 `formatDate` / `formatDuration` helpers in v2 apps: they predate i18next and thread the legacy `@probo/i18n` `__` translator. Format for display through i18next / `Intl` instead.
## Loading catalogs
Catalogs are loaded into i18next per locale at app startup (or lazily per route). Because each `_locales/` folder maps to a resource segment, catalogs can be code-split alongside the route bundle that needs them — keep a catalog scoped to the feature it serves rather than one global megafile.

View File

@@ -0,0 +1,92 @@
# Permission-gated UI
Authorization is enforced on the **server** (see [`authorization.md`](authorization.md)). The frontend never decides what a user is *allowed* to do — it asks the API and renders accordingly. The API exposes per-record permissions through the `permission(action:)` field, which a component selects as a boolean alias (`canUpdate`, `canDelete`, …) on the node it renders.
This guide covers how to **consume** those booleans. It does not grant access; hiding a button is a UX nicety, not a security control — the mutation is still authorized server-side.
## Related guides
| Topic | Guide |
|-------|--------|
| Server-side IAM policies and actions | [`contrib/claude/authorization.md`](authorization.md) |
| Fragments, colocated data | [`contrib/claude/relay.md`](relay.md) |
| Component shape and props | [`contrib/claude/react-components.md`](react-components.md) |
## Select permissions in the fragment that needs them
A component that renders an action selects the matching permission **in its own fragment**, aliased to a `can…` boolean. Keep the action string identical to the IAM action it guards.
```tsx
const documentListItemFragment = graphql`
fragment DocumentListItem_document on Document {
id
title
canUpdate: permission(action: "core:document:update")
canDelete: permission(action: "core:document:delete")
}
`;
```
Colocate the permission with the action it gates — never drill a `canDelete` boolean down as a prop from a parent (the same data-as-props rule as everywhere else; see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)).
## Gate the action on the boolean
Read the boolean via `useFragment` and gate the control. Default to **hiding** an action the user cannot perform; **disable** (with an explanatory tooltip) only when the action's *absence* would be confusing.
```tsx
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
const document = useFragment(documentListItemFragment, documentKey);
return (
<Tr>
<Td>{document.title}</Td>
<Td>
{document.canUpdate && <EditDocumentDialog documentKey={document} />}
{document.canDelete && <DeleteDocumentButton documentId={document.id} />}
</Td>
</Tr>
);
}
```
### Hide vs. disable
```text
// Hide — the user has no business with this action (most cases)
{canDelete && <DeleteButton … />}
// Disable — the action is expected to be there, but is currently unavailable;
// pair with a tooltip explaining why
<Button disabled={!canPublish} title={!canPublish ? t("noPublishPermission") : undefined}>
{t("publish")}
</Button>
```
## Bulk / toolbar actions
For list toolbars, derive the aggregate from the items and hide the bulk control when no row qualifies.
```tsx
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
{canDeleteAny && <BulkDeleteButton ids={selection} />}
```
## Don't
```text
// Bad — client-side role check standing in for a server permission
if (currentUser.role === "ADMIN") { showDelete(); }
// Bad — drilling a permission boolean as a prop instead of selecting it where used
<DocumentListItem canDelete={doc.canDelete} />
// Bad — gating on a hand-rolled action string that drifts from the IAM action
permission(action: "document_delete") // must match "core:document:delete"
// Bad — treating a hidden button as the security boundary
// (the mutation must still be authorized server-side; UI gating is UX only)
```
## Why server-derived, not role-based
Roles are coarse and change; resource-level permissions answer the exact question the UI asks ("can *this* user act on *this* record?"). Selecting `permission(action:)` keeps the frontend in lockstep with the IAM policies in [`authorization.md`](authorization.md) without re-encoding any authorization logic in the client.

View File

@@ -13,6 +13,10 @@ These rules are the **source of truth**. Where existing code (e.g. `apps/console
| App folder layout, route segments, special folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
| Error boundaries, error/fallback props, async `try`/`catch` | [`contrib/claude/error-handling.md`](error-handling.md) |
| i18next, `_locales`, translation keys | [`contrib/claude/i18n.md`](i18n.md) |
| Forms and validation (Base UI Field/Form, zod, react-hook-form) | [`contrib/claude/forms.md`](forms.md) |
| Routing, navigation, URL state, auth | [`contrib/claude/routing.md`](routing.md) |
| Client state (Relay / URL / local / context / zustand) | [`contrib/claude/state-management.md`](state-management.md) |
| Permission-gated UI | [`contrib/claude/permissions.md`](permissions.md) |
## Destructuring

View File

@@ -210,6 +210,8 @@ function ContactListItem(props: { contactKey: ContactListItem_contactFragment$ke
}
```
Select `permission(action:)` fields (aliased `canUpdate` / `canDelete`) in the fragment of the component that renders the action, and gate the UI on the resulting boolean. See [`contrib/claude/permissions.md`](permissions.md).
### Refetchable fragments
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
@@ -297,30 +299,29 @@ createCookieBanner({ variables: { ... } });
const [deleteThirdParty] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
```
For mutations with user feedback, combine with `useToast` and use `onCompleted`/`onError` callbacks:
For mutations with user feedback, queue a toast with Base UI's toast manager (`Toast.useToastManager()`; see [`ui.md`](ui.md#user-feedback-toasts)) from the `onCompleted` / `onError` callbacks:
```tsx
const { toast } = useToast();
const toast = Toast.useToastManager();
const [createObligation, isCreating] = useMutation<CreateObligationMutation>(createObligationMutation);
const onSubmit = (formData: FormData) => {
const onSubmit = (input: ObligationInput) => {
createObligation({
variables: {
input: { ...formData },
input,
connections: [connectionId],
},
onCompleted() {
toast({
title: __("Success"),
description: __("Obligation created successfully"),
variant: "success",
toast.add({
title: t("obligations.created"),
type: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create obligation"), error as GraphQLError),
variant: "error",
toast.add({
title: t("common.error"),
description: formatError(t("obligations.createFailed"), error as GraphQLError),
type: "error",
});
},
});
@@ -329,7 +330,7 @@ const onSubmit = (formData: FormData) => {
### `useMutationWithToasts` (deprecated)
**Do not use.** Use `useMutation` combined with `useToast` instead.
**Do not use.** Use `useMutation` and queue feedback with Base UI's toast manager (see [`ui.md`](ui.md#user-feedback-toasts)).
### `promisifyMutation` (deprecated)

168
contrib/claude/routing.md Normal file
View File

@@ -0,0 +1,168 @@
# Routing, navigation, and auth
Probo frontends route with [React Router](https://reactrouter.com/) (`react-router` v8), wrapped by the **`@probo/routes`** helpers and lazy-loaded with **`@probo/react-lazy`**. This guide covers how routes are declared, how to navigate and read params, how to use the URL as state, and how authenticated/protected routes are composed. Folder placement of route files is covered in [`app-arborescence.md`](app-arborescence.md); this guide is about the routing API itself.
## Related guides
| Topic | Guide |
|-------|--------|
| Where `routes.ts` lives, route-segment folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
| Loaders, `queryRef`, preloading | [`contrib/claude/relay.md`](relay.md) |
| Route error boundaries | [`contrib/claude/error-handling.md`](error-handling.md) |
| Permission-gated UI within a route | [`contrib/claude/permissions.md`](permissions.md) |
## `AppRoute` and the route tree
Routes are declared as `AppRoute[]` and converted with `routeFromAppRoute` before being handed to `createBrowserRouter`. `AppRoute` extends React Router's `RouteObject` with a `Fallback` component; `routeFromAppRoute` wraps the `Component` in a `Suspense` boundary using that `Fallback` automatically.
```ts
// routes.ts — one per resource folder (see app-arborescence.md)
import { lazy } from "@probo/react-lazy";
import { type AppRoute } from "@probo/routes";
import { MeasuresPageSkeleton } from "./MeasuresPageSkeleton";
export const measureRoutes = [
{
path: "measures",
Fallback: MeasuresPageSkeleton,
Component: lazy(() => import("./MeasuresPageLoader")),
},
{
path: "measures/:measureId",
Component: lazy(() => import("./MeasureDetailLayoutLoader")),
children: [
{ path: "overview", Component: lazy(() => import("./overview/MeasureOverviewPage")) },
],
},
] satisfies AppRoute[];
```
The app root spreads each resource's routes and maps them once:
```tsx
import { routeFromAppRoute } from "@probo/routes";
import { measureRoutes } from "./pages/organizations/measures/routes";
const routes = [
{ path: "/", Component: lazy(() => import("./pages/MainLayout")), children: [...measureRoutes] },
] satisfies AppRoute[];
export const router = createBrowserRouter(routes.map(routeFromAppRoute));
```
Rules:
- Routes declare only `path`, `Fallback`, `Component` (a lazy loader), `ErrorBoundary`, and `children`**no Relay logic in the route object** (that lives in the `*Loader`; see [`relay.md`](relay.md)).
- Use `lazy()` from `@probo/react-lazy` for the `Component` so every page is code-split.
- `Fallback` is the route-level skeleton; reuse the page's `*Skeleton`.
## Navigation
Navigate **declaratively** with `Link` for anything the user clicks, and **imperatively** with `useNavigate` only after an effect (e.g. post-submit redirect).
```tsx
import { Link, useNavigate } from "react-router";
// Declarative — preferred
<Link to={`measures/${measureId}`}>{t("measures.view")}</Link>
// Imperative — only when navigation follows an action
const navigate = useNavigate();
navigate(`measures/${newId}`);
```
Build paths from segments; never hand-concatenate query strings (see [`ts-style.md`](ts-style.md) — use `URL` / `URLSearchParams`).
## Route params
Read params with `useParams` **inside the component that needs them** — do not drill them as props from a parent that only read the URL to pass them down (see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)). Params are always `string | undefined`; narrow before use.
```tsx
const { measureId } = useParams<{ measureId: string }>();
if (measureId == null) {
return null;
}
```
Prefer a small dedicated hook (`useOrganizationId()`-style) when the same param is read across many components.
## URL as state (search params)
State that should survive reload, be shareable, or be linkable — the active tab, a filter, a sort, a search term, pagination cursors — belongs in the **URL**, not `useState`. Use `useSearchParams`.
```tsx
import { useSearchParams } from "react-router";
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get("status") ?? "OPEN";
function onStatusChange(next: string) {
setSearchParams((prev) => {
prev.set("status", next);
return prev;
});
}
```
See [`state-management.md`](state-management.md) for when to choose the URL over local/global state.
## Redirects
Redirect from a route `loader` (throwing `redirect`) for canonical/index redirects, and with `<Navigate>` for render-time redirects (e.g. role-based landing).
```tsx
// Index redirect from a loader
{ index: true, loader: () => { throw redirect("general"); } }
// Render-time redirect
<Navigate to="login" replace />
```
## Auth and protected routes
Authentication state lives in a **provider** near the root (a viewer / current-user context), not in route objects. Protected subtrees are composed by nesting routes under a layout/provider that loads the viewer; unauthenticated access is handled by the **route error boundary**, which redirects to login.
```tsx
// RootErrorBoundary — redirect to login on an auth error, render the error page otherwise
export function RootErrorBoundary() {
const error = useRouteError();
if (error instanceof UnAuthenticatedError) {
const search = new URLSearchParams({ continue: window.location.href });
return <Navigate to={{ pathname: "/auth/login", search: `?${search}` }} />;
}
return <PageError error={error instanceof Error ? error : new Error("unknown error")} />;
}
```
```tsx
// Role-based landing — redirect at render time based on the viewer's role
function OrganizationIndex() {
const { role } = use(CurrentUser);
switch (role) {
case Role.EMPLOYEE: return <Navigate to="employee" />;
case Role.AUDITOR: return <Navigate to="measures" />;
default: return <Navigate to="tasks" />;
}
}
```
Rules:
- The **server** authorizes every request; the client redirects on `UnAuthenticatedError` purely for UX.
- Per-action gating inside an authenticated page uses `permission(action:)` booleans (see [`permissions.md`](permissions.md)), not role checks.
- Attach `ErrorBoundary` at the boundary you want auth failures to bubble to (root for whole-app, a section boundary for embedded widgets — see [`error-handling.md`](error-handling.md)).
## No domain data through `Outlet` context
A layout **must not** pass fetched domain data to child routes via `useOutletContext`. Each child page that needs data follows the Loader + Page pattern with its **own** query — the same as a sibling page. This keeps every page independently loadable and avoids hidden coupling to the parent's query.
```tsx
// Bad — layout fetches and forwards domain data through Outlet context
<Outlet context={{ showBranding: banner.showBranding }} />;
const { showBranding } = useOutletContext<{ showBranding: boolean }>();
// Good — the child page owns its loader + query (see relay.md)
const [queryRef, loadQuery] = useQueryLoader(snippetPageQuery);
useEffect(() => { loadQuery({ cookieBannerId }); }, [loadQuery, cookieBannerId]);
```
`Outlet` context is fine for **non-domain** UI coordination (a layout-owned callback, a `ref`), never for loaded entities or URL ids a child can read itself.

View File

@@ -0,0 +1,73 @@
# Client state management
Probo frontends have several places state can live. Choosing the wrong one is the most common source of avoidable complexity: data drilled through props, local state that should have been in the URL, or a global store holding what is really server data. This guide is a **decision order** — start at the top and stop at the first option that fits.
## Related guides
| Topic | Guide |
|-------|--------|
| Server data (queries, fragments, mutations, store) | [`contrib/claude/relay.md`](relay.md) |
| URL/search params, navigation | [`contrib/claude/routing.md`](routing.md) |
| Props are configuration, not data | [`contrib/claude/react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data) |
## Decision order
1. **Server data → Relay.** Anything fetched from the API lives in the Relay store, read via `useFragment` / `usePreloadedQuery`, and mutated with `useMutation` that updates the store. Never copy server data into `useState` to "manage" it. (See [`relay.md`](relay.md).)
2. **Shareable / linkable / reload-surviving UI state → the URL.** Active tab, filters, sort, search query, pagination cursor, selected id in a master-detail view. Use `useSearchParams` / route params (see [`routing.md`](routing.md)). If a teammate should be able to paste the link and see the same view, it belongs here.
3. **Local component state → `useState` / `useReducer`.** Ephemeral, view-only state scoped to one component or a small subtree: an input's draft value, whether a menu is open, a hover flag. Keep it as local as possible.
4. **Shared ephemeral state across a subtree → React context.** When a handful of nearby components need the same ephemeral state and lifting to a common parent is clean (e.g. a wizard step, a selection set within a list). Context carries the state + setters; it does **not** carry server data.
5. **App-wide ephemeral client state → `zustand`.** Only for genuinely global, non-server, non-URL state that many unrelated parts of the app read/write: theme / dark-mode preference, a command palette, global toasts. Reach for it last.
```mermaid
flowchart TD
start[Need to hold some state] --> server{From the API?}
server -->|Yes| relay[Relay store]
server -->|No| url{"Shareable / linkable / survive reload?"}
url -->|Yes| search[URL: useSearchParams or route params]
url -->|No| scope{Used by one component or a small subtree?}
scope -->|One subtree| local[useState / useReducer]
scope -->|A few nearby components| ctx[React context]
scope -->|Many unrelated places| zustand[zustand store]
```
## Do / don't
### Server data stays in Relay
```tsx
// Bad — copying fetched data into local state to "edit" it
const data = usePreloadedQuery(query, queryRef);
const [measures, setMeasures] = useState(data.measures);
// Good — read from Relay; mutate through useMutation (store updates flow back)
const data = usePreloadedQuery(query, queryRef);
```
### Tab / filter belong in the URL
```tsx
// Bad — active tab in local state; lost on reload, not linkable
const [tab, setTab] = useState<"open" | "closed">("open");
// Good — tab in the URL
const [params, setParams] = useSearchParams();
const tab = params.get("tab") ?? "open";
```
### Don't reach for a global store by default
```tsx
// Bad — a zustand store for state two sibling components share
useFilterStore(); // global singleton for a local concern
// Good — lift to the nearest common parent (state + context if the tree is deep)
```
`zustand` is available (it's a `@probo/ui` dependency) — use it deliberately for app-wide ephemeral state, not as a shortcut around prop-passing or the URL.
## Anti-patterns to avoid
- **Prop-drilling data** that a child could read from Relay (`useFragment`) or the router (`useParams`) itself.
- **Mirroring** a controlled value (e.g. a dialog's `open`, a fragment field) into `useState` + `useEffect` to keep them in sync — pass the source through instead.
- **Global store as a cache** for server data — that's Relay's job.
- **Outlet context for domain data** — forbidden; see [`routing.md`](routing.md).

View File

@@ -1,5 +1,12 @@
# TypeScript Style
## Related guides
| Topic | Guide |
|-------|--------|
| GraphQL data (the default for app data) | [`contrib/claude/relay.md`](relay.md) |
| Forms and file inputs | [`contrib/claude/forms.md`](forms.md) |
## URL and query parameter construction
**Never** build URLs with template literals, string concatenation, or string formatting. Always use the `URL` and `URLSearchParams` APIs.
@@ -30,3 +37,39 @@ params.set("domain", domain);
params.set("limit", "100");
const qs = params.toString();
```
## Non-Relay HTTP
Application data is GraphQL via Relay (see [`contrib/claude/relay.md`](relay.md)) — that is the default and covers almost everything. Reach for `fetch` only for the cases GraphQL does not handle: binary uploads/downloads, REST endpoints exposed by the backend, and health/probe calls.
Rules:
- Build the endpoint with `URL` (see above). Derive the host/path from app config (e.g. `import.meta.env.VITE_API_URL`, a `pathPrefix` helper), never a hardcoded string.
- Use `fetch` directly; do not add an HTTP client dependency for a handful of calls.
- Always check `response.ok` and throw a typed error on failure so it can be caught (`try`/`catch` in the handler — see [`contrib/claude/error-handling.md`](error-handling.md)) and surfaced via a toast.
- Send credentials/auth the same way the Relay environment does (e.g. `credentials: "include"`); do not invent a second auth scheme.
```ts
// Good — URL-built endpoint, ok check, typed failure
const url = new URL(buildEndpoint());
url.pathname = `/api/console/v1/documents/${encodeURIComponent(documentId)}/download`;
const response = await fetch(url, { credentials: "include" });
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const blob = await response.blob();
```
### File upload / download
- **Uploads:** collect the file with `react-dropzone` (already a `@probo/ui` dependency) or a native `<input type="file">`, then send a `FormData` body via `fetch` (or the GraphQL upload mechanism if the schema exposes one). Validate type/size client-side with the `@probo/helpers` `fileAccept` helpers before sending; the server still validates.
- **Downloads:** prefer a direct link to a backend URL when the endpoint streams a file; use `fetch` + `blob` only when you must read the bytes (e.g. to rename or post-process). Revoke any `URL.createObjectURL` you create.
```ts
// Bad — template-literal URL, no ok check, ad-hoc auth header
const res = await fetch(`${base}/upload?id=${id}`, {
headers: { Authorization: "Bearer " + token },
body: file,
});
```

View File

@@ -9,7 +9,7 @@ These rules are the **source of truth**. The legacy tree (`Atoms/`, `Molecules/`
| Topic | Guide |
|-------|--------|
| Component shape, props, naming/suffixes | [`contrib/claude/react-components.md`](react-components.md) |
| v2 color system (Radix scale) | [`contrib/claude/v2-colors.md`](v2-colors.md) |
| v2 design tokens (color, type, radius, shadow, 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) |
@@ -18,7 +18,7 @@ These rules are the **source of truth**. The legacy tree (`Atoms/`, `Molecules/`
| Item | Convention |
|------|------------|
| Package | **`@probo/ui`** — v2 components under `src/v2`. Apps opt into v2 by importing the v2 theme (see [`v2-colors.md`](v2-colors.md)). |
| 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`** only — `import { tv } from "tailwind-variants"`. |
| Class composition | **Do not use `clsx` or `tailwind-merge`.** All conditional styling goes through `tv` variants and slots. |
@@ -417,3 +417,92 @@ const imageCard = tv({ slots: { shell: "...", image: "...", text: "..." } });
// ImageCardShell.tsx — import { imageCard } from "./variants"
// ImageCardSkeleton.tsx — import { imageCard } from "./variants"
```
## User feedback (toasts)
Transient feedback for an action's outcome uses **Base UI's Toast** (`@base-ui-components/react/toast`) — never `alert`, a hand-rolled banner, or a `console.log`. As with every other primitive, we **style** Base UI's toast; we do not build our own toast system. The legacy kit `useToast` / `Toaster` is non-compliant and is being removed — do not use it in v2.
The kit exposes a styled **`Toaster`** (a `Toast.Portal` + `Toast.Viewport` rendering styled `Toast.Root`s, keyed off each toast's `type`). Mount Base UI's `Toast.Provider` and the `Toaster` **once** at the app root; everything else queues toasts through Base UI's manager.
```tsx
// app root — Base UI provider + the kit's styled viewport, mounted once
import { Toast } from "@base-ui-components/react/toast";
import { Toaster } from "@probo/ui";
<Toast.Provider>
<App />
<Toaster />
</Toast.Provider>
```
Queue a toast with `Toast.useToastManager().add(...)` — the same API Base UI exposes. Use `type` to drive the styled variant.
```tsx
import { Toast } from "@base-ui-components/react/toast";
function CreateMeasureButton() {
const toast = Toast.useToastManager();
const { t } = useTranslation();
const [createMeasure] = useMutation<CreateMeasureMutation>(createMeasureMutation);
function onCreate() {
createMeasure({
variables: { input, connections: [connectionId] },
onCompleted() {
toast.add({ title: t("measures.created"), type: "success" });
},
onError(error) {
toast.add({
title: t("common.error"),
description: formatError(t("measures.createFailed"), error as GraphQLError),
type: "error",
});
},
});
}
// …
}
```
For code outside the React tree, create a global manager with `Toast.createToastManager()` and pass it to `Toast.Provider` via `toastManager` — still the same renderer.
Choose **toast vs. inline** by where the message belongs:
- **Toast** — the result of an action not tied to a specific field: a successful save, a delete, an unexpected mutation failure.
- **Inline** — validation tied to a field or region: render it in `Field.Error` (see [`forms.md`](forms.md)) or a section's error UI (see [`error-handling.md`](error-handling.md)), not a toast.
```tsx
// Bad — the legacy kit hook (removed in v2)
const { toast } = useToast();
toast({ title: "Saved", variant: "success" });
// Bad — field validation surfaced as a toast (belongs inline on the field)
toast.add({ title: "Name is required", type: "error" });
// Bad — browser alert / ad-hoc UI for feedback
alert("Saved!");
```
## Empty states
A `*List` (or any collection region) renders an **empty state** when it has no items — never a blank gap. Empty states are part of the component, not an afterthought, and follow the `*Empty` suffix when extracted (see [`react-components.md`](react-components.md#naming-and-suffixes)).
An empty state has: an icon (phosphor — never emoji), a short heading, optional one-line guidance, and, when the user can act, the primary call to action (gated by permission — see [`permissions.md`](permissions.md)).
```tsx
// Good — collection renders its own empty state
{measures.length === 0
? <MeasuresEmpty canCreate={canCreate} />
: measures.map((m) => <MeasureListItem key={m.id} measureKey={m} />)}
```
Distinguish empty (no data) from loading (`*Skeleton`) from error (`*Error`) — they are three different states, not one.
## Accessibility
Base UI primitives ship correct roles, focus management, and keyboard interaction — **do not re-implement or override them.** Our job is to keep that behavior intact while styling:
- Keep accessible labels: every control has a visible label or an `aria-label`; icon-only buttons (`Button icon={…}`) require an `aria-label`.
- Don't strip `aria-*` / `role` that primitives set, and don't trap or override focus the primitive manages.
- Convey state with more than color (e.g. an icon + text alongside a `red-*` tone), so meaning survives for color-blind users — the [token contrast guarantees](v2-tokens.md#contrast-guarantees) cover text legibility, not state encoding.
- Use semantic elements (`<button>`, `<a>`, `<nav>`, headings) — see the [Button vs Anchor vs Link](#no-structure-changing-variants) split.

View File

@@ -1,9 +1,23 @@
# v2 color system (Radix scale)
# v2 design tokens
The v2 UI kit is built on a small set of **numbered token scales** sourced from Radix and the "Probo Radix UI" Figma file. Every token family follows the same convention as color — a numbered scale exposed as Tailwind utilities — so the kit speaks one consistent visual language: `bg-sand-3`, `text-4`, `rounded-3`, `shadow-2`.
Theme entry: [`packages/ui/src/v2/theme.css`](../../packages/ui/src/v2/theme.css), aggregating `theme/colors.css`, `theme/typography.css`, `theme/radius.css`, `theme/shadows.css`.
| Family | Utilities | Steps | Source |
|--------|-----------|-------|--------|
| Color | `bg-/text-/border-<hue>-<112>` | 12 | Radix Colors |
| Typography | `text-<19>` (+ font weights) | 9 | Radix type scale |
| Radius | `rounded-<16>` | 6 | Radix "Medium" radius |
| Shadow | `shadow-<16>`, `inset-shadow-<13>` | 6 / 3 | Radix elevation |
| Spacing | Tailwind native (`p-4`, `gap-2`, …) | — | not tokenized |
Each `--<family>-*` is reset to `initial` in its theme layer, so a v2 build exposes **only** the numbered scales — Tailwind's default palette, t-shirt type sizes, and `shadow-sm/md/lg` are intentionally unavailable. Use the numbered token; never reintroduce an ad-hoc value.
# Colors (Radix scale)
The v2 UI kit uses [Radix Colors](https://www.radix-ui.com/colors) 12-step scales as its color primitive. Each hue provides 12 numbered steps designed for specific use cases. Components consume these through Tailwind utility classes (`bg-sand-3`, `text-red-11`, `border-gold-7`, …).
Theme file: [`packages/ui/src/v2/theme.css`](../../packages/ui/src/v2/theme.css)
## Available scales
| Scale | Role |
@@ -163,3 +177,92 @@ Steps 9 and 10 are designed for prominent, solid-color backgrounds (primary butt
// Good — amber badge with dark text (amber 9-10 are light/bright)
<span className="bg-amber-9 text-amber-12">Warning</span>
```
# Typography (`text-1``text-9`)
The type scale mirrors the color scale: numbered steps, not t-shirt sizes. Each `text-<n>` utility carries its paired font-size, line-height, and letter-spacing.
Theme file: [`packages/ui/src/v2/theme/typography.css`](../../packages/ui/src/v2/theme/typography.css)
| Step | Size | Typical use |
|------|------|-------------|
| 1 | 12px | Fine print, captions, metadata |
| 2 | 14px | Secondary / dense body, table cells |
| 3 | 16px | Body default |
| 4 | 18px | Lead paragraph, small headings |
| 5 | 20px | Section heading |
| 6 | 24px | Page heading |
| 7 | 28px | Large heading |
| 8 | 35px | Display |
| 9 | 60px | Hero |
Font family is **Inter Variable** (`font-sans`); mono is a system stack (`font-mono`). Weights: `font-light` (300), `font-normal` (400), `font-medium` (500), `font-bold` (700).
```tsx
// Good — numbered type step + weight + color step
<h1 className="text-6 font-medium text-sand-12">Measures</h1>
<p className="text-3 text-sand-11">Description</p>
// Bad — Tailwind default size (wiped in v2) or arbitrary value
<h1 className="text-2xl">Measures</h1>
<p className="text-[15px]">Description</p>
```
# Radius (`rounded-1``rounded-6`)
Numbered radius scale (Radix "Medium" set). The static `rounded-none` / `rounded-full` utilities still work; the numeric ramp replaces Tailwind's `rounded-sm/md/lg`.
Theme file: [`packages/ui/src/v2/theme/radius.css`](../../packages/ui/src/v2/theme/radius.css)
| Step | Value | Typical use |
|------|-------|-------------|
| 1 | 3px | Subtle rounding (chips, small inputs) |
| 2 | 4px | Inputs, small buttons |
| 3 | 6px | Buttons, list items |
| 4 | 8px | Cards, dialogs |
| 5 | 12px | Large surfaces |
| 6 | 16px | Hero panels, modals |
```tsx
// Good — numbered radius
<div className="rounded-4 bg-sand-2">…</div>
// Bad — Tailwind default radius (wiped) or arbitrary value
<div className="rounded-lg">…</div>
<div className="rounded-[10px]">…</div>
```
# Shadows (`shadow-1``shadow-6`, `inset-shadow-1``inset-shadow-3`)
Drop-shadow elevation ramp, sand-tinted so it adapts to dark mode automatically (built from alpha tokens that flip light↔dark). Inset shadows live in the separate `inset-shadow-*` slot.
Theme file: [`packages/ui/src/v2/theme/shadows.css`](../../packages/ui/src/v2/theme/shadows.css)
| Step | Typical use |
|------|-------------|
| 1 | Hairline lift (resting cards) |
| 2 | Raised cards, inputs |
| 3 | Dropdowns, popovers |
| 4 | Dialogs |
| 5 | Large overlays |
| 6 | Highest elevation (modals over modals) |
```tsx
// Good — numbered elevation; dark mode is automatic
<div className="rounded-4 bg-sand-2 shadow-2">…</div>
// Bad — Tailwind default shadow (wiped) or a manual dark: override
<div className="shadow-md dark:shadow-none">…</div>
```
# Spacing (Tailwind native)
Spacing is **not** tokenized — use Tailwind's native spacing scale (`p-4`, `gap-2`, `mt-6`, `size-8`). Do not invent a numbered spacing scale or use arbitrary pixel values where a native step fits.
```tsx
// Good — native spacing scale
<div className="flex flex-col gap-3 p-4">…</div>
// Bad — arbitrary spacing where a native step exists
<div className="p-[15px] gap-[7px]">…</div>
```

View File

@@ -26,7 +26,7 @@
* shadows.css elevation scale (shadow-1…6)
*
* Spacing intentionally stays on Tailwind's native scale (not tokenized).
* See contrib/claude/v2-colors.md for the full usage guide.
* See contrib/claude/v2-tokens.md for the full usage guide.
*/
@import "./theme/colors.css";
@import "./theme/typography.css";

View File

@@ -44,7 +44,7 @@
* Dark mode: toggle .dark on <html>. Radix dark imports handle the rest.
* P3 wide gamut: included automatically via @radix-ui/colors @supports blocks.
*
* See contrib/claude/v2-colors.md for the full usage guide.
* See contrib/claude/v2-tokens.md for the full usage guide.
*/
/* ---------------------------------------------------------------------------