Skip to content

feat(i18n): extract the remaining app copy into the typed catalogs (E.1b) - #65

Merged
axelhamil merged 36 commits into
devfrom
feat/e1b-i18n-extraction
Aug 31, 2026
Merged

feat(i18n): extract the remaining app copy into the typed catalogs (E.1b)#65
axelhamil merged 36 commits into
devfrom
feat/e1b-i18n-extraction

Conversation

@axelhamil

@axelhamil axelhamil commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Closes phase E.1b. Every remaining hardcoded English string in apps/app now comes from @packages/i18n through t(), and features/CLAUDE.md's partial-translation carve-out is retired.

The catalogs go from 464 keys per locale to 1019 — +555 net — over 71 rewired files (142 touched in all). An earlier revision of this description said 520; the PR review measured it properly and it was wrong. Both locales are at exactly 1019, which is the parity gate's job.

Beyond the extraction

  • Prices were built by hand (${amount/100} ${CURRENCY}/${interval}), untranslatable and wrong per locale. Now Intl.NumberFormat bound to the active locale: $12/month / 12 €/mois. Same for the admin audit log's raw Date casts, which rendered in the browser's locale instead of the app's.
  • Three pre-existing validation-copy defects the extraction surfaced: two inline Zod message: values silently overriding the translated global map, and .regex()/.startsWith() checks raising an issue code no map can localize.
  • 61 HTTPException call sites serialize as code: HTTP_<status>, which the front catalog could never match — a byStatus layer now closes the user-visible half. Business codes on the API side are a filed follow-up.
  • Legal pages get per-locale content modules. The prose stays English by design (translating a ToS is a legal act, not a dev task) — but every page now says so in French through a shared UntranslatedBodyBanner, rather than shipping a French title over a silent English body.

Gates

app 277 · i18n 21 · api 800 · type-check 13/13 · biome 880 · knip 0 · a11y 22/22 · jscpd 34 clones / 483 lines / 1.12%.

That jscpd figure is mostly dilution, not deduplication — the denominator grew ~2 900 lines; the real reduction is one clone, 13 lines. Recorded so it isn't quoted later as a win.

The parity gate grew 17 → 21 assertions. Three came from an implementer volunteering a negative result about its own work: it had collapsed a French _one onto its _other, both suites stayed green, and only a human reading "1 jours" caught it. The gate now fails on that, on a stale plural exemption, and on a plural declared in one form but not the other.

Two reviews found what the gates could not

  • Engineering: the phase had built the same policy-title map twice, under two namespaces with byte-identical values. Renaming a policy would have diverged them silently. Promoted to shared/legal/, following the branch's own precedent.
  • Native French: 20 defects with every gate green — {{label}} copié agreeing masculine against feminine labels ("URL de base SCIM copié"), a pronoun binding to the wrong noun so the SSO domain card told admins the opposite of the English, four participles imposing masculine on users of unknown gender, three terms drifted across screens, and not one non-breaking space in any French catalog.

Deliberately not done, each recorded in docs/HISTORY.md

Business codes for the API's HTTPException sites · a French legal corpus (policies/fr.tsx re-exports English by design) · EVENT_DESCRIPTIONS stays English permanently · a dead branch in domain-verification-card.tsx (@better-auth/sso puts the code in .code, the mutation reads .message) — left alone because a behaviour fix buried in a translation commit makes the diff unreviewable.

https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju

…talog

Three inline `message:` values were winning over the global Zod map, which
gives per-issue messages precedence: the api-token form overrode two already
translated keys with permanent English, and the SSO schemas used `.regex()`
and `.startsWith()`, whose `invalid_format` code can never reach `params.i18nKey`.
None of it is visible today because both screens are English throughout — it
would have surfaced as English validation on a finished French screen.

`isHttpsUrl` moves to `shared/api/` on its second occurrence.
…ting

The three `params` assertions cast to `$ZodIssueCustom` to reach past the
`$ZodIssue` union. Narrowing on `code === "custom"` gives the same access with
no cast: a cast asserts the shape, the narrowing proves it, and the cast would
have kept compiling if the `code` check above ever stopped holding.

