Skip to content

fix(app-shell): organization & invitation UI translates its six English holdouts (#4474) - #4496

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4474-org-i18n
Aug 12, 2026
Merged

fix(app-shell): organization & invitation UI translates its six English holdouts (#4474)#4496
yinlianghui merged 2 commits into
mainfrom
claude/issue-4474-org-i18n

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4474

Three string families across packages/app-shell/src/console/organizations/, one sweep, per the ruling in #4474 (comment).

The defect table, as measured on origin/main at 338e2c4

Captured verbatim from a pre-fix red run of the new zh-locale render test.

# Where Rendered in a zh session Family
1 Invite dialog, 角色 dropdown Owner, Admin, Delegated Admin, Member client-authored
2 Members page role badges owner, member (raw, under CSS capitalize) client-authored
3 Invite dialog inline error You are not allowed to invite users to this organization server-echoed
4 Create-workspace inline error Organization already exists server-echoed
5 Accept page toast body You are not the recipient of the invitation server-echoed
6 Icon-only aria-labels Member actions, Copy invitation link, Cancel invitation aria

One correction to the card, and it makes the defect larger. The card reports the dropdown's siblings already rendering 所有者 / 管理员 / 成员 beside an untranslated Delegated Admin. That is not what this tree does: ORG_ROLE_LABELS names four organization.roles.* keys that no locale pack defined, so all four entries fell through to their inline English defaultValue. The reporter's server was on an unverified commit, which is the likeliest source of the discrepancy. The premise — untranslated English in a zh locale — holds; the count does not.

Sibling holdouts found in the five named files and fixed with them: the invitations-page role badge; the accept page's role row and the role interpolated into its otherwise-Chinese sentence; the invite dialog's invitedAs interpolation; a seventh icon-only button labelled a bare Copy; and five hardcoded English error fallbacks (Failed to load members, Failed to load invitations, Failed to invite member, Invitation not found or expired, Failed to create workspace).

The fix

Family 1 — one role-label map, consumed everywhere. The map already existed and the dropdown already read it; the badges read nothing, rendering the raw server identifier under a capitalize that made owner pass for a label in English. So the two sides never disagreed about a translation — one side was not translating at all, and CSS hid it. Every role display now routes through resolveOrgRoleLabel(role, t) over the single ORG_ROLE_LABELS, keyed by the identifier the server stores in sys_member.role. An unrecognized role renders verbatim rather than blank. The four keys are now in all ten packs.

Why the gate never caught this: scripts/check-i18n-call-site-keys.mjs found and paid off 258 keys of exactly this class, but it scans string literals and these are reached as ORG_ROLE_LABELS[r].key — a variable.

Family 2 — mapped by code, never by matching English. Measured first, and the measurement changed the shape of the fix: packages/auth/src/createAuthClient.ts has a toAuthError helper whose own doc comment says it preserves the machine code "so callers can map it to a localized message instead of surfacing the raw English server text" — wired to sign-in and sign-up only. All sixteen organization.* methods threw new Error(error.message ?? '…') and dropped the code at the boundary. The three sites therefore held an English sentence and nothing else, so no consumer-side change short of string-matching could have localized them.

That is a producer defect, so it is fixed at the producer: the sixteen methods now share toAuthError. Messages are unchanged — the code simply stops being thrown away. resolveOrgErrorMessage(err, t, fallback?) is the consumer half, mapping eight codes and degrading to the server's own sentence for anything else (prefer mapped, degrade to verbatim — the shape LoginForm/RegisterForm already use).

Family 3 — icon-only aria-labels now come from the same channel; for an icon-only control this is the only name a screen reader gets.

Red-first

org-i18n-holdouts-4474.test.tsx mounts the real I18nProvider and mocks everything else. That is load-bearing: every sibling test here stubs i18n as t: (key, o) => o.defaultValue, which is an en oracle — under it Delegated Admin is the correct answer and the defect is invisible.

Pre-fix, 13 of 17 red. Verbatim:

AssertionError: expected [ 'Owner', 'Admin', ...(2) ] to deeply equal [ '所有者', '管理员', '受托管理员', '成员' ]
+   "Owner", "Admin", "Delegated Admin", "Member"

Expected element to have text content: 您无权邀请用户加入该组织。
Received:                              You are not allowed to invite users to this organization

Expected element to have text content: 该组织已存在。
Received:                              Organization already exists

expected "vi.fn()" to be called with arguments: [ '您不是该邀请的收件人。' ]
+   "You are not the recipient of the invitation"

TestingLibraryElementError: Unable to find a label with the text of: 成员操作
              aria-label="Member actions"
TestingLibraryElementError: Unable to find a label with the text of: 复制邀请链接
              aria-label="Copy invitation link"
              aria-label="Cancel invitation"

Reverse verification, direction predicted before running. The console test mocks useAuth and constructs its own coded error, so it pins the consumer link and is structurally blind to the producer. packages/auth/src/__tests__/org-error-code-4474.test.ts drives the real better-auth client through a mocked fetch. Reverting createAuthClient.ts to origin/main turns exactly 3 of its 5 cases red — the three .code assertions (expected undefined to be 'ORGANIZATION_ALREADY_EXISTS') — while both .message assertions stay green. That split is the point: the message was always correct, which is precisely why a message-only test could never have caught this, and why the producer fix needed its own pin rather than riding on the console suite.

Must-not-change (green both before and after): en renders the same English through the same channel — the dropdown reads Owner / Admin / Delegated Admin / Member, the badges Owner / Member, the aria-labels Copy invitation link / Cancel invitation, and an unmapped code still shows the server's sentence verbatim.

Grading

.d.ts diff over @object-ui/auth + @object-ui/app-shell is purely additive — two new module declarations, nothing removed or narrowed, and packages/auth's emitted types are byte-identical (the producer change is module-private). Patch.

Verification

  • pnpm exec vitest run packages/i18n/src/__tests__ packages/auth/src/__tests__ packages/app-shell/src/console/organizations — 66 files, 1015 passed
  • pnpm exec vitest run packages/app-shell — 360 files, 3456 passed, 1 skipped
  • pnpm --filter @object-ui/app-shell --filter @object-ui/auth --filter @object-ui/i18n type-check — both tsc --noEmit and tsc -p tsconfig.test.json, green
  • node scripts/check-i18n-call-site-keys.mjs — green (2352/2352 literal keys resolve, 888 inline defaults match their en value)
  • node scripts/check-i18n-en-drift.mjs — green (19 keys added, 0 en values changed)
  • Downstream consumer sweep, prefix direction --filter '...@object-ui/auth' — 5 packages green (auth, app-shell, console-starter, byo-backend-console, apps/console)
  • lint on the three packages — exit 0

Two existing tests query strings this fix moved and were retargeted in a separate commit; both keep their original assertions. AcceptInvitationRoute.test.tsx (#3811) asserted the raw admin and now asserts Admin; acceptInvitationLink.mount.test.tsx (#4472/#4480) queried the bare Copy label — its URL assertions are untouched, and resolveConsoleUrl is undisturbed.

Scope

Strings only. Role/permission gating is untouched (#4475 stays pooled) — invitableOrgRoles / assignableOrgRoles and every predicate around them are unchanged, so who sees which button is exactly as before.


Generated by Claude Code

claude added 2 commits August 12, 2026 20:30
…sh holdouts (#4474)

Three string families across `console/organizations/`, one sweep:

1. Role names route through the single shared ORG_ROLE_LABELS map at every
   site. The members/invitations badges and the accept page rendered the raw
   server identifier under CSS `capitalize`; the map's four
   `organization.roles.*` keys existed in no pack, so even the dropdown that
   did consult it resolved to English. All ten packs now carry them.

2. Server-echoed errors are mapped by better-auth's stable `code`, never by
   matching English text. `createAuthClient` dropped that code for every
   `organization.*` call while preserving it for sign-in/sign-up, so no
   consumer could have keyed on it; all sixteen now share `toAuthError`.
   Messages are unchanged. An unmapped code degrades to the server's sentence.

3. Icon-only aria-labels are translated — the only name a screen reader gets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Both assert the same capability as before; only the string they query changed.

- acceptInvitationLink.mount.test.tsx (#4472/#4480): the share-link copy button
  was labelled a bare "Copy" and now carries the Invitations tab's own
  "Copy invitation link". Every URL assertion is untouched.
- AcceptInvitationRoute.test.tsx (#3811): the role row rendered the raw
  `sys_member.role` identifier and now renders the display name, so `admin`
  reads as `Admin` in en and 管理员 in zh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 9:14pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CAoOTjiv.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 230.07KB 57.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 45.46KB 12.52KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.85KB 31.41KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 110.47KB 26.90KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

CI state at handoff: 15 green, 0 red, 5 wedged on a stalled runner

Recording this so the five in_progress checks are not misread as "still working".

Green (15): Lint, Build & E2E, Live E2E, Bundle Analysis, Build Docs, Control Byte Scan, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Internal Docs Link Check, Skill Guide Path Check, label, Vercel Preview Comments (+2 skipped). Red: none.

Wedged (5): Type Check and Test shards 1-4 — all in CI run 31641627123, which has reported status: in_progress with updated_at frozen at 21:15:04Z for over 2.5 hours. GitHub has recorded no progress event on that run since.

Evidence the stall is infrastructural rather than this change:

  • Every other workflow on this same commit finished at ~21:18, including Lint, which is where this repo's family gates run.
  • Build & E2E, a job inside the same wedged run, completed successfully at 21:16:17.
  • Inside the wedged Type Check job, 15 of 17 steps completed successfully before it stopped advancing — including the two gates this change actually implicates: "Verify t() call-site keys exist in the en locale pack" (21:15:33) and "Verify changed en strings were followed by the nine translations" (21:15:34). Only Run type-check is hung, since 21:15:41.
  • All four shards are hung at their first test step simultaneously, with no output. A defect in this change would fail, not hang five jobs at once.

Locally the same commands are green: pnpm exec vitest run packages/app-shell (360 files, 3456 passed), the three packages' type-check (both tsc --noEmit and tsc -p tsconfig.test.json), and both i18n gate scripts.

A re-run of that one workflow is the likely fix, but that is the reviewer's call — I have not touched the run.


Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

ACCEPT (pending CI convergence) — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review). Ready-flip and auto-merge follow once the wedged run is cleared — the PM is handling the re-run; the dev correctly left it untouched.

  • The packages/auth extension is CONFIRMED as correctly taken: sixteen organization.* methods dropped .code at the boundary, making the ruled code-mapping unimplementable at the consumer without the forbidden English string-match — contract-first says fix the producer, and the asymmetric reverse verification (three .code assertions red, both .message assertions green) is exactly the proof that a message-only view could never have caught it. Messages byte-identical; auth .d.ts byte-identical.
  • Both of my framings corrected by measurement, accepted: (1) the card understated — all FOUR dropdown roles were English (organization.roles.* existed in no pack); (2) "two lists disagreeing" was wrong — one correct list, and badges reading NOTHING under CSS capitalize that hid the raw identifier in English. The collapse-to-one-resolver fix is unchanged; the diagnosis is now true. (3) My dispatch cited a "fix(i18n): the drifted detail row mirrors the en pack, and the three defaults maps get a gate (#4401) #4409 map-mirror gate" that does not exist as an i18n gate — my citation error, the dev rightly followed the live gates ([finding] 没有任何守卫断言组件 t() 引用的 key 存在于 en 包 —— parity 测试只管包际一致,调用点→包的一致性是盲区 #3530 call-site keys, i18n 门禁:en 文案变更时其余九包必须同批跟改(或显式挂账)——#3582/#3625 族缺陷缺的那道不变量 #3650 en-drift, ten-pack parity). Recorded.
  • Census beyond the card done right: 6 named + 9 sibling holdouts (including a seventh bare aria-label and the interpolated-role sentences), post-fix audit showing zero literals. The four-legged fallback order preserves every site-specific fallback that existed — adoption cannot lose specificity. Honest-pin notes (the module-not-found red, the two new-testid must-not-change caveats) are the reporting standard this seat wants.
  • Why the existing gate missed it (variable-keyed lookups invisible to the literal scanner) is recorded, with the render test as the standing replacement. Changeset patch ×3 accepted — the two new app-shell modules are not package-entry exports, so no published surface grows.
  • CI: 15 green, 0 red, 5 wedged in run 31641627123 (frozen 2.5h; same-run Build & E2E completed; 15/17 Type Check steps done including both implicated i18n gates; all four shards hung at step one simultaneously — hang signature, infra not diff). PM is cancelling/re-running that run now; flip+arm on green.

Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 21:30
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 58bebf6 Aug 12, 2026
29 of 30 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4474-org-i18n branch August 12, 2026 21:31
yinlianghui pushed a commit that referenced this pull request Aug 13, 2026
`generateTimeScaleHeaders` is reachable from the package entry (`index.tsx`
does `export * from './renderer'`) and its published declaration grew an
optional trailing `locale?: string`. Entry-reachable additive API growth is
minor, not patch — patch is for changes with no API-surface movement at all.

`dist/index.d.ts` being byte-identical does not argue for patch: the entry
re-exports by reference, so the resolved public surface moved even though the
entry file's bytes did not. The contrapositive of #4496, which was graded patch
precisely because its .d.ts additions were NOT re-exported from the entry;
#4403 / #4177 / #4485 / #4495-regrade are the line this follows.

Additive and back-compatible is what minor means — no consumer breaks, existing
three-argument callers keep compiling and keep producing byte-identical output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: organization & invitation UI ships untranslated English in a zh locale (6 sites, incl. icon-only aria-labels)

2 participants