ui/. It takes ~20 minutes and
after it you should be able to add a route, wire an endpoint, and
ship without breaking the things that are deliberate. Companion to
SPA backend, which documents the server side of the
contract.
1. What this frontend is, in one paragraph
A single-page Svelte 5 app, compiled by Vite into three static files (one HTML, one JS, one CSS), committed into the Go repo and embedded into the suchi binary withgo:embed, served at /app/.
It has zero runtime npm dependencies — no component library, no
router package, no icon package, no CSS framework, no webfonts.
Everything it knows about the world arrives through suchi’s
documented HTTP API, the same one the mobile apps and agents use.
Current weight: ~50 KB gzipped JS + ~4.5 KB CSS. That number is a
feature; treat it like a budget, not a coincidence.
2. The constraints that explain everything else
Every architectural choice below falls out of four constraints. When you’re unsure how to build something, re-derive from these:- suchi is one binary. The UI must not add a runtime, a sidecar, or a deployment step. Hence: static build, embedded, no SSR, no SvelteKit (its server layer would be dead weight — the Go binary is the server).
- Contributors build the Go project without Node. Hence: the
built
dist/is committed tocore/ui/spa/dist/(marked linguist-generated). Node/bun is only needed when you touchui/. - The API is the only door. No private endpoints for the UI.
If the UI can do something, an agent token can do the same thing
with the same calls. This keeps the UI honest and the API
complete — several server endpoints (
/api/stats/,/api/events/,/api/jd/categories/) exist because the UI needed them and the need generalized. - Minimal supply chain, by org policy. Three devDependencies
(
svelte,vite,@sveltejs/vite-plugin-svelte), pinned via lockfile, no postinstall scripts. Adding a dependency is a design review, not annpm install.
3. Repo layout & the build pipeline
dist/
without rebuilding the binary, or editing ui/src without running
make ui. Source, dist, and binary are three snapshots that only
agree if you run the pipeline. The CI stale-dist check (paths-gated
on ui/**, builds and diffs against the committed dist) exists to
catch exactly this — keep it green.
Sync discipline: the ui/ tree is replaced wholesale or patched
with a reviewed diff. Never hand-port features between trees by
eye — that’s how the repo once ended up with a source tree older
than its own committed dist.
4. Routing
Hash routing (#/documents, #/doc/42, #/search?q=tax),
implemented in router.svelte.js: parse location.hash into
{path, parts, query} held in a $state object, updated on
hashchange. That’s the whole router.
Why hash and not history-API paths: the SPA is served at /app/
behind arbitrary reverse proxies. Hash routes need zero server
route configuration — no catch-all, no rewrite rules — and deep
links survive any hosting arrangement, including file:// during a
quick check. The Go handler only has to serve index.html for
/app/ and the two asset files.
Adding a route: create src/routes/Thing.svelte, import it in
App.svelte, add a branch to the {#if page === …} chain and (if
nav-worthy) an entry in the nav array. There is no route registry
beyond that chain — with ~11 routes, a registry would be
architecture cosplay.
5. State: Svelte 5 runes, and what we deliberately don’t have
The app uses runes ($state, $derived, $effect, $props)
throughout — no legacy stores, no $: labels. Cross-cutting state
lives in .svelte.js modules exporting $state objects
(session.svelte.js is the pattern). Everything else is
component-local: each route owns its data, fetches on mount, and
refetches on its own triggers.
There is deliberately no global cache/store layer (no
Redux-alike, no query library). Routes remount on navigation and
refetch; the API is local and fast, and the payloads are small. The
two pieces of genuinely shared server state — the stats snapshot and
the events feed — live in App.svelte and flow down as props
(see Dashboard). If you’re about to build a cache, first check
whether a 60-second poll and a refetch-on-visibility (Documents
does this) already solve it, because so far they always have.
Persistence uses localStorage under a suchi. prefix, always
wrapped in try/catch (private-mode Safari throws): suchi.token,
suchi.theme, suchi.jd.open (sidebar tree), suchi.events.seen
(activity read cursor), suchi.setup.dismissed, suchi.docs.view
(list/grid). Nothing sensitive beyond the API token goes in storage,
and the token is same-trust-level as the cookie.
6. The API layer — one file, several load-bearing conventions
lib/api.js is the only place fetch appears. Conventions:
- Named function per endpoint, thin, with the wire contract in a comment where it isn’t obvious. Grep-ability beats abstraction.
- Auth: every request sends
Authorization: Token …if a token is stored, pluscredentials: 'same-origin'so the session cookie rides along. JSON login returns a token and sets the cookie (server-side decision); the token covers API calls, the cookie covers things that can’t carry headers —<iframe src>previews and<a download>links. Don’t break either channel. - Errors: non-2xx throws
ApiError{status, code, message, data}.datais the parsed body — it matters (the 409 duplicate-upload body carries the matched document; that’s a feature, not an error detail). - Pagination: list endpoints wear the DRF envelope
{count, next, previous, results};qs()builds query strings, skipping empty values. Exception by server design:/api/tasks/is a live-poll queue, not a paginated list — don’t add paging UI to it. - Degrade, don’t gate. When the UI is built against an endpoint
that may not exist yet on the operator’s server (this happens —
profile editing shipped UI-first), the failure mode is a calm
toast pointing at the backend-tasks doc, never a broken screen
and never a version check. Grep
catchin Settings for the pattern. - Enums come from the server when possible with hardcoded
fallbacks, not hardcoded truth — the automations builder
pulls trigger/action specs from
GET /api/automations/schemaand only uses its literals offline. New backend enum ⇒ new UI capability with no release.
/api/jd/categories/ listing (grouping
key is area_code from the rows — never derived from a category
code; the server owns that invariant).
7. Design system — app.css is the whole thing
No preprocessor, no Tailwind, no CSS-in-JS. ~25 custom properties at
the top of app.css drive everything; dark mode is a second token
block under [data-theme="dark"], not a second stylesheet.
Palette (shared with the brand + landing page — changing the
accent here without changing brand/generate.py is a bug):
Type: system stack, with
"Schibsted Grotesk" (UI) and
"Spline Sans Mono" (codes) named first — no webfont ships, but
if the operator serves those files the brand faces light up
automatically. Don’t add a webfont; it’s the single biggest weight
line-item available and it was declined on purpose.
The signature element — the dotted index row (.irow). The
logo is ”· ————”: a dot, then a line. Every list row in the app is
that mark made functional: leading status dot (accent = normal,
green/amber/red = sensitivity or job state), optional JD chip
(mono, tinted), title, metadata to the right. If you build a
new list and it isn’t an .irow, you’re probably off-brand. Related
vocabulary: .chip (mono JD code), .pill (soft status), .card,
.index (row container), .btn/.btn.primary/.btn.sm,
.bulkbar, .seg (segmented toggle).
Rules of the stylesheet: flat class vocabulary, no nesting, no
specificity games — any rule findable by grepping its class.
Component-specific styles may live in the component’s <style>
(Svelte scopes them); anything reused twice moves to app.css.
Spacing/radii via the tokens (--r, --r-sm).
Motion & a11y baseline: animations are short, CSS-only, and
every one is disabled under prefers-reduced-motion. :focus-visible
gets the accent outline. Interactive things are real
<button>/<a> elements (the one place we style a button as a
heading — click-to-rename on detail — still uses a <button>).
Keyboard surface: ⌘K palette, j/k/x/Enter on lists, Cmd/Ctrl+Enter
on approvals, Escape closes overlays. New features should extend
this, not regress it.
Empty states are copywriting: icon + a bolded truth + a next
action (“Inbox zero. Everything is filed.” / “Nothing needs you.
The archive is running itself.”). Voice: plain, a little dry, never
exclamatory, no em-dashes in UI copy.
8. Security posture (frontend’s share of it)
- Untrusted strings never meet
{@html}with one audited exception: FTS search snippets, which pass throughsafeSnippet()— escape everything, re-allow only<mark>. If you need{@html}anywhere else, you need a sanitizer and a review, in that order. - Previews are hostile documents. They render in an iframe from
/preview/{id}; the server applies a sandboxing CSP to that response. The client’s job: never set the iframesrcfor a confidential doc until the user reveals (blurredgate inDocumentDetail), and pass?reveal=1only on explicit action. - Tokens: shown once on mint (Settings), stored hashed server-side; the UI never logs or re-displays them. Don’t put secrets in URLs.
- No third-party requests, ever. No CDN scripts, no analytics, no font hosts. The privacy page says zero egress; the UI holds that line too.
9. Feature map (route → server surface)
10. How to add a feature (the checklist that keeps quality flat)
- Contract first. Read the Go handler (or SPA backend) and pin the exact field names — this project’s history is littered with corrected assumptions; grep the server, don’t guess. If the endpoint doesn’t exist, write it into the current backend-tasks doc and build the UI to degrade.
- Add the call to
lib/api.jswith a contract comment. - Build the route/component:
.irowfor lists, tokens for colors, empty state with a next action, keyboard path if it’s a list, reduced-motion-safe if it moves. cd ui && bun run devagainst a runningsuchi serve; exercise the failure paths (401, 404-endpoint-missing, empty list) not just the happy one.make ui(check the size delta — a feature costing >5 KB gz should be able to say why) → commitui/andcore/ui/spa/dist/together →make buildto verify embed.- Update the feature-map table above.
11. Known trade-offs, so you don’t “fix” them
- Refetch over cache: chosen; see §5.
- Sequentially-numbered polls, not SSE: the 60s poll is interim
by design;
GET /api/events/streamis the planned upgrade and the drawer was built to swap onto it without layout change. {#if}route chain, hardcoded nav array: fine at this scale; revisit at ~20 routes, not before.- Per-view count probes on the dashboard: acceptable below ~10
saved views;
?with_counts=1server flag is the documented escape hatch. - Client-side JD grouping: the flat listing + client grouping is the contract; a nested endpoint was considered and rejected.
- No tests in
ui/: honest current state. The compensating controls are the tiny dependency surface, the API being the only logic boundary (tested server-side), and the CI stale-dist check. If the app grows real client-side logic (offline, optimistic writes), that’s the trigger to add Vitest — not before.
12. Glossary
JD / Johnny.Decimal — the filing taxonomy: areas (10–19, 20–29…) containing categories (11, 12…). The sidebar tree, chips, and refile pickers all speak it. Inbox — the system JD category where low-confidence documents wait; its id comes from/api/stats/
(inbox_category_id), never from name-matching. Sensitivity —
public | internal | confidential; confidential blurs previews
until revealed. Dead job — a pipeline job that exhausted
retries; shows in Approvals, the drawer, and the bell count. The
drawer — the right-side activity panel (events feed + tasks).
Bundle — one share token covering 1–200 documents. The dotted
index row — see §7; if you remember one design fact, make it this
one.