Skip to content

fix(console,i18n): localize the Applications page's own chrome, and keep the server's words verbatim (#4307) - #4344

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4307-app-management-i18n
Aug 11, 2026
Merged

fix(console,i18n): localize the Applications page's own chrome, and keep the server's words verbatim (#4307)#4344
yinlianghui merged 1 commit into
mainfrom
claude/issue-4307-app-management-i18n

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #4307

Refs #4233, PR #4300 (the four live mutations this page gained), #4208 / #4163 (the keyed-label resolver used below).

apps/console/src/pages/system/AppManagementPage.tsx was raw English end to end — headings, the search field, the selection and bulk controls, six per-row actions with their tooltip/ARIA pairs, the status badges, and every toast. It was the last un-i18n'd system page, and #4233 / PR #4300 had just wired its four mutation handlers, so the localization gap became user-visible on every non-English console exactly when operators started using the page for real work.

45 keys land under appManagement.* in all ten packs.

(Angle brackets are spaced in this body — < h1 > — so GitHub's sanitizer does not eat the DOM snippets, the same precaution PR #4208's body took. The tools printed them unspaced.)

Gate

#4334 (the #4294 sort-hint reword, same packages/i18n/src/locales/*.ts files) was confirmed squashed on origin/main as 7f1cb3323 before this worktree was created; the branch is based on that commit. The locale diffs here are purely additivegit diff shows zero deletions in packages/i18n/, so #4334's reworded key is untouched.

Convention measured, not assumed

The card says to follow how the #4208-era pass keyed sibling system pages, so that was measured rather than guessed:

convention found followed here
accessor useObjectTranslation from @object-ui/i18n (ProfilePage, ApprovalsInboxPage) yes
namespace top-level, page-named (profile.*, approvalsInbox.*) — console.* is reserved for shell-level chrome appManagement.*
call shape t('key', { defaultValue: 'English' }), inline, matching en byte for byte yes
label resolution resolveKeyedI18nLabel(label, t)t passed (AppSidebar, DashboardView, SearchResultsPage) yes

Translations were taken from neighbouring keys rather than machine-translated: home.stats.apps / layout.systemNav.applications for the page title, view.setAsDefault, home.appCard.default, console.objectView.enableSearch for the enable/disable verb per pack, console.error.connectionFailed for the unreachable-service register, and fields.file.uploadFailed (Failed to upload "{{name}}": {{error}}) as the model for the failure templates.

The one thing this PR is careful about: whose words are whose

The card makes this binding, and it is the axis the whole change is organized around.

PUT/DELETE /api/v1/meta/app/:name is gated on manage_metadata (ADR-0066 D1), so a refusal like forbidden: manage_metadata required is the server's diagnosis of one specific request. There is no fixed catalogue of those sentences for a pack to key against. So every failure toast is a keyed template with a {{reason}} hole, and what fills the hole is interpolated raw:

toast.error(t('appManagement.toast.setDefaultFailed', {
  defaultValue: 'Failed to set default app: {{reason}}',
  reason: reason(e, unknownError),   // ← the server's own sentence, untranslated
}));

The single exception is the page's own word for "the server said nothing at all"reason()'s empty-message fallback — which is now appManagement.toast.unknownError rather than a hardcoded 'unknown error'. That is the only part of that string the page authors.

Two decisions inside the conversion

1. The bulk toast's entry template and separator are keys, not literals. Failed for 2: CRM (forbidden); Ops (in use) was assembled from a hardcoded `${name} (${reason})` and a hardcoded '; '. Both are now pack-owned (bulkFailureEntry, bulkFailureJoiner), because bracket style and list punctuation are locale properties — the CJK packs set their own. This is not a new opinion: validation.formInvalidJoiner carries a comment recording the defect that came from hardcoding exactly this (objectstack#5407, a CJK comma in an English toast), and detail.userStatusTitle is keyed for its parentheses for the same reason.

2. The row's controls now name an app through the resolver, with t. This is the one behavioural change beyond pure keying, so it is called out rather than buried. The controls interpolated app.label || app.name directly while the visible heading two lines away in the same file already called resolveKeyedI18nLabel(app.label). An app carrying objectui's keyed label form therefore rendered aria-label="Select [object Object]" on all six controls.

Writing a fresh name: app.label || app.name call site would have knowingly re-authored that bug; one appTitle(app) helper resolves it once per row, passes t (so a keyed label reaches the pack instead of stopping at its authoring defaultValue), and every label and toast in the row names the app the same way. The reverse verification below shows the pre-change string actually being produced.

Tests

Two files, split by what they can actually see.

AppManagementPage.mutations.test.tsx (from PR #4300) keeps every assertion unchanged. Its t mock echoes the call site's defaultValue and fills the holes — the console convention (useConsoleActionRuntime.test.tsx, sharedInboxFeed.twoSurfaces.test.tsx) — so 'Set Ops as default' and '2 apps disabled' still read as English, because those are still the sentences an English console renders. Re-spelling them as key names would have quietly moved that suite off its subject, which is the write, not the wording. The only edit is the mock itself (partial, via importOriginal, because @object-ui/components builds createSafeTranslation probes at module scope).

That mock is also why a second file was necessary: a page that never called t at all renders identical English and passes every one of those cases. So AppManagementPage.i18n.test.tsx (new, 9 cases) mocks t to answer in no natural language — «appManagement.title», «appManagement.selectApp»[name=Ops] — which is what a non-English pack does to this page in the respect that matters: the English disappears. Each assertion is two-sided, naming the key that must be asked for and re-checking that the retired English sentence is nowhere on screen.

Local runs — repo-root vitest (objectui#3378), flock /tmp/os-heavy-verify.lock, NODE_OPTIONS=--max-old-space-size=4096, --maxWorkers=2:

$ pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build
   (build closure first — 44 tasks, Done)

$ pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/system/__tests__/ packages/i18n/
   Test Files  43 passed (43)
        Tests  711 passed (711)

$ pnpm --workspace-concurrency=2 --filter '@object-ui/console' --filter '@object-ui/i18n' type-check
   packages/i18n type-check$ tsc --noEmit && tsc -p tsconfig.test.json      Done
   apps/console type-check$ tsc --noEmit && tsc -b tsconfig.node.json --force  Done

Both packages define two tsc invocations each, so that is all four.

i18n and byte gates

$ pnpm run check:i18n-keys
Scanned 1493 files, 3530 t()/tt() call sites: 2403 pack-backed (2352/2352 literal keys resolve) …
Every in-scope call-site key resolves against the en pack (2858 keys), every literal inline
defaultValue matches the value the pack serves, every call site passes exactly the arguments
that value has holes for, and no call site carries a literal fallback beside itself.

$ pnpm run check:i18n-drift
Compared the ten locale packs at 7f1cb3323 (merge-base with origin/main) with the working tree:
0 en value(s) changed (45 key(s) added, 0 removed …)

$ pnpm run check:control-bytes
OK (scanned 4066 tracked text file(s); skipped 85 binary).

0 en value(s) changed is the mechanical confirmation of the gate note above: 45 keys added, no existing value edited.

ESLint on the changed files: 0 errors, 14 warnings, all the pre-existing no-explicit-any convention this page and its sister test already carry for app records.

Reverse verification

Direction predicted before running: reverting only the page must turn the new i18n file red on both halves of each assertion, while the mutations suite stays green — that asymmetry is the entire reason the second file exists. Taken out with a scratchpad file swap after committing, never git stash.

 Test Files  1 failed | 1 passed (2)
      Tests  9 failed | 12 passed (21)

Exactly as predicted: 9/9 red in AppManagementPage.i18n.test.tsx, 12/12 green in AppManagementPage.mutations.test.tsx.

Two assertion orders were tightened after this run, because the first pass showed the two sweep cases dying on a precondition (a sentinel query used as setup) rather than on their own claim. With that fixed, the revert now reports the real thing in each:

FAIL … ⛔ leaves NO hardcoded English on screen once the pack answers otherwise
  AssertionError: expected < h1 …(1) > < /h1 > to be null

FAIL … resolves a KEYED app label through the same `t`, rather than stringifying it
  TestingLibraryElementError: Found multiple elements with the text of: /\[object Object\]/
    < button aria-label="Select [object Object]" role="checkbox" id="select-app-crm_app" >

That second block is the [object Object] defect from decision 2 above — demonstrated by the pre-change page rather than asserted in prose.

Out of scope, filed not fixed

#4343 — the same file's search filter calls (app.label || '').toLowerCase(), which throws TypeError for that same keyed label form (an object is truthy, so the || '' guard does not catch it), taking out the page on the first keystroke. It is a read on a line this card does not touch and a different defect class from chrome localization, so it is filed unassigned for triage rather than ridden in here. Whether any shipping app metadata actually carries a keyed label today is not measured — that grading is the triage round's.


Generated by Claude Code

…eep the server's words verbatim (#4307)

`AppManagementPage` was raw English end to end — headings, search, the
selection and bulk controls, six per-row actions with their tooltip/ARIA
pairs, the status badges and every toast. It was the last un-i18n'd system
page, and #4233 / PR #4300 had just given it four live mutations, so the
gap became user-visible exactly when operators started using it.

45 keys land under `appManagement.*` in all ten packs, reached through
`useObjectTranslation` with the call site's `defaultValue` inline — the
convention `ProfilePage` and `ApprovalsInboxPage` already follow.

The split that shapes this change is between the strings the PAGE authors
and the strings the SERVER authors. A refusal like `forbidden:
manage_metadata required` is the server's diagnosis of one request, and
there is no fixed catalogue of those to key against, so each failure toast
is a keyed template with a `{{reason}}` hole filled byte for byte. The one
part that is the page's own — what it says when the server sent nothing —
is keyed as `appManagement.toast.unknownError`.

Two consequences of converting it properly rather than mechanically: the
per-entry template and separator of a bulk toast are keys, because bracket
style and list punctuation are locale properties (`validation.
formInvalidJoiner`'s rule and its past defect); and the row controls name
an app through the resolver the visible heading already used, with `t`
passed — a keyed label previously rendered `Select [object Object]`.

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

vercel Bot commented Aug 11, 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 11, 2026 5:57pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 29.5 KB 350 KB
Entry file index-DojbY4ig.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) 35.76KB 9.11KB
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.12KB 108.41KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.37KB 56.62KB
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.01KB 1.22KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 16.38KB 5.47KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
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.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.56KB 31.56KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.95KB 59.76KB
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.00KB 49.94KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.10KB 26.74KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 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.71KB 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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 18:05
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 734d186 Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4307-app-management-i18n branch August 11, 2026 18:06
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.

AppManagementPage renders raw English chrome throughout — untouched by the #4208 i18n pass, now the only un-i18n'd system page with live mutations

2 participants