Skip to content

feat(i18n): add per-instance language selection mechanism - #1442

Merged
joshunrau merged 7 commits into
mainfrom
feat/spanish-mechanism
Aug 5, 2026
Merged

feat(i18n): add per-instance language selection mechanism#1442
joshunrau merged 7 commits into
mainfrom
feat/spanish-mechanism

Conversation

@thomasbeaudry

@thomasbeaudry thomasbeaudry commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Rewritten from scratch against current main per review. Adds Spanish as a selectable interface language, and lets each instance choose which languages it offers.

The underlying defect

Spanish reached the frontends only through libui's Language type, while this repo kept its own Language at en | fr. Two types that had to agree didn't, and the seam was papered over with as casts at 8 call sites and a resolvedLanguage === 'en' || === 'fr' ternary repeated 5 times.

This separates the two concepts rather than casting between them:

  • schemas/core owns the interface language (en | es | fr) — what a user reads the application in.
  • runtime-core keeps the instrument-authoring language (en | fr) — what clinical content exists in. $InstrumentAuthoringLanguage names the narrower set, and toInstrumentAuthoringLanguage is the one place the policy for content typed in a third language lives.
  • packages/react-core derives libui's LanguageOptions from the contract (extends Record<Language, true>), so enabling a language is one edit, not one per app.

Widening translateInstrument to accept an interface language removed the need to narrow at any call site: getTargetLanguage already falls back to the language an instrument was written in. All 5 ternaries and the CustomEvent<string> widening deleted themselves. $LocalizedString and $BrandingText are now keyed by Language with a satisfies guard, so all 8 casts went with them.

Per-instance language selection

  • activeLanguages on SetupState, validated at the perimeter as z.tuple([$Language], $Language) — a tuple rather than .nonempty() so the non-empty guarantee reaches the type and consumers read activeLanguages[0] with no assertion.
  • One DEFAULT_ACTIVE_LANGUAGES, and a Prisma @default([]) so documents predating the field are covered.
  • A LanguageToggle in react-core offering the active set, hidden when only one is active, that reconciles a user stranded on a deactivated language on load rather than only moving the admin who flipped it.
  • The admin card rebuilds the set by filtering the canonical language order rather than appending, so the fallback language does not depend on click order.

Gateway

The gateway has its own SQLite database and cannot read the setup state, so the languages travel with the assignment as a snapshot taken at creation. ?lang= is read server-side in the root router and passed through RootProps, so SSR and hydration resolve the same language — no module-scope window access.

defaultLanguage is never passed to i18n.init. It is the fallback t() uses for a string with no entry in the resolved language, so pointing it at the patient's language would have rendered every untranslated string as ''. The entry points call changeLanguage instead.

Also

  • Spanish translations for all 11 apps/web namespace files (271 strings, complete).
  • idValidationRegexErrorMessage and the Prisma ErrorMessage type converged on $LocalizedString.
  • Language display names live once in react-core, serving the toggle, the email-template selector and the instrument dropdown.

Test plan

  • pnpm lint — 33/33 workspaces clean
  • pnpm test — 642 passed, 1 skipped (29 new)
  • pnpm test:e2e — could not run locally (ports held by another process); needs CI

Unit tests cover the perimeter rejecting empty and unknown language sets, the SetupService round-trip and its fallback for a pre-existing document, and the toggle's reconciliation. The e2e test drives the admin card and asserts the sidebar toggle disappears.

Notes for review

es is offered with complete translations for the namespace JSON files, but 229 inline t({ en, fr }) calls in apps/web and 15 in react-core have no Spanish entry and will fall back to English. Happy to do those in this PR or a follow-up — flagging rather than silently shipping a half-translated interface.

🤖 Generated with Claude Code

@gdevenyi gdevenyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — per-instance language selection

The mechanism is the right shape: store the active set on SetupState, derive the toggle options from it, hide the toggle when only one language is active. Hiding rather than disabling the toggle is a nice touch, and disabling the last remaining checkbox is the correct guard.

Two things make this hard to accept as-is: the perimeter isn't validated, and the type widening was absorbed by casting at ~8 call sites rather than fixed once.

Blocking

  1. activeLanguages: z.array(z.string()) validates nothing. PATCH /v1/setup {"activeLanguages": ["klingon"]} is accepted and persisted. Object.entries(ALL_LANGUAGES).filter(([key]) => activeLanguages.includes(key)) then yields {}, Object.keys(...).length > 1 is false, and the language toggle disappears from the navbar and sidebar for every user on the instance, with no way to restore it through the UI. AGENTS.md is explicit here — strict data validation at the boundary, and fail loudly on an undeclared policy. This should be z.array($Language).min(1) with $Language = z.enum(['en', 'es', 'fr']) exported as the single source of truth.

  2. The casts move the problem instead of solving it. Widening Language to include es broke indexing into the { en, fr } objects in $BrandingConfig and GroupSettings, and the fix was as undefined | { [key: string]: string } at four spots in LoginBrandingPanel, one in login.tsx, one in StartSessionForm, plus resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en' repeated five times. AGENTS.md: "no casting at call sites", "shape is never repeated", "concentrate the cost". The actual defect is that those schema fields are typed { en: string; fr: string } instead of being keyed by Language. Fixing them once in packages/schemas makes every one of these call sites type-check unmodified. (#1439 introduces a $LocalizedString that is close to what's needed — worth converging on one, in schemas/core.)

  3. No tests. AGENTS.md requires unit tests and e2e tests for new changes; this has neither, and both manual boxes in the test plan are unchecked. Minimum viable set: a $UpdateSetupStateData schema test for the accepted/rejected language arrays, a SetupService test that activeLanguages round-trips (the pattern is right there in #1441's setup.service.spec.ts), and an e2e that deactivates a language and asserts the toggle disappears.

Should fix

  1. A failed autosave still reports "All changes saved"onSettled runs on error. Same issue as #1441; see inline.

  2. Deactivating a language strands users who had it selected. i18n.changeLanguage is called only for the admin flipping the checkbox. Every other user whose persisted language was just removed keeps rendering in it, and the toggle no longer offers a way out. Needs a reconciliation on app load: if the resolved language isn't in activeLanguages, switch to the first active one.

  3. ['en', 'fr'] is hardcoded as the default in four places here (setup.service.ts, Navbar, Sidebar, settings.tsx) and five more in #1439. If SetupService guarantees a non-empty array — which it already tries to — no client needs a fallback at all.

  4. The gateway carries a fifth hardcoded language list and doesn't consult activeLanguages, so ?lang=es works there even when Spanish is deactivated instance-wide.

  5. ALL_LANGUAGES: { [key: string]: string } — a loose record over a closed key set, which AGENTS.md rules out and which is what forces the ?? code fallbacks in #1439's consumers.

  6. The libui bump isn't here. The PR notes this depends on libui#107 for libui's own Spanish strings. Until that lands and is released, selecting Español leaves every libui-owned string (form submit/reset buttons, search placeholder, date pickers, notification titles) in English. Should this merge before the bump, or wait?

Coordination with the other open PRs

Conflict Files
#1442 × #1441 apps/web/src/routes/_app/admin/settings.tsx (both replace the staged-Save block with incompatible autosave layouts — #1441 adds a Settings section and SettingSection wrappers, this adds a Languages card) and apps/web/src/components/SaveStatus.tsx (add/add)
#1442 × #1439 apps/web/src/components/SaveStatus.tsx (add/add); apps/web/src/utils/languages.ts, activeLanguages in schema.prisma / setup.service.ts / $SetupState are duplicated verbatim between the two

Where the two autosave implementations differ, #1441's is correct: it destructures mutate (referentially stable) and depends on [mutate], whereas this one depends on [updateSetupStateMutation], which React Query returns fresh every render.

Comment thread packages/schemas/src/setup/setup.ts Outdated
});

