AGENTS.md@web · git:20261103.810f429 · 2026-11-03 · sha256 abffd4543c591529
AGENTS.md@web git:20261103.810f429A
Immutable. This exact content never changes and is served at /api/v1/blob/abffd4543c591529.
# langalpha web
Frontend for langalpha — React 19 + Vite + TypeScript SPA. Talks to the FastAPI backend over REST (`api/client.ts`, a thin `fetch` client) + SSE (raw fetch). Path alias `@` → `src/` is declared once, as `paths` in the tsconfig projects, and Vite and Vitest read it through `resolve.tsconfigPaths`; `@e2e/` exists only in `tsconfig.vitest.json`, so app code cannot import the e2e fixtures.
> Single source of truth for AI coding agents in `web/`. `CLAUDE.md` imports this via `@AGENTS.md`; Codex/Cursor read it directly. Edit here, not there.
## Commands
```bash
pnpm dev # dev server on 127.0.0.1:5173 (proxies /api/v1 + /ws/v1 → VITE_PROXY_BACKEND, default :8000)
pnpm build # tsc -b && vite build && check-critical-path — typecheck and first-load budget both gate the build
pnpm typecheck # tsc -b over the app, test and node projects (gated in CI)
pnpm test # vitest run; test:e2e = Playwright
pnpm lint # ESLint 10 flat config, cached in node_modules/.cache; errors gate CI, warnings are advisory
```
Streaming perf benchmarks live in `e2e/perf/`, are gated behind `PERF=1`, and their flags and canonical invocations are in [`e2e/perf/README.md`](e2e/perf/README.md).
## Landmines (non-obvious)
- **Dual-mode auth** (`contexts/AuthContext.tsx`), switched by `VITE_HOST_MODE` (`config/hostMode.ts`, default `oss`): `platform` → `SupabaseAuthProvider` (real session; the API client reads its Bearer token from `lib/authToken.ts`); `oss` → static local-dev context, always logged in as `VITE_AUTH_USER_ID` (default `local-dev-user`). `VITE_SUPABASE_URL`/`_KEY` only gate Supabase-*client* construction, NOT the mode — check `isPlatformMode`, never `VITE_SUPABASE_URL`.
- **Chat/market SSE uses raw `fetch()` + `ReadableStream`, NOT the API client** (the client reads whole bodies): `streamFetch()` in `pages/ChatAgent/utils/api/transport.ts`, which the market chat panel streams through too. Its token comes from `lib/authToken.ts`, the same shared cache the API client uses. Most *authenticated* REST goes through the shared client (`api/client.ts`, auto-Bearer, base `VITE_API_BASE_URL`), but public/unauthenticated calls (`pages/SharedChat/api.ts`), `auth/sync`, and market-data **WebSocket** use raw `fetch`/`WS`, not the client.
- **`api/client.ts` is a thin `fetch` wrapper that keeps axios's shape on purpose**, because ~200 call sites and every error reader (`apiErrorDetail`, `apiErrorStatus`, the 429 `rateLimitInfo`/`retryAfter` readers) were written against it. A call resolves `{ data, status, headers }` with lower-cased header names; a non-2xx rejects an `ApiError` carrying `response: { status, data, headers }`; an abort rejects with `name === 'CanceledError'`; the messages are axios's (`Request failed with status code N`, `timeout of Nms exceeded`, `Network Error`), since call sites show `err.message`. `params` take scalars only, in axios's encoding (`:` `,` `$` literal, space as `+`), which is what the backend has always received; a repeated key goes as `URLSearchParams`. Never set `Content-Type` on a `FormData` body: the browser writes the multipart boundary. `onUploadProgress`/`onDownloadProgress` move that one call onto XHR, the only transport that reports upload progress.
- **Every access token comes from `lib/authToken.ts`, and nothing else calls `supabase.auth.getSession()` per request.** `getSession()` is not an accessor: its own docs say it "returns the session, refreshing it if necessary", so calling it per outbound request turned one cold load into ~20 network refreshes. On a device whose clock is off by more than `JWT_TTL - 90s` every read looks near-expiry, which exhausts Supabase's per-IP token budget (1800/hr, burst 30, not customizable) until a 429 arrives, and auth-js treats a 429 as fatal and destroys the session. So: read the cached token, never the session. `AuthContext` owns the **only** `onAuthStateChange` subscription and pushes into the cache synchronously; do not add a second subscriber, because each registration runs `_emitInitialSession` and can itself refresh. `lib/authFetch.ts` is the client's `fetch`: it strips the server's `expires_at` so auth-js recomputes expiry from `expires_in` on the local clock (skew cancels only if one clock does both halves), and it converts a 429 on the token endpoint into a retryable 503 while holding the endpoint closed for `Retry-After`. A pre-expiry margin belongs in `authToken`, never in a caller. The breaker is scoped to `grant_type=refresh_token`, because GoTrue multiplexes five grants onto that one path and signing in is how a user recovers from a storm. One caveat worth knowing before you deploy a second app beside this one: the correction lives in this bundle's JS, but the session lives in a cookie keyed by `cookieOptions.name`. Any other app sharing that origin and that key shares the session itself, and an app without this fetch can still take a fatal 429 and delete the cookie out from under this one. Give every app on the origin the same fetch, or give them different cookie names.
- **A dropped link and an unhappy network are handled differently on the SSE reconnect path** (`pages/ChatAgent/session/stream/lifecycle.ts`). Retries only spend against a network that is *present*; with `navigator.onLine` false the loop waits on the `online` event for up to 5 minutes instead, because the turn keeps running server-side and reconnect resumes it gaplessly from the cursor. Budgeting retries against a dead link is what used to truncate an answer ~15s in. Connectivity helpers live in `lib/network.ts` (`isOnline`/`waitForOnline`) — `useNetworkStatus` and the app-wide `NetworkBanner` (mounted once in `components/Main`) read the same source. A socket that died with no close is neither: `reader.read()` stays pending and no event fires, since an app switch on iOS reports only `visibilitychange`, never `pagehide` or `freeze`. So each stream the server keeps alive watches for its own silence (`lib/streamSilence.ts`) and drops itself once three keepalives are missed, or within 3s of the page's return once one is overdue. A send is watched from its headers, since the server keeps its setup audible, and those headers say only that the server heard the request, not that a run exists, so `postSSEStream` names the run to its caller with the first frame past a hold (`turn_held` is the one frame sent ahead of the run's row): a disconnect during setup cancels the turn, and before START leaves nothing at all. So a send that drops before its run's first frame goes again under its request key instead of looking for a run to resume (`DROPPED_BEFORE_RUN` in `session/stream/autoRetry.ts`), and the server's answer says which happened: a new run, a 409 to adopt, or one to `/retry`. A run adopted from a resend that then ends unheard is asked about again, since its first copy's disconnect can land after the adoption. A new long-lived stream needs a keepalive from the server and this watch; raising the server's keepalive interval past the beats there makes every quiet stream reconnect.
- **The app shell owns the viewport; route roots must not.** `.app-layout` is the only `100vh` box (`App.css`); `.app-main` → `.main` → the route wrapper are all `height: 100%` beneath it. A route root that pins itself to the viewport instead (`h-screen`, `height: 100vh`) looks identical until something else takes column height — the app-wide `NetworkBanner` does — and then it overflows the shrunken column by exactly that height, `.app-main` (`overflow: auto`) grows a scrollbar, and the bottom of the page (the chat composer) sits below the fold. Size route roots with `h-full` / `height: 100%`; `calc(100vh - …)` maxima *inside* a route's own scroll container are unaffected. **Measure the shrink point, not `.app-main`.** The shrink is done by a wrapper carrying `minHeight: 0` in `components/Main` — one per branch, and the mobile branch must also be a flex column because the mobile dashboard is `height: auto` and gets its height from being a flex item. On mobile `.app-main` reserves the bottom tab bar as `padding-bottom`, so a route that overflows by the banner's height slides *under* the tab bar and `.app-main`'s own overflow stays 0: check `.main` there. The desktop shell gives the same rule a second cause: a reserved titlebar inset makes a frameless window's viewport taller than its content box, so `vh` units overflow by exactly the inset (`100dvh` stays right where the mobile toolbar is the concern). **iOS resizes neither the layout viewport nor `dvh` for the on-screen keyboard**, so `lib/visualViewport.ts` fits the shell to what the keyboard leaves visible while a field in it has focus, and what is fixed to the screen's bottom sits on `--bottom-tab-height`, which then measures the keyboard. An overlay does not shrink with the shell and relies on Safari's pan instead: mark one that renders inside the shell with `role="dialog"` or `data-sheet`, or its field ends up behind the keyboard with no room left to pan. A composer picker is a dialog that opens from a field in the shell and measures the room above it, so it carries `data-composer-panel` and keeps the fit. A composer in a scrolling route grows downward past the fitted height, so its textarea takes a `max-height` from `roomAboveKeyboard` and scrolls inside the box, refit through `onKeyboardFit` and a `ResizeObserver` on the box.
- **The window-chrome contract with the desktop shell is decided in `index.html`, not in the bundle.** A `<meta name="langalpha-window-chrome">` declares whether this build reserves the strip the macOS window buttons float over — a declaration, because the reservation is only painted once the shell says the titlebar is gone, so a shell that measured it would only find its own last answer. An inline script in `index.html` reads the shell's preload bridge and stamps `html.desktop-mac`, and every downstream rule is gated on that class in CSS (`styles/chrome.css`, `App.css`, `Sidebar.css`, the `#window-drag` block in `index.html`), never on the bridge and never on a React branch: the class is stamped once before the bundle runs and cannot change, so a JS condition on it would be a second mechanism for a decision that already has one, and the strip the sidebar renders could disagree with the one `chrome.css` paints. `#window-drag` also lives in `index.html` so a window whose entry chunk never ran is still movable. Treat the bridge as an enhancement and feature-detect each method — the shell ships on its own slow cadence while this app deploys continuously, so a new web build must never require a new shell.
- **Agent artifact path routing has ONE source of truth: `pages/ChatAgent/utils/agentPaths.ts`.** `classifyAgentPath` (→ `memory|memo|user-data|skill|file`, normalizing `file://`, `/home/(workspace|daytona)/`, `./`, `__wsref__/<wsid>/…` cross-workspace refs; `user-data` is a data file under `USER_DATA_DIRS`, the profile files and one file per automation) + `computeAgentArtifactRouting` (pure: which panel tab/key/workspace to open). Add new path types here, not in panel components — each new location duplicates the normalization rules.
- **A destination and a path are read differently, and each string gets one reading.** `normalizeAgentHref` reads a markdown destination: it drops a `?query`/`#fragment` and percent-decodes, both exactly once. `parseAgentPath` / `normalizeAgentPath` read a path and are idempotent. The rule is not stylistic: decoding turns `%23` into a literal `#`, so a second URL reading of the result eats the rest of the name and `results/issue#1.md` becomes `results/issue` — no extension, no card, and a read for a file nothing wrote. Whatever holds the string chooses the reading; nothing downstream re-reads it. The same split is why `fileExtension` takes a path and `isImagePath` splits the location off itself.
- **The layout literals are generated, not typed.** `agentPaths.generated.ts` is emitted by `scripts/gen_agent_paths.py` from `src/ptc_agent/core/paths.py` (directory names, the sandbox roots, the system-dir list) and `agentPaths.ts` re-exports it. `tests/unit/core/test_sandbox_layout.py` regenerates and diffs the file, so a hand edit fails CI; change `paths.py` and regenerate.
- **A relative path keeps the levels it climbs.** `../data.csv` normalizes to `../data.csv`, not `data.csv`: folding it away names a different file that often exists, so the link opens the wrong document rather than missing, and `fileRefResolver.linkCandidates` loses the one thing it joins against the viewing file's directory. Only a rooted or sandbox path clamps, having nowhere to climb.
- **A folder on the same computer names its workspace.** Workspaces on one computer are sibling folders, so the agent reaches another's files as `../<folder>/x` (or `/home/workspace/<folder>/x`), and `folderWorkspacePath` routes that as a `__wsref__` link to it would. The view's own folder (`../<own>/x`) folds to `x`, since Bash reads it so. Folders match as the server's file routes match them: the current name exactly, a former one by its name key, current names first. A folder on another of the user's computers names its workspace too, at its whole path there (`<that root>/<folder>/x`, which is how the Chief of Staff links it), read only once nothing on this computer, or in the view's own folder under a root it recorded leaving, claims the path, except a stock root seen from a computer with a root of its own, which is the stock computer's before a namesake here; the rest of that machine opens nothing (`onOtherComputer`: no file card, its link is plain text, and an image there shows its name unfetched), since the panel would read it on this computer and, missing, offer a namesake. Every surface reads the folders from one `ComputerFolders` value (`computerFolders`, `useComputerFolders`, carried by `WorkspaceContext`), so a new surface takes that value rather than the folder names one by one.
- **Zod validates untrusted *persisted/user* input at the boundary, not API responses.** Widget prefs (schemas in `configSchemas.ts`, applied via `safeParse` in `migrations.ts`), onboarding prefs, MCP config — all `safeParse` + per-field `.catch()` (never throw). Typed API responses are plain TS interfaces, not runtime-validated.
- **First-load bundle is asserted, not eyeballed** (`scripts/check-critical-path.mjs`, gated in `pnpm build` + CI). Vite's build table lists every chunk as if it were lazy; only what `dist/index.html` references is actually on the critical path. A chunk group (`scripts/chunking.ts`) that captures a dependency the entry needs pins the whole group to the entry — that is how the chart bundle rode first paint for five months. If the guard trips, fix the import graph; bump `EXPECTED`/`MAX_EAGER_KB` only deliberately. Reproducing the shipped bundle needs `VITE_HOST_MODE=platform` **and** `VITE_SUPABASE_URL`/`_PUBLISHABLE_KEY` — `lib/supabase.ts` guards the client behind `url && key`, so unset vars tree-shake the whole SDK out and understate first load by ~27 kB gz. That client is auth-js's `AuthClient` alone, given `@supabase/ssr`'s cookie storage and the options `createBrowserClient` would pass, because the full client put postgrest, realtime, storage and functions on first paint (~31 kB gz) for code that only calls `.auth`. `lib/__tests__/supabaseCookies.test.ts` pins its cookies byte for byte against `createBrowserClient`'s, and the export is typed `{ auth }` so a `.from()` or a realtime channel is a deliberate new client, not a quiet regrowth of this one.
- **Stale-build recovery spans four layers that cannot see each other.** A deploy swaps every content-hashed filename, so a tab still holding the previous document asks for chunks that no longer exist. The pre-boot half is an inline ES5 IIFE in `index.html` — inline because the bundle is what failed, ES5 because a parse error there would disable recovery on exactly the browsers most likely to be stale, and in `<head>` because the listener must beat the entry script. It hands off to the app through three globals: `__LA_BOOTED__` (set by `markBooted()`, and the only thing stopping a reload from discarding a streaming turn), `__LA_STALE_BUILD__`, and the `la:stale-build` event; the app hands a boot failure its listeners cannot see back through `__LA_RECOVER__`. `lib/staleBuild.tsx` owns everything post-boot and re-implements the pre-boot classifier in TypeScript. Since `index.html` never reaches tsc, two test files pin the halves together, both by reading its source text: `lib/__tests__/staleBuild.test.tsx` asserts the shared literals (prefix, regex anchoring, event name) still match, and `lib/__tests__/staleBuildPreBoot.test.ts` lifts the IIFE out and runs it in its own jsdom, so the two classifiers are exercised against the same messages and the reload bound is reachable at all. **Same-origin `/assets/` is a hard precondition:** setting `VITE_CDN_BASE` moves the build off this origin and every layer silently classifies a real dead asset as somebody else's problem.
- **`/version.json` is build output with serving guarantees this repo cannot enforce.** The vite plugin `versionManifest` writes the entry filename; `check-critical-path.mjs` asserts it matches `dist/index.html` and that there is exactly one module script (a second one would be read as the entry and give every user a permanent "new version" prompt). At the edge it must 404 on a miss rather than fall through to the SPA shell, carry `application/json`, and never be cached — and `index.html` itself must not be long-cached, or the recovery reload refetches the same dead asset names. Those rules live in the deploy-time serving config and `public/_headers`, which are untracked here, so nothing in this repo will tell you when they break: the client fails closed and silent by design, with one `console.warn` on an unreadable manifest as the only operator signal.
- **Every frame the app makes is held by a build-time `frame-src`.** `index.html` carries a meta policy written by `scripts/vite-plugins/framePolicy.ts` from `lib/framePolicy.ts`: `'self'`, the origin of an absolute `VITE_API_BASE_URL`, TradingView's two embed hosts (`EMBED_FRAME_ORIGINS`), `mailto:`/`tel:` (`HANDOFF_SCHEMES`, which load no document) and `blob:`/`data:` (`DOWNLOAD_SCHEMES`, which Firefox and WebKit check a page's own download against, and which hold only the page's bytes). A frame's navigations, its own included, are checked against it, which is what keeps a report from replacing itself with another site inside the app's frame. A meta policy can be added to but never widened, so a frame on an origin the app learns only at runtime (a running app, a page on the preview base) renders through `viewers/HostedFrame.tsx`, which puts it in `public/frame-host.html` under a policy naming that one origin. A plain `<iframe src>` to such an origin is blocked, and so is a new third-party embed until its host is listed. `srcdoc` and `about:blank` frames are untouched. At the edge `/frame-host.html` must be served as itself (not the SPA shell) and must carry no CSP of its own beyond `frame-ancestors 'self'`, since a header policy there would intersect with the one the app writes.
- **A served HTML document is limited by what it can read, never by what it can load.** One policy covers the live view, a published version and a share (`src/server/utils/html_policy.py`): anything from anywhere, scripts and eval included, because a report that cannot reach its chart library looks broken to its reader, and what it can read is settled by its grant. What stays is the opaque origin on the API origin, so `allow-same-origin` must never join `SERVED_HTML_SANDBOX` (`viewers/html/sandbox.ts`, which holds the same tokens as the server's header; the two intersect). An opaque origin has no storage and no workers from its own files, so the bridge gives it stand-ins (`bridge/opaque.js`), IndexedDB reading as absent so a library keeps to localStorage. The bridge also drops a message from any window with no tie to the page (`isKin`), and the frame sends no referrer, so a stranger's site cannot hand the page text through a `message` listener or a crafted link. Publishing lists what the source names (`services/artifacts/manifest.py`) and what the page reports loading (`page:loads`, kept as `loaded_files`), so a path built in code still goes out; the dialog loads the page out of sight under a grant of its own (`useOwnFileGrant`), since a frame open in the panel, or the browser's minute-long cache of what it loaded, may hold code the agent has changed, and loads it again once the page's code changes (`page_sha256`, which a publish sends back and the server refuses as `files_changed` once it moved). The list is read once before that load, so the scout never runs code older than the hash it is paired with, and a later read naming other code loads the page again.
- **The React Compiler compiles the app and the unit suite** (`scripts/react-plugins.ts`; `REACT_COMPILER=off` builds and tests without it, which is how to tell a compiler regression from an app one). It caches each computation in a component on that computation's inputs, so whatever render reads behind its back freezes at the first value it saw: `Date.now()`, the module `i18n` instance, a DOM attribute, a ref, a mutable object that keeps its identity. Forcing a re-render does not help, since the tick that forced it is not an input. Hand render what it depends on: the language from `useLocale()`, the time from `useNow()`, the theme from `useTheme()`, and anything else as state or a `useSyncExternalStore` snapshot with a new identity per change. A dependency listed in `useMemo` but never read in its body is dropped, so a version counter must reach the computation itself. Functions the compiler cannot compile (a ref read in render, a `try`/`finally` anywhere in the component, a `react-hooks` eslint suppression) run as before, so a trap can sit unnoticed in one until a refactor makes it compile; the `react-hooks/*` lint warnings are that list.
- **i18n:** locale lives in a `locale` cookie (cookie → browser → `en-US`), no live cross-tab sync. Every formatter in `lib/format.ts` takes the locale as an argument, from `useLocale()`, and `relativeTime` takes `now` from `useNow()`; a helper that reads `i18n.language` itself keeps the old language after a switch. `useSyncUserLocale` can switch the language after first load with no user action, so this is not only a Settings concern.
- **Catalogs load on demand, one chunk per locale.** `main.tsx` renders only after `initI18n()` has the active one, so the first paint never shows keys, and a catalog that fails leaves `#root` to the pre-boot recovery. Change language through `switchLocale()` (`lib/locale.ts`), never `i18n.changeLanguage`: it waits for the chunk and keeps the current language if the chunk is gone. There is no fallback language, so a key missing from `zh-CN.json` renders as the key; `locales/__tests__/keys.test.ts` fails on one. `scripts/locale-preload.ts` has `index.html` fetch the active catalog alongside the entry, inlining the same `resolveLocale()` (`lib/resolveLocale.ts`) the app boots with (pinned by `lib/__tests__/localePreload.test.ts`), and `check-critical-path.mjs` counts the larger catalog in the first-load sum. Unit tests hand both catalogs to `initI18n` in `test/setup.ts`, so a switch there is synchronous. The page intros' copy is a second namespace, `intros` (`locales/intros/`), fetched the first time an intro opens rather than on every load; a step's keys are `<intro id>.<step id>.title` and `.body`, and `pages/Onboarding/__tests__/introCopy.test.ts` checks both locales against the registry.
- **`typescript` in `package.json` is the TS 6 API; `tsc` is TS 7.** TS 7.0 is a native compiler with no JS API, and `@typescript-eslint` throws on import when `typescript` resolves to 7. So `typescript` aliases `@typescript/typescript6` (the TS 6 API for ESLint; its CLI is `tsc6`), and `@typescript/native` aliases `typescript@7`, which owns the `tsc` bin that `pnpm typecheck` and `pnpm build` run. This is the side-by-side setup from the TS 7.0 release notes: collapse it to a plain `typescript@^7` only once typescript-eslint supports TS 7 (typescript-eslint#10940). Separately, TS 6+ defaults `types` to `[]`, so an `@types/*` package that only adds globals or ambient `declare module`s must be listed in the project's `types`, or its declarations drop out of the program.
- **`tsconfig.json` only lists three projects, so `tsc --noEmit` against it checks nothing; run `tsc -b` (`pnpm typecheck`).** `tsconfig.app.json` is the app: `src` minus tests, DOM types only, so `process`, `Buffer` and a `NodeJS.Timeout` from `setTimeout` fail there as they would in the browser. `tsconfig.vitest.json` is the tests plus `src/test`, which run in Node and add its types. `tsconfig.node.json` is the config files, `scripts/*.ts`, the gate script and the e2e global setup, Node types only; its JavaScript is checked through JSDoc. A module a test and the app share must pass both. The e2e specs and `scripts/perf-summary.mjs` are in no project.
- **`web/` stands alone: nothing in it resolves a path outside it.** The app builds from this directory by itself, so a tsconfig `include`, a Vite or ESLint config, an import or a test fixture read that reaches into `../` breaks any build that has only `web/`. CI's `frontend-unit` runs from a `git archive HEAD web` export to catch that. Code outside `web/` that the app's types or tests should cover is checked from where it lives, on web's toolchain: the page bridge the server splices into agent HTML (`src/server/preview_proxy/bridge/*.js`) is typed against `viewers/html/pageProtocol.ts` and tested in a jsdom page by `make test-bridge`, and the contracts shared with the server's suite (`src/lib/__tests__/__fixtures__/`) live here for that suite to read.
## Conventions
- **API layering:** each page group owns its calls in a local `utils/api.ts` (`ChatAgent`, `Dashboard`, `MarketView`, `Automations`); cross-page data goes through shared hooks in `hooks/`.
- **React Query:** hierarchical key factory in `lib/queryKeys.ts` enables prefix invalidation (e.g. invalidate `queryKeys.user.all`). Dashboard prefs write back through a guarded writer that survives cross-tab races + cold-cache mounts.
- **Styling:** Tailwind 4 (CSS-first config in `src/index.css`, v3 defaults kept in `src/styles/tailwind-v3-theme.css`) + theme-aware CSS custom properties (`var(--color-*)`) used directly in style props; `cn()` (clsx + tailwind-merge) for conditional classes; Radix primitives in `components/ui/` via `class-variance-authority`.
- **Browser floor: Chrome/Edge 111, Safari 16.4, Firefox 128.** Tailwind 4 sets it (cascade layers, `@property`, `color-mix()`); Vite 8's default build target alone would allow Firefox 114. Below the floor the page still loads but styles break, so a feature above it needs a fallback rather than a polyfill.
- **Design system** — palette, typography, accent discipline, and status/liveness vocabulary are defined in the repo-root [`DESIGN.md`](../DESIGN.md) ("Quiet Workspace"). Read it before styling anything user-visible.
- **Background roles** — pick by surface, not by matching a hex: `bg-page` (app ground) → `bg-canvas` (ground under a card grid) → `bg-card` / `bg-tool-card` (cards on it) → `bg-elevated` (menus, tooltips) → `bg-input` (fields) → `bg-popover` (Radix popover/select). The full table is the comment above the background group in `styles/tokens.css`.
- Floating surfaces deliberately do **not** share one fill yet (popover/select on `--popover`, tooltips + menus on `bg-elevated`, four dialogs still on `bg-page`); unification is deferred pending a side-by-side visual call — don't converge one of them in isolation.
- **A control drawn smaller than a finger takes `.tap-target`** (`styles/base.css`): a 44px hit area under a coarse pointer, no change in layout or on desktop. Where a neighbor sits closer than that, `--tap-target-w`/`-h` keep it to the room there is.
- Every `var(--color-*)` must be declared in `tokens.css` (`styles/__tests__/tokenRefs.test.ts` fails on undeclared names); canvas painters that can't read CSS variables go through `lib/themeTokens.ts`, never a fresh hex literal.
- **Tests:** co-located in `__tests__/` next to the code; Vitest + jsdom + Testing Library. Global setup mocks `matchMedia`/`IntersectionObserver`/`ResizeObserver` (`src/test/setup.ts`).
- `vitest.config.ts` splits the suite into two projects. A new file runs under jsdom in the vm pool. A DOM-free file declares `// @vitest-environment node` on its first line, where reading any browser global fails the file (`src/test/browserTraps.ts`, armed by `src/test/setup.ts` when there is no `window`). A file that replaces `window` or `window.location`, or needs Node's `ReadableStream`, goes in `FORKS_FILES`, since a vm context's global is the jsdom window itself.
- Playwright specs import `test` from `e2e/fixtures.js`, which gives each worker its own mock server and routes the app's API origin to it. On bare `@playwright/test`, an API call reaches a port nothing listens on.
- **Side-by-side ChatView + FilePanel headers must stay height-aligned** — if you touch either header's padding/icon size, verify they still line up (`FilePanel.css` `file-panel-header`).
## Working principles
- **Keep API calls in the api layer, not in components.** Endpoint/fetch calls belong in a page's `utils/api.ts` or a shared `lib/*` client module (e.g. market data in `lib/bars`, `lib/quotes`) — never inline in a component. Server-state access goes through `hooks/` + React Query; React-lifecycle singletons through `contexts/`. Components compose `components/ui/` primitives (they don't hand-roll Radix).
- **One source of truth — don't duplicate the cross-cutting modules.** Agent-path logic → `agentPaths.ts`; query keys → `queryKeys.ts` (never inline key arrays — it breaks prefix invalidation); locale/formatters → `lib/locale.ts` + `lib/format.ts` (never ad-hoc `Intl.*`); class merging → `cn()` (never concatenate class strings).
- **Server state is React Query; validate untrusted input at the boundary.** Don't mirror server data into local state; invalidate by key prefix. Zod (`safeParse` + `.catch`, never throwing) guards only *persisted/user* input — never runtime-validate trusted API responses, never `.parse()` at a boundary. Module-level singletons that outlive React must be reset on logout.
- **Types are the hard gate; lint gates only its errors.** Keep `pnpm typecheck` green (it gates build + CI) and lint at zero errors (CI); avoid `any` — narrow `unknown` instead — even though ESLint won't stop you.
## Env
| Variable | Default | Purpose |
|---|---|---|
| `VITE_HOST_MODE` | `oss` | `platform` → Supabase auth mode; `oss` → local-dev, no auth |
| `VITE_API_BASE_URL` | (empty = same-origin) | Backend base URL for the API client and raw streams (dev: same-origin, proxied) |
| `VITE_PROXY_BACKEND` | `http://localhost:8000` | Dev-server proxy target for `/api/v1` + `/ws/v1` |
| `VITE_DEV_ALLOWED_HOSTS` | (unset = Vite default) | Comma-separated extra Host headers the dev server accepts, for tunnels (ngrok etc.) |
| `VITE_SUPABASE_URL` | — | Supabase project URL (gates client creation, not mode) |
| `VITE_SUPABASE_PUBLISHABLE_KEY` | — | Supabase anon key |
| `VITE_AUTH_USER_ID` | `local-dev-user` | User id in `oss` mode |
| `VITE_CDN_BASE` | `/` | Asset base for CDN builds. **Disables stale-build recovery** — every layer assumes same-origin `/assets/` (see Landmines) |
| `VITE_COOKIE_DOMAIN` | (unset = host-only) | Parent domain to share auth/locale cookies across subdomains |
| `VITE_APP_ENTRY_PATH` | `/app` (platform) / `/` (oss) | Where the SPA entry (login + anonymous bounce) mounts; set `/` on a dedicated app subdomain |
| `VITE_PLATFORM_URL` | `/account` | Platform console origin/path for account, plans and integrations links. Absolute origin on a split-host deploy; **no trailing slash** (call sites concatenate) |
| `VITE_CONTACT_EMAILS` | (unset = no contact rows) | Comma-separated support addresses for the dashboard help popover; the first is also the spec dialog's "Contact support" link |
| `VITE_PRIVACY_URL` | (unset = no privacy link) | Address of the privacy policy. The sign-in and legal pages link to it, and `/privacy` forwards to it |