Skip to content

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.

@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. danger on Button, Badge, Alert and menu items. Alert’s error and the destructive of Badge and menu items keep working. Badge also takes success and warning, Alert’s tints.
  • Slider. A thumb is a role="slider" element, which <label for> cannot name: pass aria-labelledby={labelId} from FormField’s snippet, or aria-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:layout restores the sizes, and onLayoutChange hands 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.
  • Drawer and swipeToClose. A sheet a swipe closes; Escape and the close button still do.
  • @repo/ui/primitives. bits-ui, re-exported, for a primitive @repo/ui does not style (Collapsible, Accordion, RadioGroup), so apps/web declares no bits-ui of its own.
Terminal window
bunx shadcn-svelte@latest add <name> -c packages/ui # then export it from packages/ui/src/index.ts

The 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.

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.

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.ts
export 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).

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).

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.

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.

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.

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.

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 (*.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.

  • getByText matches 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.js too.
  • page.viewport(width, height) sets the frame; frames up to 1920×1200 render, and screenshot, at full scale.
  • bind:clientWidth on 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.
  • Server-only code stays server-only: @repo/db, @repo/auth/server, @repo/env, @repo/billing are never imported by apps/web or apps/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.ts and 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/web typechecks with noUncheckedIndexedAccess, .svelte files included, so an index access yields T | undefined. It cannot extend packages/tsconfig/base.json, because SvelteKit owns the generated base its config extends, so that one rule is repeated in apps/web/tsconfig.json.