Skip to content

fix(app-shell): de-developerize the Studio interface property panel (#8218) - #8232

Merged
hotlong merged 1 commit into
mainfrom
fix/studio-property-panel-de-developerize-8218
Sep 7, 2026
Merged

fix(app-shell): de-developerize the Studio interface property panel (#8218)#8232
hotlong merged 1 commit into
mainfrom
fix/studio-property-panel-de-developerize-8218

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #8218

Clause-②: no

The diff is packages/app-shell/src/views/metadata-admin/** plus one changeset. It touches no contract or spec surface: no @objectstack/spec type, no authorable-surface declaration, no governed path (AGENTS.md / CLAUDE.md / .claude/** / docs/adr/**). The contract-shaped half of the card was measured to belong upstream and was filed there instead — see below.


What changed

Four symptoms, one cause: SchemaForm + widgets.tsx were written for an administrator editing metadata, and Studio's 「界面」 panel now grafts that same form in front of an AI-build maker.

1. Machine-name tooltip. title="Machine name" on the identifier chip now resolves through the engine string table (en + zh).

2. Master-detail column headers. {itemProps[c]?.title ?? c} printed the raw JSON Schema key (actionUrl, actionType) as if it were a column name. It now humanises the key.

This is not a lenient contract fallback in the sense AGENTS §5 #0.1 forbids. title is an OPTIONAL JSON Schema annotation; its absence is not off-spec metadata, and the renderer must render something. The change reuses the convention this tree already applies wherever a title is missing, rather than inventing one:

  • inspectors/json-schema-to-fields.tsprop.title || humanizeKey(key), three sites;
  • SchemaForm's own grid repeater <th>s.label || prettify(s.field).

The master-detail <th> was the last place in these two files still printing a bare key.

3. Numeric fields. Both numeric renderers (SchemaForm's field control and the master-detail cell) now:

  • gray the schema's default in as a placeholder, so an empty box reads "using the default" instead of "unknown". Placeholder, never a written value — "left on the default" stays distinguishable from "pinned to today's default" in saved metadata;
  • forward minimum / maximum / multipleOf onto the control. Only fieldSpec.min / .max were read before, and a spec-derived authoring form (dashboardForm, …) declares neither — the bounds live on the JSONSchema the same spec produced. That is why the panel accepted a negative column count its own contract (columns: minimum 1, maximum 24) had already ruled out. The form spec still wins where it speaks; a test pins that.

4. Sweep. widgets.tsx + SchemaForm.tsx swept end to end: 45 user-visible literals at 45 call sites, over 37 new keys plus one reuse of the existing engine.form.selectEllipsis. aria-labels (move / remove / reveal / configure), placeholders, empty-state prose ("Bind a source object to …"), the segmented filter-element control, secret-field copy, the code editor's read-only and Loading editor…, Not configured, Section {n}.

Both en and zh tables were written. This console's engine.* table is the one string table the repo's i18n gates cannot see by construction (see metadata-admin/i18n.ts's header and packages/i18n/README.md, "Scope — the engine.* carve-out"), so a parity pin over all 37 keys ships with the change — nothing else would notice a key added to en and forgotten in zh.

Deliberately out of scope, and why

  • The refreshInterval dropdown the card floated as optional. Declined. This panel is spec-driven on purpose — dashboard-schema.ts says in as many words that a new dashboard prop flows through with zero code changes here. Hardcoding a per-field enum widget for one property of one metadata type would break exactly that property, and it is the shape #0.1 warns about. If a dropdown is the right affordance, refreshInterval should declare its options in the spec and flow through.
  • Whether a maker should see the machine name at all. Filed as finding(app-shell): the machine-name chip is unconditional in every non-English panel — its "label already spells the name" predicate cannot hold once the label is translated #8231 rather than decided: the chip's guard (prettify(name) === label) can never hold once the label is translated, so the chip is unconditional in zh and near-invisible in en. Three defensible answers, all product calls.
  • Ten locale packs. Not touched, correctly: these strings live in the engine.* carve-out (en + zh only, by that carve-out's documented posture), not in packages/i18n/src/locales/. check:i18n-drift confirms zero pack values moved.

Where the localized column name landed, and why

The card asked me to decide this rather than follow its guess. The upstream guess was right about the direction and wrong about the mechanism, so the fix went upstream but not to the place the card named. Filed as objectstack#16458.

Three measured facts:

  1. The bare keys are not only a translation gap — they leak in en-US too. DashboardHeaderAction's Zod fields carry .describe(...) and no title, so z.toJSONSchema emits description and no title for all four. itemProps[c].title is undefined in every locale. An English maker reads label / actionUrl / actionType / icon just the same. Adding zh entries to a catalog would not have fixed even the English surface.

  2. No localization channel reaches this <th> at all. The only path for these forms is resolveMetadataFormLabels, which decorates the FormView field specs. The array headers come from the JSONSchema — a different object no overlay touches. A dashboard.fields['header.actions[].label'] catalog entry, which is what the card proposed, would land on a FormFieldSpec that MasterDetailWidget never reads; its props do not even include fieldSpec. Necessary-but-not-sufficient at best, and today not reachable in that direction.

  3. The generated catalog is stale for this very form. zh-CN.metadata-forms.generated.ts's dashboard.fields names refreshIntervalSeconds where the spec's field is refreshInterval, and carries no header.* children at all. objectui ships a private overlay for those three, which is why the panel reads 显示标题 / 显示描述 / 操作按钮 today despite the catalog being silent.

So: the root cause is a missing authoring annotation in the contract, plus an undesigned localization channel for item-level property paths — both objectstack's. objectstack#16458 carries all of that, plus a fourth item this PR ran into: DashboardSchema.columns documents "(default 12)" in prose and declares no default, so the new placeholder is empty for exactly columns / gap / refreshInterval. A number invented on the renderer side would be a second source of truth for a value the contract owns, so it was not hard-coded here. What stayed in this repo is only what is genuinely the renderer's decision: what to print when the optional annotation is absent.


Verification

No screenshots. The card's acceptance asks for them from a live prod-like stack; there is none running (the local one on :5451 has exited) and standing one up for a polish card is not proportionate. Stating that plainly rather than claiming a frame I did not take. What replaces it is rendered-output assertions plus one piece of incidental in-DOM evidence, below.

Gates run at a13bde6cd (the branch head), exit codes captured before any pipe:

command exit result
pnpm exec vitest run packages/app-shell/ 0 Test Files 640 passed (640) · Tests 6148 passed | 1 skipped (6149)
pnpm exec vitest run apps/console/ 0 Test Files 89 passed (89) · Tests 1052 passed
pnpm --filter @object-ui/app-shell build 0 dist completeness: 1 package(s) complete (912 emitted files verified)
pnpm --filter @object-ui/app-shell type-check 0 tsc --noEmit && tsc -p tsconfig.test.json, clean
pnpm check:i18n-keys 0 3982 call sites, every in-scope key resolves
pnpm check:i18n-drift 0 0 en value(s) changed in the ten packs
pnpm check:control-bytes 0 6547 tracked text files, OK
node scripts/check-changeset-presence.mjs 0 3 source files, 1 changeset
node scripts/check-changeset-no-major.mjs 0 no major declared

Tests are run from the repo root with path filters, per AGENTS' "怎么跑测试" (the pnpm --filter <pkg> test form named in the dispatch is safe for this package specifically, since its script pins --root ../.., but the root form is used so the invocation guard is the one doing the deciding).

Lint. eslint --no-inline-config over the 5 touched files: 0 errors, 53 warnings, every warning pre-existing no-explicit-any / react-refresh / hook-deps at lines the diff does not touch. Narrowing evidence, all three legs: the total population is 4402 files read from eslint's own config (eslint . --format json, not an estimate); the narrowed run linted 5 files, counted from --format json; and the config enables no type-aware linting (no project / projectService in eslint.config.js), so this diff cannot move the verdict on any file it does not contain. The repo-wide run reports 94 errors across 78 files under --no-inline-config, none of them in this diff and all in files byte-identical to origin/main here.

New pin. SchemaForm.deDeveloperize-8218.test.tsx — 13 tests, green: tooltip in both locales and the literal's absence from the zh DOM; humanised headers plus a negative (actionUrl gone) plus the title-wins case; placeholder / min / max / step, form-spec-bound precedence, an all-absent case, and the master-detail cell; the 37-key parity pin with the notation-only exemption named rather than silently weakened.

Reverse verification (ablation). Run against the committed fix, with a trap ... EXIT INT TERM restore. All three arms were mutated back to their pre-fix text; on-disk landing was proved by grepping the injected and removed texts separately (injected title="Machine name" = 1, removed engine.form.machineName call = 0; injected ?? c} = 1, removed humanizeKey(c) = 0; removed placeholder={defaultValue = 0 in both files) and by both blobs hashing differently from the HEAD blobs printed before the mutation. Result: VERDICT ablation-exit=1, Tests 6 failed | 7 passed (13) — every behavioural arm turned red, the 7 pure-table-parity tests correctly stayed green. Restore proved by an empty git diff HEAD and by both files hashing back to the exact HEAD blobs (4d40dd825…, 1b1a5054e…).

Incidental in-DOM evidence, and a test the change legitimately broke. DashboardDefaultInspector.test.tsx went red on three cases with Found multiple elements with the text: Label — its helper's bare getByText('Label') started matching both the curated Label field and the header-actions column, because that <th> now reads Label where it used to read label. That is a rendering of the real dashboard inspector, not a synthetic fixture, so it is the closest thing to a screenshot available here: the change demonstrably reaches the panel the card is about. The helper is now scoped to selector: 'label', which is what it always meant.


Filed alongside: objectstack#16458 (upstream contract half) · #8231 (the machine-name chip's degenerate predicate).

Not merged, not enqueued, left as draft per the dispatch.


Generated by Claude Code

Studio's interface panel grafts the metadata-admin form in front of an
AI-build maker, so four habits written for an administrator landed in an
otherwise fully Chinese surface. One cause, one pass.

- The machine-name chip's `title="Machine name"` goes through the engine
  string table.
- A master-detail column whose item schema carries no `title` humanises
  the key instead of printing `actionUrl` raw. `title` is an OPTIONAL
  annotation, so its absence is not off-spec metadata: this reuses the
  convention already applied wherever a title is missing
  (`json-schema-to-fields`'s `prop.title || humanizeKey`, and this file's
  own grid repeater `s.label || prettify(s.field)`), not a lenient
  contract fallback.
- Both numeric renderers gray the schema `default` in as a placeholder
  and forward `minimum` / `maximum` / `multipleOf`. Only `fieldSpec.min`
  and `.max` were read before, which a spec-derived authoring form never
  declares, so the panel accepted a negative column count its own
  contract had ruled out. The default stays a placeholder, never a
  written value.
- 45 user-visible literals across the two files now resolve through the
  engine table in en and zh.

`DashboardDefaultInspector.test.tsx`'s helper is scoped to `<label>`: the
humanised `Label` column header now collides with the curated Label field
in a bare `getByText`, which is itself in-DOM evidence the change reaches
the real dashboard panel.
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3190.8 KB 3191.4 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CmomNxGw.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) 15.67KB 5.75KB
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) 5.13KB 2.35KB
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) 498.00KB 113.91KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 189.11KB 52.55KB
fields (index.js) 243.04KB 61.36KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.84KB 10.94KB
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) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 47.67KB 13.25KB
plugin-charts (index.js) 70.62KB 19.71KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 247.68KB 63.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.83KB 14.63KB
plugin-list (index.js) 113.35KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
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.74KB 1.41KB
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 (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

@hotlong
hotlong marked this pull request as ready for review September 7, 2026 02:40
@hotlong
hotlong added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 7258eaf Sep 7, 2026
33 of 34 checks passed
@hotlong
hotlong deleted the fix/studio-property-panel-de-developerize-8218 branch September 7, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant