Skip to content
10 changes: 8 additions & 2 deletions .agents/docs/packages/libui.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,23 @@ There is no root `.` export — always import from a subpath.

### Translation — this is a hard rule

Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file:
Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, es, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file:

```tsx
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';

const { t } = useTranslation(); // inline strings
const { t } = useTranslation('datahub'); // scoped to a translation namespace

<Button>{t({ en: 'Accept', fr: 'Accepter' })}</Button>;
<Button>{t({ en: 'Accept', es: 'Aceptar', fr: 'Accepter' })}</Button>;
```

**Every interface language needs an entry.** A missing one resolves to English silently, so
`requireCompleteTranslations` is declared for `apps/web`, `apps/gateway` and `packages/react-core`
(`packages/react-core/src/complete-translations.d.ts` and each app's `src/services/i18n.ts`), which
makes an incomplete `t({ … })` a type error caught by `pnpm lint`. `apps/playground` is deliberately
outside that opt-in — its language selector offers only English and French.

`useTranslation` also returns `resolvedLanguage` when you need the active locale.

### Typing i18n to this app (`apps/web/src/services/i18n.ts`)
Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/playbooks/add-web-data-hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ step type-checks and compiles, and fails at runtime or serves the wrong cached d
(`throwOnError: true` for both queries and mutations, in `apps/web/src/services/react-query.ts`,
which routes errors to the route error boundary). Set `throwOnError: false` on the mutation _and_
`meta: { disableDefaultErrorNotification: true }` on the request, then notify in `onError` with
`getApiErrorMessage(err, t({ en: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows
`getApiErrorMessage(err, t({ en: …, es: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows
two toasts. Canonical: `apps/web/src/hooks/useDeleteSeriesInstrumentMutation.ts`.

11. **Write the unit test in `apps/web/src/hooks/__tests__/<hookName>.test.ts` — never under
Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/playbooks/promote-to-react-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ so nothing that passed lint in `apps/web` fails on style here; what breaks is re
the component is an internal part of `InstrumentRenderer` rather than something a consumer imports
directly, leave it off the barrel and add it to that AGENTS.md's deliberately-unexported list.

4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', fr: '…' })`** — libui's own
4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', es: '…', fr: '…' })`** — libui's own
registered namespace is the exception (`.agents/skills/odc-frontend/SKILL.md`). `apps/gateway`
initialises i18n with no resources (`apps/gateway/src/services/i18n.ts`), and libui's
`Translator.t()` logs `Failed to extract translation from object '{}'` and returns the
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ not every tool loads nested files.
workspace name without the prefix. A `commit-msg` hook and the `Commitlint` CI job enforce it, and
`CHANGELOG.md` is generated from the `feat`, `fix`, `perf` and breaking commits: one commit is one
entry.
- **All frontend user-facing strings go through `useTranslation`.** Prefer inline
`t({ en: '...', fr: '...' })` unless the string is used more than once.
- **All frontend user-facing strings go through `useTranslation`.** Every language the frontend
exposes gets an entry. In web, gateway and react-core, prefer inline
`t({ en: '...', es: '...', fr: '...' })` unless the string is used more than once; each workspace
enables `requireCompleteTranslations`, so omitting a language is a type error caught by `pnpm lint`.
- **Never run the `apps/web` route-tree generator.** `src/route-tree.ts` is generated and
git-tracked, but the user regenerates it manually after route changes. Never hand-edit it either.
- **If code needs a comment to be understood, the code is wrong** — rewrite it. Comments are for
Expand Down
4 changes: 3 additions & 1 deletion apps/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ hydrated tree can disagree with the SSR'd HTML until you do.
- The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`,
`jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still
required, and there are no translation resource files — `src/services/i18n.ts` initializes with
`{}`, so every string is inline `t({ en, fr })`.
`{}`, so every string is inline `t({ en, es, fr })`. Every interface language needs an entry;
`requireCompleteTranslations` in that service makes a missing one a type error caught by
`pnpm lint`.
- Shared UI comes from `packages/react-core` (`InstrumentRenderer`, `Branding`). Put anything both
apps need there, not here.
- Validation messages are localized by `src/services/zod.ts`, a thin call to react-core's
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/components/Cap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve })

const label = t({
en: "I'm a human",
es: 'Soy una persona',
fr: 'Je suis un humain'
});

Expand All @@ -33,10 +34,12 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve })
data-cap-api-endpoint="/api/auth/"
data-cap-i18n-error-label={t({
en: 'Error',
es: 'Error',
fr: 'Erreur'
})}
data-cap-i18n-initial-state={t({
en: "I'm a human",
es: 'Soy una persona',
fr: 'Je suis un humain'
})}
data-cap-i18n-solved-label={label}
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/services/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ axios.interceptors.request.use((config) => {
config.timeout = 10000; // abort request after 10 seconds
config.timeoutErrorMessage = i18n.t({
en: 'Network Error',
es: 'Error de red',
fr: 'Erreur de réseau'
});
return config;
Expand All @@ -20,6 +21,7 @@ axios.interceptors.response.use(
notifications.addNotification({
message: i18n.t({
en: 'Unknown Error',
es: 'Error desconocido',
fr: 'Erreur inconnue'
}),
type: 'error'
Expand All @@ -30,6 +32,7 @@ axios.interceptors.response.use(
notifications.addNotification({
message: i18n.t({
en: 'HTTP Request Failed',
es: 'Error en la solicitud HTTP',
fr: 'Échec de la requête HTTP'
}),
title: error.response?.status.toString(),
Expand Down
11 changes: 11 additions & 0 deletions apps/gateway/src/services/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/* eslint-disable @typescript-eslint/no-namespace */

import { i18n } from '@douglasneuroinformatics/libui/i18n';

declare module '@douglasneuroinformatics/libui/i18n' {
export namespace UserConfig {
export interface Options {
requireCompleteTranslations: true;
}
}
}

// `defaultLanguage` is deliberately left unset. It is what `t()` falls back to when a string has no
// entry in the resolved language, so pointing it at the patient's language would make every string
// missing a translation render as nothing. The session's language is set with `changeLanguage`.
Expand Down
9 changes: 7 additions & 2 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,17 @@ In components use a selector: `useAppStore((store) => store.currentGroup)`. Outs
`useTranslation` comes from `@douglasneuroinformatics/libui/hooks`. Two forms:

```tsx
t({ en: 'Connection Problem', fr: 'Problème de connexion' }); // inline — prefer this
t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' }); // inline — prefer this
t('layout.tabs.table'); // keyed, from a namespace JSON
```

**Every interface language needs an entry.** libui resolves `obj[resolvedLanguage] ?? obj.en`, so a
missing language renders in English and nothing complains — which is why
`requireCompleteTranslations` is set in `src/services/i18n.ts`: omitting a language from an inline
`t()` call is a type error caught by `pnpm lint`.

Use the keyed form only when a string is reused. Resource files are `src/translations/*.json`, keyed
per-leaf as `{ "en": ..., "fr": ... }` and kept sorted by
per-leaf as `{ "en": ..., "es": ..., "fr": ... }` and kept sorted by
`pnpm --filter @opendatacapture/web format:translations`.

**Adding a namespace means three edits in `src/services/i18n.ts`** — the import, the `declare module`
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/AppErrorComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,24 @@ export const AppErrorComponent = ({ error, reset }: ErrorComponentProps) => {
<WifiOff aria-hidden className="text-muted-foreground h-8! w-8!" />
)}
<h1 className="mt-4 text-2xl font-extrabold tracking-tight sm:text-3xl">
{t({ en: 'Connection Problem', fr: 'Problème de connexion' })}
{t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' })}
</h1>
<p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">
{isOnline
? t({
en: "We couldn't reach the server. This is usually temporary — please try again.",
es: 'No se pudo contactar con el servidor. Suele ser algo temporal: vuelva a intentarlo.',
fr: 'Impossible de joindre le serveur. Le problème est généralement temporaire — veuillez réessayer.'
})
: t({
en: "You appear to be offline. We'll reconnect automatically as soon as your connection returns.",
es: 'Parece que no tiene conexión. Nos reconectaremos automáticamente en cuanto se restablezca.',
fr: 'Vous semblez être hors ligne. La reconnexion se fera automatiquement dès le retour de votre connexion.'
})}
</p>
<div className="mt-6">
<Button type="button" variant="primary" onClick={handleRetry}>
{t({ en: 'Try again', fr: 'Réessayer' })}
{t({ en: 'Try again', es: 'Vuelva a intentarlo', fr: 'Réessayer' })}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
// The group's active template sorts first; boolean subtraction is a total order, unlike the
// ternary chain it replaces, so the result does not depend on the sort's visiting order.
const templateOptions = [
{ label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION },
{
label: t({ en: 'Built-in default', es: 'Predeterminada integrada', fr: 'Modèle par défaut' }),
value: DEFAULT_TEMPLATE_OPTION
},
...templates.map((template) => ({ label: template.name, value: template.id }))
].sort((a, b) => Number(b.value === activeValue) - Number(a.value === activeValue));

Expand All @@ -68,7 +71,11 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
setFeedback(null);
const fail = (message: string) => {
setFeedback({ message, tone: 'error' });
addNotification({ message, title: t({ en: 'Email failed', fr: 'Échec du courriel' }), type: 'error' });
addNotification({
message,
title: t({ en: 'Email failed', es: 'Error al enviar el correo', fr: 'Échec du courriel' }),
type: 'error'
});
};
sendEmailMutation.mutate(
{
Expand All @@ -78,18 +85,33 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate
},
{
onError: () => fail(t({ en: 'The email could not be sent', fr: "Le courriel n'a pas pu être envoyé" })),
onError: () =>
fail(
t({
en: 'The email could not be sent',
es: 'No se pudo enviar el correo',
fr: "Le courriel n'a pas pu être envoyé"
})
),
onSuccess: (result) => {
if (result.status !== 'SENT') {
fail(mailErrorMessage(result.error));
return;
}
const message = t(
{ en: 'Assignment link sent to {}', fr: "Lien d'évaluation envoyé à {}" },
{
en: 'Assignment link sent to {}',
es: 'Enlace de la tarea enviado a {}',
fr: "Lien d'évaluation envoyé à {}"
},
{ args: [recipient] }
);
setFeedback({ message, tone: 'success' });
addNotification({ message, title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), type: 'success' });
addNotification({
message,
title: t({ en: 'Email sent', es: 'Correo enviado', fr: 'Courriel envoyé' }),
type: 'success'
});
setRecipient('');
}
}
Expand All @@ -104,7 +126,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
<div className="flex flex-col gap-2" data-testid="assignment-email-form">
{templates.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="assignment-template">{t({ en: 'Email template', fr: 'Modèle de courriel' })}</Label>
<Label htmlFor="assignment-template">
{t({ en: 'Email template', es: 'Plantilla de correo', fr: 'Modèle de courriel' })}
</Label>
<Select value={selectedTemplate} onValueChange={setTemplateChoice}>
<Select.Trigger className="w-full" data-testid="assignment-template" id="assignment-template">
<Select.Value />
Expand All @@ -121,7 +145,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="assignment-language">{t({ en: 'Email language', fr: 'Langue du courriel' })}</Label>
<Label htmlFor="assignment-language">
{t({ en: 'Email language', es: 'Idioma del correo', fr: 'Langue du courriel' })}
</Label>
<Tooltip>
<Tooltip.Trigger className="p-0 hover:bg-transparent" size="icon" variant="ghost">
<CircleHelpIcon className="text-muted-foreground h-4 w-4" />
Expand All @@ -131,6 +157,7 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
{/* Only the email is localized: the gateway does not read the link's `lang` param. */}
{t({
en: 'The email is sent in the selected language when the template has been written in it. Participants choose their own language when they open the assignment.',
es: 'El correo se envía en el idioma seleccionado cuando la plantilla se ha redactado en ese idioma. Los participantes eligen su propio idioma al abrir la tarea.',
fr: "Le courriel est envoyé dans la langue sélectionnée lorsque le modèle a été rédigé dans celle-ci. Les participants choisissent leur propre langue à l'ouverture de l'évaluation."
})}
</p>
Expand All @@ -146,14 +173,22 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
/>
</div>
<Label htmlFor="assignment-email">
{t({ en: 'Email link to participant', fr: 'Envoyer le lien au participant par courriel' })}
{t({
en: 'Email link to participant',
es: 'Enviar el enlace al participante por correo',
fr: 'Envoyer le lien au participant par courriel'
})}
</Label>
<div className="flex gap-2">
<Input
className="h-9"
data-testid="assignment-email"
id="assignment-email"
placeholder={t({ en: 'recipient@example.org', fr: 'destinataire@exemple.org' })}
placeholder={t({
en: 'recipient@example.org',
es: 'destinatario@example.org',
fr: 'destinataire@exemple.org'
})}
type="email"
value={recipient}
onChange={(event) => setRecipient(event.target.value)}
Expand All @@ -171,8 +206,8 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
onClick={sendEmail}
>
{sendEmailMutation.isPending
? t({ en: 'Sending…', fr: 'Envoi en cours…' })
: t({ en: 'Email assignment', fr: 'Envoyer par courriel' })}
? t({ en: 'Sending…', es: 'Enviando…', fr: 'Envoi en cours…' })
: t({ en: 'Email assignment', es: 'Enviar la tarea por correo', fr: 'Envoyer par courriel' })}
</Button>
</div>
{feedback && (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ConnectivityBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ export const ConnectivityBanner = () => {
{isOnline
? t({
en: 'Reconnecting…',
es: 'Reconectando…',
fr: 'Reconnexion…'
})
: t({
en: 'Offline — waiting for connection…',
es: 'Sin conexión: esperando la conexión…',
fr: 'Hors ligne — en attente de connexion…'
})}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const EmailTemplateEditor = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-language`}>{t({ en: 'Language', fr: 'Langue' })}</Label>
<Label htmlFor={`${idPrefix}-language`}>{t({ en: 'Language', es: 'Idioma', fr: 'Langue' })}</Label>
<LanguageSelect
data-testid={`${idPrefix}-language`}
id={`${idPrefix}-language`}
Expand All @@ -63,7 +63,7 @@ export const EmailTemplateEditor = ({
</div>

<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-subject`}>{t({ en: 'Subject', fr: 'Objet' })}</Label>
<Label htmlFor={`${idPrefix}-subject`}>{t({ en: 'Subject', es: 'Asunto', fr: 'Objet' })}</Label>
<Input
data-testid={`${idPrefix}-subject`}
id={`${idPrefix}-subject`}
Expand All @@ -77,10 +77,12 @@ export const EmailTemplateEditor = ({

<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor={`${idPrefix}-body`}>{t({ en: 'Body', fr: 'Corps' })}</Label>
<Label htmlFor={`${idPrefix}-body`}>{t({ en: 'Body', es: 'Cuerpo', fr: 'Corps' })}</Label>
{!readOnly && variables.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground text-xs">{t({ en: 'Insert:', fr: 'Insérer :' })}</span>
<span className="text-muted-foreground text-xs">
{t({ en: 'Insert:', es: 'Insertar:', fr: 'Insérer :' })}
</span>
{variables.map((variable) => {
// Placeholder syntax, not copy — it must render verbatim in every language.
const tag = `{{${variable}}}`;
Expand Down
Loading
Loading