# web/AGENTS.md

Conventions for the web monorepo. Cross-cutting rules live in [../AGENTS.md](../AGENTS.md).

## Layout

pnpm workspace at `web/`. Three apps, all Next.js 16 + React 19 + TypeScript + Tailwind v4:

- `apps/app`, the product UI + auth (port 3001).
- `apps/marketing`, the public landing / waitlist (port 3000).
- `apps/blog`, the MDX blog served at `openmagpie.ai/blog` (port 3002).

The shared `@source` glob (see Tailwind v4 below) already covers every app, so a new app needs no per-app Tailwind wiring.

Packages:

- `@magpie/schema`, the generated zod contract (from the Pydantic `schema.json`); server-shared shapes come from here (see the Schemas + types section)
- `@magpie/ui`, shared UI primitives (Button, Input, PasswordInput, FormField, Logo, Emblem, ...)
- `@magpie/api-utils`, route registry + fetch wrapper + zod schemas + one action per endpoint
- `@magpie/auth`, Zustand store + auth hooks
- `@magpie/tailwind-config`, Tailwind theme + source globs

## App-local code

Per-app non-component helpers (data registries, constants, URL/format utils) live in `src/app/_lib/`; `src/app/_components/` holds only React components.

## Tailwind v4

Source scanning is driven by `@source` globs in `packages/tailwind-config/theme.css`, which covers every package + every app under the workspace. Don't add per-package globs; the shared theme glob picks them up.

Theme tokens (Ink / Paper / Signal / Glow scales, fonts) live in the same file. Apps import it by package specifier (`@import "@magpie/tailwind-config"`); the package declares a `style` export so Tailwind v4's CSS resolver finds `theme.css` (no deep relative path).

## Dark mode

Class-strategy via `next-themes` (`attribute="class"`) with `@custom-variant dark (&:where(.dark, .dark *))` in `globals.css`. Render both light + dark variants and let CSS pick to avoid hydration flicker.

The theme contract (cookie name, localStorage key, `light`/`dark`/`system` values) lives in `@magpie/ui`'s `theme-constants.ts`. The provider, toggle, and blocking head-script all key off it, so a bare literal can't drift and silently reintroduce the first-paint flash.

## Blog (`apps/blog`)

- **Bundler:** the blog runs Webpack (`next dev/build --webpack`), not the Turbopack default the other apps use. Its MDX pipeline (`@next/mdx` + custom rehype/recma with Shiki) isn't Turbopack-compatible yet, so don't "fix" the flag to match the siblings. Deploy is unaffected: `opennextjs-cloudflare build` runs the app's own `build` script, inheriting the flag.
- **Brand assets read off disk for OG cards must stay in sync with the `@magpie/ui` bundle.** Satori (the OG image renderer) can't use bundled imports, so each app rendering an OG card keeps a `public/brand/*` copy read at build time: `apps/blog/public/brand/emblem.svg`, `apps/marketing/public/brand/emblem.svg`, and `apps/marketing/public/brand/mascot.png` (the blog favicon is a third emblem copy). When a brand asset changes, update the `@magpie/ui` original AND every `public/brand` copy (blog emblem + favicon, marketing emblem + mascot) together.

## `@magpie/api-utils`

### Routes

Two namespaces in `src/routes.ts`:

- `apiRoutes` mirrors the Django URL surface (`apiRoutes.auth.me`, `apiRoutes.auth.deviceSessionInfo(id)`).
- `webRoutes` mirrors the Next.js page surface (`webRoutes.login`).

`withNext(path, next)` / `safeNext(next, fallback)` standardize post-login redirect threading. `safeNext` rejects cross-origin paths, protocol-relative URLs, backslash-paths, and `/logout`.

When a route changes in Django, update `routes.ts` and `types.ts` together. TypeScript is the single source of truth on the client side.

### Schemas + types: zod is the single source

Split by origin:

