Files
suaveplan/openspec/archive/2026-05-10-react-ui-i18n-deferred-tooling/proposal.md
2026-05-27 23:29:53 +01:00

9.7 KiB

React UI i18n — deferred tooling: stories, key extraction, registry ejection gate

Packages: @suaveplan/react-ui Status: PROPOSED Type: tooling + completion follow-up

Why

The react-i18n-plugin-registry-and-ui-primitives change (archived 2026-05-10) shipped the registry substrate, the seven <FormattedX/> primitives, the <ReactUiI18nBridge>, the tanstack collator helpers, <InputNumber>, and the validation message bridge. Three items from that change's tasks list were intentionally deferred so the runtime surface could ship coherent without dragging tooling along:

  1. Storybook stories for the seven new primitives + <InputNumber> + <ReactUiI18nBridge> — CLAUDE.md mandates every React component carry a .stories.tsx (default + playground + variants); these primitives shipped without stories so the gates would not slip on cosmetics. Reviewers and design integrators rely on stories to discover, browse, and visually verify components in isolation.
  2. bun run check:i18n-keys — an AST-walking script over src/components/**/*.tsx and src/i18n/**/*.tsx that diffs t("react-ui.…") calls and <FormattedMessage i18nKey="react-ui.…" /> usages against the canonical src/locales/en-GB.json. The script catches missing keys (in code, not in bundle) AND orphan keys (in bundle, not in code). Specified in the new react-ui-i18n capability spec but not implemented yet.
  3. bun run check:registry-eject — a smoke test that resolves every shadcn registry/r/*.json entry, applies the registry transform, and type-checks the transformed source against a synthesized consumer tsconfig.json so an ejected component still compiles. Needed because the registry transform preserves @suaveplan/react-i18n imports in ejected sources, and the new primitives + <ReactUiI18nBridge> exercise that contract more aggressively than any prior component.

These are tooling, not runtime — but they are part of the surface the proposal committed to. Closing them is the difference between "shipped" and "shipped, gates intact." Reviewers picking up this branch downstream will trip over the missing stories first; the two scripts close the drift loop.

What changes

@suaveplan/react-ui

Stories for the new i18n primitives + InputNumber + Bridge

  • ADDED: src/i18n/formatted-number/formatted-number.stories.tsx — default + playground + variants (Latin digits, Arabic-Indic via numberingSystem: "arab", percent / scientific / unit notation, as="strong" | "data" | "output").
  • ADDED: src/i18n/formatted-currency/formatted-currency.stories.tsx — default + playground + variants (USD / EUR / GBP / JPY across en-GB / fr-FR / zh-CN, display modes).
  • ADDED: src/i18n/formatted-date/formatted-date.stories.tsx — default + playground + variants (Date / ISO string / epoch ms inputs, preset names + raw options, invalid-input renders empty).
  • ADDED: src/i18n/formatted-list/formatted-list.stories.tsx — default + playground + variants (conjunction / disjunction / unit, long / short / narrow styles).
  • ADDED: src/i18n/formatted-relative-time/formatted-relative-time.stories.tsx — default + playground + variants (auto-mode interval, explicit unit, future / past).
  • ADDED: src/i18n/plural/plural.stories.tsx — default + playground + variants (en-GB collapses zero into other; ar-AE distinguishes zero / one / two / few / many).
  • ADDED: src/i18n/formatted-message/formatted-message.stories.tsx — default + playground + variants (rich-text tag interpolation, plural via count).
  • ADDED: src/i18n/react-ui-i18n-bridge/react-ui-i18n-bridge.stories.tsx — default + playground + variants showing the Radix <DirectionProvider> propagation (popover, dropdown-menu, dialog) under en-GB and ar-AE.
  • ADDED: src/i18n/formatted-cell/formatted-cell.stories.tsx — default + playground showing the helper inside a <DataTable> for each formatter kind.
  • ADDED: src/i18n/validation-message-bridge/validation-message-bridge.stories.tsx — default + playground showing the bridge inside an Alert and a Toast.
  • ADDED: src/components/input/input-number/input-number.stories.tsx — default + playground + variants (en-GB / de-DE / ar-AE locale, controlled vs uncontrolled, percent / currency display options).

Every story file follows the existing .stories.tsx convention (no @storybook/react dep — the meta shape is inlined). Each story renders inside <TranslationProvider> + <RegistryProvider> + <ReactUiI18nBridge> so the runtime path matches production.

bun run check:i18n-keys — translation-key drift gate

  • ADDED: scripts/check-i18n-keys.ts — AST walks src/components/**/*.{ts,tsx} and src/i18n/**/*.{ts,tsx} for:
    • t("react-ui.…") calls in useTranslation() consumers
    • <FormattedMessage i18nKey="react-ui.…" /> usages
    • <Trans i18nKey="react-ui.…" /> usages (for already-shipped components)
    • tDynamic("react-ui.…") calls
  • Diffs the discovered keys against the canonical src/locales/en-GB.json.
  • Reports MISSING: <key> at <path>:<line> for keys referenced in code but absent from the bundle.
  • Reports ORPHAN: <key> for bundle keys not referenced anywhere in code.
  • Exits non-zero on any mismatch.
  • ADDED: scripts/check-i18n-keys.test.ts — fixtures-directory test exercising both the missing-key and orphan-key paths, plus a happy-path where source and bundle agree.
  • MODIFIED: package.json::scripts adds "check:i18n-keys": "bun scripts/check-i18n-keys.ts".
  • MODIFIED: the package's turbo.json lint pipeline includes the new check so CI fails on drift.

