diff --git a/.cursor/rules/v2-color-scale.mdc b/.cursor/rules/v2-color-scale.mdc index 9241445c0..33ab151eb 100644 --- a/.cursor/rules/v2-color-scale.mdc +++ b/.cursor/rules/v2-color-scale.mdc @@ -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` diff --git a/AGENTS.md b/AGENTS.md index 44782df94..86a2fc02f 100644 --- a/AGENTS.md +++ b/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/.json per routes.ts) +- [`contrib/claude/i18n.md`](contrib/claude/i18n.md) — Frontend i18next (key-based translations, _locales/.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) diff --git a/contrib/claude/error-handling.md b/contrib/claude/error-handling.md index 28f1ebd05..9eabe8abe 100644 --- a/contrib/claude/error-handling.md +++ b/contrib/claude/error-handling.md @@ -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 ; + return ; } ``` diff --git a/contrib/claude/forms.md b/contrib/claude/forms.md new file mode 100644 index 000000000..1441f001f --- /dev/null +++ b/contrib/claude/forms.md @@ -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 `` 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 ( +
+ + {t("measures.form.name")} + + {t("measures.form.nameRequired")} + {t("measures.form.nameTooLong")} + + +
+ ); +} +``` + +> `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 + + value === formValues.password ? null : t("auth.passwordsDoNotMatch") + } +> + {t("auth.confirmPassword")} + + + +``` + +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>({}); + + return ( +
{ + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + setErrors(z.flattenError(result.error).fieldErrors); + return; + } + onValid(result.data); + }} + > + + {t("measures.form.name")} + + + + {/* … */} +
+ ); +} +``` + +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); +const [errors, setErrors] = useState>({}); + +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); +{/* … … */} +``` + +## 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 +``` diff --git a/contrib/claude/i18n.md b/contrib/claude/i18n.md index 0428beeff..482f357ee 100644 --- a/contrib/claude/i18n.md +++ b/contrib/claude/i18n.md @@ -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 `` 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. diff --git a/contrib/claude/permissions.md b/contrib/claude/permissions.md new file mode 100644 index 000000000..bb00bb95e --- /dev/null +++ b/contrib/claude/permissions.md @@ -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 ( + + {document.title} + + {document.canUpdate && } + {document.canDelete && } + + + ); +} +``` + +### Hide vs. disable + +```text +// Hide — the user has no business with this action (most cases) +{canDelete && } + +// Disable — the action is expected to be there, but is currently unavailable; +// pair with a tooltip explaining why + +``` + +## 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 && } +``` + +## 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 + + +// 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. diff --git a/contrib/claude/react-components.md b/contrib/claude/react-components.md index b73cad8be..4e45be935 100644 --- a/contrib/claude/react-components.md +++ b/contrib/claude/react-components.md @@ -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 diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 79a42ad07..29a87d659 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -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(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); -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) diff --git a/contrib/claude/routing.md b/contrib/claude/routing.md new file mode 100644 index 000000000..ec58c6f98 --- /dev/null +++ b/contrib/claude/routing.md @@ -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 +{t("measures.view")} + +// 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 `` for render-time redirects (e.g. role-based landing). + +```tsx +// Index redirect from a loader +{ index: true, loader: () => { throw redirect("general"); } } + +// Render-time redirect + +``` + +## 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 ; + } + return ; +} +``` + +```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 ; + case Role.AUDITOR: return ; + default: return ; + } +} +``` + +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 +; +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. diff --git a/contrib/claude/state-management.md b/contrib/claude/state-management.md new file mode 100644 index 000000000..febf5b282 --- /dev/null +++ b/contrib/claude/state-management.md @@ -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). diff --git a/contrib/claude/ts-style.md b/contrib/claude/ts-style.md index b8d09a172..dfc2d70ca 100644 --- a/contrib/claude/ts-style.md +++ b/contrib/claude/ts-style.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 ``, 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, +}); +``` diff --git a/contrib/claude/ui.md b/contrib/claude/ui.md index f879b37be..5f4a23772 100644 --- a/contrib/claude/ui.md +++ b/contrib/claude/ui.md @@ -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"; + + + + + +``` + +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); + + 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 + ? + : measures.map((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 (`