- **Server-shared shapes** (anything the Django API returns, e.g. `AuthUser`) come from `@magpie/schema`, which is GENERATED from the Pydantic contract (`packages/openmagpie-schema` → `schema.json` → zod; see that package's generator). Import the schema + inferred type from there; never hand-redeclare a server shape, or it drifts from the one contract the server and CLI also validate against:

```ts
import { AuthUserSchema, type AuthUser } from "@magpie/schema";
```

- **Browser-only shapes** (request bodies the client builds, UI-local state, anything with no server counterpart) live in `src/types.ts` as a zod schema with the TS type inferred from it:

```ts
export const SomeClientBodySchema = z.object({ ... });
export type SomeClientBody = z.infer<typeof SomeClientBodySchema>;
```

Either way: one declaration, two outputs (a TypeScript type AND a runtime `.parse(...)` validator) that can't drift, and server shapes stay anchored to the shared contract.

### `apiFetchParsed` over `apiFetch<T>`

`apiFetch<T>` is a compile-time cast; it trusts the server. Use it only for write endpoints whose response body the caller ignores.

`apiFetchParsed(schema, path, opts)` runs the schema at runtime. A malformed body throws `ZodError` instead of silently flowing into the auth store. Use this for **every** auth-shaped response.

### One action per endpoint

Call sites don't thread `(schema, route, method, body)`. They call a named action that already binds those:

```ts
// in src/actions/auth.ts
export const authActions = {
  me: () => apiFetchParsed(AuthUserSchema, apiRoutes.auth.me),
  signup: (body: AuthSignupBody) =>
    apiFetchParsed(BrowserAuthResponseSchema, apiRoutes.auth.signup, {
      method: "POST",
      body,
    }),
  ...
};

// call site
const result = await authActions.signup({ email, password });
```

When you add a new endpoint, add an action. Don't sprinkle `apiFetchParsed(...)` directly across UI components.

### Fetch wrapper invariants

- `credentials: "include"` on every API call so the `auth_token` cookie ships.
- JSON in, JSON out. Throws `ApiError` (carrying `status` + parsed `body`) on non-2xx.
- Base + path are joined by `buildApiUrl(path)` (in `routes.ts`), which resolves the API origin lazily via `resolveApiBase()`. In production `resolveApiBase()` throws if `NEXT_PUBLIC_API_URL` is missing (no silent localhost); the localhost fallback applies only in dev. Resolution is lazy, so the throw surfaces on the first request, not at module load — keeping importers side-effect-free.

## `@magpie/auth`

Zustand store + two hooks:

- `useUser()` runs the `/v1/auth/me` check once per hook instance via a `phase` state machine. Returns `{ user, loading, error }`. Distinguishes 401 ("logged out") from transient failure ("don't know").
- `useRequireAuth(redirectTo)` waits for `loading=false`, only redirects on confirmed 401, never on network errors. Preserves the current URL as `?next=...`.

Cross-tab is intentionally not handled. Refresh re-runs the `/me` check.

## Async/await over promise chains

Default to `async/await` in components and hooks. `useEffect` callbacks can't be async themselves, so the pattern is an inner async function + a `cancelled` flag:

```ts
useEffect(() => {
  let cancelled = false;
  async function load() {
    try {
      const x = await someAction();
      if (!cancelled) setX(x);
    } catch (err) {
      if (cancelled) return;
      // handle
    }
  }
  load();
  return () => { cancelled = true; };
}, [deps]);
```

## State-machine values

(Cross-cutting rule from root `AGENTS.md`, restated for TS clarity.)

Phase / status values get a `const` object + derived union type from the start. Never bare string literals in match arms or status checks:

```ts
const PHASE = {
  CONFIRM: "confirm",
  COMPLETING: "completing",
  COMPLETE: "complete",
} as const;
type Phase = (typeof PHASE)[keyof typeof PHASE];
```

## Env

- `NEXT_PUBLIC_API_URL` is **required in production**. `resolveApiBase()` throws if missing.
- `NEXT_PUBLIC_API_VERSION` is the prefix string (default `"v1"`); keep in lockstep with Django's `API_VERSION_PREFIX`.
- `NEXT_PUBLIC_SITE_URL` is the CURRENT app's own origin, **bare (scheme + host, no path)**, used for canonical / OG / sitemap / feed (`siteMeta.url`). For the blog set it to the bare apex (`https://openmagpie.ai`), NOT `.../blog`: the blog composes its `/blog` URLs in code via `blogBaseUrl` (which prepends `BLOG_BASE_PATH`), so a value carrying `/blog` would double-prefix. It's stripped defensively, but keep it bare.
- `NEXT_PUBLIC_MARKETING_URL` / `NEXT_PUBLIC_APP_URL` / `NEXT_PUBLIC_BLOG_URL` are the **bare origins** of the sibling apps, for cross-app links (`origins` in `api-utils/src/site.ts`). Each are **required in production** for the origins an app actually links to: `resolveOrigin()` throws at build if one is missing (the same fail-loud contract as `NEXT_PUBLIC_API_URL`), rather than baking in localhost. Dev falls back to the ports (3000 / 3001 / 3002). In prod marketing + blog share the apex (`https://openmagpie.ai`) and the app is on its subdomain (`https://app.openmagpie.ai`); the blog's `/blog` segment is appended by `BLOG_BASE_PATH` (see `blogLinkUrl`), so keep these bare origins and never bake `/blog` into `NEXT_PUBLIC_BLOG_URL`.
- `ASSETS_URL` (used by `apps/email-render`) is the public base for brand image URLs baked into emails — the recipient's mail client fetches them, so it must be publicly reachable. Defaults to the marketing site; dev sets it to `http://localhost:3000` (see the `web` service in `docker-compose.yml`). Set it in prod to wherever `/brand/*` is served.
