Skip to content

feat(console): a "Language" item on the profile page for the user’s own sys_user.locale - #10050

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-7501-my-language-profile
Sep 20, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-7501-my-language-profile

Conversation

@os-tesla

@os-tesla os-tesla commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7501

Clause-②: no

Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq — the seat this branch was dispatched from.

What this adds

A Language card on the console profile page that reads and writes the signed-in user's own sys_user.locale (a BCP-47 tag). The platform made that column user-writable on 2026-09-03; the page edited name and the avatar only — exactly the {name, image} whitelist that ruling widened — so the capability existed as a database column and nowhere else.

The four decisions the order asked me to make and report

1. Which language list. The control offers offerableLanguages from useObjectTranslation() — the i18n provider's own answer, which is the deployment's published locale list (the console feeds it from the platform's locales endpoint) intersected with what this renderer can resolve. That is the same set LocaleSwitcher renders, so no second source of truth is introduced. A stored tag the deployment no longer publishes is prepended so the control shows the account's truth instead of rendering blank.

2. UI language vs sys_user.locale — two different things, deliberately not wired together. Read off the i18n provider: the interface language is a per-device preference kept in localStorage under LOCALE_STORAGE_KEY, seeded per device from the tenant's /auth/me/localization answer through cacheLanguageSeed, and switched by the globe menu. sys_user.locale is a per-user, server-stored column the messaging channels read per recipient at delivery time. Saving here therefore does not call changeLanguage, and the globe menu does not write the column. Whether the two should be joined is a product decision nobody has made — it is in the report's open_questions, not decided here. The card's description states the distinction to the user instead of leaving them to discover it.

3. The refusal path. A malformed tag comes back as 400 VALIDATION_FAILED with a per-field locale entry. The adapter's normaliseClientError turns that into a typed error carrying validationErrors, and extractFieldErrors — this repo's one normaliser for that envelope — reads it. The message is rendered on the item, with aria-invalid and aria-describedby pointing at it, and no success alert appears. Anything else falls back to extractWriteErrorMessage in the card's own alert.

4. Save semantics — same feedback, and the writer CANNOT match. name and the avatar are written by useAuth().updateUser, which is a thin pass-through to better-auth's /update-user; locale is deliberately not a better-auth additionalFields entry, so that endpoint does not know the column. The write therefore goes through the data adapter on the sys_user row. A different writer gets its own card with its own submit — which is the shape PasswordCard already establishes in this same file, for the same reason. What is kept identical to the name form is the FEEDBACK: the same success / failure Alert pair and the same disabled-while-saving submit.

Acceptance, item by item

card's acceptance line how it is met
appears on the signed-in user's own settings and nowhere else rendered inside ProfilePage, against user.id; nothing else mounts it
picking a language persists and survives a reload the value is written to the row and re-read from the row on mount
clearing returns the account to the deployment default the Use the deployment default option writes null, not an empty string
a refused value renders the server's per-field message on the item see decision 3
hidden or read-only with a reason when the write route is not the user's the field/object permission answer for sys_user.locale decides read-only-with-reason; an unread or refused row read renders nothing at all

Evidence

Every command was run from the repo root in a dedicated worktree, after the last edit it measures.

command exit reading
pnpm exec vitest run apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx 0 Test Files 1 passed (1) / Tests 7 passed (7)
pnpm --filter @object-ui/console type-check 0 clean, after building the dependency closure first
pnpm --filter @object-ui/i18n type-check 0 clean
pnpm --filter @object-ui/console lint + --filter @object-ui/i18n lint 0 212 problems (0 errors, 212 warnings); the one warning on the edited file is the pre-existing setName effect, not new
node scripts/check-i18n-call-site-keys.mjs 0 every new key resolves and every inline default matches its en value
node scripts/check-i18n-en-drift.mjs 0 0 en value(s) changed (7 key(s) added, 0 removed)
node scripts/check-i18n-dead-keys.mjs 0 report-only
node scripts/check-changeset-presence.mjs 0 11 source files of 2 released packages, 1 changeset declared
node scripts/check-changeset-no-major.mjs 0 no major declared
node scripts/check-new-cross-file-line-citations.mjs 0 0 new citation(s)
node scripts/check-control-bytes.mjs 0 8101 tracked text files scanned

Reverse verification — both legs, run from the committed fix

Leg A — restore the page to its origin/main state (no card at all): 7 failed (7), exit 1. Restored blob hash compared equal to the HEAD blob and git diff HEAD empty.

Leg B — keep everything and re-point only the write at useAuth().updateUser: 3 failed | 4 passed (7), exit 1. The three reds are exactly the save cases; the menu, seeding, permission and hidden cases stay green. That is the leg that shows these assertions are about the write and not merely about something rendering. Restored blob hash compared equal to HEAD again.

Both legs verified the mutation reached disk before running (occurrence counts in both directions plus a blob-hash comparison), and both restored with an explicit checkout HEAD under a trap.

Not measured here

⚠️ Replaced by the reviewing seat (domain:ui#2, PM session session_018HrVaotisyhgmot9o2MLRq), because the dev writes this body once and does not PATCH it: the paragraph that stood here said the two project suites were unmeasured after a verify-lock queue timeout (exit 99). That was true when this PR was opened and is now stale — the slot was re-taken and both suites ran green afterwards, reported in the dev's hand-back: packages/i18n 69 files / 1145 tests, apps/console 103 files / 1173 tests, both exit 0.

⇒ The one genuinely unmeasured item is the browser / live-stack path: no backend was started, so the 403-free write at the installed 17.4.0 is read from the published packages and the permission instrument, ⛔ not exercised end to end.

Acceptance notes

  • localeLabel is unreachable from this app. @object-ui/app-shell owns the hand-kept native names for the built-in packs, but exports only LocaleSwitcher from its entry and its exports map has no subpath, so the console cannot import the helper. This card names its languages through Intl.DisplayNames instead, deliberately not by re-typing the ten strings — a copy would be a second source of truth for language NAMES that nothing re-derives. Filing the export gap is proposed in the report; packages/app-shell is out of bounds on this card (in flight on objectui#9954).
  • The card's Blocked-by: line still points at a platform card that stopped being the blocker on 2026-09-05. Two seats said so at the time and neither could edit the card face. Not touched here.
  • userEvent.selectOptions leaves this controlled select's value unchanged, and a userEvent.click on the submit button never reaches the form's onSubmit, under the DOM environment these console tests run in. The tests use fireEvent.change + fireEvent.submit — the idiom this repo's other form tests already use — and still assert the submit button is present and ENABLED at that moment.

🤖 Generated with Claude Code

https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq


Generated by Claude Code


Generated by Claude Code

…r's own sys_user.locale

The platform made `sys_user.locale` user-writable on 2026-09-03 and nothing in the
product let anyone set it: the profile page edited `name` and the avatar only, which
is exactly the `{name, image}` whitelist that ruling widened.

The new card reads and writes the signed-in user's own `sys_user` row through the data
adapter. It is a card of its own rather than another field in the Personal Information
form because that form's writer, `useAuth().updateUser`, posts to better-auth's
`/update-user`, and `locale` is not a better-auth `additionalFields` entry, so that
endpoint cannot carry the column. Its feedback vocabulary is the form's.

The offered set is the i18n provider's `offerableLanguages` — the same intersection
`LocaleSwitcher` renders — so no second language list is introduced; each entry is
named in its own language, and a stored tag the deployment no longer publishes is
shown rather than dropped. "Use the deployment default" writes `null`, which is the
documented meaning of an unset column and the only way back once a tag is stored.

A refused tag renders the server's per-field message on the item, with `aria-invalid`
and `aria-describedby` pointing at it. When the field/object permission answer for
`sys_user.locale` is no, the control is read-only and says why rather than offering a
button that would answer 403; it does not render at all while the row is unread or if
the read is refused.

The interface language is deliberately untouched: it is a per-device preference the
i18n provider keeps in `localStorage`, while `sys_user.locale` is a server column read
per recipient at delivery time. Joining them is a product decision nobody has made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
…pins

The docblock predicted one leg and guessed the shape of the other. Both were
run from the committed fix: removing the card reds all seven, re-pointing the
write at better-auth's `updateUser` reds exactly the three save cases and
leaves the four menu / seeding / permission / hidden cases green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6661-app-launcher-nav-menu-renderers.md

  • names en.tspackages/i18n/src/locales/en.ts — edited by this change

    Three new strings — the launcher's and the menu's accessible names, and the menu's empty state — are declared under console.nav in en.ts and its nine sibling packs. An inline defaultValue alone is not a fix: it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517).

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with e86445f57 (merge-base with origin/main): 12 file(s) changed outside .changeset/, read against 1221 pending declaration(s) that publish a body (1781 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3057.1 KB 3104.5 KB
Main entry chunk (gzip) 145.9 KB 350 KB
Entry file index-CtYQrhgZ.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 545.92KB 130.72KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 221.99KB 61.72KB
fields (index.js) 249.62KB 63.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.90KB 10.97KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 50.26KB 14.36KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.18KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 139.56KB 35.40KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 213.44KB 58.21KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.53KB 27.99KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

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.

Account settings: a "my language" form item for the user's own sys_user.locale

2 participants