feat(i18n): extract the remaining app copy into the typed catalogs (E.1b) - #65
Merged
Conversation
…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.
|
🎉 This PR is included in version 1.24.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes phase E.1b. Every remaining hardcoded English string in
apps/appnow comes from@packages/i18nthrought(), andfeatures/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
${amount/100} ${CURRENCY}/${interval}), untranslatable and wrong per locale. NowIntl.NumberFormatbound to the active locale:$12/month/12 €/mois. Same for the admin audit log's rawDatecasts, which rendered in the browser's locale instead of the app's.message:values silently overriding the translated global map, and.regex()/.startsWith()checks raising an issue code no map can localize.HTTPExceptioncall sites serialize ascode: HTTP_<status>, which the front catalog could never match — abyStatuslayer now closes the user-visible half. Business codes on the API side are a filed follow-up.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
_oneonto 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
shared/legal/, following the branch's own precedent.{{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.mdBusiness codes for the API's
HTTPExceptionsites · a French legal corpus (policies/fr.tsxre-exports English by design) ·EVENT_DESCRIPTIONSstays English permanently · a dead branch indomain-verification-card.tsx(@better-auth/ssoputs 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