Skip to content

Map-valued widget titles are editable again, writing back only the active locale (#5428) - #5588

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5428-dashboard-title-locale-writeback
Aug 21, 2026
Merged

Map-valued widget titles are editable again, writing back only the active locale (#5428)#5588
os-sales merged 1 commit into
mainfrom
claude/issue-5428-dashboard-title-locale-writeback

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#5428

Both dashboard authoring surfaces made a map-valued widget title read-only, on a premise @objectstack/spec had already invalidated, deferring the real answer to a card that has since closed. This adopts the write-back rule that #5301's maintainer ruling settled, at both surfaces.

The premises, re-verified on origin/main

ClaimVerdict
DashboardEditor.tsx guards the title input with isAuthorableTitle (defined :152, used :300 / :304 / :307)confirmed
Its doc comment says "Nothing can reach this path from stored metadata yet — I18nLabel was plain string through rc.5"confirmed
resolveWidgetTitle sixty lines above documents the rc.6 widening that makes a map reachableconfirmed — the two cannot both hold
The deferral at :292 points at #4163, closed as completed 2026-08-15confirmed
DashboardWidgetInspector.tsx:233-250 carries the identical placeholder and premiseconfirmed, verbatim
The two surfaces are in different published packagesconfirmed — this is a cross-package diff, correcting the triage note's "same file family"

One premise is stronger than the card states: the installed @objectstack/spec is 17.0.0 (final), not rc.6 — node -p "require('@objectstack/spec/package.json').version". I18nLabelSchema is z.union([z.string(), z.record(z.string(), z.string())]). So the "safe without a ruling" clause has not merely expired; what the read-only branch did in practice was deny an author the ability to edit a widget title in their own locale.

The change, at each surface

packages/plugin-designer/src/DashboardEditor.tsxisAuthorableTitle (module-local, never exported) is replaced by writeWidgetTitle, which delegates to setLocalized. The input's value is the resolved active-locale string (resolveWidgetTitle, unchanged) and its readOnly prop is gone; the dead read-only:* classes go with it. onChange writes writeWidgetTitle(widget.title, language, e.target.value).

packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx — the value ternary collapses to resolveInlineI18nLabel(widget.title, locale) ?? '' (the spec resolver passes a plain string through and answers undefined for null, so the branch was redundant), readOnly is gone, and onChange writes setLocalized(widget.title, locale, e.target.value).

This surface reads through the spec's resolveI18nLabel (which #4163's ruling requires of this package) and writes through objectui's setLocalized. That pairing is sound because i18nLabel-resolver-parity.test.ts holds the two resolvers limb for limb — they differ only in how each spells a miss, and setLocalized always produces a hit for the locale it wrote. The comment says so, and the new round-trip assertions check it here rather than only citing it.

Both doc comments are rewritten rather than re-pointed: the stale premise is replaced by the rule actually in force, and the dangling deferral is not re-filed against another tracker — a comment pointing at a closed card is how this premise survived in the first place.

No new exports.isAuthorableTitle was module-local and its replacement is too; setLocalized is imported, not modified. Neither package gains a dependency — both already declare @object-ui/i18n, and @object-ui/i18n is already an eager edge of the inspector's module graph via metadata-admin/i18n.ts, so the console's eager closure is unchanged.

The pin that matters

Every new assertion is a preservation assertion. "The input is no longer read-only" is equally green against a fix that flattens the map — which is the exact data loss the read-only branch existed to prevent — so each case asserts the shape of what was written: the edited locale carries the new string, every other entry is byte-identical, and the key set is unchanged.

Both directions of non-vacuity are pinned: editing under zh-CN writes zh-CN and leaves en alone (a fix hard-wired to en fails), a locale the map does not carry adds an entry instead of overwriting the display fallback, and a plain-string title still saves as a plain string (a fix that wrapped every edit into a map fails).

Reverse verification

Both write-backs mutated to the flattening form onChange({ title: e.target.value }), each mutation proved on disk with anchored grep -c in both directions plus git diff --stat, restored by a trap ... EXIT INT TERM:

PRE : A fix-form 1 / A naive 0 · B fix-form 1 / B naive 0
POST: A fix-form 0 / A naive 1 · B fix-form 0 / B naive 1
git diff --stat: 2 files changed, 2 insertions(+), 2 deletions(-)

Ablated run — red at both surfaces, on the preservation assertions only:

DashboardEditor.i18nTitle.test.tsx (8 tests | 1 failed)
x writes ONLY the displayed locale - every other entry survives byte-identical
AssertionError: expected 'string' to be 'object'
DashboardWidgetInspector.test.tsx (19 tests | 3 failed)
x writes ONLY the active locale entry - every other locale survives byte-identical
x the ACTIVE locale is the one written - editing under zh-CN leaves `en` alone
x a locale the map does not carry ADDS an entry rather than overwriting the displayed one
Test Files 2 failed (2) · Tests 4 failed | 23 passed (27)

The "shows a map-valued title resolved, and EDITABLE" cases stayed green under the flattening mutation at both surfaces — the card's warning, reproduced.

Restore verified: post-restore counts back to fix-form 1 / naive 0 at both files, git status --porcelain empty.

No dist rebuild is stated because none is in this path: both test files import the component under test by relative source path, and @object-ui/i18n is aliased to packages/i18n/src in vitest.config.mts. Nothing here resolves through a package's exports to dist/, so a mutation lands in the code under test directly.

Gates — all on 69335280e, each quoting its own verdict line

Exit codes captured before any pipe (written into the log by the command itself), never read from a tail.

GateVerdict
pnpm --filter '@object-ui/app-shell^...' --filter '@object-ui/plugin-designer^...' buildVERDICT command-exit 0
pnpm --filter @object-ui/plugin-designer type-checkPD_TYPECHECK_EXIT=0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm --filter @object-ui/app-shell type-checkAS_TYPECHECK_EXIT=0 (run after the dependency-closure build)
pnpm --filter @object-ui/plugin-designer lintPD_LINT_EXIT=067 problems (0 errors, 67 warnings), all pre-existing classes, none at a changed line
pnpm --filter @object-ui/app-shell lintAS_LINT_EXIT=02509 problems (0 errors, 2509 warnings), likewise
pnpm exec vitest run packages/plugin-designer/ packages/app-shell/.../inspectors/VITEST_EXIT=0Test Files 63 passed (63) · Tests 665 passed, 1 skipped (666)
node scripts/check-control-bytes.mjsOK (scanned 4637 tracked text file(s); skipped 85 binary)
node scripts/check-changeset-presence.mjs4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
node scripts/check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.
node scripts/check-lint-coverage.mjs46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs45/46 via type-check ... 41/41 packages compile their tests
node scripts/check-skills-paths.mjs95/96 stated path(s) resolve

The type-checks above are a re-run on the final commit. The first pass ran before a test-helper fix, so its verdict described a tree that was no longer HEAD; both were re-run at 69335280e rather than reported from the earlier run.

node scripts/check-control-bytes.mjs is complemented by a self-scan of the five changed files for the bytes a gate that scans tracked files would miss on an unstaged tree: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', no matches.

Declared narrowing

Repo-wide pnpm lint was not run locally; CI owns it. The evidence that this narrowing cannot hide a failure:

  1. Population from eslint's own resolution, not a guess — root lint is turbo run lint, and each package's script is plain eslint .. The repo-wide run is exactly the union of the 46 per-package runs.
  2. What ran — 2 of those 46, each in full (not narrowed to files). Those two packages contain all four changed source files; the fifth changed file is the changeset markdown, which no package's eslint . reaches.
  3. Invariance for untouched files — type-aware linting is not enabled (no projectService and no parserOptions anywhere in eslint.config.js, and neither package carries its own config), so a change inside these two packages cannot move a lint verdict on a file in any of the other 44.

check-eager-closure-budget.mjs was not run: it reads apps/console/dist/eager-closure.json, emitted only by a vite build of the console, and it says so itself ("This is a broken gauge, not a passing budget"). CI-owned. The reasoning above for why this diff adds no closure edge is an argument, not a measurement.

Scope

A full multi-locale editor stays out of scope and is not built here — an author still reaches only the entry for the locale they are in. That remains an open product question; see the report comment on #5428 for why it is deliberately not re-filed against a tracker.


Generated by Claude Code

…-valued widget titles (#5428)
Both dashboard authoring surfaces guarded their widget-title input by making a
map-valued title read-only, justified by "nothing can reach this path from
stored metadata yet - I18nLabel was plain string through rc.5". The pinned
@objectstack/spec is 17.0.0, whose I18nLabelSchema admits the map, and the same
file family documented that widening sixty lines away. The branch was denying
authors an edit rather than protecting an unreachable path, and its deferral
pointed at objectui#4163, closed as completed on 2026-08-15.
Adopts @object-ui/i18n's setLocalized (objectui#5301's ruling, shipped by
PR #5427) at both surfaces: a save replaces only the active locale's entry and
carries every other locale across untouched. Plain-string titles are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.8 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-BXTZCwvS.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.88KB113.68KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.96KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.48KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 15:45
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 4db5989Aug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5428-dashboard-title-locale-writeback branch August 21, 2026 15:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-designer: DashboardEditor's map-valued title stays read-only on a premise the spec invalidated, and the card it defers to (#4163) is closed

2 participants

@os-sales@claude