Verified the tests still fail on broken production code after the rewrite.
…llbacks

`zod-error-map.ts` resolves custom issues with `t(params.i18nKey as never)`, so a
mistyped key compiles and renders the raw key to the user — the one part of the
i18n surface the type-checker cannot see. A source scan now asserts every
`i18nKey` literal resolves, plus a second case asserting the scan finds the call
sites at all, so the guard cannot pass by matching nothing.

The 22 `throwApiError` fallbacks in `shared/api/` move to an `errors.fallback`
group, read through `getErrorsT()` since a queryFn has no React tree to read from.
Notification bell/inbox item, theme toggle, secret reveal dialog and the
shared clipboard-copy family (secret-reveal-dialog + sso/copy-row, rule
#2's second occurrence) move to @packages/i18n. Category enum values
reaching notification-item.tsx's DOM go through a typed
common.notifications.categories.<category> lookup so Task 6 can reuse
the same keys for its preference-matrix CATEGORY_LABELS.

notification-labels.ts's unreadLabel takes t as a parameter instead of
calling useTranslation itself (it's a plain helper, not a component or
hook) — same pattern as applyZodErrorMap/getErrorsT. Its test no longer
pins the English literal "Notifications, none unread": it derives the
expected string from the catalog via Intl.PluralRules per locale, since
French puts 0 in the singular plural category and English does not.

The three keyboard-shortcut literals (⌘K, ⌘, Ctrl in app-shell.tsx,
command-palette.tsx, user-menu.tsx) are left untouched on purpose —
they're key names printed on hardware, not copy.

This leaves SecretRevealDialog and the bell French while some of the
screens that mount them (e.g. webhooks.route.tsx) are still English.
That mismatch is expected between commits on this branch: the phase
ships as a single PR to dev, never an intermediate commit.

Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Review of 4fc336f raised three Important findings, all fixed here:

- CATEGORY_KEYS had no test asserting the mapping itself: satisfies
  Record<NotificationCategory, string> proves every category is
  present, never that it points at the right key. A swapped org/security
  pair type-checked and left the full suite green. Added a test that
  asserts each category against its literal key, and verified it by
  mutation: swapping the pair turned it red, restoring turned it back
  green (see task-3-report.md for the transcript).

- latest.category was cast to NotificationCategory instead of guarded.
  The cast's justification was also factually wrong: z.enum(
  NOTIFICATION_CATEGORIES) only validates the PUT preferences/
  org-preferences request bodies, never the GET /notifications read
  path this value travels. The real guarantee is TS-level only
  (NotificationConfig at write time in packages/events), which is
  exactly why a runtime guard is the correct tool here, not a
  preference. isNotificationCategory() now proves the shape, with an
  "unknown" catalog fallback for the branch it rejects.

- unreadLabel(0) had regressed from "Notifications, none unread" to
  "Notifications, 0 unread" to route through the _one/_other plural
  pair. The "no _zero" rule bans an i18next mechanism, not the wording:
  a count === 0 branch with its own unreadNone key restores the
  original copy without touching the plural mechanism.

Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The type guard added in the previous commit had an untested fallback branch:
the guard proved the value belongs to the union, but nothing showed that the
`else` renders sensible copy when it does not. An untested fallback is a guard
whose whole purpose has never been exercised.

The resolution moves out of the JSX into `categoryKeyFor`, which makes the
branch reachable from a test, and the branch is now asserted for an unknown
value, an empty string, and for real copy existing in both locales.

Verified by mutation: forcing `categoryKeyFor` to always return the security
key turns both new tests red.
Extracts hardcoded English copy from /dashboard, /org/new and
/accept-invitation into the common.dashboard, common.orgNew and
common.invitation i18n groups (en source + fr translation), and routes
accept.route.tsx's raw mutation.error.message through formatApiError
so a backend message reaches the user translated like every other
error surface.
…ed-in-as

