Development
Frontend guide
Working inside apps/frontend — SvelteKit structure, the Bits UI rules, and the theme tokens.
SvelteKit (Svelte 5), Bits UI, Tailwind CSS 3.4, Tiptap, Lucide.
Directory map
| Directory | Responsibility |
|---|---|
src/routes/ |
Pages (SSR) plus /api/[...path], the reverse proxy to the backend |
src/lib/api/ |
Typed API client, plus contract.ts — the compile-time check that those types still match the backend |
src/lib/components/ |
Application components; Icon.svelte is the single Lucide wrapper |
src/lib/components/ui/ |
Bits UI (headless) wrappers |
src/lib/editor/ |
Tiptap integration, lazy-loaded on /compose |
The reverse proxy
/api/[...path] proxies to the backend, so the browser only ever talks to its
own origin. That means no CORS configuration and cookies that just work —
the session cookie is same-origin by construction.
INTERNAL_API_URL tells the frontend how to reach the backend inside the
container network.
API types and the backend contract
src/lib/types.ts declares the shape of every API payload this app consumes.
They are written by hand, on purpose: they carry the reader’s half of the
documentation — which field to build a link from, which is null and when, which
endpoint omits which — and a generated type holds none of that.
The risk in writing them by hand is drift. src/lib/api/contract.ts removes it.
It imports the backend’s serializers as types only and asserts that each payload
the backend produces is assignable to the type declared for it here. Change a
serializer in a way the frontend cannot read, and pnpm check fails and names
the pair, instead of the mismatch surfacing at runtime in whichever component
read the field.
The check is one-way — backend payload must fit the frontend type:
| Change | Result | |
|---|---|---|
| Backend changes a field’s type | Fails | The frontend would misread it |
| Backend stops sending a field | Fails | The frontend expects it |
| Frontend declares a field never sent | Fails | undefined at runtime |
| Backend adds a field the UI ignores | Passes | Harmless |
Requiring the two to match exactly would reject that last row, which is normal,
and would break every type that deliberately widens across endpoints — Post
has optional fields precisely because barePost returns a subset.
Adding a type to types.ts does not require an assertion, and many have none:
the check covers the payloads a named serializer produces. Shapes built inline
in a route handler have no named backend type to assert against, and would need
naming in the backend first.
UI rules
These are strict, and they are what keeps the app visually coherent.
1. Use Bits UI for every primitive that has one
Button, Avatar, DropdownMenu, Tabs, Toolbar, Label, Separator, Dialog, Tooltip,
and the rest. Fall back to native HTML only where Bits UI ships no equivalent
— text <input>, <form>, headings, layout — because Bits UI is headless and
has no such component.
2. Style with the ported theme tokens, never ad-hoc colours
| Kind | Tokens |
|---|---|
| Colours | foreground, foreground-alt, muted, muted-foreground, background, background-alt, dark, dark-10, accent, destructive, border / border-input |
| Radii | rounded-input, rounded-card, rounded-9px, rounded-button, … |
| Shadows | shadow-mini, shadow-popover, shadow-btn, shadow-card |
3. Copy the docs’ class strings verbatim
The Bits UI docs are the single source of truth for appearance. Copy each component page’s example classes as-is, adapting only for Tailwind v3 (the docs are v4):
| Docs (v4) | This project (v3) |
|---|---|
outline-hidden |
outline-none |
ring-0! |
!ring-0 |
data-highlighted: |
data-[highlighted]: |
Where the theme lives
- Tokens:
apps/frontend/tailwind.config.ts - CSS variables, ported verbatim from the docs’
:root:apps/frontend/src/app.css
Keep them in sync with the Bits UI docs theme. Do not invent new design tokens.
Icons
One wrapper, Icon.svelte, around Lucide. Import icons through it rather than
scattering direct Lucide imports — it keeps sizing and stroke width consistent
and the bundle predictable.
The editor
Tiptap lives in src/lib/editor/ and is lazy-loaded on /compose only. It
carries Markdown input rules, image upload on paste and drop, and corner-drag
image resizing. It is the heaviest dependency in the app; keep it off every other
route.
Typography and fonts
- Inter (variable, self-hosted) for UI — no render-blocking third-party font request.
- Georgia and friends for rendered post content: a Medium-like serif reading column.
- Twemoji as a self-hosted COLR colour font, scoped by
unicode-rangeto emoji codepoints only, so every reader sees the same emoji artwork regardless of OS while the document keeps plain Unicode emoji — federation-friendly, selectable, copyable.
Rendered post content
.prose-omicron styles the HTML Tiptap produces. Media never overflows the
reading column, and wide preformatted blocks are scaled to fit rather than
pushing the page sideways.
Checks
cd apps/frontend
npm run check # svelte-check against tsconfig.jsonFound a mistake?Edit this page on GitHub.