# Frontend Architecture Map
React 19 + React Router 7, served by `vite-express` from the same Node process
that serves the API. No SSR, no state library, no CSS framework. Roughly 73 JSX
files plus a single 9070-line `src/index.css`, on top of the vendored design
primitives (section 6).
---
## 1. Routing and code splitting
`src/main.jsx` mounts, `src/App.jsx` routes. Only `HomePage` is imported
eagerly; **eleven pages are `lazy()`-loaded** behind one
`}>`, and the whole thing sits inside a
single ``.
**`ErrorBoundary` (`src/components/ErrorBoundary.jsx`) is the only one in the
app,** wrapping `Suspense` in `App.jsx`. Before it existed, any render throw
unmounted the React root and left a blank page with no message and no way back
except a manual reload; that is what turned the editor teardown race into a
total outage (`open-questions.md` B11) and what would have made a null
workspace owner take down the admin console. It renders a message plus "Reload
the page" and "Try again", and logs with the project's
`[timestamp] Unhandled render error:` format.
Note what it cannot do: React error boundaries never see errors from event
handlers, async callbacks, or `setTimeout`. It is a floor under render errors,
not a general catch-all.
| Route | Page | Notes |
|---|---|---|
| `/` | HomePage | eager |
| `/reset-password` | ResetPasswordPage | |
| `/editor/:logId` | Editor | wrapped in `MobileEditorGuard`; the page wraps its `EditorView` in `Std_Layout`, which `ArchiveView` also embeds it in |
| `/account`, `/settings` → `/account` | AccountSettings | `/settings` is a redirect |
| `/archives`, `/archives/:archiveId` | ArchivesPage | |
| `/archives/:archiveId/doc/:logId`, `/archives/:archiveId/doc` | ArchiveView | |
| `/workspaces`, `/workspaces/:workspaceId` | WorkspacesPage | |
| `/github`, `/github/:owner/:repo` | GitHubPage | |
| `/admin` | AdminPage | |
| `/notifications`, `/notifications/preferences` | NotificationsPage, NotificationPreferences | |
| `/activity`, `/activity/workspace/:workspaceId` | WorkspaceActivity | |
| `/404`, `*` | NotFoundPage | catch-all redirects to `/404`; a standalone card (`src/pages/NotFoundPage.jsx`) |
`MobileEditorGuard` (`App.jsx:43-47`) checks
`window.matchMedia('(max-width: 768px)')` **once at render** and redirects to
`/`. It does not subscribe to the media query, so resizing a desktop window
below 768px does not evict an open editor. On mobile, documents are read-only
through `ArchiveView`.
There is no route-level auth guard. Pages check auth themselves, and the API
returns 401 regardless.
### Vite `manualChunks` (`vite.config.js:15-38`)
Five named vendor chunks, split by what a page actually needs:
```
vendor-react react, react-dom, react-router every page
vendor-tiptap @tiptap/*, prosemirror Editor, ArchiveView
vendor-highlight lowlight, highlight.js editor code blocks
vendor-yjs yjs, y-protocols, lib0 collab pages
vendor-markup marked, turndown, dompurify import/export paths
```
**Before adding a heavy frontend dependency, add it here**, or it lands in the
default chunk and every page pays for it. Anything not matched falls through to
Rollup's default chunking.
## 2. The API layer: `src/util.jsx`
727 lines, and the single place any component should talk to the server from.
`apiFetch(method, url, data)` (`util.jsx:28-52`) reads the session token from
the cookie via `getSessionTokenFromCookie()` (`util.jsx:661`), sets
`Authorization: Bearer`, JSON-encodes the body for non-GET, and on a non-2xx
throws an `Error` carrying `.status` and `.body`. `getErrorMessage(err)`
(`util.jsx:102`) is the standard way to render that. A 401 `Invalid or expired session` on a request that
carried a cookie is the one dead-session refusal: apiFetch forgets the cached user and announces it on the
window (`src/lib/session-ended.js`, `c2:session-ended`), and `Std_Layout` runs the signed-out front door
(section 3c) at once, the same as a cookie-less visit. Other 401s stay with their caller.
`setSessionCookie(token)` (`util.jsx:676`) is the one client-side writer of the
session cookie: `__Host-sessionToken` with `secure` on an https page,
`sessionToken` without it on plain http (a browser keeps neither a `__Host-`
nor a `Secure` cookie there), both `path=/`, seven-day `max-age`,
`samesite=strict`, never a `Domain`. Sign-in (`Login.jsx`, all three success
paths) and the account panel's session rotation both call it, so a token
rotated after an email or password change lives exactly as long as a fresh
sign-in's. Write the cookie any other way and the two drift.
`clearSessionCookie()` (`util.jsx:688`) expires both names, and sign-out
(`src/lib/sign-out.js`, `signOut()`, moved from the account panel in W6-CDX-40) goes through it.
`getSessionTokenFromCookie()` prefers the prefixed name and, on an https page,
does not read a lone legacy `sessionToken` at all, since another host could
have planted it. `upgradeLegacySessionCookie()` (`util.jsx:706`) moves a real
one across: `main.jsx` awaits it before the first render, it asks
`POST /api/validate-session` with `legacyCookie: true`, and it rewrites the
token under the prefixed name only on a yes. It gives up after 5 seconds, so a
silent server cannot hold the first render, and remembers a refusal for the
tab. Every cookie name is matched exactly by `readCookie` (`util.jsx:645`),
which strips only ASCII space and tab, never Unicode whitespace (W6-CDX-3;
[request-lifecycle.md](request-lifecycle.md) section 3, "The session cookie").
`serverReq` (`util.jsx:62`) is the legacy predecessor. It does **not** attach
auth. Do not use it in new code; it exists for the handful of pre-auth calls.
Below the two primitives, `util.jsx` is a flat catalogue of ~130 named wrappers
grouped by area: workspaces (`:117`), archive repos (`:124`), GitHub link and
sync (`:134`), PR and issue (`:145`), CI and releases (`:166`), squads
(`:183`), permissions (`:191`), members (`:199`), invitations (`:206`), admin
(`:218`), search and browse (`:241`), watches (`:265`), activity (`:275`),
notifications (`:291`), favorites (`:307`), comments (`:315`), archives
(`:332`), logs (`:346`), documents and versions (`:356`).
**Add new API calls here as a named export**, next to their neighbours, rather
than calling `fetch` from a component. Two paths bypass `apiFetch` deliberately
because they are not JSON: `uploadDocument` (`util.jsx:379`, multipart) and
`exportDocument` (`util.jsx:415`, blob download).
`exportDocument(..., 'pdf')` is the exception inside the exception: it opens an
`about:blank` window, writes the sanitized document into it, and **prints it
from the opener** (`printWindow.print()` on the window's `load`, and `close()` on
its `afterprint`). It must not write a `