Skip to main content

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.

Source facts

Repository
latitude-dev/latitude-llm
Last source activity
September 2, 2026 at 09:05
Detected SKILL.md language
English
Stars
4,722
Forks
402

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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> ) ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub