# Web Frontend

> 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.

- Skill: `latitude-dev/web-frontend` (Agent Skill)
- Install (CLI): `npx skillmds@latest add latitude-dev/web-frontend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/latitude-dev/web-frontend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: latitude-dev (https://skillmd.com/u/latitude-dev)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/latitude-dev/web-frontend

---


# 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<typeof SomeComponent>` 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<ElementRef<typeof Primitive>, InputProps>(({ className, ...props }, ref) => (
  <Primitive ref={ref} {...props} />
))
Input.displayName = "Input"

// ✅ React 19 — ref is a regular prop
function Input({ className, ref, ...props }: InputProps & { ref?: React.Ref<React.ComponentRef<typeof Primitive>> }) {
  return <Primitive ref={ref} {...props} />
}
```

## 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 `<Button><Text.H5>Save</Text.H5></Button>`).
- **Lucide icons:** import from `lucide-react` and pass the component to `@repo/ui`’s `Icon` via the `icon` prop (e.g. `<Icon icon={Pencil} size="sm" />`). Prefer that over raw `<Pencil />` 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** — `<Modal title=… description=… footer=… open dismissible onOpenChange={…}>{children}</Modal>`. 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
<Modal
  open
  dismissible
  onOpenChange={(next) => (!next ? onClose() : undefined)}
  title="Change sampling rate"
  description="Percentage of incoming traces this evaluation runs against."
  footer={
    <>
      <CloseTrigger />
      <Button onClick={() => void form.handleSubmit()}>Save</Button>
    </>
  }
>
  <form onSubmit={…}>{/* fields */}</form>
</Modal>
```

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 `<Modal.Header>{custom JSX}</Modal.Header>` 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 `<Modal.Body>` 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<string, unknown>
    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: `<Link>` vs `useNavigate`

**Rule:** if navigation is triggered by a user clicking something, render a **`<Link>`** (from `@tanstack/react-router`). Reserve **`useNavigate`** for *programmatic* redirects — work the user didn't directly click "go there" for.

A real anchor (`<Link>` → `<a href>`) 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 | **`<Link>`** |
| 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 `<Link>` 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 (
  <Badge
    role="button"
    tabIndex={0}
    onClick={open}
    onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open()}
  >
    {label}
  </Badge>
)

// ✅ Good — real anchor wraps the visual badge
return (
  <Link
    to="/projects/$projectSlug/settings/flaggers"
    params={{ projectSlug }}
    search={{ flagger: slug }}
    aria-label={`Open the ${name} flagger settings`}
    className="inline-flex"
  >
    <Badge variant="secondary" size="small" className="cursor-pointer hover:bg-muted">
      {label}
    </Badge>
  </Link>
)
```

`Badge` (and other `@repo/ui` primitives that are plain `<div>`s without `asChild`) should be wrapped *inside* the `<Link>` as visual children — the `<Link>` is the interactive anchor.

For `<Button>` (which **does** support `asChild`), use the Radix Slot pattern: `<Button asChild><Link to="…">…</Link></Button>`.

When the clickable element sits inside a larger row-level click handler, add `data-no-navigate` to the `<Link>` (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 `<Link>` element.

```tsx
<InfiniteTable
  data={items}
  columns={columns}
  getRowKey={(r) => r.id}
  renderRowLink={(row, props) => (
    <Link to="/items/$id" params={{ id: row.id }} aria-label={`Open ${row.name}`} {...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
<div className={cn("base-class", isActive && "bg-accent")} />

// ✅ Good
<div className={cn("base-class", { "bg-accent": isActive })} />
```

### Example

```tsx
// ❌ Bad - using margins and space-y
<div className="space-y-4 mt-4">
  <div className="mb-2">Item 1</div>
  <div className="mb-2">Item 2</div>
</div>

// ✅ Good - using flexbox with gap
<div className="flex flex-col gap-4 pt-4">
  <div>Item 1</div>
  <div>Item 2</div>
</div>
```

## 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()
})
```

