Frontend platform
apps/web is a SvelteKit 3 / Svelte 5 application and a thin client of the API: no database
access, no server-only business logic. load functions and components call the API through
@repo/api-client. Auth goes through @repo/auth/client. Everything below works on every target:
SSR on Node or Cloudflare, and the static SPA inside the Tauri shells.
Components
Section titled “Components”@repo/ui exports the app’s form vocabulary (Button, Input, Label, Select, Textarea,
FormField, Alert, EmptyState, ThemeScope) and the shadcn-svelte components generated into
the package (Dialog, DropdownMenu, ContextMenu, Command, Tooltip, Popover, Sheet,
Tabs, Card, Avatar, Badge, Checkbox, Separator, Skeleton, Slider, Switch,
Toggle, ToggleGroup, Kbd, Toaster), plus Resizable and Drawer, which it writes itself.
One copy for every surface. Button, Input, Select and Textarea are thin wrappers over the
shadcn components with one 32 px scale and one focus treatment. They keep the prop names the pages
use (primary / secondary / outline / ghost / danger, sm / md / lg, invalid;
Button href renders an <a>). Icon-only buttons, the link variant and file inputs import the
shadcn component directly. packages/ui/README.md has the mapping table and a paragraph on each
component.
- One word for red.
dangeronButton,Badge,Alertand menu items.Alert’serrorand thedestructiveofBadgeand menu items keep working.Badgealso takessuccessandwarning,Alert’s tints. Slider. A thumb is arole="slider"element, which<label for>cannot name: passaria-labelledby={labelId}fromFormField’s snippet, oraria-label. PageUp and PageDown move it by a tenth of the range, as well as bits-ui’s arrows, Home and End.ToggleGroup.type="single"is a radio group with one tab stop;deselectable={false}keeps its choice when the pressed item is pressed again, for a choice that always has an answer. Name it like a slider.Resizable. The WAI-ARIA window splitter: arrows, Home, End and Enter on a focused handle, the arrows also collapsing a pane at its minimum and bringing it back.bind:layoutrestores the sizes, andonLayoutChangehands them to you to save.ContextMenu. Opens on a right click or a 700 ms press, with the dropdown menu’s own items. It is a shortcut: every action in it must also be on a visible control.DrawerandswipeToClose. A sheet a swipe closes; Escape and the close button still do.@repo/ui/primitives. bits-ui, re-exported, for a primitive@repo/uidoes not style (Collapsible,Accordion,RadioGroup), soapps/webdeclares no bits-ui of its own.
bunx shadcn-svelte@latest add <name> -c packages/ui # then export it from packages/ui/src/index.tsThe CLI rewrites the catalog: ranges in packages/ui/package.json. Restore them before
committing (the README shows the step). Icons come from @lucide/svelte/icons/<name>. Tooltip
needs the TooltipProvider the app’s root layout mounts. In development apps/web serves
/dev/ui: every export in both themes with focus states visible. It is a 404 in every build.
Theme and dark mode
Section titled “Theme and dark mode”packages/ui/src/theme.css holds shadcn’s neutral token set as light-dark() pairs mapped in
@theme inline. With color-scheme: light dark every surface follows the OS with no JavaScript, so
the Astro sites get dark mode for free. The app adds a Light/Dark/System toggle: mode-watcher writes
.light / .dark on <html> and persists the choice. The dark: variant matches both the forced
class and “OS dark unless .light”. The tokens deviate from shadcn’s neutral base where its
defaults miss WCAG 2.2 (--ring, --sidebar-ring, --input, --muted-foreground,
--destructive) and add --overlay, --success* and --warning*.
bun run --cwd packages/ui test recomputes every contrast ratio from theme.css and fails when one
drifts. It is part of bun run verify. Consumers import the tokens with:
@import 'tailwindcss';@import '@repo/ui/theme.css';A section that is always dark, such as a media viewer or an editor, wraps itself in
<ThemeScope mode="dark">, or calls setThemeScope('dark') in its component. .dark on an
element darkens its subtree, but dialogs, sheets, popovers, menus and tooltips portal to
document.body, out of it. Inside a scope each of them, and a dialog’s scrim, takes the scope’s
class too, so they open dark as well; class="light" on one opts it out. Outside a scope nothing
changes. The README’s “A part of the page in one scheme” lists what follows and what does not. A
scope is for a surface designed in one scheme, never to override the visitor’s choice.
@repo/ui/gestures recognizes tap, double tap, long press, pan, swipe and, when asked, pinch, for
an element that handles its own touch: use:gestures={{ onGesture }}. The element decides what the
browser keeps with its own touch-action (touch-none, or pan-y to keep vertical scrolling).
swipeToClose on Sheet.Content closes a sheet on a drag toward its edge, and Drawer is a bottom
sheet with it on. Slider thumbs and Resizable handles grow under a coarse pointer, and
ContextMenu opens on a long press. packages/ui/README.md, “Touch”, has the thresholds and the
rules.
sveltekit-superforms in SPA mode with Zod 4. There are no form actions, because the API is
the backend on every target and the static build has no server. Schemas come from the contract
where a procedure exists (WorkspaceCreateInputSchema, WorkspaceUpdateInputSchema) and from
apps/web/src/lib/schemas.ts otherwise (auth, organization). spaForm in #lib/forms.js carries
the shared options, and applyApiError maps an oRPC failure back onto fields or the form. Submit
buttons stay disabled until hydration, because a native POST to a page without actions is a 405.
A failed call is phrased by messageOf(error, fallback) (#lib/errors.js, which #lib/forms.js
re-exports), and applyApiError uses it for anything that is not an input’s issue. Neither shows
the error’s message, which the API and Better Auth write in English for developers. A code that
means the same wherever it comes from (UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
TOO_MANY_REQUESTS, INVALID_EMAIL_OR_PASSWORD) gets the kit’s copy in the user’s language, any
other the fallback you pass, your words for what failed, and without one a generic message. A
code of your contract, or one whose meaning depends on the procedure (CONFLICT,
PRECONDITION_FAILED), is yours to phrase: check it with isDefinedError or codeOf first, or
name it in applyApiError’s map. An input’s issue the API reports is phrased like the form’s own.
A public demo’s refusal (DEMO_MODE) reads as the demo’s wherever it comes from, so a procedure of
yours in productDemoRefused needs no copy of its own.
A refinement that tags its refusal with params: { reason } gets its copy from
productIssueMessages (#lib/product-forms.ts), after the kit’s own reasons. A regular
expression shows the kit’s message only for the kit’s own patterns, and any other its schema’s
own message: write a rule that needs translated copy as a refinement with a reason instead of
.regex().
@tanstack/svelte-query v6 with @orpc/tanstack-query: orpc.workspaces.list.queryOptions(…)
gives the key, the fetcher and the types. Mutations invalidate orpc.workspaces.key(). Queries are
disabled during SSR (they would run without the session cookie). Pages render skeletons on the
server and fetch on the client, or load data in load with apiFor(fetch) when the HTML must
contain it. One QueryClient per browser session, provided from the root layout.
codeOf(error) from @repo/api-client reads the code of any API failure, defined in the
contract or not; use it where isDefinedError has already said no, after which TypeScript will
not let instanceof ORPCError through.
App shell
Section titled “App shell”Skip link and one <main id="main"> per page; a responsive sidebar (a Sheet on small screens)
that holds the app: the organization’s menu at the top (switch, its settings, a new one), search,
Home and your product’s links, the active organization’s workspaces, and at its foot the account’s
menu (Settings, Admin for a platform administrator, the theme, “Install app” when the browser
offers it, sign out); a settings dialog over the page; a command palette on Ctrl/⌘K (pages,
workspaces, settings, organization switch, theme, sign out, and your product’s commands from
productCommands()); toasts after every mutation; dialog confirmations for destructive actions;
skeletons and empty states for every list; an error boundary and error pages with a reference id;
banners for unverified email, failed payment and active impersonation.
Sign-out is signOut() from #lib/sign-out.js, everywhere the app signs out: it ends the session
and clears the bearer token, the analytics identity, the service worker’s caches and the query
cache, whatever the API answered. Your product’s step is productOnSignOut()
(#lib/product-sign-out.ts), which runs first, while the session still authenticates your calls;
sign-out waits for it at most 5 seconds and goes on whatever it throws.
Settings. What a person configures opens in a dialog over the page on screen, which lists its
sections down its side: the account’s, then your product’s, from productSettingsSections
(Settings of your own).
The organization’s sit between them, each listed for the roles that may open it.
Each section is a route with its own address and +page.ts, and #lib/settings.ts names them.
A link or a goto to one from a page under /app opens it over that page by a shallow
navigation: the page stays mounted as it was, the address bar shows the section’s address, and
Back closes the dialog. A visit to the address itself renders the route in the same dialog over
the shell, and closing it goes to /app. So a settings page reads its route parameters from its
params prop, never page.params, and has no layout of its own. On a narrow screen the dialog
fills the screen, and its list and the page take turns. The command palette lists every section
under Settings.
Error pages. Everything under /app, a page of yours included, fails into the app’s error page
inside the shell. A 404 or 403 your route throws is titled with the message it carries
(error(404, m.product_report_not_found())), or by its status when it carries none, above the
generic copy for its status; anything unexpected shows a reference id.
Only the 404 and 403 requireWorkspace throws, marked workspace: true on App.Error, say
that the workspace is missing or closed and lead back to the workspaces.
A page that draws its own chrome. A layout load under /app that returns chrome: 'none'
keeps the shell around its pages: the session guard and the other loads, Ctrl/⌘K, the check that
signs out an expired session, and the error boundary. The shell draws nothing else: no sidebar,
top bar, banners or padding.
A workspace page also loses its breadcrumb and tabs.
The page supplies the one <main id="main" tabindex="-1"> the skip link jumps to, a search
button that calls getShell().openPalette() (#lib/shell.js), a way into settings,
getShell().openSettings(href?), which opens the dialog over the page (at its first section
without href), and a sign-out button that calls signOut(). UserMenu
(#lib/components/UserMenu.svelte) with compact is the shell’s account menu drawn as an avatar,
for a header: pass it the user and onSettings, and it holds settings, the theme, “Install app”
and sign-out. While an administrator is signed in as someone else, the shell keeps a notice with
“Stop impersonating” at the bottom of the screen. The update notice and the verify-email banner
are the page’s to offer, from pwa and data.session.
// routes/(app)/app/reports/+layout.tsexport const load = () => ({ chrome: 'none' as const });A layout that resets to the root (+layout@.svelte) skips the shell and its loads, so its load
calls requireSession(event) from #lib/guards.js itself.
A workspace page’s also calls requireWorkspace(event, session).
Feature flags
Section titled “Feature flags”The (app) layout load fetches system.flags for the active organization. Components read
flag('key') from #lib/flags.js. Flags are created on /admin/flags (Admin).
PWA and offline
Section titled “PWA and offline”static/manifest.webmanifest and theme-color make the app installable.
src/service-worker/index.ts precaches exactly the build manifest (the immutable build output, the
static folder and the prerendered public pages, /offline among them) and serves only those from a
cache. No navigation is ever cached. A navigation to /app or /admin is one signed-in user’s
HTML, and on
a single-origin deployment /api/* and /uploads/:org/:doc are same-origin GETs as well. A cached
copy would hand the next person at that machine another user’s page. Everything outside the
manifest reaches the network untouched. Only a navigation that fails offline is answered, with
/offline. A new build replaces the cache on activation, and signing out clears it. The worker is
registered in production browsers only: never in development, and never inside the Tauri
shells, whose files ship in the bundle. The same module captures beforeinstallprompt for the
install button.
Security headers and CSP
Section titled “Security headers and CSP”The Content-Security-Policy for every page this app serves is derived at build time from the
same PUBLIC_* values the client is built with, by csp() in apps/web/vite.config.ts. That file
also holds the whole SvelteKit configuration; there is no svelte.config.js. connect-src is
'self' plus the API origin, plus PUBLIC_STORAGE_ORIGIN, the Sentry DSN’s host and PostHog’s
hosts
when those are configured. A value that is not an absolute URL fails the build. img-src is
'self' data: blob: and media-src is 'self', each plus the API origin (and
PUBLIC_STORAGE_ORIGIN when it is set), so what the API serves renders in a page on another
origin too. A remote avatar (a GitHub or Google profile picture) is blocked until you list its
origin. Your product lists its own third-party origins in productCsp() in
apps/web/product-csp.ts, which the shells’ policy merges too; csp() holds only the kit’s.
script-src carries no 'unsafe-inline'. The only inline script on a page is SvelteKit’s own
hydration bootstrap, which it nonces on a server-rendered page and hashes (sha256) on a prerendered
one. To keep that true, the root layout passes disableHeadScriptInjection to <ModeWatcher>, so
a visitor who forced a theme against their operating system’s preference sees one frame of the
other theme before hydration. apps/web/e2e/csp.e2e.ts loads /login, /signup and
/offline in a real browser and fails on a console CSP violation, on an 'unsafe-inline' in the
directive, or when img-src or media-src leaves out the API origin.
hooks.server.ts adds Cross-Origin-Opener-Policy: same-origin to every page and
Cache-Control: private, no-store to /app and /admin, and strips session.token out of the
session response before SvelteKit inlines it into the HTML for hydration. static/robots.txt
disallows /app and /admin. The remaining browser headers come from Caddy. See
Operations.
Internationalization
Section titled “Internationalization”i18n uses Paraglide JS. Messages live in apps/web/messages/{en,de}/*.json, one file per area
of the app, and are compiled before check, test and build (bun run i18n:compile in
apps/web). The locale is resolved from a cookie, then the browser’s Accept-Language,
then en. There are no URL prefixes, so the static SPA works unchanged. Users switch the
language in Settings.
Your product’s messages go in product.json, or in a product-<area>.json for each area of your
product, which i18n:compile and vite add to pathPattern in project.inlang/settings.json
themselves (commit it with the file, and restart a running vite dev). A kit message in your
own words goes in product-overrides.json, under the kit’s key: Paraglide reads it last.
The Inlang plugins that compile the catalogues are dev dependencies of apps/web, pinned by
bun.lock, and project.inlang/settings.json names their files under node_modules, so
i18n:compile, check, build and the Docker image need no network. A plugin that does not load
costs Paraglide only a warning, and it compiles no message at all, so each of those fails first
when a plugin’s file is missing (run bun install) or settings.json names one by URL, which the
Inlang SDK would fetch at every compile. A plugin of your own goes in packages/product-web
(bun add --cwd packages/product-web <package>), with the path of its bundled file in modules.
Keys are sorted as Inlang sorts them: _ before digits, digits before letters (app_x_y before
app_x1), unlike a plain sort(). bun run --cwd apps/web i18n:add puts a message in its place
in every locale. In a message, {name} is always a variable, with the same names in every
locale; write a literal brace as \\{ in the JSON ("Use \\{yyyy\\}" reads Use {yyyy}), or
pass the text as a variable. apps/web/i18n-catalogues.spec.ts fails on a key that is not
lowercase words joined by _, a key out of order, a product key outside the product catalogues
or a kit key inside them, a key in two catalogues, a variable one locale lacks, a {…} that is
not a name, an override of a key the kit does not have or with a variable it does not pass, and
a catalogue file that is not compiled. Text that never went through a message is the one thing it
cannot see: render a page in German in a spec (inLocale, Component specs)
and any English left is such a string.
The plan catalogue is translated too: planLabel, planDescription and planHighlights in
#lib/i18n.js render a plan’s name, blurb and bullet list from Paraglide messages.
@repo/billing/catalog still owns the prices and limits, and its English strings are what the API
and the Astro pricing page use. Plan copy lives in two places, so edit both.
Deployment switches (all off by default)
Section titled “Deployment switches (all off by default)”| Switch | Variable | Behaviour |
|---|---|---|
| Error tracking | PUBLIC_SENTRY_DSN (browser), SENTRY_DSN (SSR) |
The Sentry SDK is imported dynamically only when a DSN is set. Deployments without it ship none of it. Works on Node and Cloudflare. |
| Analytics | PUBLIC_POSTHOG_KEY |
A consent banner appears. PostHog loads only after the visitor accepts. analytics.capture() is a no-op otherwise. |
Accessibility
Section titled “Accessibility”bun run test:e2e runs axe (WCAG 2.2 AA) on the public pages and checks the skip link. Dark-mode
danger buttons use dark text on the lighter dark-mode red, because white would not reach the
required contrast.
Biome lints the markup too. In .svelte files it leaves WAI-ARIA widget roles alone
(role="group", radiogroup, search: useSemanticElements is off there) and counts the
@repo/ui controls a <label> wraps as its control. It still wants an element with an
interactive role to be focusable (useFocusableInteractive); the options of a listbox that moves
focus with aria-activedescendant are the exception, with one line each:
<!-- biome-ignore lint/a11y/useFocusableInteractive: focus stays on the listbox (aria-activedescendant) -->Component specs
Section titled “Component specs”Component specs (*.svelte.spec.ts) render the real component in Chromium, and in WebKit or
Firefox too when VITEST_BROWSERS names them (apps/web/README.md); mock the modules that reach
the network or the router, and nothing else. #lib/spec-helpers.fixture.ts has what
they share: navigationModule() (every export of $app/navigation: a partial mock fails at
import once the page pulls in a module that needs another, and spreading importOriginal()
there makes Vitest hang without a word), appStateModule(original, address) (a page at an
address of the spec’s choosing, ?tab= and params included) and inLocale(locale, run).
A vi.mock factory cannot use a spec’s own variables, so it imports the helper inside itself, as
the helpers’ comments show. route-harness.fixture.svelte renders layouts and a page the way
SvelteKit nests them, inside the root layout’s providers and its toaster, so a toast shows.
getByTextmatches a substring: “0 of 412” also matches “200 of 412”. Pass{ exact: true }.- Look for a message’s text through the message,
getByRole('button', { name: m.common_save() }), not its English: a product may reword it (product-overrides.json). - Mock the product files a component reads and the spec does not test (
#lib/product.js,#lib/product-sign-out.js,#lib/product-commands.js,#lib/product-settings.js): they import what the product’s own pages need, which this spec’s mocks do not provide. The shell’s specs mock#lib/product-workspace.jstoo. page.viewport(width, height)sets the frame; frames up to 1920×1200 render, and screenshot, at full scale.bind:clientWidthon an element whose height follows its width loops its ResizeObserver, which Vitest reports as an unhandled error: measure a sibling of zero height instead.- Render each page once in German (
inLocale('de', …)): English left on screen is text that skipped the catalogues.
Conventions
Section titled “Conventions”- Server-only code stays server-only:
@repo/db,@repo/auth/server,@repo/env,@repo/billingare never imported byapps/weborapps/site. Browser-safe surfaces:@repo/auth/client,@repo/auth/permissions,@repo/billing/catalog,@repo/api-client,@repo/ui.
- Forms validate the contract’s schema, never a copy.
- Every environment variable the app reads is declared in
apps/web/src/env.tsand imported from$app/env/public(public: true, reaches the browser) or$app/env/private. SvelteKit 3 hands the app nothing that is not declared there, so a new variable starts with a line in that file. - Components added later must be exported from
packages/ui/src/index.ts. apps/webtypechecks withnoUncheckedIndexedAccess,.sveltefiles included, so an index access yieldsT | undefined. It cannot extendpackages/tsconfig/base.json, because SvelteKit owns the generated base its config extends, so that one rule is repeated inapps/web/tsconfig.json.