Kwisple
Browse the docs

The platform repository › How it works

Languages and design system

How the dashboard speaks Dutch, French and English (catalogues, translators, formatter, the i18n check) and how its screens are built from the design tokens and primitives.

The dashboard is one product shown in three languages and in the colours of hundreds of agencies. Both only work if every screen takes its words from the catalogues and its looks from the tokens. This page is the developer's view of both systems; the full visual catalogue is docs/DESIGN-SYSTEM.md in the repository and the live gallery on /admin/design (team members only, not linked from the navigation).

Languages

Source language and catalogues

FileRole
apps/dashboard/messages/nl.jsonThe source of truth. Its shape types every key (src/i18n/types.ts), so a missing or misspelt key is a type error.
apps/dashboard/messages/fr.jsonFrench, same keys and variables.
apps/dashboard/messages/en.jsonEnglish, same keys and variables.

Top-level keys are namespaces, nested keys are camelCase. The namespaces today: common, account, social, cms, stats, editor, shell, website, toolkit, brand, media, access, activity, settings, cli, appsApi, auth, admin, agent, agency, overview, studio, email, report, billing, review, insights, help, portal, guide, requests. Use the namespace of the screen; put generic words in common.

This is the language of the dashboard. Client websites have their own content locales in the CMS (Localisation and URIs).

Which language a person sees

src/i18n/config.ts defines locales = ["nl", "fr", "en"], defaultLocale = "nl", defaultTimeZone = "Europe/Brussels" and intlLocale (nl → nl-BE, fr → fr-BE, en → en-GB), the tag handed to Intl.

resolveLocale (src/i18n/resolve.ts) picks, in order:

  1. the user's own choice (user.locale, set under Account);
  2. the agency default (agency.default_locale), from the person's agency or the agency of their workspace; signed out, the agency of a white-label dashboard host;
  3. the browser's Accept-Language (q-values honoured);
  4. nl.

getLocale() (src/i18n/server.ts) does this once per request (React cache). Outside a request (worker, scripts, tests) it returns nl. The root layout sets <html lang> from it.

Server code: getT and getFormatter

import { getFormatter, getT } from "@/i18n/server";

const t = await getT("requests");
t("title");                                   // a plain string
t("turn.agency", { agency });                 // with values
const fmt = await getFormatter();
fmt.dateTime(row.createdAt);                  // "24 okt 2026"

Use these in Server Components, server actions and route handlers. A server action resolves its own t, so its errors come back in the caller's language.

For code without a request (the worker, mails from the queue, pure helpers that receive a locale, tests), use getTranslator(locale, namespace) from @/i18n/static. It falls back to the Dutch text for a key that is missing in the target language.

Client code: useT and ClientMessages

"use client";
import { useFormatter, useLocale, useT } from "@/i18n/client";

export function RequestAdd() {
  const t = useT("requests");
  const fmt = useFormatter();
  return <Button>{t("add")}</Button>;
}

Client components only see the namespaces that a <ClientMessages namespaces={[…]}> above them passes down (src/i18n/client-messages.tsx, a Server Component). It sends just those namespaces, with Dutch filling any gap, and nested providers add to their parent's. The root layout provides common; the workspace layout adds shell, access, media, agent, settings and review. When a client component needs another namespace, add it to the ClientMessages of the nearest layout of that area. In development useT throws when its namespace was not passed, so you notice immediately.

The translator API

createTranslator (src/i18n/translator.ts) returns a function with a few members:

