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:
@@ -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: 1–2 backgrounds, 3–5 component bg, 6–8 borders, 9–10 solid bg, 11–12 text
|
||||
|
||||
Full guide: `contrib/claude/v2-colors.md`
|
||||
Full guide: `contrib/claude/v2-tokens.md`
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -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)
|
||||
|
||||
@@ -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
205
contrib/claude/forms.md
Normal 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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
92
contrib/claude/permissions.md
Normal file
92
contrib/claude/permissions.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
168
contrib/claude/routing.md
Normal 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.
|
||||
73
contrib/claude/state-management.md
Normal file
73
contrib/claude/state-management.md
Normal 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).
|
||||
@@ -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,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>-<1–12>` | 12 | Radix Colors |
|
||||
| Typography | `text-<1–9>` (+ font weights) | 9 | Radix type scale |
|
||||
| Radius | `rounded-<1–6>` | 6 | Radix "Medium" radius |
|
||||
| Shadow | `shadow-<1–6>`, `inset-shadow-<1–3>` | 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>
|
||||
```
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user