Skip to content

F182 B0: i18n sweep foundation — English-first parity, typed enums, html-lang, pilot retrofit#237

Merged
mforce merged 9 commits into
mainfrom
f182-i18n-b0-foundation
Jul 27, 2026
Merged

F182 B0: i18n sweep foundation — English-first parity, typed enums, html-lang, pilot retrofit#237
mforce merged 9 commits into
mainfrom
f182-i18n-b0-foundation

Conversation

@mforce

@mforce mforce commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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.

Base main. B1 stacks on this branch. This lands the plan revised after a 5-lens plan review (findings folded); see the sweep plan in the tracker.

What this adds

  • English-first publication model. New sweep namespaces go in en.ts only; es/tl fall back to English. catalogParity.test.ts now enforces full parity only for TRANSLATED_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 is namespace → flat key under keySeparator:false; a nested object wouldn't resolve at runtime). translations-status.ts holds the allowlist.
  • Typed enums module (src/i18n/enums.ts) — 11 closed-vocabulary families (status, role, waterSource, waterUnit, gradeType, inventoryCategory, inventory/flock/stock movement types, unitSystem, weekday) as flat enums:* keys + per-family label helpers keyed off exhaustive Record<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 unit are deliberately not here — they stay in the already-translated sales namespace (moving them to English-only enums would regress shipped es/tl).
  • StatusBadge exports the canonical STATUS_VALUES; its status prop stays raw (variant class + text-query tests unchanged).
  • <html lang> follows the UI language — one i18n.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 to setup.ts.
  • Complete common atom set (17) seeded up-front (prevents cross-batch catalog churn); the shipped sales.cancel duplicate removed (its value survives byte-identical as common.cancel).
  • SalesPage pilot retrofit — raw status badges now use statusLabel (identical English text); {p.method} now renders via the translated sales: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 on src/routes+src/components; each later batch drives it down). CONTRIBUTING-i18n.md narrows the goal to client-authored copy and documents the ApiError.message server-English boundary.

Deliberate decisions

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:coverage 748 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 typecheck clean; npm run build succeeds.

mforce 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.
@mforce
mforce merged commit c21c0b5 into main Jul 27, 2026
7 checks passed
@mforce
mforce deleted the f182-i18n-b0-foundation branch July 27, 2026 03:39
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.

1 participant