CallUse
t(key, values?)A plain string. Keys are type-checked against nl.json.
t.rich(key, values)React nodes, for messages with tags: t.rich("help", { link: (c) => <Link href="/hulp">{c}</Link> }).
t.dynamic(key, values?)A key built at runtime (t.dynamic(\status.${s}`)`) that the type system cannot see.
t.has(key)Whether a key exists (in the language or the Dutch fallback).
t.locale, t.namespaceThe language and namespace of this translator.

A key that exists nowhere throws in development and tests, and renders as the key in production, so a page never crashes over a missing string.

Message syntax

src/i18n/message-format.ts implements a small ICU MessageFormat subset:

Hallo {name}
{count, plural, =0 {Geen posts} one {# post} other {# posts}}
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
{role, select, owner {Eigenaar} other {Lid}}
{n, number}  {d, date}  {d, time}
Lees de <link>handleiding</link>

Write whole sentences with variables; never glue fragments together, because word order differs between languages. An apostrophe only escapes before {, }, <, # or another apostrophe, so "pagina's" needs nothing; a literal brace is '{'.

The agency by name

Never write "je bureau" in a string a client sees. Use {agency} (or {Agency} at the start of a sentence). Every translator fills it with the white-label name of the agency the request belongs to: the proxy names the workspace or agency of the path in the x-kwisple-workspace / x-kwisple-agency headers, requestAgencyName() resolves it once per request, and getT() and ClientMessages pass it as a default value. Outside an agency (worker, platform pages) the catalogue's generic words apply (common.agencyName.lower / common.agencyName.upper). An explicit value in the call still wins.

Dates and numbers

Never call toLocaleString("nl-BE") or write "nl-BE" anywhere. Use the formatter (src/i18n/format.ts): getFormatter() on the server, useFormatter() on the client, or createFormatter(locale, timeZone) with a known locale.

MethodOutput (nl)
fmt.dateTime(d, options?)24 okt 2026 (any Intl.DateTimeFormatOptions)
fmt.dateTimeShort(d)24 okt 2026, 09:00
fmt.time(d)09:00
fmt.number(n, options?)1.234,5
fmt.relative(d, now?)3 minuten geleden, over 2 dagen
fmt.list(items, type?)a, b en c
fmt.region(code)BE → België
fmt.language(code)fr → Frans

fmt.intl gives the BCP 47 tag for the rare API that needs it directly. Brussels is the default time zone.

@kwisple/ui stays language-neutral

The shared UI package never imports the catalogues. The few words its components say themselves (the "Zeker?" of ConfirmButton, CopyButton, the DateTimeField popover, status words, the meter) live in packages/ui/src/locale.ts as uiLabels for nl, fr and en. Client components read them through UiLocaleProvider (the dashboard's root I18nProvider sets it); server components such as StatusBadge and PixelMeter take a locale prop (t.locale or useLocale()). Every built-in label can be overridden per use with a prop. Everything else gets its text from the caller.

Text that must stay Dutch

Client content, sample data, route segments and data fallbacks are not UI copy. Mark them so the check leaves them alone, and say why in the same comment:

redirect(`/w/${own.slug}/overzicht`); // i18n-ignore: route
owner: "Eigenaar", // i18n-ignore: data fallback, UI uses roleLabel(common)

// i18n-ignore covers its own line or the line below it; // i18n-ignore-file anywhere in a file skips the whole file. Model-facing text, such as MCP tool descriptions, is English and marked the same way.

pnpm check:i18n

tools/check-i18n.mjs (also in CI) fails when:

  1. a key used through getT, useT or getTranslator is missing from a catalogue;
  2. nl.json and another catalogue disagree on keys or {variables}, or a message has unbalanced braces or quotes;
  3. Dutch-looking copy is hardcoded in apps/dashboard/src: JSX text, label-like props (aria-label, title, placeholder, label, alt, description, …), toasts, returned errors and sentences in string literals.

Its own tests are in apps/dashboard/test/i18n/ together with tests of the formatter, the message format, the resolver and the translator.

Adding a string

  1. Add the Dutch text to nl.json under the screen's namespace.
  2. Add the French to fr.json and the English to en.json with the same key and the same variables. Write plain, friendly product language in each, not a literal translation, and keep tight labels short.
  3. Use the key through t(...). On the client, make sure the namespace is passed by a ClientMessages above the component.
  4. Run pnpm check:i18n.

Design system

Tokens

packages/ui/src/styles.css defines everything in a Tailwind v4 @theme, imported by the dashboard as @kwisple/ui/styles.css:

GroupTokens (utilities bg-*, text-*, border-*)
Surfacesbg (grey canvas), surface (white sheet), sunken, field / field-hover (input wells)
Textfg, muted, faint, ink / ink-fg
Lineshairline (= border), border-strong
Accentaccent / accent-fg: ink by default, the agency colour when white-labelled
Signalsignal (alias flame): "needs you now", at most one use per screen
Soft toneslavender, mint, peach, sky (each with -ink), aubergine (+ -fg)
Statedanger, success, warning
Radiirounded-ui 12, rounded-card 16, rounded-sheet 16, rounded-art 6, rounded-px 2, buttons rounded-full
Elevationnone at rest; shadow-float for floating glass
Glass.glass, .glass-float

Type scale, each utility carrying size, line height, tracking and weight:

UtilitySizeUse
text-meta12/16eyebrows, counts, captions
text-small13/18meta lines, help text
text-body14/20default
text-lead16/24intros, page descriptions
text-title20/26, 500tile and section titles
text-h228/32, 500sheet titles, section heads
text-display44/44, 500page titles
text-numeral72/64, 300big numbers (with numeric for tabular figures)

One typeface, Inter Tight (--font-sans); mono (--font-mono) only for code-like values. Weights 300, 400 and 500; never font-semibold. Never below 12px, and no text-[13px]-style brackets. .display is the tight one-colour headline (never a lighter "continuation" in a second colour), .eyebrow the small muted label that names the parent. cn() is configured with these tokens, so cn("text-body", "text-muted") keeps both. The dashboard is light only; the dark identity belongs to the agency and platform frames.

Primitives in @kwisple/ui

GroupComponents
ActionsButton (variants primary, ink, secondary, ghost, danger, glass; pending, success), IconButton (required label), ConfirmButton (instead of window.confirm), CopyButton, useFlash
FieldsField, Input, Textarea, Select, Label, fieldClassName, DateTimeField, Switch, Toggle, Segmented
LayoutCard, CardHeader, CardTitle, CardBody, StatCard, Section, ListRow, Thumbnail, TileGrid / Tile, Disclosure, Collapse
StatusBadge, StatusDot, StatusBadge, PixelMark, PixelMeter, Numeral, CountUp
ArtworkPixelField (palettes flame, dusk, meadow, mist), BrandMosaic (a client's own colours, seeded by its id), PixelAvatar, PixelOverlay
IdentityProductLogo, ProductMark (only where the product may show itself)
LocaleUiLocaleProvider, uiLabels, labelsFor

PixelMotion (pixel-motion.tsx) is the client-side animation layer behind the artwork: BrandMosaic with interactive and PixelOverlay render it. It is not exported on its own; use it through those components. Pixel cells are 8px and always square; glass only floats over art or content, never behind forms or body text.

Dashboard pieces

These live in apps/dashboard/src/components because they depend on the app:

ComponentFileUse
PageHeadercomponents/page-header.tsxTitle (.display), optional eyebrow and description, actions on the right (the screen's one primary action)
EmptyStatecomponents/page-header.tsxTitle, description, action, optional art (e.g. the client's BrandMosaic)
HeroPanelcomponents/page-header.tsxPixel-gradient header with a glass grid, for overview screens
TabNavcomponents/ui/tab-nav.tsxTabs with a measured sliding underline
Sheetcomponents/ui/sheet.tsxThe side panel every create flow opens in
ActionFormcomponents/action-form.tsxA form bound to a server action; saveBar shows the floating save bar
SaveBarcomponents/ui/save-bar.tsxThe save bar for hand-built editors
toastcomponents/ui/toast.tsxFeedback with tones and an optional undo action
Tile, TileGridcomponents/ui/tile.tsxThe @kwisple/ui tiles wired to next/link
SharedElementcomponents/ui/motion.tsxView-transition morphs between pages
AppShell, ContextBarcomponents/app-shell.tsx, components/context-bar.tsxThe frame and the "where am I" trail

Principles

  • Show the thing, not the record: thumbnails, previews and mosaics lead, meta comes second.
  • Create via a header button and a Sheet, never a permanently open form.
  • Lists with hairlines (divide-y divide-border with ListRow) instead of bordered boxes or tables.
  • One primary action per screen.
  • Plain language without jargon; technical options behind "Geavanceerd" (a Disclosure) and staff-only where possible.
  • Hide what a role may not do (can()), never show a disabled dead end.
  • No serif or display fonts, no ad-hoc colours, no new coloured status pills (use StatusBadge), no ▶ triangles, no text below 12px.
  • Motion: feedback within 80ms, state changes 120–280ms, only transform and opacity, and every transform transition gets a reduced-motion variant (motion-reduce:transition-none).

Levels, frames and the context bar

The three levels must never look alike. AppShell takes level:

levelFrameUsed by
clientlight canvas; for staff a band in the client's colours along the top of the sheet/w/<client>/…
agencydark lila frame (lila-canvas) with the agency's identity/bureau/<agency>/… and the agency's own workspace
platformthe plain dark frame with the product's logo/admin/… (Platformbeheer)

Every page starts with the context bar (ContextBar): the path from your top level down to this page (Platform / Bureau / Klanten / Klant / Toegang), every step clickable, each level with its marker. The layout passes the fixed steps (trail), the base path and a labels map per route segment; segments without a label (ids) are skipped. A new sub-route therefore needs a label in its layout's labels, or it does not appear in the trail. Because of the bar, page headers do not repeat the level and pages add no "← back" links of their own.

Inside a client account, staff also get "← Alle klanten", the client card with the ⌘K switcher and "Bekijk als klant"; clients see only their own business, with "Begeleid door ".

White-label accent

The accent tokens are the only theming hook. brandingStyle(branding) in apps/dashboard/src/lib/branding.ts returns --color-accent and --color-accent-fg (readableOn picks the readable foreground) when the agency is white-labelled with its own colour, and nothing otherwise; the layout puts that style on the shell. accentStyle(color) does the same for one colour (a client's sign-in page in its brand colour). Everything else in the design system stays, so never build a screen that depends on a specific accent hue.

The product name never appears in UI copy: use product from @kwisple/core or the agency's branding. The product shows itself only where no agency branding applies (Platformbeheer, the main domain's login), as the browser tab icon, and in the small "Powered by" credit in the sidebar. The assistant launcher is a neutral pixel spark, not the product mark.

Last updated 2026-10-30