The shape of it
The dashboard is a SvelteKit 2 + Svelte 5 single-page app that builds to a plain directory of static files. There is no Node process in production:@sveltejs/adapter-staticwrites straight intointernal/webui/dist(web/svelte.config.js), withfallback: "index.html".src/routes/+layout.tssetsssr = falseandprerender = false: every route renders client-side from that oneindex.html.internal/webui/embed.goembedsdist/and serves it. A path that isn’t a real file getsindex.html, so a hard refresh on/projects/3/deployments/7works and SvelteKit’s client router takes over from there.- SvelteKit’s server features (
+page.server.ts, form actions, serverload, hooks) are deliberately unused. Pages fetch from the Go API themselves. Adding a server-only file would break the static build (strict: truein the adapter config fails it loudly).
Caching contract
kit.appDir is "assets", so the build output looks like:
Only
assets/immutable/ is cached forever. If you ever change appDir or
add an unhashed file under it, re-check this table;
internal/webui/embed_test.go covers all three cases (the asset tests skip
when the frontend hasn’t been built).
dist/ is gitignored except for a .gitkeep, so go build works on a
fresh clone. Vite empties dist/ on every build, which is why the build
script ends with touch ../internal/webui/dist/.gitkeep.
Commands
All run fromweb/:
Dev loop
- Run a Mangrove backend on
:7777(go build -o mangrove ./cmd/mangrove && ./mangrove; it needs Docker and a Caddy admin API, see the README’s Quickstart). cd web && npm run devand open the URL Vite prints (port 5173 by default). Session cookies work because the browser only ever talks to Vite’s origin, which forwards/api/*.- Before committing:
npm run build && npm run check && npm run lint.
npm run dev against a production backend is fine for reading, but
anything you click is real. Prefer a throwaway backend (see
QA against a throwaway instance).
Source layout
Routes
Route files stay thin. They read params, pick a page component, and pass numbers in. All logic lives insrc/lib/pages/.
+error.svelte also renders NotFoundPage for a 404 and ErrorFallback for anything else, but the [...rest] catch-all means unknown paths normally never reach it. An unknown path must never silently show the Projects list.
The root layout
routes/+layout.svelte does four jobs, in order:
- Imports global CSS: the
@fontsourcefaces (Latin and Latin Extended subsets only, to keep the binary small),lib/styles.css, andlib/stores/theme.sveltefor its side effect of applying the theme. - Session gate. It calls
GET /api/auth/status, thenGET /api/auth/me, and lands in one ofloading,needs-setup,needs-loginorauthenticated. Anything but authenticated rendersLoginPage(in setup mode on a fresh install). An unreachable API is treated as needs-login, so the user gets a form rather than an endless spinner. - Shell. Once authenticated it renders
Layout(sidebar, station switcher, ⌘K search, host-critical dot) around the page. - Error boundary. Each page renders inside
{#key page.url.pathname}and a<svelte:boundary>. A page that throws while rendering showsErrorFallbackinstead of a blank screen. The#keymakes navigation remount the page, which resets its state and polls cleanly.
ToastStack sits outside the gate so toasts survive a logout.
State: rune stores
App-wide state lives insrc/lib/stores/*.svelte.ts. Each module exports a $state object and a few functions. There are no Svelte 4 writable stores and no context providers. Import the object and read it; Svelte tracks the reads.
Other localStorage keys:
mangrove-sidebar-collapsed (in Layout.svelte) and mangrove-milestone-* (in milestones.ts).
Rules every store follows:
- Wrap every localStorage access in
try/catch. Private browsing and blocked storage throw. The feature should still work for the session; it just doesn’t persist. - Validate what you read back. An unknown stored value falls back to the default. For example, a deleted workspace id falls back to “All workspaces”.
- Role checks are cosmetic.
isOwner()andworkspaceRole()hide buttons. The real boundary is the API (auth.RequireOwner,auth.RequireWorkspaceRole); see multi-user.md.workspaceRole()returns""when unknown, so=== "admin"checks fail closed.
Talking to the API
lib/api.ts is the only place that calls fetch for JSON:
- Requests are same-origin with the session cookie. There are no tokens in JavaScript.
- A non-2xx response throws
ApiErrorwith.statusand the server’s{ "error": "..." }message. Show it witherrMsg(e). 204resolves toundefined.- The TypeScript interfaces in
api.tsmirrorinternal/modelsand the handler response shapes by hand. When you add or rename a JSON field in Go, update the interface in the same change.
LogViewer.svelte opens an EventSource on /api/services/:id/logs/stream and closes it in the effect cleanup.
Polling
There are no websockets for state. Detail pages poll, and every poll follows the same shape:latestHealth.svelte.ts), 5 s for Server health, and 5 min for the sidebar’s host-critical check. Keep new polls at 4 s or slower. The box this runs on is small, and some endpoints (/api/admin/resource-budget) are expensive.
Editable cards under a poll
A settings card (sleep, self-heal, scale, access control…) gets a freshdeployment object every 4 seconds. Copying props into local state on every update would wipe whatever the user is typing. The cards only re-sync when the server’s values actually changed:
Components worth knowing
Modal.svelte: spring open/close, Escape and backdrop to dismiss. Itschildrensnippet receivesclose. Wire Cancel buttons to it so they play the exit animation:Nested components can get the same function withgetContext("modal-close").ConfirmModalwraps this for yes/no prompts.size="lg"(760 px) is for dialogs that lay out a grid.TideGauge.svelteandTideBanner.svelte: the status language. Both take a rawstatusand map it withtideLevel(), so a gauge, its banner and itsStatusPillalways agree.TideBannerswitches to night palettes whenappearanceState.scheme === "dark".StatusPill.svelte: the text partner of a gauge. Simple mode usespages/simple/PlainStatusPill.sveltefor plain-language labels.Gauge.svelte: tide bars for resource meters (memory, disk).Icon.svelte:<Icon name="deploy" />. Names come fromlib/icons.ts. To add an icon, add an entry there: inner SVG markup on a 24 px grid, 1.9 stroke, round caps. No icon fonts and no emoji.LogViewer.svelte: streamed logs with stick-to-bottom auto-scroll.ErrorFallback.svelte,CenterLoading.svelte, andcomponents/skeleton/*: error, loading and skeleton states. Use the skeletons for lists so the layout doesn’t jump when data arrives.components/deployment/*: the cards on a deployment’s Overview tab, one per feature (sleep, self-heal, scale, staging, previews, promote, auto-deploy, access control, webhook health).
Styling, themes and dark mode
All styling is in one hand-written stylesheet,src/lib/styles.css. There is no Tailwind and no component library. Components use its shared classes (.card, .btn, .btn-primary, .pill, .field, .kv-list, .page-header, .empty-state, …) and only add scoped <style> for layout that truly belongs to one component.
Colours, spacing, radii and shadows are CSS custom properties on :root. Never hard-code a colour in a component. If no token fits, add one, and give it a dark value too.
Two independent attributes on <html> change the palette:
Appearance has three choices. System (the default) follows
prefers-color-scheme live, including when the OS switches at sunset. Light and Dark override it. Whatever is chosen, the store resolves it to data-color-scheme="light" or "dark". That one attribute is all the CSS keys on; the CSS never uses @media (prefers-color-scheme).
No flash on load: src/app.html runs a tiny inline script before the app bundle loads. It reads mangrove-theme and mangrove-appearance from localStorage and sets both attributes. The store then re-applies them and keeps them in sync. If you add another visual preference, set it in both places.
When you add UI, check it in both schemes. In practice:
- Use tokens such as
--bg-card,--bg-input,--bg-modal,--text*,--border*and the status washes, not literal hex values. - SVG art that can’t use CSS variables (like
TideBanner’s gradients) readsappearanceState.schemeand picks a palette in script. - Dark status pills use opaque fills, because translucent washes go muddy on the dark page. See DESIGN.md’s “Dark appearance” section.
Recipes
Add a page
- Create
src/lib/pages/FooPage.svelte. Poll with the pattern above if it shows live state. - Create
src/routes/foo/+page.sveltethat imports and renders it, with numeric params converted usingNumber(page.params.x). - If it has a simple-mode version, branch on
uiMode.modein the route file, the way/does (see modes.md). - Add a sidebar entry in
components/Layout.svelteif it’s top-level. Keep the active-state check explicit, so a 404 never highlights it. - Update
docs/REPO_MAP.md(theweb/row and “Where to look”).
Add a field from the API
- Add the field to the Go model/handler.
- Add it to the matching interface in
src/lib/api.ts. - Render it. If it’s editable on a polled page, include it in that card’s sync signature.
Add a design token
- Add it to
:rootinstyles.csswith a light value and a short comment. - Add its dark value to the
:root[data-color-scheme="dark"]block. - If it’s part of the documented palette, add it to DESIGN.md.
QA against a throwaway instance
Don’t point experiments at a live install, and don’t run./e2e/run.sh on a box that also runs production Mangrove: its teardown force-removes every mangrove-* container. Instead, run a second, isolated binary:
- The separate data dir, Docker network and port range keep it away from the real install’s DB, containers and ports.
- Pointing the Caddy admin address at a closed port (
:9) means it can never touch the live Caddy config. - Seed data through the API. To see every visual state at once, set a spread of deployment statuses (
running,building,failed,stopped) directly in the scratch SQLite DB. - Check both appearances: change Settings → Appearance, or emulate the colour scheme in Playwright with
colorScheme: "dark". Also check 1440 px and 390 px widths. - Sign-in (and setup) is rate-limited to 5 requests per 5 minutes per IP (
internal/api/router.go). A script that signs in for every page will start failing with a generic error; sign in once and reuse the cookie, or restart the instance to reset the limiter. - Tear down when done: stop the process by its PID,
docker network rm mangrove-uitest-net, and delete$SCRATCH/data.
Shipping a frontend change
The dashboard is compiled into the binary, so a UI change ships like any backend change:npm run build, go build, then the binary swap in deployment.md. Open tabs pick up the new UI on their next full load, because index.html is never cached and the hashed bundles it points to are new filenames.