"Connecté avec {{email}}" reads as the means of signing in rather than the
identity signed in as. The catalog already settled this sense twice — "agit en
tant que" for impersonation, "en tant que {{role}}" in the invitation email —
so the wording now matches instead of introducing a third phrasing.
Extracts the English-only copy from the organization route, the member
row and the invitation row into settings.organization (en/fr), plus a
shared common.roles lookup for the owner/admin/member enum.

- organization.route.tsx: the two hand-built plurals
  (`count === 1 ? "" : "s"`) become i18next plural keys
  (membersCount_one/_other, pendingInvitationsCount_one/_other) — French
  puts 0 in the `one` category, so both `_one` forms interpolate
  {{count}} rather than hardcoding a singular.
- member-row.tsx: role select items and the read-only role badge now go
  through ROLE_LABEL_KEYS (apps/app/src/features/organization/role-labels.ts),
  a `satisfies Record<OrgRole, string>` lookup with its own entry-by-entry
  test — a swapped pair would still type-check, so the test asserts the
  mapping directly, not just its exhaustiveness. The remove-member dialog
  description mixes an interpolated name with a nested <strong>, so it
  uses <Trans> rather than string concatenation.
- invitation-row.tsx: the "Cancel" button reuses the existing
  common.actions.cancel instead of a new local key. invitation.role stays
  untranslated here on purpose — it is not one of the three files the
  common.roles lookup is scoped to (member-row, invite-member-form,
  transfer-leave-dialog); fixing it is out of this task's scope.

Gates: @packages/i18n test (17/17), app test (200/200, +4 new), app
type-check, biome — all green.
Extracts the remaining English-only copy from the two org forms, the
transfer-leave dialog and the danger card (the largest single file in
the phase, 22 hardcoded strings with heavy internal duplication).

- invite-member-form.tsx: the toast built as a template literal
  (`Invitation sent to ${email}`) becomes an interpolated key; role
  select items reuse the same ROLE_LABEL_KEYS lookup wired in the
  previous commit. The "[email protected]" placeholder keeps
  example.com (RFC 2606) and translates only the local part.
- transfer-leave-dialog.tsx: "(current role: {{role}})" resolves the
  role through ROLE_LABEL_KEYS behind an isOrgRole guard (never a cast)
  since `role` arrives widened to `string` on this prop.
- org-danger-card.tsx: "Leave organization" (5 occurrences — the
  inventory only lists 4, missing the DestructiveActionDialog trigger
  button) and "Delete organization" (4 occurrences) each collapse to one
  key referenced from every call site. The three misaligned "lose
  access"/"cannot be undone" variants and the sentence split around the
  nested account-settings <Link> are each a single <Trans> key, modeled
  on account/components/data-rights-notice.tsx.
- update-org-form.tsx / org-notification-defaults-card.tsx: straight
  key extraction, no non-trivial shapes.

Gates: @packages/i18n test (17/17), app test (200/200), app type-check,
biome — all green.
…tion left raw

Fix round 1 on the organization screen: extracting "{{role}} · expires
{{date}}" made `invitation.role` visible for the first time (it used to
sit inside an all-English string), and translating the sentence around
it without also translating the enum produced "member · expire le 15
janv. 2026" — English mid-French, which reads worse than leaving the
whole line untranslated.

- invitation-row.tsx: `invitation.role` now resolves through the same
  ROLE_LABEL_KEYS lookup used in member-row.tsx and
  transfer-leave-dialog.tsx, behind `isOrgRole` (never a cast — the prop
  is typed as a widened `string`).
- invitation-row.tsx: the status Badge ("pending") gets the same
  treatment via a new INVITATION_STATUS_LABEL_KEYS lookup
  (invitation-status-labels.ts), covering all four of BetterAuth's
  invitation statuses even though only "pending" ever reaches this
  component in practice (organization.route.tsx filters upstream) — the
  Badge's prop type doesn't encode that, so the fallback branch keeps it
  honest. Same shape as role-labels.ts: `satisfies Record<...>` plus an
  entry-by-entry test, since exhaustiveness alone can't catch a swapped
  key.
