Repository navigation
ADR 0020 Web App Architecture
Accepted (2026-10-09)
ADR 0003 chose SvelteKit for the web app: an installable PWA for phones and desktops, German by default with English, organised as a shell with modules, small enough to be served as static files. Phase 2 (phases) needs that app on top of the Phase 1 services: login, the mower list and detail with live status, commands, blade height and speed, settings, sub-users and API tokens. The services expose REST APIs with OpenAPI documents and Realtime's WebSocket (ADR 0018); their module descriptors announce ui_areas, but the descriptors are not reachable through Traefik. The app is the first non-Python component of the stack, so its toolchain, its type safety against the Python contracts and its delivery must fit the existing CI and Compose conventions.
The design (Design: web app, sections 1, 3, 6, 9 and 12) proposed the architecture; this ADR records it as built in Task 10 (section 14 of the design lists the corrections).
SvelteKit 2 as a static SPA. @sveltejs/kit 2.70.3 with Svelte 5 (runes), Vite 8 and @sveltejs/adapter-static with the fallback 200.html and ssr = false: every route is client rendered and nothing needs a Node runtime. SvelteKit 3 had been released three days before the task started; the owner chose the mature 2.x line (the module layout works with both, a later migration is mechanical).
Served by the web service at /. An unprivileged nginx image (nginxinc/nginx-unprivileged, uid 101, port 8080, read-only root file system with a tmpfs at /tmp) behind Traefik with router priority 1, on the same origin as /api/v1/*. Same origin means no CORS, the refresh cookie (SameSite=Strict, Path=/api/v1/identity/session) works unchanged and Realtime's Origin check can name one origin. nginx sends the security headers itself (a CSP with the hashes of the inline bootstrap script computed at build time, nosniff, Referrer-Policy, Permissions-Policy, X-Frame-Options: DENY, COOP), caches /_app/immutable/ for a year and revalidates everything else.
A pnpm workspace beside the uv workspace. pnpm-workspace.yaml lists apps/*, libs/mowdeck-ui, tests/e2e-web and tools/ts-codegen; pnpm 10 is pinned in packageManager, Node 22 in .node-version. Playwright and the code generator are separate packages so their dependencies stay out of the app. The root pyproject.toml excludes the two JavaScript packages under libs/ and tools/ from the uv workspace.
libs/mowdeck-ui as the design system. A Svelte 5 component library (Apache-2.0) without fetch, global state or i18n runtime (texts arrive as props), exported as sources. Design tokens are CSS custom properties in one file (tokens.css) with light and dark themes; there is no utility framework and no component kit. A Stylelint rule allows raw colours, font stacks and radii only in tokens.css, and a contrast check fails the tests when a token pair misses its WCAG target. The emergency stop is a component that the command bar always renders.
Types generated from committed sources. A Python script exports every service's OpenAPI document in memory to apps/web/openapi/ (no running stack); tools/ts-codegen turns them, plus the Realtime JSON Schemas of mowdeck-contracts, into TypeScript (openapi-typescript, json-schema-to-typescript). The app calls the API with openapi-fetch. Both steps have freshness checks in CI, like the contracts' JSON Schemas.
Built-in route modules, enabled by ui_areas. Each module (src/lib/modules/<name>/module.ts) declares route, label, icon, required permission and order. Identity aggregates the ui_areas of the descriptors it already polls (GET /api/v1/identity/ui-areas, filtered by the caller's permissions). A module is shown when the user holds its permission and either it is core or a deployed service announces its route; if the endpoint fails, the permission alone decides. Remote UI modules (micro-frontends) are out of scope.
Paraglide JS for i18n. Messages in messages/de.json (base locale) and messages/en.json compile to typed functions, so a missing key is a type error; an ESLint rule forbids literal text in the app's markup and a script checks that both locales have the same keys and parameters.
The native SvelteKit service worker precaches the shell per version, serves navigations network-first with the cached 200.html as fallback and never touches /api/ (it has no token).
Live state through one socket per tab, with reference-counted subscriptions, full jitter backoff and re-authentication on every token refresh; per-mower stores in which the newest observed_at wins.
- Node joins Python in CI: a
webjob (lint, check, test, codegen check, build), the web image in thedockerjob and ane2e-webjob with Playwright against the Compose stack. - No SSR: fine for an authenticated app without SEO needs; the first paint waits for the JavaScript bundle and one refresh request.
- A service that changes a route without regenerating
apps/web/openapifailspoe test; an OpenAPI change withoutpnpm codegenfails thewebjob. - The menu follows the installation: a stack without Fleet shows no mower module.
- The map is a schematic SVG plan until Task 11 adds Leaflet; geometry stays in local metres on the wire and is converted in the browser.
- Moving to SvelteKit 3 later is a separate, mechanical migration (
$libimports, configuration invite.config.ts,$app/statealready in use).
Mirrored from docs/adr/0020-web-app-architecture.md at commit cbd78dccfdc4d224880f335502cd648a59b5f2a3. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit cbd78dccfdc4d224880f335502cd648a59b5f2a3. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- Development
- Operations
- Spikes
- Guides
- Original plan (German)