The web app¶
nextjs-frontend/ is a Next.js App Router app, the Lens web app (built on the Aladdin design system). NextAuth holds
the session; every screen talks to the API through the generated, typed client.
Layout¶
nextjs-frontend/
app/
(auth)/ sign in, first-run setup, password reset, signin-link/ (emailed sign-in links),
external-signin/ (sign in with another provider)
(setup)/welcome/ the setup wizard after the first admin signs in (components/setup/)
(public)/explore/ pages for visitors, no sign-in needed (docs/access.md): the home page,
collections/[ns], search, recordings/[id]
(app)/ every signed-in screen, inside the app shell
page.tsx Home, with the welcome tour for new people (components/home/welcome-tour.tsx)
library/ import/ reports/ recordings/[id]/ notes/
search/ chat/ speakers/ graph/ topics/ entities/ batches/ collections/
activity/ sources/ sensors/ pipelines/ templates/ routines/ workflows/
extensions/ content-types/ resources/
settings/ admin/ account/ iiif/
api/v1/, embed/, s/, iiif/, reports/, static/, mcp/, id/, ns/, .well-known/
route handlers that proxy those paths to the API (lib/api/backend-proxy.ts)
styles/tokens.css design tokens: Aladdin + Lens additions, light and dark
components/
ui/ primitives from the design's Foundations (see below)
app-shell/ nav rail, top bar, namespace switcher, ⌘K palette, activity drawer, account menu
<area>/ one folder per screen area; pure logic in *-model.ts / model.ts, tested in __tests__/
lib/
api/ typed clients (server and browser), the proxy, SSE streams
hooks/ session and archive context (roles, current namespace), jobs
format.ts times, dates, sizes
app/openapi-client/ generated from the API's OpenAPI schema; never edit by hand
Navigation¶
The nav rail (components/app-shell/nav-config.ts) is in four groups, each named above its first item (a line when
the rail is collapsed to icons): what people use every day (Home, Library, Search, Chat, Notes), Explore what Lens
found (Timeline, Entities, Topics, Graph, Podcasts, Reports), Automate what runs by itself (Approvals while
something waits, Automations, Pipelines, Extensions, Sensors, or Sources for people who aren't admins), and
Manage Lens (Costs, Analytics, Settings, Admin). Every page stays one click from Home; ⌘K reaches every page,
recording and setting by name. The nav fits a phone screen without a swipe (ci/clicks counts one), so a new page
shares a row rather than adding one: Schedules is a tab of Automations and the Routines page opens from both (New
routine is at the top of each), and workflows, templates and content types are tabs of Pipelines.
Design system¶
Colour is semantic, never decorative: blue means working, selected or a link; green means ready or succeeded; red means failed or destructive; gold ◆ means a person needs to decide; grey means waiting. Pipeline steps use the four-colour loop (Transcribe blue, Diarize red, Analyze green, Summarize and Report gold).
- Tokens live in
app/styles/tokens.cssand are mapped into Tailwind (bg-surface,text-fg-secondary,border-blue-border,spk-1…8,emo-*,rounded-pill,shadow-2...). Don't hard-code colours: the dark theme switches by variables ([data-theme=dark], or the system preference). - Tailwind 4 is configured in CSS: the
@themeblock inapp/globals.cssmaps the tokens, and there is notailwind.config.js. Its utilities stay outside cascade layers there, so a class still beats the notes editor's global element styles (BlockSuite adds rules such asinput { font-size: 12px }to the page). - Type: DM Sans for the UI (13–14 px), Source Serif 4 for an asset's text (
.transcript-text, 17.5/1.6), JetBrains Mono for paths and code. All three are self-hosted inpublic/fonts. - Primitives (
components/ui): Button (pill;disabledReasonexplains a disabled action), IconButton, Badge, StatusChip, RoleChip, SpeakerChip, EmotionChip, JobStepChip, Field/Input/Select/Switch/Checkbox, SecretField, Tabs, Dialog, Drawer, Menu, Popover, Toast, Banner, Table with sortable headers and Pagination, Skeleton, EmptyState, CodeBlock, DateTime, Avatar, Progress, PageHeader, Panel, StepLoop, VerdictCard, AgentChip. - On a phone (below 640 px, Tailwind's
sm), nothing needs a sideways swipe and nothing is out of reach: the namespace switcher sits at the top of the menu, Import is an icon in the top bar, settings sections open as a list from the current section's name, tabs wrap, and wide tables (sensors, connections) become cards. A form's main buttons takephone-actions(app/globals.css) to stay pinned at the bottom of the screen, clear of the floating Ask button; a Dialog's actions stay in sight on their own. - The floating Ask button (
components/page-chat/ask-place.ts) finds what it would cover with the browser's own hit testing, through a transparent probe just under it, and rises above any control there that scrolling can't carry clear (one in a bar that's fixed or stuck to the bottom, or with the page at its end), or steps out of sight when that would take it past the middle of the screen, so a new page needn't leave room for it (phone-actionsand the Settings save row still do, and it stays put there). The clicks survey fails a journey whose control it covers, and checks every page with the page scrolled to its end. The clicks survey measures every journey on a phone too. - The role rule: actions a person can't take stay visible, disabled, with a tooltip saying who can
(
needRole()); namespaces they have no role in never appear.useArchive()givescan(role, ns)and the current namespace.
Data¶
- Client components use
useApiClient()with React Query and the generated SDK;data()unwraps a call and throwsApiErrorwith the API's message, andpage()unwraps a list call into{items, total}(the total comes from theX-Total-Countheader). Server components usegetApiClient(). - The loading bar along the top (NProgress,
lib/progress.ts) runs while a page or its first data loads, as in Gmail.instrumentation-client.tsstarts it on every page change (a link,router.push, back and forward) and finishes it when Next writes the new address into the browser's history, which it does for every page change that lands, even a redirect back to the page showing.DataProgressin the providers runs it while a query has no answer yet. Refreshing data already on screen (polling, refetching after a change) and anything quicker than 150 ms don't show it. - Streams (job events, chat answers) use
streamSSE()with the session's access token. - Reading again after a change the server just made:
refetchFresh(qc, key)(components/chat/data.ts), notinvalidateQueriesalone. A query's first read still on its way was asked before the change, and invalidating waits for that read rather than making a new one, so a new conversation showed its question without the answer just saved. - Media links in API responses are signed relative URLs; the proxy serves them from the app's origin, so they work in
<audio>,<video>and<img>.
The proxy¶
/api/v1, /embed, /s (short share links), /iiif, /reports, /static, /mcp (the MCP server), /id/... and
/ns (linked-data URIs and vocabulary) and /.well-known/ (OAuth discovery) are forwarded to API_BASE_URL by route
handlers at request time (bodies, byte ranges and event streams pass straight through). Because it runs per request,
one build works against any API; next.config rewrites would have fixed the API address at build time.
Developing¶
pnpm dev # :3000, with API_BASE_URL pointing at the API
pnpm test # jest
pnpm lint && pnpm tsc
pnpm build
Several dev servers can run side by side with separate build folders:
NEXT_DIST_DIR=.next-a next dev --turbopack -p 3021. The dev server runs on Turbopack (pnpm dev); the production build (pnpm build) stays on webpack. Type errors in dev come from your editor or pnpm tsc: there is no type checker in the dev server.
next dev adds its build folder's type paths to tsconfig.json; don't commit those lines.
When the API changes, regenerate the client: make openapi from the repository root.
Features the design shows but the API can't provide yet are listed in What the design needs from the API next.