const $SetupState = z.object({
activeLanguages: z.array(z.string()).optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

z.array(z.string()) accepts any string, and this is the perimeter — $UpdateSetupStateData is what UpdateSetupStateDto validates the PATCH body against.

PATCH /v1/setup {"activeLanguages": ["klingon"]} is stored as-is. Navbar/Sidebar then compute Object.entries(ALL_LANGUAGES).filter(([key]) => activeLanguages.includes(key)){}Object.keys(...).length > 1 is false → the language toggle vanishes for every user, and the admin settings page shows all three boxes unchecked with no way back through the UI. [] is likewise accepted here and only papered over by a ?.length check in the service.

AGENTS.md: "Strict data validation at the boundary, trusting inside" and "Correctness is structural, not vigilant".

const LANGUAGES = ['en', 'es', 'fr'] as const;
const $Language = z.enum(LANGUAGES);
const $ActiveLanguages = z.array($Language).min(1);

Exporting LANGUAGES/$Language also gives ALL_LANGUAGES, the gateway's VALID_LANGUAGES, and #1439's MAIL_LANGUAGE one thing to derive from instead of four independent copies.

(data: Parameters<typeof updateSetupStateMutation.mutate>[0]) => {
setSaveState('saving');
updateSetupStateMutation.mutate(data, {
onSettled: () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSettled runs on failure too, so a rejected save still shows the green check and "All changes saved". Because useUpdateSetupStateMutation doesn't disable the default error notification, the user gets a red error toast and a green "saved" pill simultaneously — and for the language checkboxes specifically, the box stays flipped even though nothing was stored.

onSuccess for the saved state, onError for a visible failure (and revert the optimistic checkbox).

Same issue in #1441, which rewrites this same block.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry did you look at this?

};
});
},
[updateSetupStateMutation]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Query returns a new mutation result object on every render — only mutate/mutateAsync are referentially stable. Depending on [updateSetupStateMutation] means this useCallback re-creates autosave every render, so the memo does nothing, and it becomes a real bug the moment autosave is used in an effect dependency array.

#1441 gets this right in the same file:

// `mutate` is referentially stable across renders, so callbacks that depend on `autosave` stay stable too.
const { mutate } = updateSetupStateMutation;
const autosave = useCallback((data) => { ... }, [mutate]);

Worth adopting that version wholesale when these two branches are reconciled.

: activeLanguages.filter((l) => l !== code);
if (updated.length > 0) {
autosave({ activeLanguages: updated });
if (!updated.includes(i18n.resolvedLanguage)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with switching the language here.

It only moves the admin. Every other user whose persisted language was just deactivated keeps rendering in it — t() still resolves their strings, and the toggle no longer lists it, so they can't switch away either. There's no reconciliation anywhere on app load. Something like this in the _app route (or wherever setupStateQuery is first read) would close it:

if (!activeLanguages.includes(i18n.resolvedLanguage)) i18n.changeLanguage(activeLanguages[0]);

updated[0] is arbitrary. [...activeLanguages, code] appends, so the array's order reflects the sequence of toggles, not any canonical order. Deactivating English can drop the admin into Spanish or French depending on click history. Sorting against the canonical LANGUAGES tuple (or just preferring 'en' when present) makes it deterministic.

Minor: the if (updated.length > 0) guard silently does nothing when it fails. The disabled={isLastActive} prop already prevents that path, so the guard is either dead or masking a case where it isn't — worth deciding which.

Comment thread apps/api/src/setup/setup.service.ts Outdated
// older $BrandingConfig will silently drop newer branding fields on read.
const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null);
return {
activeLanguages: savedOptions?.activeLanguages?.length ? savedOptions.activeLanguages : ['en', 'fr'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first of four copies of ['en', 'fr'] in this PR (also Navbar, Sidebar, settings.tsx) and #1439 adds five more. AGENTS.md: "One source of truth; everything derived."

Export DEFAULT_ACTIVE_LANGUAGES from schemas/setup and use it here. Then, since this method already guarantees a non-empty array, $SetupState.activeLanguages can drop .optional() and every client-side ?? ['en', 'fr'] disappears — the clients stop needing to know the default exists.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree

Comment thread apps/api/prisma/schema.prisma Outdated
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages String[]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A required scalar list added to a model with existing documents. #1441 adds its SetupState field as Int?; this one has neither ? (not permitted on lists) nor @default([]).

Prisma's MongoDB connector should return [] for a list field absent from a stored document, so this probably works — but "probably" is doing real work in a field that gates the language toggle for the whole instance. @default([]) costs nothing and removes the question for every instance created before this deploys.

Also worth noting initApp creates SetupState without this field, so the very first document an instance writes exercises exactly that path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree, add the default

Comment thread apps/gateway/src/services/i18n.ts Outdated
}
}

const VALID_LANGUAGES = ['en', 'es', 'fr'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third hardcoded language list in this PR (after ALL_LANGUAGES and LanguageOptions), and #1439 adds a fourth (MAIL_LANGUAGE). Import the tuple from schemas and derive.

Two behavioural notes:

  1. The gateway ignores activeLanguages. ?lang=es renders Spanish even on an instance where Spanish has been deactivated. Since the emailed assignment links in feat(mail): add outgoing email, group email templates, and assignment emails #1439 are built as ${assignment.url}?lang=${language}, that's reachable in practice.

  2. SSR/hydration. detectLanguage() reads window.location.search at module scope; the guard makes the server always resolve 'en' while the client can resolve 'es'. Worth confirming the first paint doesn't flash English or trip a hydration mismatch — a ?lang= link is precisely the case where the two disagree.

Minor: the two /* eslint-disable */ lines at the top disable those rules for the whole file rather than the declare module block that needs them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not change the eslint-disable though

Comment thread apps/web/src/hooks/useInstrument.ts Outdated
setInstrument(
translateInstrument(
instrument,
resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en' appears five times in this PR — here, useInstrumentInfoQuery, group/manage.tsx, InteractiveContent, and useInterpretedInstrument. AGENTS.md: "Shape is never repeated."

One named helper says what it means and gives you a single place to change the policy:

/** Instruments are authored in en/fr only; other UI languages fall back to English. */
export const toInstrumentLanguage = (language: Language): 'en' | 'fr' =>
  language === 'fr' ? 'fr' : 'en';

The policy itself is also worth surfacing: a user who has selected Español gets instruments silently rendered in English with no indication that a translation is missing. That's defensible, but right now it's an implicit consequence of five scattered ternaries rather than a stated decision.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Translate instrument should handle this already downstream. I think it already does

const instanceDetails = branding?.instanceDetails?.[lang]?.trim() || null;
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- blank strings must fall back, so `||` not `??` */
const instanceName =
(branding?.instanceName as undefined | { [key: string]: string })?.[lang]?.trim() || DEFAULT_INSTANCE_NAME;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These casts (four in this file, plus login.tsx and StartSessionForm.tsx) are the symptom, not the fix. AGENTS.md: "no casting at call sites" and "Concentrate the cost: complex type machinery belongs in a small number of utilities."

The root cause is that $BrandingConfig types instanceName/instanceTagline/instanceDetails and resourceLinks[].label as literal { en, fr } objects, so they can't be indexed once Language widens. Keying them by Language in packages/schemas fixes all six sites without touching them:

const $LocalizedString = z.partialRecord($Language, z.string().nullish());

That also makes tl = (obj) => obj[lang] ?? obj.en ?? '' unnecessary and — importantly — makes adding a fourth language a compile-time task rather than a runtime fallback to ''. Today the cast means a missing language silently renders as an empty instance name.

Note #1439 adds a $LocalizedString in schemas/mail for the same reason; one shared definition in schemas/core would serve both PRs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


const handleChangeLanguageEvent = useCallback(
(event: CustomEvent<Language>) => {
(event: CustomEvent<string>) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widening a react-core event contract from CustomEvent<Language> to CustomEvent<string> to work around a downstream type error loses the checking for every other consumer of the changeLanguage event — including instrument authors, since this is the interactive-task bridge.

The runtime guard on the next line already narrows to 'en' | 'fr', so the type can stay Language and the guard keeps doing its job. If the incompatibility is with document.addEventListener's global CustomEventMap (which is where the widening pressure comes from), that map is the thing to update — not this handler's signature.

While here: console.error on an unsupported language is invisible to the participant, who just sees the language not change. Given AGENTS.md's "fail loudly on an undeclared policy", a user-visible signal (or not offering the option at all) would be better.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, this is not okay

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The instance-level activeLanguages idea is the right shape, and the settings page is a sensible place for it.

Two questions on this PR need a maintainer decision — whether an admin-selectable language should ship before its translations exist, and how the new required Prisma field behaves against already-deployed instances. I'll follow up separately on both. The items below stand either way.

Gateway — this is the most serious part

  1. apps/gateway/src/services/i18n.ts:19-31detectLanguage() reads window.location.search at module scope, in a file imported by src/Root.tsx. apps/gateway/AGENTS.md ("SSR traps") forbids exactly this. It also diverges SSR from hydration: renderToString resolves en while the client resolves es/fr. Read the param server-side in the root router and pass it through RootProps instead.

  2. i18n.ts:30?lang=es renders a gateway page with blank labels and buttons. Passing the detected language as defaultLanguage destroys libui's fallback: Translator.t resolves obj[resolvedLanguage] ?? obj[defaultLanguage], so when both are es and no Spanish string exists it logs Failed to extract translation… and returns ''. Keep defaultLanguage at en.

  3. apps/gateway/src/Root.tsx:82-88 — the gateway's LanguageToggle still hard-codes { en, fr } and nothing there reads activeLanguages, so the per-instance setting never reaches the patient-facing app at all.

Types and single-source-of-truth

  1. packages/schemas/src/setup/setup.ts:180,191z.array(z.string()).optional() accepts [] and arbitrary language codes. The "at least one language" invariant is enforced only in the settings UI (settings.tsx:171). Make it a non-empty array of a language enum so the perimeter rejects bad input.

  2. apps/web/src/utils/languages.ts:1 — type ALL_LANGUAGES as Record<Language, string> rather than { [key: string]: string }, so adding a language to LanguageOptions fails to compile until it has a display name.

  3. The ['en', 'fr'] default is written four times: apps/api/src/setup/setup.service.ts:54, Navbar.tsx:30, Sidebar.tsx:36, settings.tsx:67. One source of truth.

  4. resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en' is copy-pasted five times: useInstrument.ts:25, useInstrumentInfoQuery.ts:29, group/manage.tsx:382, InteractiveContent.tsx:184, useInterpretedInstrument.ts:50. Extract one helper mapping a UI language to an instrument language.

  5. Casting at call sites is banned by CLAUDE.mdLoginBrandingPanel.tsx:149,237,239,241, auth/login.tsx:40, StartSessionForm.tsx:215, settings.tsx:174. The root cause is $BrandingText (packages/schemas/src/setup/setup.ts:42) being fixed at { en, fr }; key it by Language and the casts disappear rather than needing to be written.

  6. InteractiveContent.tsx:66 widens the handler to CustomEvent<string> while packages/react-core/src/types.ts:11 still declares changeLanguage: CustomEvent<Language>. The two now disagree.

The autosave rewrite

  1. apps/web/src/routes/_app/admin/settings.tsx:51-63 is unrelated to i18n and regresses the page — please split it out. The Save button and success notification are gone; onSettled reports "All changes saved" even when the PATCH failed; and both controls now derive straight from server state with no optimistic update, so each click waits for a PATCH plus a query invalidation before the checkbox visibly moves.

  2. settings.tsx:18 — the deleted comment explaining that Toggle is hand-rolled because libui ships no Switch is exactly the non-obvious kind CLAUDE.md says to keep. Please restore it.

  3. SaveStatus.tsx:12 — raw slate/white scales; apps/web/AGENTS.md asks for semantic libui tokens (bg-muted, text-muted-foreground).

Tests

  1. No tests at allCLAUDE.md requires a unit test and an e2e test per change. There's no e2e page object for /admin/settings in testing/src/pages/_app/, and the new checkboxes at settings.tsx:164 carry no data-testid, which apps/web/AGENTS.md asks for on new UI an e2e test will drive.

PR description

  1. The body says es was added to runtime-core's Language. It wasn't — packages/runtime-core/src/types/core.ts:2 is still 'en' | 'fr', which is precisely why the five-way narrowing in item 7 exists. Worth fixing so reviewers aren't looking for a change that isn't there.

Overlap with #1439. The two PRs share seven files and both add activeLanguages to the Prisma schema, setup.ts and setup.service.ts, plus SaveStatus.tsx and languages.ts, with divergent contents. They can't both merge as written — we'll sort out the ordering and let you know which one rebases onto the other.

@joshunrau

Copy link
Copy Markdown
Collaborator

Following up on the overlap with #1439 — that's now settled in this PR's favour.

This PR owns the shared files. activeLanguages (schema + setup.ts + setup.service.ts), apps/web/src/components/SaveStatus.tsx and apps/web/src/utils/languages.ts belong here. #1439 has been asked to delete its copies rather than wait for a rebase — the field is read in seven places there and written nowhere, so this is the only PR that actually wires it end to end. You don't need to coordinate with that branch or hold anything back; carry on as though those files are yours alone.

That makes the review items I left earlier the whole of what's outstanding, with the gateway ones (1–3) being the ones I'd start on — ?lang=es currently renders blank labels and buttons, which is the most user-visible problem in the PR.

Still with the maintainer: whether Español ships before its translations exist, and whether this waits on the libui release. I'll follow up. If the answer on Spanish is to gate it behind real translations, this PR gets smaller rather than larger — so nothing you'd do now is at risk from that decision.

@joshunrau

Copy link
Copy Markdown
Collaborator

Both remaining questions on this PR are now settled.

Español ships. Add it as a selectable language even though the translations don't exist yet — users who pick it get an English interface via the fallback, and that's accepted. Translators can then work against a real setting.

libui #107 is merged and released as 6.10.0. Please bump the catalog pin in pnpm-workspace.yaml:25 from ^6.9.3 to ^6.10.0 in this PR, so libui's own Spanish strings resolve rather than falling back. No need to worry about the 7-day minimumReleaseAge gate — @douglasneuroinformatics/* is already in minimumReleaseAgeExclude, so a same-day release installs fine. Also worth correcting the PR description, which currently describes #107 as an unmet dependency.

One item is now more urgent than when I first wrote it. Since Español becomes genuinely selectable, review item 2 is no longer theoretical: apps/gateway/src/services/i18n.ts:30 passes the detected language as defaultLanguage, which destroys libui's fallback. Translator.t resolves obj[resolvedLanguage] ?? obj[defaultLanguage], so when both are es and no Spanish string exists it logs Failed to extract translation… and returns ''. In apps/web a user selecting Español gets English, which is the accepted outcome; on the gateway, ?lang=es gets a page with blank labels and buttons. Please keep defaultLanguage at en so the gateway degrades the same way the web app does.

Items 1 and 3 on the gateway matter for the same reason — the SSR/hydration split, and the fact that the gateway's LanguageToggle still hard-codes { en, fr } so the per-instance setting never reaches the patient-facing app at all.

Everything else from my review stands unchanged.

@joshunrau

Copy link
Copy Markdown
Collaborator

Correcting my earlier guidance on the gateway — I said "keep defaultLanguage at en", which understates it. Apps shouldn't pass defaultLanguage at all.

apps/gateway/src/services/i18n.ts:30 is the only place in this repo that sets it. On main the gateway is just i18n.init({ translations: {} }), and apps/web never sets it either.

The reason it looked like the right knob is that libui's Translator.init gives the parameter two unrelated jobs (translator.ts:100-108 in 6.9.3):

init({ defaultLanguage, translations }: TranslatorConfig) {
  this.#config = {
    defaultLanguage: defaultLanguage ?? 'en',   // ← the fallback used by t()
    ...
  };
  this.changeLanguage(this.#config.defaultLanguage);   // ← also seeds resolvedLanguage
}

You wanted the second effect — start the session in the detected language — and got the first as collateral. t() resolves obj[resolvedLanguage] ?? obj[defaultLanguage] (line 121), so setting both to es removes the fallback entirely and any string without a Spanish entry returns ''.

Use changeLanguage instead. It's public, it sets only resolvedLanguage, and it's exactly what LanguageToggle calls (via useTranslation.ts:28, which binds i18n.changeLanguage):

i18n.init({ translations: {} });        // defaultLanguage stays 'en' — don't pass it
i18n.changeLanguage(detectedLanguage);  // start in the detected language, fallback intact

That also composes better with the SSR fix in item 1: read ?lang= server-side in the root router, pass it through RootProps, and call changeLanguage from there rather than reading window.location.search at module scope.

Worth noting changeLanguage throws if called before init (it's decorated @InitializedOnly), so ordering matters — but in a module that owns both calls that's not a constraint you can trip over.

@joshunrau

Copy link
Copy Markdown
Collaborator

Suggested model: Fable 5

Sizing the remaining work on this PR for whoever picks it up, human or agent. It's an automatic escalation on apps/api/prisma/schema.prisma, and the work spans gateway SSR, packages/schemas, packages/react-core, apps/web and testing/ simultaneously.

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Total rewrite required to meet the standards. Tell Opus 5 Max Thinking to revise the entire PR in accordance with the standards and documentation now in the app (see all the new AGENTS.md files)

Comment thread apps/api/prisma/schema.prisma Outdated
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages String[]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree, add the default

Comment thread apps/api/src/setup/setup.service.ts Outdated
// older $BrandingConfig will silently drop newer branding fields on read.
const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null);
return {
activeLanguages: savedOptions?.activeLanguages?.length ? savedOptions.activeLanguages : ['en', 'fr'],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree

Comment thread apps/gateway/src/services/i18n.ts Outdated
}
}

const VALID_LANGUAGES = ['en', 'es', 'fr'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry I agree

Comment thread apps/gateway/src/services/i18n.ts Outdated
}
}

const VALID_LANGUAGES = ['en', 'es', 'fr'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not change the eslint-disable though

boldResourceLinks: boolean;
fontSize: null | number | undefined;
lang: 'en' | 'fr';
lang: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unacceptable. We do not use magic strings in this codebase. Language needs to be adjusted and used here.

Comment thread apps/web/src/hooks/useInstrument.ts Outdated
setInstrument(
translateInstrument(
instrument,
resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Translate instrument should handle this already downstream. I think it already does

});
const infos = await $InstrumentInfo.array().parseAsync(response.data);
return infos.map((instrument) => translateInstrumentInfo(instrument, resolvedLanguage ?? 'en'));
const instrumentLang = resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be handled downstream

(data: Parameters<typeof updateSetupStateMutation.mutate>[0]) => {
setSaveState('saving');
updateSetupStateMutation.mutate(data, {
onSettled: () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


const handleChangeLanguageEvent = useCallback(
(event: CustomEvent<Language>) => {
(event: CustomEvent<string>) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, this is not okay

{ALL_LANGUAGES[language][resolvedLanguage]}
{
ALL_LANGUAGES[language][
resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no no no

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch now conflicts with main — GitHub reports the merge as CONFLICTING, and the CI lint-and-test job has not run against this commit (only the CodeQL jobs did).

Please rebase or merge main into the branch and resolve the conflicts, so that CI can run and the merge result can be reviewed. Review resumes automatically once the branch is mergeable and the checks are green.

Reviewed at commit 094c92b.

thomasbeaudry added a commit that referenced this pull request Jul 30, 2026
Implements the maintainer's settled decisions and the standing items from
both review rounds. Net -553 lines.

SMTP only (decision 1, removal half). Drops the four-provider HTTP client,
the hand-rolled AWS SigV4 signer, `apiKey`, and the provider/region/domain
fields. Every provider implemented here exposes SMTP, so nodemailer already
reached them. Consuming libnest's lazy transporter is left until it ships.

No more emailed password (decision 2, partial). `{{password}}` is gone from
the default templates and the welcome email no longer carries a credential.
The invite-token flow is a follow-up.

Drops the files #1442 owns (decision 4): `activeLanguages`, `SaveStatus`,
`languages.ts` — and `SegmentedControl`, which nothing used once the
transport switch and category tabs went.

Correctness:
- `isMailEnabled` is one function over the raw stored value, so the public
  flag and the server's behaviour cannot disagree.
- `POST /v1/mail/test` no longer authenticates to a caller-supplied host
  with the stored password; changing the server requires re-entering it
  rather than silently blanking a working credential.
- `emailTemplates` updates carry `expectedUpdatedAt`, which constrains the
  write itself, so a concurrent edit conflicts instead of vanishing.
- `AssignmentEmailForm` resolves templates from the assignment's own group,
  matching the server, and clamps the language to what is on offer.
- Mail settings save explicitly; autosave was persisting half-typed secrets
  and reporting failures as success.
- Assignment emails are audit-logged and rate-limited; the expiry renders
  with a time and zone instead of a bare UTC date.
- Only `NotFoundException` is swallowed when resolving group names.
- `$LocalizedString` moves to `core` and derives from `Language`.
- `remote-assignment.tsx` keeps the libui `Form` (reverted; unrelated).
- The `INFORMATION` template category is removed — nothing sent one.

Structure: `SectionCard`, `FormField` and `EmailTemplateEditor` extracted;
`useGroupQuery` replaces two inline fetches; `useUpdateGroupMutation` gets
its success toast back behind an option.

Tests: schemas coverage for the mail contract, web tests for the hook and
the two form bugs, and e2e specs plus page objects for `/admin/mail` and
`/group/email-templates`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasbeaudry added a commit that referenced this pull request Jul 30, 2026
Reconciles the SMTP-only refactor with the two mail fixes pushed to the
branch and with everything main gained since (#1441, #1444, #1477).

The branch had patched the HTTP transport rather than removed it, so the
conflicts were resolved toward the maintainer's decision 1: the HTTP client
and SigV4 signer go, which makes both patches unnecessary rather than wrong.
`isMailEnabled` no longer needs an HTTP branch, and the provider names come
back out of `eslint.config.js` since no JSX mentions them any more.

Kept from the other side:
- #1441's `getDefaultAssignmentExpiry` replaces the hardcoded one-year
  default in the create-assignment form.
- #1444's `$Email`/`$PhoneNumber`/`omittedIfBlank` validation helpers in the
  user-create form, replacing the old `PHONE_REGEX`.
- The deduplicated welcome-email notification in `useCreateUserMutation`,
  now also opted out of the router error boundary so the call site's own
  password-error handling is the only path.
- #1477's page objects and specs, unioned with the two added here.

`SaveStatus.tsx` is restored from main: #1442 has landed and `admin/settings`
now consumes it, so decision 4's "pick it back up once #1442 lands" applies.
`activeLanguages` and `utils/languages.ts` are still absent from main, so
those stay dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasbeaudry and others added 4 commits August 4, 2026 23:31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spanish reached the frontends only through libui's type, while this repo kept
`Language` at en|fr, so every site that indexed localized content needed a cast
or a narrowing ternary.

Separate the two concepts instead. `schemas/core` now owns the interface
language (en|es|fr) and `runtime-core` keeps the instrument-authoring language
(en|fr), with `$InstrumentAuthoringLanguage` naming the narrower set. react-core
derives libui's `LanguageOptions` from the contract, so enabling a language is
one edit rather than one per app.

Widening `translateInstrument` to accept an interface language removes the need
to narrow at any call site: `getTargetLanguage` already falls back to the
language an instrument was written in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds activeLanguages to the setup state, validated at the perimeter as a
non-empty tuple of interface languages, with an admin card to toggle them. The
set is rebuilt by filtering the canonical language order rather than appending,
so the fallback language does not depend on click order.

Deactivating a language previously stranded everyone already reading in it, so
the shared LanguageToggle reconciles on load and hides itself when only one
language is active.

The gateway has its own database and cannot read the setup state, so the
languages travel with the assignment. `?lang=` is read server-side and applied
with `changeLanguage`, never `defaultLanguage` — that is the fallback `t()` uses
for an untranslated string, and pointing it at the patient's language renders
those strings as nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit tests for the perimeter ($ActiveLanguages rejecting an empty or unknown
set), the SetupService round-trip and its fallback for a document written before
the setting existed, and the toggle's reconciliation of a user left on a language
the instance no longer offers.

The e2e test drives the admin card and asserts the sidebar toggle disappears,
restoring the language it deactivated since the setup state is instance-wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thomasbeaudry
thomasbeaudry force-pushed the feat/spanish-mechanism branch from 094c92b to 8eb257c Compare August 5, 2026 04:17
Comment thread apps/api/prisma/schema.prisma Outdated
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages String[] @default([])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would suggest adding a Language enum here

Comment thread apps/api/prisma/schema.prisma Outdated
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages String[] @default([])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should default be [en]?

Comment thread apps/api/src/setup/setup.service.ts Outdated
// Parsed rather than read through: the column is a bare `String[]` and predates this setting,
// so a document written by an older release holds `[]` and every client would be left with no
// language to offer.
const activeLanguages = $ActiveLanguages.safeParse(savedOptions?.activeLanguages);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once enum is added at the db level, this can be removed

Comment thread apps/gateway/prisma/schema.prisma Outdated
id String @id
// SQLite has no scalar list, so this is JSON, read back through $ActiveLanguages. The default
// covers assignments created before an instance could choose which languages to offer.
activeLanguagesStringified String @default("[\"en\",\"fr\"]")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong. We can use JSON type in sqlite now with the same generator as in api

Comment thread apps/gateway/src/routers/root.router.ts Outdated

// Read server-side rather than from `window.location` so the SSR pass and the hydration pass
// resolve the same language; anything else renders the page in English and then swaps it.
const activeLanguages = $ActiveLanguages.safeParse(JSON.parse(assignment.activeLanguagesStringified));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be removed once db is done properly

(data: Parameters<typeof updateSetupStateMutation.mutate>[0]) => {
setSaveState('saving');
updateSetupStateMutation.mutate(data, {
onSettled: () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasbeaudry did you look at this?

Addresses the review on #1442.

`activeLanguages` on `SetupState` is a Prisma `Language` enum rather than
`String[]`, so an unknown code cannot be stored and `getState` no longer
parses what it reads back. Its `@default` is `[en, fr]` — the same set
`DEFAULT_ACTIVE_LANGUAGES` names, so an instance upgrading onto this keeps
the toggle it has today rather than silently dropping French.

The gateway stores the languages an assignment offers as a `Json` column
typed through `prisma-json-types-generator`, the generator apps/api already
uses. SQLite supports `Json` as of the pinned Prisma, so the stringify /
JSON.parse / re-validate round trip in the router is gone and the column
reads back as the non-empty tuple directly.

A rejected autosave on /admin/settings reported "All changes saved":
`onSettled` runs on failure too. It reports the failure instead, and the
mutation opts out of the app-wide `throwOnError` so the status is reachable
rather than replaced by the router error boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thomasbeaudry

Copy link
Copy Markdown
Collaborator Author

Pushed as 2a9894a. All six comments addressed; one of them I answered differently than suggested, flagged below.

apps/api/prisma/schema.prisma — add a Language enum

Added, in the // Setup section. Values are the language codes themselves (en/es/fr), so Mongo stores the same strings it stored before and a document read back through Prisma is already a Language.

This is now a third hand-maintained list that has to agree with packages/schemas (alongside AppSubject), so I noted it in apps/api/AGENTS.md next to the existing warning.

schema.prisma — should the default be [en]?

I made it @default([en, fr]) rather than [en], and I'd like a second look at that call.

[en] changes behaviour for every instance that upgrades onto this. initApp writes SetupState without the field, and a document written before the field existed doesn't have it either — both take the default. With [en], French disappears from the toggle on instances that have been bilingual all along, and the toggle hides itself entirely since one language is active. [en, fr] is what main effectively offers today and what DEFAULT_ACTIVE_LANGUAGES names, so the two now agree and upgrading is a no-op.

Happy to switch it to [en] if the intent was that a fresh instance opts into languages deliberately — it's a one-word change, but it's a behaviour change for existing deployments rather than a default for new ones, so I didn't want to make it silently.

setup.service.ts:58 — remove the parse once the enum exists

Removed. What's left is a destructure, not a validation:

const [fallbackLanguage, ...otherLanguages] = savedOptions?.activeLanguages ?? [];
activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES,

The column can't hold an unknown code any more, so nothing is parsed. The destructure is only what carries the non-empty guarantee into the type — Prisma returns Language[], and ActiveLanguages is [Language, ...Language[]] so consumers can read activeLanguages[0] with no assertion. The ?? branch covers an instance with no SetupState document at all, which the DB default can't reach.

It also means the read path doesn't depend on whether Prisma's MongoDB connector applies @default on read or returns [] for a missing field — either way the answer is DEFAULT_ACTIVE_LANGUAGES. The spec that pinned the old zod behaviour was replaced with one covering the no-document case.

apps/gateway/prisma/schema.prisma:19 — use Json, same generator as the api

You're right, and I checked it against the pinned version rather than taking it on faith: SQLite Json validates and pushes cleanly on Prisma 6.19.3.

activeLanguagesStringified String is now:

/// [ActiveLanguages]
activeLanguages Json @default("[\"en\",\"fr\"]")

with prisma-json-types-generator added to the gateway and the namespace declared in src/typings/prisma-json-types-generator.d.ts, pointing at ActiveLanguages from schemas/core. The generated client uses it on both the read and the write side, so api.router.ts writes activeLanguages with no JSON.stringify. Documented the mechanism in apps/gateway/AGENTS.md, which listed the ambient declarations and didn't know about this one.

root.router.ts:46 — remove once the db is done properly

Gone. JSON.parse, the $ActiveLanguages.safeParse and the DEFAULT_ACTIVE_LANGUAGES fallback all deleted — it's const { activeLanguages } = assignment and the field is already the non-empty tuple, so activeLanguages[0] needs no narrowing.

settings.tsx:77 — did you look at this?

I had not, and it was still broken. Two things were wrong:

onSettled runs on failure, so a rejected PATCH showed the green "All changes saved". That's onSuccess now, with onError reporting the failure.

The less obvious half: the error state was unreachable as written. queryClient sets throwOnError: true for mutations, so a failed PATCH replaces the page with the router error boundary and any inline status goes with it. useUpdateSetupStateMutation takes a throwOnError option and this page passes false — the boundary is right for the branding editor's explicit Save, but not for an autosave the admin didn't ask for. LoginPageEditor is unchanged.

The option defaults to true rather than being left undefined, because useMutation spreads its options over the client defaults and an explicit undefined would read as false and opt every caller out.

The failure status stays put instead of clearing on a timer: every control on the page renders from server state, so once the pill goes there's nothing left telling the admin their change never landed. Also moved SaveStatus off the raw slate/white scales onto border-border/bg-card, which was review item 12 from the first round and still outstanding.


Verification. pnpm lint 33/33 clean, pnpm test 646 passed / 1 skipped (3 new). pnpm test:e2e does run locally now — the browsers just weren't installed. 140 passed, plus a new gateway spec asserting ?lang=fr reaches the SSR'd HTML (Aperçu in the first paint, not swapped in after hydration) and that an unoffered language falls back rather than rendering blanks. Two failures are unrelated flakes under full-suite parallelism — upload.spec.ts on firefox one run, user-account.spec.ts on chromium the next, and a teardown 429 from the login throttler; all 17 pass when run on their own.

@joshunrau
joshunrau merged commit 0156215 into main Aug 5, 2026
5 checks passed
thomasbeaudry added a commit that referenced this pull request Aug 7, 2026
Deactivating the language a user is reading left the sidebar in it.

The reconciliation added in #1442 runs from `useLanguageOptions`, inside
`LanguageToggle`. It corrects `i18n.resolvedLanguage` and emits
`languageChange` — but libui's `useTranslation` subscribes in an effect, and
React runs effects child-first, so a tree that mounts already stranded fires
the correction before the toggle's ancestors have subscribed. They never hear
it and keep rendering the deactivated language.

The sidebar is the casualty precisely because it renders the toggle. `Layout`
mounts the navbar first, so the navbar subscribes in time and updates while
the sidebar does not — which is why the symptom names one and not the other.

Fixes it where the ordering cannot matter: `_app`'s `beforeLoad` reconciles
before any component renders, so every `useTranslation` initialises from the
corrected language instead of waiting to be told. The policy itself moves to
`resolveActiveLanguage` in schemas/core, next to the authoring-language
policy, so the pre-render pass and the live in-session effect decide the same
thing rather than each carrying a copy.

`useLanguageOptions` keeps its effect: it covers an admin deactivating a
language mid-session, where every component is already subscribed and it
demonstrably works. Its doc now says which case it can and cannot cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasbeaudry added a commit that referenced this pull request Aug 7, 2026
#1442 made Español selectable and translated the namespace JSON files, but
left 572 inline `t()` calls naming only `en` and `fr`. libui resolves
`obj[resolvedLanguage] ?? obj.en`, so those rendered in English with nothing
reporting it — the admin panel and its submenu among them.

Adds the missing `es` entry at all 572 sites across apps/web,
packages/react-core and apps/gateway, reusing the terminology the completed
namespace files already established (Panel de control, Centro de datos,
Sujeto, Tarea remota) rather than inventing a second vocabulary. Five
strings that carry no words — the `{}: {}` format strings whose `fr` exists
only for French's space-before-colon — get an entry identical to English, so
the set is complete rather than partly deliberate and partly forgotten.

A unit test scans all three frontends and fails on an inline call that names
`en` but not `es`, which is what keeps this from decaying; the four
AGENTS.md files that taught the `{ en, fr }` shape now teach `{ en, es, fr }`
and point at it.

apps/playground is untouched: it hard-codes its LanguageToggle options as
{ en, fr }, so Spanish cannot be selected there at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants