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
| File | Role |
|---|---|
apps/dashboard/messages/nl.json | The 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.json | French, same keys and variables. |
apps/dashboard/messages/en.json | English, 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:
- the user's own choice (
user.locale, set under Account); - 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; - the browser's
Accept-Language(q-values honoured); 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:
| Call | Use |
|---|---|
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.namespace | The 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.
| Method | Output (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:
- a key used through
getT,useTorgetTranslatoris missing from a catalogue; nl.jsonand another catalogue disagree on keys or{variables}, or a message has unbalanced braces or quotes;- 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
- Add the Dutch text to
nl.jsonunder the screen's namespace. - Add the French to
fr.jsonand the English toen.jsonwith the same key and the same variables. Write plain, friendly product language in each, not a literal translation, and keep tight labels short. - Use the key through
t(...). On the client, make sure the namespace is passed by aClientMessagesabove the component. - 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:
| Group | Tokens (utilities bg-*, text-*, border-*) |
|---|---|
| Surfaces | bg (grey canvas), surface (white sheet), sunken, field / field-hover (input wells) |
| Text | fg, muted, faint, ink / ink-fg |
| Lines | hairline (= border), border-strong |
| Accent | accent / accent-fg: ink by default, the agency colour when white-labelled |
| Signal | signal (alias flame): "needs you now", at most one use per screen |
| Soft tones | lavender, mint, peach, sky (each with -ink), aubergine (+ -fg) |
| State | danger, success, warning |
| Radii | rounded-ui 12, rounded-card 16, rounded-sheet 16, rounded-art 6, rounded-px 2, buttons rounded-full |
| Elevation | none at rest; shadow-float for floating glass |
| Glass | .glass, .glass-float |
Type scale, each utility carrying size, line height, tracking and weight:
| Utility | Size | Use |
|---|---|---|
text-meta | 12/16 | eyebrows, counts, captions |
text-small | 13/18 | meta lines, help text |
text-body | 14/20 | default |
text-lead | 16/24 | intros, page descriptions |
text-title | 20/26, 500 | tile and section titles |
text-h2 | 28/32, 500 | sheet titles, section heads |
text-display | 44/44, 500 | page titles |
text-numeral | 72/64, 300 | big 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
| Group | Components |
|---|---|
| Actions | Button (variants primary, ink, secondary, ghost, danger, glass; pending, success), IconButton (required label), ConfirmButton (instead of window.confirm), CopyButton, useFlash |
| Fields | Field, Input, Textarea, Select, Label, fieldClassName, DateTimeField, Switch, Toggle, Segmented |
| Layout | Card, CardHeader, CardTitle, CardBody, StatCard, Section, ListRow, Thumbnail, TileGrid / Tile, Disclosure, Collapse |
| Status | Badge, StatusDot, StatusBadge, PixelMark, PixelMeter, Numeral, CountUp |
| Artwork | PixelField (palettes flame, dusk, meadow, mist), BrandMosaic (a client's own colours, seeded by its id), PixelAvatar, PixelOverlay |
| Identity | ProductLogo, ProductMark (only where the product may show itself) |
| Locale | UiLocaleProvider, 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:
| Component | File | Use |
|---|---|---|
PageHeader | components/page-header.tsx | Title (.display), optional eyebrow and description, actions on the right (the screen's one primary action) |
EmptyState | components/page-header.tsx | Title, description, action, optional art (e.g. the client's BrandMosaic) |
HeroPanel | components/page-header.tsx | Pixel-gradient header with a glass grid, for overview screens |
TabNav | components/ui/tab-nav.tsx | Tabs with a measured sliding underline |
Sheet | components/ui/sheet.tsx | The side panel every create flow opens in |
ActionForm | components/action-form.tsx | A form bound to a server action; saveBar shows the floating save bar |
SaveBar | components/ui/save-bar.tsx | The save bar for hand-built editors |
toast | components/ui/toast.tsx | Feedback with tones and an optional undo action |
Tile, TileGrid | components/ui/tile.tsx | The @kwisple/ui tiles wired to next/link |
SharedElement | components/ui/motion.tsx | View-transition morphs between pages |
AppShell, ContextBar | components/app-shell.tsx, components/context-bar.tsx | The 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-borderwithListRow) 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:
level | Frame | Used by |
|---|---|---|
client | light canvas; for staff a band in the client's colours along the top of the sheet | /w/<client>/… |
agency | dark lila frame (lila-canvas) with the agency's identity | /bureau/<agency>/… and the agency's own workspace |
platform | the 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.