- fr/settings.ts: `organization.inviteDescription` no longer defaults an
  unknown-gender invitee to masculine ("Il recevra…") — reworded to
  "Cette personne recevra…", matching the existing convention on this
  exact flow (errors.ts's "Cette personne a déjà été invitée").
- ROLE_LABEL_KEYS now stores its `common:`-prefixed keys directly
  (`owner: "common:roles.owner"`) instead of building the prefix at the
  call site with a template literal — call sites are now
  `t(ROLE_LABEL_KEYS[role])`, matching the repo's existing literal
  cross-namespace pattern (`t("common:actions.cancel")`).

Second Step F pass (grep for `t(...{ x: rawVar })` and `{var.(role|
status|type|category|state|kind)}`) across all 8 files found no further
raw enum interpolation beyond the two fixed here; the one other match
(`member-row.tsx`'s `<Select value={member.role}>`) is a controlled
component's data value, not rendered copy.

Gates: @packages/i18n test (17/17), app test (204/204, +4 new for the
invitation-status lookup), app type-check, biome — all green.
Extracts the /settings/notifications screen (route header/card/toast plus
the shared PreferenceMatrix table) into packages/i18n's settings catalog.
CATEGORY_LABELS collapses onto Task 3's common.notifications.categories.*
via the existing CATEGORY_KEYS lookup instead of duplicating it; the four
concatenated aria-labels now interpolate an already-translated category
label rather than the raw enum.
`imposer` is a direct-transitive verb, so the interpolated category read as
an anarthrous noun ("Imposer Sécurité à tous les membres"). Naming the
category explicitly also matches the column header this label describes.
This screen was the phase's live rule violation: a translated ConsentSettings
card sitting between four English ones, drawing from three feature directories
at once. All five cards now come from the catalog.

Two enums that the extraction would otherwise have stranded are translated with
the sentences that interpolate them, not left raw:

- `summarizeUserAgent` returned display strings ("Mac", "iOS device"). It now
  returns a `DeviceKind`, and `DEVICE_KEYS` names the catalog entry. That keeps
  the classifier unit-testable against raw user-agent strings while the copy
  stays where the parity gate can see it.
- The sub-processor register's `purpose` and `region` are the two fields a user
  reads on screen, so they move to the catalog keyed by a new stable `id`. The
  register itself stays English — it restates the signed DPA — and the public
  `/legal/sub-processors` page, still fully English, reuses the same keys when
  its own task translates it.

Both key maps carry entry-by-entry tests: `satisfies Record<Union, string>`
proves every entry is present and never that any one of them is correct.
…wns them

The register's purpose and region are read by the privacy settings card and by
the public register page, so they belong to neither page's namespace. The plan
already sited them under `common.legal`; parking them in `settings` would have
forced the legal task to move keys this task had just frozen.
Extracts the remaining English literals in /settings/billing and
/pricing into the settings/common catalogs, and fixes a real bug along
the way: the pricing table built prices by hand
(`${amount} ${CURRENCY}/${interval}`), which is wrong per locale.
Prices now go through Intl.NumberFormat bound to the active locale, so
$12/month and 12 €/mois render correctly instead of both locales
showing the English shape.

The tier, subscription status and plan interval enums interpolated
into translated sentences are resolved through typed lookups
(TIER_KEYS, STATUS_KEYS, INTERVAL_KEYS), each backed by a guard and an
entry-by-entry mapping test, rather than rendered raw or reached
through a template-literal key.

Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Extracts the /settings/webhooks route and its two tables (endpoints,
deliveries) into the settings.webhooks catalog. Adds a shared
common.states.{endpoint,delivery} enum catalog with typed lookups and
guards in webhook-labels.ts (ENDPOINT_STATUS_KEYS, DELIVERY_STATUS_KEYS)
so a delivery/endpoint status is never rendered raw — this also fixes an
untracked leak at webhooks.route.tsx:246 where the delivery badge
rendered `{d.status}` verbatim.

Also adds the errors.fallback keys used by the API query/mutation
fallbacks (webhooks.mutations.ts, webhooks.queries.ts), including
loadWebhookEndpoints/loadWebhookDeliveries which the route's own
isError paragraphs now read through the errors namespace so the JSX
text and the API fallback message can never drift a trailing period
apart again. The row, delivery sheet, forms and API call sites land in
the next commit.
Extracts the remaining webhooks call sites into settings.webhooks: the
endpoint row (status badge, tooltip, dropdown actions), the verify
snippet's heading (the Node.js SNIPPET source stays untouched), the
event type picker's "all events" label and its group-wildcard line
(one {{group}} key, interpolated twice so French can reorder it), the
webhook form's field labels, and the mutation fallbacks
(create/update/delete endpoint, replay, rotate, send test).

delivery-sheet.tsx's status line is the plural + enum-interpolation
shape: `deliverySheet.statusLine` carries native _one/_other variants
and only ever receives an already-translated status label (through
DELIVERY_STATUS_KEYS, guarded), never the raw wire value.
The webhooks, billing and organization surfaces are translated now, so naming
them as deliberate exceptions made the rule read as decorative — the exact
failure the carve-out exists to prevent.
Extracts the last hardcoded English strings on /settings/api-tokens
into the settings/errors i18n catalogs: page copy, table headers,
row badges, the create form, and the expiry select. EXPIRY_OPTIONS
moves from a module constant to a useExpiryOptions() hook so its
labels can call t() — the 365-day option renders through a separate
year plural (expiryYears) rather than the day plural, so it reads
"1 an" in French instead of "365 jours". Scope identifiers stay raw
per design (API identifiers, not copy), and the secret-reveal
dialog's description is dropped in favor of falling back to its
existing common:secretReveal.description key from Task 3.
The parity gate compares the two locales against each other, so it is
structurally blind to a catalog that is wrong within one of them. A French
`_one` set equal to its `_other` renders "1 jours", and both suites stayed
green under exactly that mutation — only a human reading the output caught it.

Three assertions close it: no French `_one` may equal its `_other` unless
listed with a reason, that list is swept for stale entries like the identical
value list beside it, and a plural declared in one form but not the other fails
in both locales. English is deliberately exempt from the first: its two forms
are legitimately identical wherever the noun does not inflect around the count.
Extracts the /settings/sso screen's 64 strings into the settings.sso
catalog across all 9 files (route, 4 cards, 2 forms, mutations,
queries). Deletes provider-card's hand-rolled friendlyRegisterError()
- SSO_PLAN_REQUIRED and SSO_ORGANIZATION_REQUIRED now resolve through
formatApiError against errors.byCode like every other business error,
with 8 more errors.fallback entries covering the mutationFn/queryFn
factories (getErrorsT(), outside React).

Adds sso-labels.ts (SSO_PROVIDER_TYPE_KEYS + guard) so the provider
type flowing into "{{type}} provider for {{domain}}" and the
registration Tabs triggers share one source instead of duplicating
the OIDC/SAML literal - both interpolated untranslated as protocol
acronyms, with matching ALLOWED_IDENTICAL entries in the parity gate.
Task 12 of E.1b: /admin/users and /admin/users/$id, 78 strings across 8
files, creating the admin namespace (packages/i18n/src/catalogs/{en,fr}/
admin.ts, registered in both index.ts files in the same commit).

- admin-user-labels.ts: PLATFORM_ROLE_LABEL_KEYS (admin/user platform role,
  distinct from common.roles' org-membership concept — "admin" reuses
  common:roles.admin, "user" gets its own key) behind isPlatformRole, and
  USER_STATUS_LABEL_KEYS (active/suspended) shared by the list filter, the
  list row badge and the detail page badge. Both maps get an entry-by-entry
  test plus the guard's accept/reject paths.
- ban-form.tsx: DURATION_OPTIONS (module-level, shape 4) becomes
  useDurationOptions(); its "Permanent" option and the detail page's static
  "Permanent" display share one key (same concept: no ban expiry). The ban
  dialog's title and the ban form's submit button share
  users.suspendAccountTitle (identical phrase, same flow).
- sessions-card.tsx's session count is a plain interpolation, not an
  i18next plural key — "Sessions actives (N)" doesn't inflect around N in
  either language, so there is no grammar for _one/_other to protect.
- admin-users.mutations.ts / admin-users.queries.ts: request fallbacks
  through getErrorsT() (shape 5), new errors.fallback.* entries.
- admin-users.schema.ts needed nothing — its 2 strings were already
  removed in Task 1.
- Toasts and detail-page copy describing account state agree with "compte"
  (masculine noun) rather than a gendered pronoun for the user, per the
  no-imposed-gender rule: "Compte suspendu.", "Emprunt d'identité
  démarré.", never "il".

Verification:
- Deleted users.pageTitle from fr/admin.ts: parity gate went RED naming
  it (missingInFr: ["users.pageTitle"]); restored, green again.
- Swapped PLATFORM_ROLE_LABEL_KEYS.admin/user and
  USER_STATUS_LABEL_KEYS.active/suspended in turn: each swap failed both
  the mapping test and the resolved-label test; both restored, green.
- Rendered every French user-state sentence (toasts) and the interpolated
  session count at 0/1/5 — no "il"/"elle"/gendered agreement anywhere in
  fr/admin.ts (grep confirms zero hits).

Gates: @packages/i18n test 21/21 (was 17), app test 257/257 (was 249, +8
for admin-user-labels.test.ts), app type-check clean, biome clean.

Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Extends the admin i18n namespace to /admin/orgs, /admin/orgs/$orgId and
/admin/audit-log (~48 strings incl. the aria-label and the chain badge's
interpolated "Broken at #N"). Also fixes admin-audit-log's two raw date
casts (audit-row.tsx, metadata-sheet.tsx) to go through useFormatDateTime
instead of rendering in the browser's locale.

Two bare-enum call sites caught by the second grep pass: the org detail
member table's role (guarded via a shared isOrgRole/ROLE_LABEL_KEYS lookup,
promoted from features/organization to shared/auth since a second
route-owning feature now needs it) and the audit row's actor type (a new
typed, guard-free lookup since the wire type is already a closed union).
"Créé" alone labelled a date column, and had to agree with whatever noun the
row described — masculine for a compte, feminine for an organisation. Getting
the agreement right in four places leaves the fifth someone adds wrong. "Date
de création" is the standard French header for this column and has no gender
to get wrong.
… chrome

