Files
probo/contrib/claude/i18n.md
Émile Ré 393c538de1 Fill frontend rule gaps and broaden v2 tokens
Add the frontend guides the v2 UI kit and compliance-portal need but
that the first rework left uncovered: forms, routing, client state, and
permission-gated UI.

forms.md documents a tiered approach on Base UI Field/Form -- native
constraints, then a validate function, then zod parsed in onSubmit, and
react-hook-form only for large or dynamic forms -- and drops the custom
useFormWithSchema wrapper. routing.md covers @probo/routes, navigation,
typed params, URL-as-state, redirects, auth/protected routes, and the
folded-in no-outlet-context rule. state-management.md gives a decision
order across Relay, URL, local state, context, and zustand.
permissions.md gates UI on the canUpdate/canDelete permission(action:)
fields without re-encoding authorization in the client.

Rename v2-colors.md to v2-tokens.md and add the typography, radius,
shadow, and native-spacing scales alongside color. Extend ui.md with
user feedback, empty-state, and accessibility sections; standardize
toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy
useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an
Intl formatting section to i18n.md and a non-Relay HTTP / file
upload-download section to ts-style.md. Update the AGENTS.md index and
the v2-color-scale cursor rule for the new and renamed guides.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-26 18:52:05 +02:00

5.1 KiB

Internationalization (i18next)

The v2 apps (starting with apps/compliance-portal) localize with i18next. Translations are key-based: code references a stable key, and each locale supplies the human-readable string in a JSON catalog. Catalogs live in _locales/ folders colocated with routes.

This differs from the legacy @probo/i18n translator used by apps/console, where the English source string itself is the key (__("Save changes")). New code uses i18next with explicit keys; do not copy the source-string-as-key pattern into v2 apps.

Topic Guide
Where _locales/ folders live in the tree contrib/claude/app-arborescence.md
Routes and resource folders contrib/claude/app-arborescence.md

Catalog files

  • One JSON file per locale, named by its BCP 47 locale tag: en-US.json, fr-FR.json.
  • The filename is the locale — there is no locale field inside the file; the file is the namespace's catalog for that locale.
  • Keys are stable, descriptive identifiers (not the English text). Nest by feature/component to avoid collisions.
// pages/organizations/measures/_locales/en-US.json
{
  "measures": {
    "title": "Measures",
    "empty": "No measures yet",
    "actions": {
      "create": "New measure"
    },
    "count_one": "{{count}} measure",
    "count_other": "{{count}} measures"
  }
}
// pages/organizations/measures/_locales/fr-FR.json
{
  "measures": {
    "title": "Mesures",
    "empty": "Aucune mesure pour le moment",
    "actions": {
      "create": "Nouvelle mesure"
    },
    "count_one": "{{count}} mesure",
    "count_other": "{{count}} mesures"
  }
}

Where catalogs live

_locales/ is colocated with a routes.ts, at the folder that names a resource. The constraint is exact:

  • No more _locales/ folders than routes.ts files. A folder without a routes.ts does not get its own _locales/; its strings belong to the nearest ancestor resource that has one.
  • Resource boundaries own their translations: organizations/routes.tsorganizations/_locales/; organizations/measures/routes.tsorganizations/measures/_locales/.

See app-arborescence.md for the folder-tree examples.

Using translations in components

Read translations with the i18next hook and a key. Keep keys close to where they are defined (the feature namespace), and pass interpolation values as the second argument.

import { useTranslation } from "react-i18next";

export function MeasuresPage() {
  const { t } = useTranslation();
  return (
    <section>
      <h1 className="text-6 text-sand-12">{t("measures.title")}</h1>
      <p className="text-sand-11">{t("measures.count", { count })}</p>
    </section>
  );
}

Do / don't: keys, not source strings

// Bad — English source string as the key (legacy @probo/i18n pattern)
__("No measures yet");

// Good — stable key resolved from the locale catalog
t("measures.empty");

Do / don't: no string building

Never assemble translated sentences by concatenation — it breaks word order in other languages. Use interpolation and pluralization keys instead.

// Bad — concatenation
`${count} ${t("measures.unit")}`;

// Good — pluralized key with interpolation
t("measures.count", { count });

Formatting dates, numbers, and currency

Locale-aware formatting is presentation and must follow the active locale — never hand-format with string templates or hardcoded separators. Use i18next's Intl-based formatting (which wraps Intl.DateTimeFormat / Intl.NumberFormat with the current language) or Intl directly when outside a translation string.

// i18next interpolation formatters — locale comes from the active language
t("measures.updatedAt", { date: updatedAt, formatParams: { date: { dateStyle: "medium" } } });
t("invoice.total", { amount, val: amount, formatParams: { val: { style: "currency", currency: "EUR" } } });

// Outside a translation string — Intl directly with the active locale
const { i18n } = useTranslation();
new Intl.NumberFormat(i18n.language, { style: "percent" }).format(ratio);
// Bad — hand-rolled, locale-blind formatting
`${(ratio * 100).toFixed(0)}%`;
`${day}/${month}/${year}`;

Reuse @probo/helpers only for its non-presentational date utilities — parsing and <input type="date"> shaping (parseDate, toDateInput, todayAsDateInput). Do not use its v1 formatDate / formatDuration helpers in v2 apps: they predate i18next and thread the legacy @probo/i18n __ translator. Format for display through i18next / Intl instead.

Loading catalogs

Catalogs are loaded into i18next per locale at app startup (or lazily per route). Because each _locales/ folder maps to a resource segment, catalogs can be code-split alongside the route bundle that needs them — keep a catalog scoped to the feature it serves rather than one global megafile.