bun run check:registry-eject — shadcn ejection compile gate

  • ADDED: scripts/check-registry-eject.ts — walks every registry/r/*.json, copies the listed source files into a tmp directory, applies the registry transform (rewriting @suaveplan/react-ui imports to local relative paths, preserving @suaveplan/react-i18n imports), and runs tsc --noEmit against the transformed tree using a synthesized consumer tsconfig.json (extends @suaveplan/typescript-config/library.json, lists @suaveplan/react-i18n and the React peers in compilerOptions.paths).
  • Reports EJECT FAILURE: <component> with the underlying tsc diagnostics on any component whose ejected source fails to compile.
  • Reports MISSING REGISTRY ENTRY: <path> when a public component / primitive in src/components/** or src/i18n/** lacks a corresponding registry/r/<name>.json.
  • Exits non-zero on either failure.
  • ADDED: scripts/check-registry-eject.test.ts — happy-path test that ejects a known-good entry; failure-path test that introduces a missing import and asserts the diagnostic appears.
  • MODIFIED: package.json::scripts adds "check:registry-eject": "bun scripts/check-registry-eject.ts".
  • MODIFIED: turbo.json lint pipeline includes the new check.

Out of scope

  • Adding stories to the 70 existing react-ui components. Those already have stories from the chassis Wave 3 work. This change is scoped to the i18n surface added in react-i18n-plugin-registry-and-ui-primitives.
  • Authoring a repo-wide @suaveplan/i18n-extract CLI. The check:i18n-keys script is package-local — covers only react-ui's react-ui.* namespace. A repo-wide CLI lives in a separate change.
  • Extending the registry transform itself. The transform exists and is correct today; the new gate verifies the transform's output against a real type-checker pass.
  • Visual regression testing. Stories enable visual review; automated visual regression is out of scope.

Parity Checklist

Genesis-original; no upstream parent. The "Current genesis" column captures whether each artifact exists today (post-react-i18n-plugin-registry-and-ui-primitives).

Capability Current genesis Post-change Notes
Stories for <FormattedNumber> / <FormattedCurrency> / <FormattedDate> / <FormattedList> / <FormattedRelativeTime> / <Plural> / <FormattedMessage> 🚫 ✅ One .stories.tsx per primitive.
Stories for <ReactUiI18nBridge> 🚫 ✅ Demonstrates Radix <DirectionProvider> propagation under RTL locales.
Stories for formattedCell + useValidationMessageBridge 🚫 ✅ Composed inside <DataTable>, <Alert>, <Toast> to show real-world placement.
Stories for <InputNumber> 🚫 ✅ Controlled / uncontrolled, locale variants.
bun run check:i18n-keys 🚫 ✅ AST-walking translation-key drift gate.
bun run check:registry-eject 🚫 ✅ shadcn-eject smoke compile gate.
Both gates wired into bunx turbo lint 🚫 ✅ CI fails on drift.

Verification approach

  • bun run check:i18n-keys exits 0 on the current source + bundle pair; exits non-zero when an obviously-missing key is introduced (test fixtures cover both paths).
  • bun run check:registry-eject exits 0 on every current registry/r/*.json entry; exits non-zero when a known-good entry is corrupted (test fixtures cover both paths).
  • Storybook smoke pass: every new story renders without console errors / warnings under en-GB and ar-AE.
  • Pre-publish gate: bunx turbo lint typecheck test --filter=@suaveplan/react-ui green.

Impact

Affected dependents

  • @suaveplan/react-ui — [x] tests run / [x] docs reviewed / [ ] no action needed (this change AUTHORS the new tooling)
  • Downstream consumers of @suaveplan/react-ui — [ ] tests run / [ ] docs reviewed / [x] no action needed (this change is purely additive: new scripts, new story files; no runtime surface changes)

@suaveplan/react-ui is on the foundation path of the React stack. The dependent sweep bunx turbo test --filter=...@suaveplan/react-ui SHALL be run as a sanity check, but no behavior change is expected.

Migration impact

None — additive only. No runtime surface is touched.