Splits the privacy/terms bodies into policies/{en,fr}.tsx (fr re-exports en
verbatim today, R3) with an honest banner on policy-doc-view.tsx whenever the
resolved body is still the English one by identity. Translates the chrome
common to every legal page (titles + metadata subtitle), the acceptance
screen in full, the sub-processor table headers, and wires the register's
purpose/region cells to the existing SUB_PROCESSOR_KEYS map instead of the
raw English fields. LEGAL_ROUTES moves to the labelKey pattern already used
by OPERATOR_ROUTES. Drops the dead POLICY_TYPES re-export and the title/Body
fields off PolicyDoc now that both are sourced elsewhere.

Body prose (accessibility statement, cookie register, data-rights
procedures, sub-processor narrative) stays English on purpose — placeholder
legalese with [domain]-style markers throughout, same rationale as R3
extended to the whole surface.
Round 1 review: bounding translation scope to chrome was the right call, but
leaving accessibility, cookies, data-rights and sub-processors silently
serving English body prose under a translated title is the exact "half a
page" defect the recipe warns about. Extends the honest-fallback banner from
policy-doc-view.tsx to all four, promoted into a shared
UntranslatedBodyBanner so the <Alert> isn't copied five times — each call
site still owns why it fires (identity check against the English policy body
vs. a plain non-English-locale check for the four pages that have no French
body at all).

