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 to ~150 LOC each: a state module + route-local sub-components.
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, targeting ~150 LOC per file; the big examples are photos/[id]/, upload/batch/, setup/, groups/, and settings/.
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 — colour, type scale, spacing, radii — is docs/design.md; read it before touching UI and update it in the same change when you add a token.
The colour tokens (used as bg-paper2, text-ink3, border-rule, text-danger, …):
| Token | Hex | Use |
|---|---|---|
paper | #ffffff | primary surface |
paper2 | #f4f4f4 | subtle fill (chips, secondary buttons) |
paper3 | #eaeaea | depressed / hover-on-paper2 |
ink | #111111 | primary text, primary button fill |
ink2 | #444444 | secondary text |
ink3 | #8a8a8a | tertiary text, placeholders, icons |
rule | #e5e5e5 | borders, dividers |
danger | #dc2626 | destructive actions, error text |
danger-bg / danger-border | #fef2f2 / #fecaca | error banner |
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.