web-frontend
latitude-dev/latitude-llm/.agents/skills/web-frontend/SKILL.md
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.
Skill4.7k starsChanged 8 days ago
What's in it
- Web app frontend (apps/web)
- Required design context
- Existing V2 UI reference
- React 19
- Components
- Modal: short form vs composition
- Route-level component organization
- Design system showcase
- State management (TanStack)
- Navigation: <Link> vs useNavigate
- Anti-pattern to avoid
- Table rows that navigate
- Command palette (Cmd+K)
- TanStack Form + Zod field errors (createFormSubmitHandler + fieldErrorsAsStrings)
- Layout and spacing
- Conditional classes (cn)
- Example
- React effects (useEffect policy)
---
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<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()
})
```
More agent context in latitude-dev/latitude-llm
30 other files this repository gives its agents.
AGENTS.md
Skill
- agentation-watch-mode.agents/skills/agentation-watch-mode/SKILL.md
- analyze-problem.agents/skills/analyze-problem/SKILL.md
- api-endpoints.agents/skills/api-endpoints/SKILL.md
- architecture-boundaries.agents/skills/architecture-boundaries/SKILL.md
- artifact-designer.agents/skills/artifact-designer/SKILL.md
- async-jobs-and-events.agents/skills/async-jobs-and-events/SKILL.md
- authentication.agents/skills/authentication/SKILL.md
- backoffice.agents/skills/backoffice/SKILL.md
- better-auth-best-practices.agents/skills/better-auth-best-practices/SKILL.md
- ci-watchdog.agents/skills/ci-watchdog/SKILL.md
- code-style.agents/skills/code-style/SKILL.md
- create-pr.agents/skills/create-pr/SKILL.md
- database-clickhouse-weaviate.agents/skills/database-clickhouse/SKILL.md
- database-postgres.agents/skills/database-postgres/SKILL.md
- docs.agents/skills/docs/SKILL.md
- effect-and-errors.agents/skills/effect-and-errors/SKILL.md
- env-configuration.agents/skills/env-configuration/SKILL.md
- explain-diff-html.agents/skills/explain-diff-html/SKILL.md
- fix-datadog-issues.agents/skills/fix-datadog-issues/SKILL.md
- gh-issue.agents/skills/gh-issue/SKILL.md
- humanizer.agents/skills/humanizer/SKILL.md
- managing-maintenance-windows.agents/skills/managing-maintenance-windows/SKILL.md
- mintlify-preview.agents/skills/mintlify-preview/SKILL.md
- notifications.agents/skills/notifications/SKILL.md
- production-release.agents/skills/production-release/SKILL.md
- review-pr-comments.agents/skills/review-pr-comments/SKILL.md
- temporal-developer.agents/skills/temporal-developer/SKILL.md
- testing.agents/skills/testing/SKILL.md
- toolchain-commands.agents/skills/toolchain-commands/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
No reports yet. Be the first to say whether it worked.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

