Localisation
How interface copy is translated, how messages are named, and how tests find translated text.
The app is translated with Paraglide. Copy lives in apps/app/messages/<locale>.json, one file per locale, and compiles to typed message functions under apps/app/src/paraglide. That output is generated, not committed. bun run test and bun run ci compile it first, and the Vite plugin does the same during dev and build, but the app's own test script expects it already compiled.
Locale resolution
The locale is not in the URL. Paraglide resolves it from the PARAGLIDE_LOCALE cookie, then the browser's Accept-Language, then falls back to the base locale en. The language switchers in the header and on the sign-in and account creation pages set the cookie and reload.
The locales are en (the base, in British spelling) and de. localeSchema in @orcai/schema lists them for everything outside the Paraglide runtime.
Stored preference
Each user has a nullable user.locale, a Better Auth user field, so it travels with the session. The cookie stays the source for every request, and the stored value never triggers a lookup. They meet at three points:
- Creating an account stores the request's locale.
- Sign-in sets the cookie from the stored value, so a new browser follows the user. A user without one gets the request's locale stored instead, which backfills accounts that predate the column.
- A signed-in user who switches language saves the choice through Better Auth before the reload, and a failed save keeps the current language.
The stored value is what out-of-request work, such as emails, uses for that user.
Messages
User-facing interface strings in apps/app/src belong in message functions:
import { m } from "@/paraglide/messages";
<Button>{m.common_save_button()}</Button>Message functions are plain functions, so hooks, utilities, loaders, and server functions call them the same way. On the server the Paraglide middleware in apps/app/src/server.ts scopes the locale to the request.
Keys are flat, lower snake case, and named after where the text lives, not after what it says:
<area>_<component or screen>_<element>[_<qualifier>]
chat_composer_placeholder
bots_tile_edit_action
access_dialog_titleA key never encodes its English text, so rewording the copy leaves the key alone. When the surrounding component is renamed, rename the key with it. Interpolation uses {name} placeholders, plural and other variants use Paraglide's variant syntax in the message file, and copy with inline links or emphasis is split into separate messages around the marked-up part. Dates, numbers, and relative times go through Intl with the active locale from getLocale().
Dates and time zones
Dates show in the browser's time zone, and the server renders them too. Components format them with useDisplayTime() from apps/app/src/hooks/use-display-time.ts, or with <DisplayTimestamp> and <DisplayDate> in table cells, never with Intl and a bare Date. The hook stores the browser's zone in the time_zone cookie. The server renders in the zone that cookie names, UTC without one, and hydration uses the same zone, so the markup matches before React switches to the browser's. A relative time such as "5 minutes ago" depends on the clock, which differs between server and browser, so a server-rendered one sets suppressHydrationWarning on its element, as the recent chats on the home page do.
Content that users author, such as bot names, descriptions, assets and organisation copy, is not translated.
Emails
Notification emails have their own catalogue in packages/notifications/messages/<locale>.json, with keys prefixed email_, so the package never imports from the app. Every outbox payload carries a locale, and the templates pass it to each message function, to Intl, and to <html lang>. A payload without one renders in English.
- Verification and password reset emails use the recipient's stored locale, falling back to the locale of the request that sent them.
- Invitations use the language the inviter picks in the invitation form, which defaults to the inviter's own. The invitation stores no language and its link carries none, so the account creation page follows the visitor's cookie or browser.
The catalogue compiles to packages/notifications/src/paraglide, which is generated, not committed. The package has no bundler plugin, so its paraglide:compile script runs before the app's and the workers' dev and build (which the workers image runs), bun run lint and bun run test. A message change needs a restart of the dev server or workers.
German
German uses the formal "Sie". Every product term and its German form is in the Glossary, and the writing skill in .agents/skills/writing/ carries the German style rules.
bun run machine-translate fills gaps in the non-base locales from en.json. Review its output before you commit it.
Server errors
The server never sends user-facing prose. Every refused request carries a stable reason code that the client translates, so a German user never sees English from the server.
Reasons are SCREAMING_SNAKE values of the ErrorReason union in packages/schema/src/shared/error-reason.ts, for example CHAT_NOT_FOUND or SYSTEM_GROUP_IMMUTABLE. Prefer a specific reason over a generic one. A reason that needs data declares its params type there as well. User data such as a file or block name travels raw in params, while app vocabulary in params is typed and translated on the client.
BadRequestError, ConflictError and ForbiddenError in apps/app/src/lib/effect/utils/errors.ts require a reason. NotFoundError and UnauthorizedError take one optionally, and a NotFoundError may name an entity as a fallback. The English message on these classes is for logs only and never reaches the client.
The oRPC contracts in packages/contracts/src/base.ts type data.reason and data.params on every defined error. error-mapping.ts forwards reason, params and the typed data, nothing else.
The client holds one Record<ErrorReason, (params) => string> in apps/app/src/lib/errors/reason-messages.ts, pointing at error_<reason> message keys. Adding a reason means adding it to the union, the English and German catalogues and that map, and TypeScript refuses an incomplete map. There is no dynamic key lookup.
describeError always returns a translated sentence, either the reason's or a generic one for the transport code when the reason is missing or unknown. reasonMessageOf returns the reason's sentence or nothing. Raw server text goes to the console only. Mutation toasts keep the hook's own error title and show the reason's sentence as the description, and without a reason they show the title alone.
Channels outside oRPC use the same union. The AI chat stream returns a reason as its error text and the chat translates it. The Better Auth client maps the library's own error.code to a reason, and our server hooks throw APIError with a reason as the code.
Validation
Zod issues are translated per issue kind, not per field. apps/app/src/lib/errors/zod-error-map.ts installs a global z.config({ customError }) on the client and the server that turns too_small, invalid_format and the other built-in kinds into messages such as "Must be at least 8 characters." from the issue's structured data. Schemas in packages/schema therefore carry no English for built-in checks. A custom refinement passes params: { reason } with a reason from the same union, for example PASSWORDS_MISMATCH, and the error map renders it through the reason map. Never put a code in message.
Tests
Unit tests never depend on copy. End-to-end specs locate elements by role and accessible name, and take the name from the message catalogue rather than repeating the English:
import { message } from "../../fixtures/messages";
page.getByRole("button", { name: message("common_save_button") });The helper reads apps/app/messages/en.json directly, so it needs no compile step. It resolves plain strings and {placeholder} interpolation. A message with variants throws, so the spec targets the rendered text another way. Test ids are for containers that have no accessible name, never for anything a message names.