Also collapses cookies.route.tsx's local CATEGORY_LABELS onto the consent
panel's own cookieConsent.categories.*.label keys instead of a second
English-only copy, and fixes a version-line test that production data
(version === effectiveDate for both policies) made blind to an
interpolation-order swap.
…okie captions

Round 2 review: exporting AccessibilityPage/CookiesPage/DataRightsPage/
SubProcessorsPage so tests could render them broke the "route components stay
internal" rule and silently re-attached each page to the main bundle
(confirmed by the build's main chunk shrinking back to its pre-export size
once reverted). Replaces that page-level test with a direct test of
UntranslatedBodyBanner, the component actually worth pinning.

Also collapses cookies.route.tsx's CATEGORY_TABLE_CAPTIONS — a hardcoded
English record sitting under an otherwise-translated header, the exact
half-translated shape this phase keeps catching — onto a single
legal.cookies.tableCaption key with the category interpolated through the
consent panel's own category labels, so there is one source for each
category name instead of two.
…ion carve-out

Phase E.1b extracted the remaining 536 strings over 97 files (admin, webhooks,
sso, billing, organization, the rest of settings, every legal page) into the
catalogs E.1a shipped. features/CLAUDE.md's carve-out, which named admin and
legal as deliberate holdouts, is now false and is retired: the rule is
unconditional except for three permanent exclusions (legal prose, event
descriptions, the sub-processor register's non-display fields).

docs/HISTORY.md gets the as-built record: the admin namespace split, R2-R5
rulings, the three pre-existing validation-copy defects the extraction
surfaced and closed, the currency-formatting sibling of formatDate, the
parity gate's growth from 17 to 21 assertions (three of them exist because a
subagent caught a French singular/plural collapse that both suites had missed),
the five-of-nine undercounts in the phase's own inventories (always low, always
a bare enum interpolated raw rather than a quoted literal), and the four
follow-ups left deliberately open: API business codes on the 61 HTTPException
call sites, the French legal corpus, EVENT_DESCRIPTIONS staying English, and
domain-verification-card.tsx's dead err.message check.

Final gates at 751d22c: ci:check (biome) 881 files clean, test 800+279 passing
across 19/19 turbo tasks, type-check 13/13, check:unused exit 0, a11y 22/22.

jscpd moved from the measured baseline of 35 clones / 496 duplicated lines /
1.24% down to 34 / 483 / 1.13% -- not flat, as the phase's own hypothesis
expected. No en/fr catalog pair is flagged (the hypothesis held there); the
small drop is a side effect of promoting a couple of pre-existing duplicated
helpers (isHttpsUrl, role-labels.ts) to shared modules while the extraction
was already touching those files.

ROADMAP.md and docs/FEATURES.md updated to move E.1b from "what's left" to
shipped, with the real numbers and the admin/legal catalog structure.

Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Three files still presented the extraction as work to come, and the ROADMAP
quoted the plan's estimate rather than the branch. Measured from the diff:
520 catalog keys per locale over 71 rewired files, 142 files touched in all.
The gap against the plan's ~536 source strings is the strings that collapsed
onto keys the catalog already had.
The final review found the phase had built the same map twice. The privacy
settings card carried its own policy-title lookup under `settings:`, while the
legal pages carried an identical one under `common:` — same union from
`@packages/policies`, same concept, byte-identical values in both locales.
Renaming a policy would have diverged them silently.

The branch already set the precedent: `role-labels.ts` moved to `shared/auth/`
the moment a second route-owning feature needed it, because two such features
may not import each other. `policy-labels.ts` moves to `shared/legal/` for the
same reason, `policyLabelFor` goes with it — a pure function over the shared
map has no business living in a feature the shared test would then import —
and the four orphaned `settings:` keys are gone from both locales.

`isEndpointStatus` had no call site: `endpoint-row.tsx` indexes the map
directly, which the compiler already proves exhaustive over a locally computed
union. Its comment claimed a raw-code fallback the call site does not have,
which is worse than no comment. Removed both. If that status ever becomes
wire-sourced its type widens to `string` and `tsc` will demand a guard then.
Completes the previous commit, which landed only the file moves: the call
sites, the emptied `settings:` keys and the webhook guard removal.
The parity gate proves a French value exists and differs from English. It
cannot read. A native-speaker pass over all ~520 strings found 20 defects that
every automated gate was green on, clustered exactly where the plan predicted.

Interpolation sites, where a string was translated as a template and never
rendered with a value. `{{label}} copié` agreed masculine with labels that are
feminine three times out of five, so copying the SCIM base URL announced "URL
de base SCIM copié"; it is now "Copié dans le presse-papiers : {{label}}",
which has no agreement left to get wrong. Same class: "Lire {{title}} en
intégralité" and "pour {{category}}" injected a noun where French wants an
article, and "tous les événements {{group}}" stacked nouns the way only English
can. Three of the four notification aria-labels had this — the fourth was
fixed weeks ago and its siblings were missed, which is how a template defect
survives review.

Participles that escaped the gender pass. "Connecté en tant que", "Vous devez
être connecté", "le seul propriétaire" and the invitation email's "Vous avez
été invité" all imposed masculine on a user whose gender we never know. Each is
restructured around the account, the session or the action rather than reworded
to guess better.

Two strings misled outright. The SSO domain card's "ne peuvent pas s'y
connecter" bound the pronoun to the domain rather than the provider, telling an
admin the opposite of what the English means. The dashboard's "commencez à
livrer" reads as delivering a parcel — and this app uses "Livraisons" for
webhook deliveries two screens over.

Three terms had drifted apart across screens: plan was forfait, offre and Plan;
enforce was imposer in settings and appliquer in admin; Created was "Date de
création" in admin and "Créé" in webhooks, where the row is a feminine
livraison. Unified on the billing screen's own word, on the majority verb, and
on the invariable header.

Finally, no French catalog had a single non-breaking space; 19 strings now
carry a narrow one before their high punctuation, applied with sed so nothing
normalizes it away.

Two assertions moved with the copy they pin, and the stale-exemption gate did
its job: translating planLabel to "Forfait" failed its own ALLOWED_IDENTICAL
entry until the entry was removed.
The subject dropped "Vous avez été invité", which imposed masculine on an
invitee whose gender the app never knows. The assertion follows the copy.
The PR review found three literals the phase had left, and it was right to
block on them: shipping an unconditional rule the repo violates means the next
contributor who runs the rule's own test finds hits on their first try.

Two are exactly what that test names — a quoted string in a `toast` argument:
`use-sign-out.ts` and `use-accept-policies.ts`. Both also passed `err.message`
straight to `toast.error`, leaking raw server strings where the branch's own
pattern is `toastError(err, t(...))`. Fixed together, since it is one edit.

The third is `/developers/events`, whose heading and intro were English. The
`EVENT_DESCRIPTIONS` exception covers the table's descriptions, never the
page's own chrome — a French shell around an English page with nothing saying
so is the failure the legal pages were fixed for. The chrome is translated and
the page now states that event names and descriptions stay in English, which is
the disclosure the exception was always relying on.

The rule's legal exception also named only `features/legal/policies/`, while
untranslated prose sits directly in four legal routes. Those routes do carry
`UntranslatedBodyBanner`, so the wording was wrong, not the code: it now names
them and says outright that what makes untranslated prose acceptable is the
disclosure, never the omission. `authorization-devtool.tsx` gets one clause —
dev-only, gated behind `import.meta.env.DEV`, never in a user's DOM.

"Déconnexion effectuée" rather than "Vous êtes déconnecté": the participle
would have imposed a gender, the same defect corrected across the catalog one
commit earlier.
The only plain space before high punctuation left in the French catalogs, and
I introduced it in the previous commit — the sed sweep that placed the other
23 had already run. No gate catches this: the parity test compares keys, not
typography.
@axelhamil
axelhamil merged commit af97b93 into dev Aug 31, 2026
1 check passed
@axelhamil
axelhamil deleted the feat/e1b-i18n-extraction branch August 31, 2026 23:14
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.24.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant