F182 B0: i18n sweep foundation — English-first parity, typed enums, html-lang, pilot retrofit#237
Merged
Merged
Conversation
added 9 commits
July 26, 2026 18:51
Add TRANSLATED_NAMESPACES as the allowlist of namespaces es/tl must mirror key-for-key; future namespaces can ship to en.ts only and fall back to English until translated. Rework catalogParity.test.ts to scope key-set/ non-empty/placeholder/tag parity to that allowlist, assert es/tl carry no namespace outside it, and add a shape guard (direct own keys per namespace, no recursive flatten) so a nested value — which wouldn't resolve at runtime under keySeparator:false — fails the build instead of silently passing.
Register a single i18n.on("languageChanged") listener at init so
document.documentElement.lang tracks the resolved language before the
gated shell renders (bootstrap) and after every LanguageSelector switch,
without duplicating the sync logic at both call sites. Also reset i18n to
"en" in afterEach so language state set by one test can't leak into the
next. (#182)
Adds the full common atom set (cancel/save/close/delete/edit/add/confirm/ loading/retry/required/optional/actions/search/all/none/yes/no) to en/es/tl so later namespaces can reuse them instead of re-declaring per-screen copies. Removes sales.cancel, byte-identical to common.cancel, and points SalesPage's two Cancel buttons at the shared key. sales.close stays as an intentional lowercase variant (matches the page's lowercase link-buttons) since merging it into common.close would change the rendered casing.
Add web/src/i18n/enums.ts — the single sanctioned path from an enum-like
wire value to display text. Each family (status, payment method, sale unit,
role, water source/unit, grade type, inventory category, inventory/flock/egg
movement types, unit system, weekday) exports a raw-value union plus a label
function backed by an exhaustive `Record<Union, EnumsKey>`:
- `satisfies Record<Union, …>` makes a missing/stray member a compile error;
- `EnumsKey = enums:${keyof en.enums}` makes a key that drifts from the
catalog a compile error too (verified: removing a Record entry → TS2741;
a stale key → TS2820), so the unions and en.ts cannot silently diverge.
Flat `enums` keys land in en.ts (namespace → "family.RawValue"), English-only
and deliberately NOT in TRANSLATED_NAMESPACES, so es/tl fall back until a
native enum pass. Every label equals today's on-screen text (the one raw≠
display case is ManagerAdjusted → "Adjusted"), so Task-5+ retrofits change
nothing. StatusBadge gains an exported STATUS_VALUES const (rendering
unchanged) so the status union and the coupling test share one source.
enums.test.ts asserts every union member resolves to a real, non-empty
en.enums key and that STATUS_VALUES is fully covered by enums:status.*.
Fix round 1 on the Task 4 enums module (coordination).
1. Remove the `method` and `saleUnit` families entirely — union types,
*_KEYS maps, label fns, en.ts keys, and ENUMS registry entries. They
duplicate the TRANSLATED `sales` namespace (sales:method*, sales:unit*,
which carry es/tl). Since `enums` is English-only, wiring those helpers
would REGRESS Spanish/Tagalog coverage. `method` is SalesPage-only;
`saleUnit`'s only other site (ProductsPage {p.defaultUnit}) already
renders raw English, so nothing is lost by leaving it until a real
translation pass. All other families kept; the exhaustive-Record drift
guard is unchanged.
2. Correct the overstated "changes nothing" invariant. Wiring is
text-preserving EXCEPT two sites the retrofit changes deliberately (its
reviewer must eyeball them): Dashboard's label-less ManagerAdjusted pill
(raw -> "Adjusted") and UsersPage's raw `{u.role}` cell ("ReadOnly" ->
"Read-only"). Amended the en.ts comment accordingly.
typecheck clean; enums+catalogParity 71 passed; full suite 737 passed.
Wires the two raw status render sites (order-list StatusBadge, active-order
header) to enums.ts's statusLabel, and the raw payment-method table cell to
the existing translated sales:method* keys (mirroring the picker's own
t(`method${m}`) pattern) instead of the English-only enums module, which
dropped method in Task 4 to avoid regressing es/tl. All four sites render
identical English text before and after. Notes sales:status* in en.ts as a
temporary translated duplicate of enums:status, to reconcile in a future
status native-translation pass.
Adds a pragmatic, repeatable scan (npm run i18n:scan) that flags likely un-externalized JSX text/attribute literals in .tsx files, with an allowlist for intentional survivors (data/format examples, brand name). Current baseline on src/routes + src/components: COUNT 843 — later sweep batches use this to prove their count didn't increase. CONTRIBUTING-i18n.md: narrows the stated goal to client-authored SPA copy, documents that uncoded/unmatched validation messages render server English by design (ApiError.message, src/api/client.ts) while coded errors already translate, notes enums is English-only for now, and documents the new scan + allowlist convention.
Unused isSelfClosing() helper and unused `relative` import left over from an earlier iteration of the scan script.
Hardening pass before the B0 PR (both whole-branch reviewers flagged these
Minors; the helpers are about to be wired across ~20 screens).
1. Every label helper now accepts `<Union> | (string & {})` and falls back to
the raw string when the value is outside the union:
`const key = KEYS[value as Union]; return key ? i18n.t(key) : String(value)`.
The API types status/role/movement/etc. as plain `string`, so a backend
value the SPA hasn't caught up to renders its raw text (the old passthrough)
instead of i18n.t(undefined) rendering BLANK. Runtime-only: the *_KEYS maps
stay `as const satisfies Record<Union, EnumsKey>`, so catalog<->union drift
is still a compile error both directions (drift-probe still fails typecheck).
The `(string & {})` arm also drops the now-redundant `as StatusValue` casts
(and the unused type import) in SalesPage.
2. Fix an inaccurate en.ts comment: HistoryPage does NOT pass label="Adjusted"
to StatusBadge; it renders its own <span class="badge badge-warn">Adjusted
</span> (HistoryPage.tsx 249-250). Reworded to match.
3. Add a maintenance note: the guard is 1:1 union<->KEYS map, so a catalog-only
enums key with no union member would be uncaught.
Adds a test asserting every family's label fn returns the raw value for an
unmapped input. typecheck clean; enums+catalogParity+SalesPage 103 passed;
full suite 748 passed.
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.
B0 — the foundation batch for the #182 string sweep. Prerequisite for B1–B6; upgrades the shared i18n machinery so the ~23 remaining screens can be externalized cleanly. No screen is swept here (SalesPage is only retrofitted onto the new shared helpers). Behavior-preserving in English; a small es/tl gain on the Sales payment-method cell.
What this adds
en.tsonly; es/tl fall back to English.catalogParity.test.tsnow enforces full parity only forTRANSLATED_NAMESPACES(common/auth/account/errors/sales), asserts es/tl carry no namespace outside that allowlist, and asserts flat-string-leaf shape per namespace (the catalog isnamespace → flat keyunderkeySeparator:false; a nested object wouldn't resolve at runtime).translations-status.tsholds the allowlist.enumsmodule (src/i18n/enums.ts) — 11 closed-vocabulary families (status, role, waterSource, waterUnit, gradeType, inventoryCategory, inventory/flock/stock movement types, unitSystem, weekday) as flatenums:*keys + per-family label helpers keyed off exhaustiveRecord<Union, EnumsKey>. Catalog↔union drift is a compile error in both directions (renamed key →TS2820; missing member →TS2741; stray key →TS2353). No dynamic-key +defaultValue. Helpers degrade to the raw value for any off-union runtime value (never blank).payment method+sale unitare deliberately not here — they stay in the already-translatedsalesnamespace (moving them to English-only enums would regress shipped es/tl).StatusBadgeexports the canonicalSTATUS_VALUES; itsstatusprop stays raw (variant class + text-query tests unchanged).<html lang>follows the UI language — onei18n.on("languageChanged")listener at init, set synchronously before the no-flash gated shell paints (a11y: es/tl pages no longer announced as English). Per-test i18n reset added tosetup.ts.commonatom set (17) seeded up-front (prevents cross-batch catalog churn); the shippedsales.cancelduplicate removed (its value survives byte-identical ascommon.cancel).statusLabel(identical English text);{p.method}now renders via the translatedsales:method*keys (es/tl gain). No translated keys deleted;sales:status*kept as a documented temporary duplicate to reconcile in the status native-translation pass.npm run i18n:scan— a pragmatic hardcoded-string counter + allowlist (baseline 843 onsrc/routes+src/components; each later batch drives it down).CONTRIBUTING-i18n.mdnarrows the goal to client-authored copy and documents theApiError.messageserver-English boundary.Deliberate decisions
sales, and no translated key is migrated into the English-onlyenums.Review
4-way per policy (codex + 2 Claude + pi) whole-branch, on top of a per-task review of each of the 6 tasks (the enums module got a rigorous review with live compile-drift probes). Both Claude lenses returned READY; the correctness lens empirically proved both drift directions fail typecheck and cross-checked all 11 families against the backend domain enums; the no-regression lens confirmed zero es/tl loss and no coverage floor lowered. (codex's whole-branch run produced no usable verdict — diff-dump; pi timed out on the large diff.) All findings were Minor; the actionable ones (helper raw-value fallback so an off-union value degrades to raw; a comment nit) were fixed in this branch.
Testing
npm run test:coverage748 tests green; coverage 84.59% stmts / 74.26% branches / 78.7% funcs / 87.95% lines — above the (unchanged) floors, no locked path touched.npm run typecheckclean;npm run buildsucceeds.