One Svelte codebase. It is not a collection of feature pages — it's a renderer that fetches the attachment manifest and draws whatever the backend describes. Adding an attachment never touches this codebase.
| Target | How | Status |
|---|---|---|
| Laptop web app | SvelteKit + @sveltejs/adapter-static → static bundle, served by the spine |
v1 |
| Android APK | Wrap the same static build with Capacitor | v1 (temp) |
| Native Android | Flutter renderer reading the same manifest schema | later |
Static adapter means no Node runtime on the Pi — the spine just serves files.
Capacitor wraps that same build/ output into a WebView APK, so the phone gets the
identical app for near-zero extra work.
frontend/
├── src/
│ ├── lib/
│ │ ├── api.ts # auth-aware fetch client (token/cookie, refresh)
│ │ ├── manifest.ts # fetch + cache the /api/manifest
│ │ ├── ws.ts # WebSocket client, event subscriptions
│ │ ├── cache.ts # IndexedDB/localStorage cache of UI specs+bundles
│ │ └── renderer/
│ │ ├── Renderer.svelte # walks a UISpec, picks components
│ │ ├── components/ # one Svelte comp per descriptor `type`
│ │ │ ├── View.svelte
│ │ │ ├── List.svelte
│ │ │ ├── Table.svelte
│ │ │ ├── Input.svelte
│ │ │ ├── Button.svelte
│ │ │ └── …
│ │ └── bundleHost.svelte # Tier-2 escape hatch loader (custom elem)
│ ├── routes/
│ │ ├── +layout.svelte # nav built from manifest (one entry per attachment)
│ │ ├── login/+page.svelte
│ │ └── a/[name]/+page.svelte # renders attachment `name` via Renderer
│ └── app.html
├── capacitor.config.ts
└── package.json
- Determine the base URL of the spine. The web app served by the spine uses same-origin. The APK needs a configured server address (see §6, multi-transport).
- Check auth (valid token/cookie?). If not →
login/. GET /api/manifestwith anIf-None-Match(ETag). 304 → use cached manifest.- Build the nav: one item per attachment (
title+icon). - Open the WebSocket for live events.
- Navigating to an attachment renders its
uidescriptor throughRenderer, or loads its Tier-2 bundle.
Renderer.svelte takes a UISpec node and recursively renders. A registry maps
node.type → Svelte component. Each component:
- reads static props (
text,placeholder, …), - resolves data from
source/getvia theapiclient (scoped to the attachment's/api/att/<name>/prefix), - wires
actions (get/post/…) back through the same client, - supports simple bindings (
bind,$varinterpolation from local form state).
Keep it small and declarative. The component set is the contract with attachment authors — see vocabulary list in 20-attachments.md §Tier 1.
For attachments that ship a JS bundle: bundleHost loads
/api/att/<name>/ui.js, which registers a custom element, and mounts it inside
a boundary. It passes the element a scoped, authenticated API client (no raw
session cookie/token exposed — the client signs requests on the bundle's behalf and
only allows the attachment's own endpoints). CSP restricts what the bundle can do.
This tier won't port to native Flutter (would need a WebView fallback there), which is why the shell nudges authors toward Tier 1.
The web app served by the spine is same-origin → trivial. The APK / standalone client needs to know where the spine is, and you have several paths to the same box:
- A server picker / settings screen: store one or more base URLs —
e.g.
https://pi.yourdomain.com(Cloudflare/port-forward),http://raspy.tailnet-name.ts.net(Tailscale),http://192.168.1.x:PORT(LAN). - Optionally auto-select: try Tailscale/LAN first (fast, local), fall back to the public domain. Since auth is identical on every transport (30-auth-security.md §5), switching transports doesn't re-authenticate beyond the normal session.
- The session token works regardless of which URL reached the spine.
- Manifest + per-attachment UI specs cached (IndexedDB), keyed by
ui_version. - Tier-2 bundles cached by version too.
- On launch the shell renders from cache immediately, then revalidates via ETag — so the UI is instant and survives brief disconnects. Actual data still needs the server (it's a control plane for a live Pi, not an offline app).
- Svelte static build gives laptop + APK from one build with Capacitor — fastest path to a working product.
- The declarative descriptor is framework-neutral, so a Flutter renderer later reads
the same
/api/manifestand builds native widgets — no backend changes. - We only pay the Flutter cost once the web/Capacitor version proves the model and we want real native polish.