--- name: web-frontend description: apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, navigation (Link vs useNavigate), forms (useForm + createFormSubmitHandler + fieldErrorsAsStrings for Zod field errors), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy. --- # Web app frontend (`apps/web`) **When to use:** `apps/web` UI — routes, `@repo/ui`, TanStack Start server functions and collections, **navigation (`Link` vs `useNavigate`)**, forms (**`useForm`** with **`createFormSubmitHandler`** + **`fieldErrorsAsStrings`** when Zod validation errors should appear on fields), Tailwind layout rules, design-system updates, and **`useEffect` / `useMountEffect` policy**. ## Required design context Before designing or editing any user-facing UI, read the root [`design.md`](../../../design.md). It defines the Latitude product UI rules for reuse-first discovery, composition, hierarchy, spacing, actions, responsive behavior, and route versus modal versus drawer decisions. Treat `packages/ui/src/components/`, `packages/ui/src/tokens/`, the live `apps/design-system` inventory, and adjacent `apps/web` routes as the implementation source of truth. Search for an existing component and a comparable in-product pattern before creating a component, introducing custom styling, or adding a new token. Follow `design.md` when deciding whether new UI is justified; follow this skill for the required React, TanStack, route, form, and component APIs. For project-route UI, use the local `-components/section-header.tsx` `SectionHeader` once for the route-level header, matching the Behaviors page's `Layout.Header` composition. A project overview defaults to the standard variant so its title has the same size and weight as the Behaviors route header; use `variant="xl"` only when the task and a comparable existing route justify it. Import the existing header and layout rather than recreating their structure or classes locally. Project overview content has a strict default hierarchy: metric values may use `Text.H5`; panels use muted `Text.H6` labels; repeated row and issue titles use compact `Text.H6B`; supporting copy uses muted `Text.H6`. Do not use `Text.H3*`, `Text.H4*`, or `Text.H5*` as panel, row, issue, or block titles. Do not use `SectionHeader` as a panel heading. For project overview metrics, first inspect the compact `AggregationItem` strips in `users/-components/users-analytics-panel.tsx`, `tools/-components/tools-analytics-panel.tsx`, and `-components/aggregations/general-aggregations.tsx`. Preserve that anatomy and keep metrics in one horizontally scrollable row; do not create a grid, separate `Card` per metric, or a route-local approximation of the existing aggregation item. Detail sections use muted labels plus `divide-y` rows, not nested cards or dashboard-specific mini-panels. Read the `Project Overview Contract` in `design.md` before implementation. ## Existing V2 UI reference - Before building new UI, inspect comparable V2 routes and components in `apps/web/src/routes/` and `packages/ui/src/components/`. - Reuse the closest V2 interaction and composition pattern when it is solid; preserve consistency across the active product rather than recreating the pattern locally. - Treat the V2 application and `apps/design-system` as the visual and behavioral reference. Do not use V1 as a default source of UI guidance. ## React 19 The project uses **React 19**. Follow modern patterns and avoid deprecated APIs: - **No `forwardRef`** — `ref` is a regular prop in React 19. Declare it in the props type and destructure it directly. - **No `ElementRef`** — use `React.ComponentRef` instead (the `ElementRef` alias is deprecated). - **No gratuitous `useMemo` / `useCallback` / `React.memo`** — the React Compiler (enabled in the build) auto-memoizes. Only add manual memoization when profiling shows a concrete bottleneck; remove existing wrappers when they have no measured benefit. - **Prefer `use()`** for consuming promises and context where appropriate. ```tsx // ❌ Deprecated React 18 pattern const Input = forwardRef, InputProps>(({ className, ...props }, ref) => ( )) Input.displayName = "Input" // ✅ React 19 — ref is a regular prop function Input({ className, ref, ...props }: InputProps & { ref?: React.Ref> }) { return } ``` ## Components - **Always** use `Text` from `@repo/ui` for text content - **Always** use `Button` from `@repo/ui` for buttons - **Do not** nest `Text` inside `Button`. `Button` already sets font size, weight, and color; use plain text (and optional icons) as direct children. Wrapping the label in `Text` duplicates styles (e.g. avoid ``). - **Lucide icons:** import from `lucide-react` and pass the component to `@repo/ui`’s `Icon` via the `icon` prop (e.g. ``). Prefer that over raw `` so shared sizing and color tokens apply. Buttons and other primitives that accept an `icon` prop follow the same pattern; otherwise wrap with `Icon`. - **Always** use `GoogleIcon` and `GitHubIcon` from `@repo/ui` for OAuth provider icons ## Modal: short form vs composition **Default to the short form** — `{children}`. It auto-wraps `children` in `Modal.Body` with the standard `px-6` padding and the same scroll behavior every other modal uses, so content inside it can't drift out of the design system. This is the shape `RenameProjectModal` (the form reference cited above) uses — treat it as the canonical pattern. ```tsx (!next ? onClose() : undefined)} title="Change sampling rate" description="Percentage of incoming traces this evaluation runs against." footer={ <> } >
{/* fields */}
``` Reach for the composition form (`Modal.Root` / `Modal.Content` / `Modal.Header` / `Modal.Body` / `Modal.Footer`) **only** when the short form can't express what you need: - Custom header JSX beyond a string `title` + `description` — render `{custom JSX}` directly. - Multiple body regions, or content that must opt out of the standard body padding / scroll. - Conditional rendering of header or footer that can't be driven by passing `undefined`. Notes: - `dismissible` defaults to `false` — pass it to show the close X. - `scrollable` defaults to `true`. For short bodies that shouldn't grow to fill height, pass `scrollable={false}`. - **Do not** wrap your children in your own `` while using the short form — children are already wrapped, double-wrapping nests padding. ## Route-level component organization Place React components close to the routes that use them, inside a `-components/` subfolder within the route directory. This keeps route files (which TanStack Router auto-discovers) clearly separated from supporting components. ``` routes/_authenticated/projects/$projectId/datasets/ ├── index.tsx # route file ├── $datasetId.tsx # route file └── -components/ # supporting components for these routes ├── dataset-table.tsx ├── row-detail-panel.tsx └── version-badge.tsx ``` - Route files live directly in the route directory — TanStack Router discovers them - Supporting UI for those routes lives in the adjacent `-components/` folder - `domains/` directories (`apps/web/src/domains/`) are for state management only: server functions (writes) and collections/queries (reads) — **not** UI components ## Design system showcase - When adding a new implemented UI component in `packages/ui` (or replacing a placeholder export with a real implementation), update `apps/web/src/routes/design-system.tsx` to include a usage example for that component in both light and dark mode previews. - Treat `apps/web/src/routes/design-system.tsx` as the canonical visual inventory for `@repo/ui` components. ## State management (TanStack) The web app uses a **server-centric, query-driven** architecture built on the TanStack ecosystem. No Zustand, Redux, or global stores. **Server functions** — All data fetching and mutations use `createServerFn` from `@tanstack/react-start`: ```typescript import { Effect } from "effect" import { ProjectRepository, createProjectUseCase } from "@domain/projects" import { ProjectRepositoryLive, SqlClientLive } from "@platform/db-postgres" import { getPostgresClient } from "../../server/clients.ts" // Query (GET) export const listProjects = createServerFn({ method: "GET" }).handler(async () => { const { organizationId } = await requireSession() const client = getPostgresClient() return await Effect.runPromise( Effect.gen(function* () { const repo = yield* ProjectRepository return yield* repo.findAll() }).pipe( Effect.provide(ProjectRepositoryLive), Effect.provide(SqlClientLive(client, organizationId)), ), ) }) // Mutation (POST) with Zod validation export const createProject = createServerFn({ method: "POST" }) .inputValidator(createProjectSchema) .handler(async ({ data }) => { const { userId, organizationId } = await requireSession() const client = getPostgresClient() return await Effect.runPromise( createProjectUseCase({...}).pipe( Effect.provide(ProjectRepositoryLive), Effect.provide(SqlClientLive(client, organizationId)), ), ) }) ``` Server functions live in `apps/web/src/domains/*/functions.ts`. **Collections** — Client-side reactive state uses TanStack React DB + Query via `queryCollectionOptions`: ```typescript const projectsCollection = createCollection( queryCollectionOptions({ queryClient, queryKey: ["projects"], queryFn: () => listProjects(), getKey: (item) => item.id, onInsert: async ({ transaction }) => { /* optimistic insert */ }, onUpdate: async ({ transaction }) => { /* optimistic update */ }, onDelete: async ({ transaction }) => { /* optimistic delete */ }, }), ) export const useProjectsCollection = (...) => useLiveQuery(...) ``` Collection files live in `apps/web/src/domains/*/collection.ts`. **Route middleware vs route data** - Use `beforeLoad` for middleware-style checks that should block the route tree early: auth redirects, authorization gates, and other preconditions. - Use `loader` for data the route or layout actually renders. This keeps rendered data in TanStack Router's loader lifecycle, so it can use `staleTime`, `useLoaderData({ select })`, and avoid unnecessary refetching on same-route search-param navigations. - If the same lookup is both your guard and your rendered data source, prefer doing that work in `loader` once instead of duplicating it across `beforeLoad` and `loader`. - When multiple descendant routes need parent loader data, prefer a small route-scoped wrapper around `getRouteApi("...")` instead of repeating the route id string in every file. ```typescript export const Route = createFileRoute("/admin")({ beforeLoad: async () => { const session = await getSession() if (!session?.user.isAdmin) throw redirect({ to: "/" }) }, }) ``` ```typescript export const Route = createFileRoute("/_authenticated")({ staleTime: Infinity, loader: async () => { const session = await getSession() if (!session) throw redirect({ to: "/login" }) const sessionData = session.session as Record const organizationId = typeof sessionData.activeOrganizationId === "string" ? sessionData.activeOrganizationId : null if (!organizationId) throw redirect({ to: "/welcome" }) return { user: session.user, organizationId, } }, }) ``` ```typescript const authenticatedRoute = getRouteApi("/_authenticated") export function useAuthenticatedUser() { return authenticatedRoute.useLoaderData({ select: (data) => data.user }) } ``` **Key rules:** - Server functions are the only data-fetching mechanism — no direct REST API calls from the client - Use collections for reactive, queryable client state with automatic server sync - Use `useState` for local UI state (modals, form visibility); no global stores - Invalidate query cache after mutations: `getQueryClient().invalidateQueries({ queryKey: [...] })` - Forms use TanStack React Form (`useForm` + `form.Field`) ## Navigation: `` vs `useNavigate` **Rule:** if navigation is triggered by a user clicking something, render a **``** (from `@tanstack/react-router`). Reserve **`useNavigate`** for *programmatic* redirects — work the user didn't directly click "go there" for. A real anchor (`` → ``) is the only way to get: - `href` (hover-preview the URL, right-click → copy/open, browser history correctness) - **cmd/ctrl-click and middle-click to open in a new tab** - Keyboard activation (Enter) and focus-visible handling for free, no manual `onKeyDown` - Works before client-side JS has hydrated | Trigger | Use | | --- | --- | | User clicks a button / badge / row / link-styled element to go to a different page | **``** | | Same-route navigation that only flips a search param (drawer toggle, "apply saved search" on the page you're already on) | `useNavigate` is fine — there is no new document to link to | | After a mutation completes (`createX` → go to the new resource, `deleteX` → go back to the listing) | `useNavigate` | | Auth flows (sign in, sign out, OAuth callback, redirect-after-login) | `useNavigate` | ### Anti-pattern to avoid Do **not** put `role="button"`, `onClick`, and a hand-rolled `onKeyDown` on a `Badge`/`div`/`span` to call `navigate(...)`. That re-implements (badly) what `` already provides, and silently breaks cmd-click / middle-click / right-click flows. ```tsx // ❌ Bad — click-to-navigate via useNavigate const navigate = useNavigate() function open() { navigate({ to: "/projects/$projectSlug/settings/flaggers", params: { projectSlug }, search: { flagger: slug } }) } return ( (e.key === "Enter" || e.key === " ") && open()} > {label} ) // ✅ Good — real anchor wraps the visual badge return ( {label} ) ``` `Badge` (and other `@repo/ui` primitives that are plain `
`s without `asChild`) should be wrapped *inside* the `` as visual children — the `` is the interactive anchor. For ``. When the clickable element sits inside a larger row-level click handler, add `data-no-navigate` to the `` (the parent handler skips elements under `[data-no-navigate]`) and `onClick={(e) => e.stopPropagation()}` for belt-and-suspenders. ### Table rows that navigate If clicking an `InfiniteTable` row opens a different page, use the **`renderRowLink`** prop — not `onRowClick` + `useNavigate`. `renderRowLink` is router-agnostic: the table renders a stretched-link overlay, the app supplies the `` element. ```tsx r.id} renderRowLink={(row, props) => ( )} /> ``` Keep `onRowClick` only for **same-route** row interactions: selecting a row to open a drawer, applying a saved filter on the same page, toggling expansion state, etc. — the cases where there is no new URL to navigate to. ## Command palette (Cmd+K) The global command palette (`apps/web/src/components/command-palette/`, mounted in `_authenticated.tsx`) is the keyboard launcher for navigation, actions, contextual entity actions, and in-project search. **Full design + maintenance guide: `dev-docs/command-palette.md`** — read it before changing palette behavior. **Keep it in sync when you add UI.** Anything navigable or actionable should also be reachable from the palette: - **New project section / settings page** → add it to `apps/web/src/domains/projects/project-sections.ts`. The sidebar, settings sub-nav, **and** the palette navigation all consume that module, so it surfaces everywhere automatically — don't hardcode a nav entry in the palette. - **New global action** → add a command to `command-palette/commands/use-global-commands.tsx` (switch/navigate actions rank above create actions; keep Log out last). - **New action on an entity with a detail view** (issue/trace drawer, etc.) → contribute it from that view with `useRegisterCommands(...)` (`section: "context"`, a `group` label, reusing the view's existing handler). - **New searchable entity** → add a `commands/use-*-search-commands` hook gated on `useCurrentProject()` + a non-empty query, then wire a group into `command-palette.tsx`. The palette runs cmdk with `shouldFilter={false}` and filters in React — never rely on cmdk's built-in `keywords`/filter for query-driven rows (cmdk snapshots keywords on first registration). See the dev-doc for the rationale. ## TanStack Form + Zod field errors (`createFormSubmitHandler` + `fieldErrorsAsStrings`) **When:** `useForm` submits work that can fail with **Zod validation** (for example server functions using `inputValidator`), and you want **inline errors on `@repo/ui` fields** (not only a toast). **Module:** `apps/web/src/lib/form-server-action.ts` | Helper | Use | | --- | --- | | **`createFormSubmitHandler`** | Pass as `useForm({ onSubmit: createFormSubmitHandler(async (value) => { ... }, { onSuccess, onError }) })`. On validation failure it maps serialized Zod issues onto TanStack Form field meta via `extractFieldErrors` in `apps/web/src/lib/errors.ts`. Non-field errors go to `onError`. On success it resets the form and runs `onSuccess`. | | **`fieldErrorsAsStrings`** | On every `Input`, `Textarea`, or other control with an `errors` prop inside `form.Field`, set `errors={fieldErrorsAsStrings(field.state.meta.errors)}` so those meta errors display. | **Always use both** when you want Zod-driven field errors: the submit handler wires errors into form state; the helper wires form state into `@repo/ui`. **Do not** duplicate the inline pattern `field.state.meta.errors.length > 0 ? field.state.meta.errors.map(String) : undefined` — use `fieldErrorsAsStrings` instead. **Reference:** `apps/web/src/routes/_authenticated/index.tsx` — `RenameProjectModal`: `createFormSubmitHandler` in `useForm` (~line 225), `fieldErrorsAsStrings` on the name field `Input` (~line 287). ## Layout and spacing - **Always** use flexbox for layout (`flex`, `flex-col`, `flex-row`) - **Never** use margin utilities (no `m-*`, `mx-*`, `my-*`, `mt-*`, etc.) - **Always** use `gap` utilities for spacing between elements (`gap-*`, `gap-x-*`, `gap-y-*`) - **Always** use `p-*` (padding) for internal spacing within containers ## Conditional classes (`cn`) With `cn()`, use **object syntax** `{ "class-name": condition }` — not short-circuit `condition && "class-name"`. ```tsx // ❌ Bad
// ✅ Good
``` ### Example ```tsx // ❌ Bad - using margins and space-y
Item 1
Item 2
// ✅ Good - using flexbox with gap
Item 1
Item 2
``` ## React effects (`useEffect` policy) - Do **not** call `useEffect` directly in components; use **`useMountEffect`** from `@repo/ui` for mount/unmount-only sync (listeners, imperative widgets, one-time setup). - If raw `useEffect` is unavoidable, add `TODO(frontend-use-effect-policy)` with a short reason. **Prefer:** derive values during render; run work in event handlers; controlled vs uncontrolled via `value !== undefined`; reset by **`key`** when an entity id changes. **Avoid:** deriving state from props in an effect; fetching in effects to set state; mirroring props into local state; effects as command dispatchers. ```ts import { useMountEffect } from "@repo/ui" useMountEffect(() => { const cleanup = subscribeToExternalSystem() return () => cleanup() }) ```