Frontend Architecture
The frontend is SvelteKit 5 in runes mode (enforced globally in svelte.config.js). This chapter covers how routes and layouts are organised, the house patterns for reactive state, the shared component vocabulary, and the styling system.
Routes and chrome
Section titled “Routes and chrome”Routes live in apps/web/src/routes/. No route groups ((group)) are used. The notable pages:
/ landing /people/[id] person detail (+ /settings)/feed authenticated feed /photos/[id] photo detail (5 sub-components)/groups People / circles /upload single-photo upload/setup setup wizard /upload/batch batch upload/login /register auth pages /upload/publish single publish/reset-password auth /upload/batch/publish batch publish/invite/[token] invite accept /search /notifications /settings/admin/** admin (guarded) /api/** thin API wrappers/accessibility /privacy /terms prerendered static pagesLayout hierarchy:
+layout.server.ts— root server load: auth redirect, encryption-key check, setup redirect. Returns{ user, hasEncryptionKey, vapidPublicKey }.+layout.svelte— the app chrome: starts the notification poller, the background-task runner, push resync, and ML model warm-up; renders the desktop top bar, the mobile bottom tab bar, and the globalOfflineBanner/InstallPrompt/Toaster. Chrome is gated entirely ondata.user— logged-out routes render only a bare<main>. There is no per-route chrome suppression.admin/+layout.server.ts— the sole location of theINSTANCE_ADMIN_IDguard. Admin+page.server.tsfiles carry no auth of their own; don’t add empty page loads to “re-check”.
Page data. Interactive pages use +page.server.ts (Drizzle access); the three static legal/accessibility pages use +page.ts with prerender = true. Two shared server loaders are worth knowing because several pages funnel through them:
$lib/server/publish-page-load.ts→loadPublishPage(locals)— shared by both publish pages.$lib/server/person-group-gate.ts→requirePersonGroupMember(locals, groupId)— used by/people/[id]and its settings sub-route; redirects to/loginor/feedand returns{ group, member }.
PWA. static/manifest.webmanifest (display: standalone, lang: en-GB). The service worker (src/service-worker.ts) is registered automatically by SvelteKit — there is no manual register() call. It precaches the build + static assets in APP_CACHE = brightblur-app-${version} and serves the ML models cache-first from MODEL_CACHE = brightblur-models-${EMBEDDER_MODEL_VERSION}. That model-cache key is load-bearing: keying it to the embedder version means a model swap auto-invalidates the stale binary — a fixed name once left every existing install serving the old model and silently broke recognition (see Recognition).
State: the Svelte 5 runes patterns
Section titled “State: the Svelte 5 runes patterns”Three patterns cover almost all reactive state in the app. Recognising them is most of what you need to read the frontend fluently.
1. The accessor-factory in a .svelte.ts module
Section titled “1. The accessor-factory in a .svelte.ts module”Complex per-route state lives in a .svelte.ts module that exports a factory returning an object of $state and methods, with dependencies injected through a Deps type for testability:
export type UploadDetectionDeps = { setFaces: (faces: FaceEntry[]) => void; runRecognition: () => Promise<void>; /* …injectable seams… */};
export function createUploadDetection(deps: UploadDetectionDeps) { let imageData: ImageData | null = $state(null); let processingPhase: 'detecting' | 'recognising' | null = $state(null); // …returns an accessor object exposing the state + methods}The page calls the factory once, as a const in the component <script> — never at module scope (module-scope runes would make one shared instance across every mount). Tests call the same factory with mock deps. This is how the big pages (upload, batch, photo, setup) were split: a state module + route-local sub-components, each small enough to hold in your head.
2. Module-scope singleton stores
Section titled “2. Module-scope singleton stores”App-wide state — the notification count is the canonical example — lives as a $state object owned by the module itself:
export const notificationCount = $state({ value: 0 });export function startNotificationCountPolling(): () => void { /* subscriber-counted */ }There is exactly one notification poller (layout-owned, subscriber-counted, 30 s). New bell surfaces read the store and do a one-shot refresh; they never start their own interval.
3. $state on classes
Section titled “3. $state on classes”PhotoFeed and SearchController are ES classes with $state fields (valid in runes mode), e.g. photos = $state([]). PhotoPipeline is deliberately kept whole — do not extract methods from it (ticket bri-7407).
Supporting idioms
Section titled “Supporting idioms”.svelte.tsvs.ts: use.svelte.tsonly when the module calls a rune ($state/$derived/$effect/$effect.root).attachments/photo-render.svelte.tsmust be.svelte.tsbecause it calls$effect.root; pure logic (batch-utils.ts,batch-thumbnails.ts) stays plain.ts.$derivedfor computed values (e.g. the layout’smax-w-*from the pathname).$bindablefor two-way props (open = $bindable(false)on dialogs/menus;faces = $bindable()onFaceEditor).- Attachments (
{@attach …}): factory functions in$lib/attachments/returningAttachment<HTMLElement>—clickOutside,focusTrap,gateOnVisibility/renderWatermark(lazy decrypt + watermark),infiniteScroll.ModalcomposesfocusTrap+clickOutside. - Injectable seams everywhere: every factory takes a
Depsinterface with default implementations, so tests swap in fakes for decode/render/fetch/crypto without touching the DOM or the network.
Components and UI primitives
Section titled “Components and UI primitives”Shared components live in src/lib/components/. The contracts that matter:
| Component | Contract |
|---|---|
Modal |
onClose, closeOnBackdrop=false, backdropClass, panelClass, children. No internal busy-guard — callers pass closeOnBackdrop={!busy} and guard onClose. Composes focusTrap + clickOutside. |
ErrorBanner |
The one role="alert" banner. Every error surface uses it; never hand-roll a role="alert" div. |
Spinner |
xs/sm/md/lg; hero spinners are lg. The border-rule border-t-ink classes rely on Tailwind emission order. |
PageHeader |
title (string or Snippet) + optional actions Snippet; every mobile page renders one (desktop uses the fixed top bar). |
SkeletonFeed / SkeletonGrid |
Loading placeholders. |
Toaster / OfflineBanner / InstallPrompt |
Global, layout-owned. |
NotificationBell |
Reads notificationCount.value; one-shot refresh on mount. |
UI primitives: Bits UI (headless, accessible) — but in practice only Button is used, in a handful of files. No DaisyUI (confirmed absent). Pages are split into route-local sub-components plus state modules; the big examples are photos/[id]/, upload/batch/, setup/, groups/, and settings/.
Cohesion over line count. The small (~150-line) size of these files is a symptom of good factoring — one job per module, an injectable seam, a focused component — not a target to hit. Split a file when it does two jobs or grows an untestable seam, not to chase a number; the
PhotoPipelinecounter-example is deliberately kept whole (bri-7407) because its steps are one cohesive sequence. If a split would leave a sub-40-line fragment with a single caller and no test of its own, it has gone too far — inline it.
When you create or edit a .svelte file, run it through the Svelte MCP autofixer before finalising (and use the MCP’s list-sections / get-documentation for Svelte 5 API questions). See Conventions.
Styling
Section titled “Styling”Tailwind 4, configured in CSS — there is no tailwind.config.js. The @tailwindcss/vite plugin is used (not PostCSS), and the design tokens live in @theme blocks in apps/web/src/routes/layout.css.
The canonical design system — the hex values, type scale, spacing, and radii — has a single source of truth in docs/design.md, mirrored into @theme in layout.css. Read it before touching UI, and when you add a token update docs/design.md + layout.css + the usages in one change. The hex values are deliberately not repeated here — a second copy would only drift; this table is the token vocabulary and what each is for:
| Token | Use |
|---|---|
paper |
primary surface |
paper2 |
subtle fill (chips, secondary buttons) |
paper3 |
depressed / hover-on-paper2 |
ink |
primary text, primary button fill |
ink2 |
secondary text |
ink3 |
tertiary text, placeholders, icons |
rule |
borders, dividers |
danger |
destructive actions, error text |
danger-bg / danger-border |
error-banner fill / border |
Used as bg-paper2, text-ink3, border-rule, text-danger, …. Plus --font-sans / --font-mono and a few bb-* component classes (bb-eyebrow, bb-mono, bb-display) defined in layout.css.
Two hard rules, both enforced in review:
- Literal class strings only. Never compose Tailwind class names from variables or template literals — the compiler can’t see them and the formatter can’t sort them. Use ternaries for conditional classes:
class={active ? 'text-ink' : 'text-ink3'}. - No arbitrary values and no raw colours. No
text-[13px],p-[7px], or raw#hex/rgba()— use the committed Tailwind steps and the design tokens. Adding a token is a deliberate change todocs/design.md+layout.css+ a sweep, in one PR.
The formatter is Oxfmt via vp fmt (tabs, single quotes, no trailing commas, print width 100) with Tailwind class sorting keyed to layout.css.