Skip to content

fix(fields): 注册路径的全屏长文本对话框接上 i18n(#3404) - #3405

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3404-fullscreen-editor-i18n
Aug 5, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-3404-fullscreen-editor-i18n

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3404

问题

packages/fields/src/widgets/FullscreenFieldEditor.tsx 四处英文字面量:展开按钮的可访问名(Edit ${label ?? 'text'} fullscreen)、标题兜底 Edit text、Cancel、Done。

键一个都不缺 —— form.fullscreen.* 与 common.cancel 自 #3272 给内置分支做国际化起就在十个语言包里齐全,只是这条路径从没消费。可达性也不是理论上的:mobile.fullscreenLongText 打开后 ObjectForm 给每个长文本字段盖 mobile_fullscreen,于是同一张表单内,内置分支渲染的字段显示「取消 / 完成」,注册 widget 渲染的显示 Cancel / Done。

改动

四处全部改为消费既有键,零新增键、十个语言包一行未动。另补上内置分支早已有的 sr-only form.fullscreen.description,详见下。

关键实现选择:走 useFieldTranslation() 而不是 issue 正文提到的裸 useObjectTranslation。 这一点是实测决定的,不是偏好。本包 widget 会被独立/嵌入宿主以及本包绝大多数测试不带 I18nProvider 裸渲染,而在改动前的树上实测裸 hook 在无 provider 时返回的是原始键:

{"cancel":"common.cancel","done":"form.fullscreen.done",
 "title":"form.fullscreen.title","toggle":"form.fullscreen.toggle"}

{{label}} 插值也整个塌掉 —— 比它要替换的英文字面量更差。useFieldTranslation(createSafeTranslation)正是内置分支自 #3272 起用的形状,其英文兜底与原字面量逐字节相同,因此无 provider 的渲染结果完全不变。相应地,英文兜底项加在了本包既有的 FIELD_DEFAULTS(useFieldTranslation.ts)里 —— 那是代码内的英文兜底表,不是语言包,仍是零新增 i18n 键;common.cancel 本就在表内,直接复用。

「顺带观察」的 DialogDescription:补了,但理由与 issue 猜测的不同

issue 猜测可能触发 Radix 的 missing-Description 警告。实测该警告不存在:锁定的 @radix-ui/react-dialog@1.1.23 整个文件里一个 console.* 调用都没有,上游已移除该警告,baseline 跑也确实没有任何告警输出。

补它的真实理由是另外两条:Radix 仅在存在 Description 时才设置 aria-describedby(descriptionPresent ? descriptionId : undefined),所以改动前这条路径的对话框根本没有可访问描述,而逐像素相同的内置对话框有;且 form.fullscreen.description 在此路径上是个无消费者的键 —— 与本 issue 主体是同一处漂移。同文件、零新键。

测试

新增两个文件,合计 14 例,textarea 与 richtext 两个 widget 各覆盖:

  • __tests__/FullscreenFieldEditor.i18n.test.tsx —— en 正向(与旧字面量逐字节相同,证明 en 是 no-op)、zh/ja 正向 + 英文字面量反向断言(只做正向断言的话,重新内联的字面量挨着翻译过的兄弟节点照样能过);ja 是因为 toggle 名是插值的、两个包把通用名词放在句子两端,只测 zh 会蒙混过关。另含 label 插值、aria-describedby、以及提交/取消仍然接线正常。
  • __tests__/FullscreenFieldEditor.no-provider.test.tsx —— 无 provider 的那条腿,含 Commandment #-1 的无 CJK 断言。

为什么无 provider 那几例必须单独成文件(这是本 PR 返工过的一处): 它们最初写在 i18n 文件里。反向验证(把 useFieldTranslation 换回裸 useObjectTranslation,预期变红)时它们依然全绿,而同一次实验正确地把 RichTextField.mobileFullscreen.test.tsx 弄红了。原因是 I18nProvider 会调用 initReactI18next,把实例装成 react-i18next 的全局默认;同文件内只要有一例挂过 provider,就再也没有「无 provider」状态可观测 —— 那几例是靠读泄漏的全局实例而绿的,属于「因为什么都没发生而通过」的假绿。拆成独立文件后,同样的替换实验让它们如实变红(aria-label="form.fullscreen.toggle"),这才算真的钉住。同样的拆分理由见 TagsField.placeholder.no-provider.test.tsx。

反向验证方向

  • 主改动: 预期红 → 实测红。改动前跑新 i18n 套件:Tests 6 failed | 2 passed,失败的正是 zh/ja 各例(Received: aria-label="Edit text fullscreen");通过的 2 例是两个 en 用例 —— 它们改动前后都绿,因为字面量与 en 包逐字节相同。这一点如实记录:en 用例是 no-op 保护,不是缺陷复现器。
  • 实现选择: 换回裸 hook → 4 例红,报出 aria-label="form.fullscreen.toggle" 原始键,其中一例是既有的 RichTextField.mobileFullscreen.test.tsx(它按 'Edit Release notes fullscreen' 查按钮)。

命令与结果

从仓根跑(包内跑会假绿 / 路径过滤被吞,见 #3288/#3378),并以文件名核对确实执行:

npx vitest run --maxWorkers=2 packages/fields packages/plugin-form \
  packages/components/src/renderers/form packages/i18n
  → Test Files 139 passed (139) / Tests 1552 passed (1552)

pnpm --filter @object-ui/fields type-check   → 干净(需先 build 依赖图)
pnpm check:control-bytes                     → OK(3598 个文件)
eslint(4 个改动文件)                          → 0 问题

按消费半径而非编辑包扫了 fixture:packages/plugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx 在 packages/fields 之外也消费这个组件,已纳入上面的运行范围;packages/i18n 的语言包 parity 套件一并跑过,确认没有包漂移。

范围

严格限定在 FullscreenFieldEditor.tsx + 测试 + changeset(patch),外加 useFieldTranslation.ts 里的英文兜底项 —— 那是让本次改动不产生回归所必需的最小外扩(理由见上),且不属于裁定禁止的 form.tsx / 语言包 / TextAreaField / RichTextField 任何一项。未碰 form.tsx,未碰任何 locale 文件,未改两个宿主 widget。


Generated by Claude Code

…log (#3404)

`FullscreenFieldEditor` — the expand button and dialog `TextAreaField` and
`RichTextField` render under `mobile.fullscreenLongText` — carried four English
literals: the toggle's accessible name, the `Edit text` title fallback,
`Cancel` and `Done`.

No translation was missing: `form.fullscreen.*` and `common.cancel` have
shipped in all ten locale packs since #3272 translated the built-in branch;
this path never consumed them. Inside one form a zh session saw the built-in
branch's translated footer next to this path's English one.

All four now consume those EXISTING keys — no new keys, no locale-pack change
— plus the sr-only `form.fullscreen.description` the built-in branch already
carries, so the dialog has an `aria-describedby` instead of none.

Copy resolves through `useFieldTranslation()` (`createSafeTranslation`), as the
built-in branch does, not bare `useObjectTranslation`. Measured with no
provider mounted, the bare hook returns raw keys
(`common.cancel` -> "common.cancel") and drops the `{{label}}` interpolation —
strictly worse than the literals. The safe hook's English defaults are
byte-identical to those literals, so provider-less hosts render what they did
before.

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

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

Request Review

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-_Ryj8XSd.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
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) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
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) 478.45KB 105.05KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 227.94KB 55.90KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
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.05KB 1.53KB
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) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 231.54KB 57.09KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 105.02KB 25.36KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
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) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
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 (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) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
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 5, 2026 20:19
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 6fe485b Aug 5, 2026
17 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3404-fullscreen-editor-i18n branch August 5, 2026 20:19
os-zhuang pushed a commit that referenced this pull request Aug 23, 2026
…s master_detail gets the #3405 hints

`ActionParamDialog` decided whether to show the "paste a record id" placeholder
and help text with its own literal over RAW param spellings
(`param.type === 'lookup' || param.type === 'reference'`), while the degradation
it was reacting to is performed by `paramToField` over RESOLVED widget keys
(`LOOKUP_WIDGET_TYPES`). The two sets overlapped without either containing the
other: `master_detail` degraded to a plain text box and got no hints at all
(an unexplained empty field asking for a bare UUID), and `reference` was a
hand-copy of a `PARAM_TYPE_ALIASES` row the adapter folds away before it tests
membership.

`paramToField` now exports `paramDegradesWithoutTarget(param)` — the one answer
to "did this param fall back for want of a declared target?" — and both the
adapter's own fallback branch and the dialog's two hint readers ask it. One
membership table remains in this surface.

Deliberate behaviour change: a targetless `master_detail` param now receives
`actionDialog.lookupPlaceholder` and `actionDialog.lookupHelpText`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
yinlianghui pushed a commit that referenced this pull request Aug 24, 2026
…ies of the reference-bearing field family

Four faces restated `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES` inline, none
deriving from it and none pinned against it, and every one diverged from it in
BOTH directions: all four lacked `user` and `tree`, and all four carried a
fifth spelling `reference` that no producer can emit. Each now reads the
published family and carries an identity pin (a spy on its `has`) so a
member-identical private copy fails rather than quietly re-forking the table.

`resolveActionParams` also lacked `master_detail`, so a field-backed
`master_detail` action param inherited no picker config and degraded to the
"paste a record id" text input #3405 exists to prevent. It keeps answering for
`reference` through the one alias table in `paramToField`, which the dialog
still accepts from params already authored with it — the same expression
`paramToField` evaluates one step later, so the half that populates a param's
picker config and the half that forwards it can no longer disagree.

Also corrects the two now-false "fourth and last private copy" claims in
`paramToField.ts` / `paramToField.test.ts`, pointing at the census in the
family's canonical home rather than restating a count locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…s master_detail gets the objectstack-ai#3405 hints (objectstack-ai#5882)

* fix(app-shell): ask the adapter which params degraded, so a targetless master_detail gets the objectstack-ai#3405 hints

`ActionParamDialog` decided whether to show the "paste a record id" placeholder
and help text with its own literal over RAW param spellings
(`param.type === 'lookup' || param.type === 'reference'`), while the degradation
it was reacting to is performed by `paramToField` over RESOLVED widget keys
(`LOOKUP_WIDGET_TYPES`). The two sets overlapped without either containing the
other: `master_detail` degraded to a plain text box and got no hints at all
(an unexplained empty field asking for a bare UUID), and `reference` was a
hand-copy of a `PARAM_TYPE_ALIASES` row the adapter folds away before it tests
membership.

`paramToField` now exports `paramDegradesWithoutTarget(param)` — the one answer
to "did this param fall back for want of a declared target?" — and both the
adapter's own fallback branch and the dialog's two hint readers ask it. One
membership table remains in this surface.

Deliberate behaviour change: a targetless `master_detail` param now receives
`actionDialog.lookupPlaceholder` and `actionDialog.lookupHelpText`.

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

* chore(changeset): declare the app-shell degraded-picker hint change (objectstack-ai#5654)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…ies of the reference-bearing field family, and correct the "fourth and last" claim (objectstack-ai#6064)

* fix(plugin-kanban,plugin-detail,app-shell): converge four private copies of the reference-bearing field family

Four faces restated `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES` inline, none
deriving from it and none pinned against it, and every one diverged from it in
BOTH directions: all four lacked `user` and `tree`, and all four carried a
fifth spelling `reference` that no producer can emit. Each now reads the
published family and carries an identity pin (a spy on its `has`) so a
member-identical private copy fails rather than quietly re-forking the table.

`resolveActionParams` also lacked `master_detail`, so a field-backed
`master_detail` action param inherited no picker config and degraded to the
"paste a record id" text input objectstack-ai#3405 exists to prevent. It keeps answering for
`reference` through the one alias table in `paramToField`, which the dialog
still accepts from params already authored with it — the same expression
`paramToField` evaluates one step later, so the half that populates a param's
picker config and the half that forwards it can no longer disagree.

Also corrects the two now-false "fourth and last private copy" claims in
`paramToField.ts` / `paramToField.test.ts`, pointing at the census in the
family's canonical home rather than restating a count locally.

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

* fix(app-shell): give the new paramToField import an explicit .js extension

`check:esm-specifiers` (inside the Type Check CI job) caught the relative
specifier this PR added: Node's ESM resolver does not extension-search relative
specifiers and app-shell's build emits them unchanged, so the published entry
could not be imported under plain Node.

Part of objectstack-ai#5874

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…, and an unreadable one is refused (objectstack-ai#10158)

Fixes objectstack-ai#10129

Clause-②: no — `node scripts/check-governed-queue-guard.mjs --test` over
this branch's 16 paths answers `NOT GOVERNED — none matched`. The diff
is `packages/app-shell`, `packages/core`, `packages/i18n` and one
changeset; it touches no `AGENTS.md`, no `CLAUDE.md`, nothing under
`.claude/**`, `skills/**` or `docs/adr/**`.

## Step 1 first: which side dropped the binding

The card refused to name a side and asked for one to be established.
**Outcome: the binding reaches the client, and the renderer path ignores
it.** The fix is here, in this repo.

The decision site is `paramToField()` in
`packages/app-shell/src/utils/paramToField.ts` — the
`paramDegradesWithoutTarget(param)` branch that rewrites `type` to
`'text'`. But that is not where this defect lives, and finding that out
is most of the work: for the reported param that branch **never fires**.

One step earlier, `resolveActionParam()` in
`packages/app-shell/src/utils/resolveActionParams.ts` resolves a
field-backed param against the `objects` list its **caller** hands it:

```ts
const owner = ctx.objects.find((o) => o?.name === ownerName);
const field: RuntimeField | undefined = owner?.fields?.[param.field];
if (!field) { /* … type: param.type ?? 'text' … */ }
```

A field-backed param (`{ field: 'contract_type' }`) declares no inline
`type`, so when the owner object is absent from that list the param
becomes a `text` param — before any picker logic runs. Measured on this
tree, both before and after the change:

| resolver input | resolved `type` | `referenceTo` |
`paramDegradesWithoutTarget` | widget |
|---|---|---|---|---|
| `{ field }`, owner object present | `lookup` | `clm_contract_type` |
false | `lookup` |
| `{ field }`, owner object **absent** | `text` | undefined | **false**
| `text` |
| `{ name, type: 'lookup', reference }` inline | `lookup` |
`clm_contract_type` | false | `lookup` |

Row 2 is the card, in every detail. `paramDegradesWithoutTarget`
answering **false** is the load-bearing half: by the time anything
downstream looks, the param *is* a `text` param, and text params do not
degrade. So no "no reference target" warning fired, the "paste a record
id" placeholder and help text (objectstack-ai#3405) did not apply, and the user got an
unannotated empty box — no dropdown, no typeahead, and **no request for
the referenced object on the wire, because no picker was ever built**.
That silence is why this survived a version.

### Which callers cannot hold the right list

- `packages/app-shell/src/console/ConsoleShell.tsx` —
`GlobalActionRuntimeProvider` calls `useConsoleActionRuntime({
dataSource })` with **no** `objects` and **no** `objectName`. Its own
docblock says why it exists: every `action:button` *outside* ObjectView
/ RecordDetailView / PageView / DeclaredActionsBar binds to it, and it
was added so a `type: 'flow'` action there stops failing with "Flow
handler not registered". The card's action is a flow trigger (`POST
/api/v1/automation/contract_intake/trigger`).
- `packages/app-shell/src/views/DeclaredActionsBar.tsx` — `objects:
objectDef ? [objectDef] : []`. One object, so any `objectOverride` param
misses; and when the bar is driven by its `actions` prop,
`useMetadataItem('object', undefined)` leaves `objectDef` undefined and
the list is empty.
- `ObjectView` and `RecordDetailView` pass the full list and were never
affected.

### What the server serves, versus what the record form gets

Read in `/home/user/objectstack` (read-only; nothing was committed,
pushed or opened there):

- `packages/spec/src/ui/action.zod.ts` — `ActionParamSchema` is
`strictObject`, and its own docblock names the two declaration modes:
field-backed `{ field }`, and inline, where "a `lookup` /
`master_detail` param declared this way MUST name its target object via
`reference`". So for a **field-backed** param the server serves no
object binding *on the param at all*, and is not supposed to: the
binding lives on the object's field def, and the client already holds
it.
- `packages/spec/src/ui/action-params.zod.ts` — "Field-backed params are
resolved to their effective value-shape inputs by the CALLER (the
runtime, which holds the object metadata registry)". The client side of
that same contract is `resolveActionParams`, and its registry is
`useMetadata().objects`.
- `packages/spec/src/conversions/registry.ts`,
`field-reference-to-alias` — the serve path canonicalises a stored
`reference_to` to `reference` so objectui may delete its fallback arms.
That interlock is intact here: `normalizeSchemaReferenceKeys` still
stamps both spellings at ingestion in `MetadataProvider`, and a control
leg confirms a def carrying only `reference_to` resolves its target once
it has been through that door.

⇒ the object binding is on the client, in the same store the record form
reads. The action-param seam simply never asked the store for it. ⛔
Nothing in the server's contract had to change, and nothing upstream is
filed.

## The lit control

`legD` in the new pin file drives two paths in one run, against the same
field def:

- **record form** — the object's `contract_type` def handed to the
shared field widget the form renders. Clicking the trigger queries
`clm_contract_type` and offers its records. This is the card's "it works
there in this same build", measured here rather than quoted.
- **action param, through the console runtime** —
`useConsoleActionRuntime({ dataSource })`, `ConsoleShell`'s supply
verbatim, under a metadata store that has the object. Same outcome now:
a picker, a query naming `clm_contract_type`, and options offered.

With the supply half reverted, the second leg alone goes red and the
record-form control stays green — which is the difference the card asked
to be located.

## The change

1. **`useConsoleActionRuntime`** resolves field-backed params against
the caller's objects unioned with the metadata store's, **caller first**
(`withKnownObjects`, exported beside the resolver with its own
docblock). A draft or preview overlay the caller carries still wins on
any name it defines. The handler awaits `ensureType('object')` first —
it is already promise-returning by contract — so "this field does not
exist" is an answer and not a cold-boot race, which is what makes the
refusal below sound.
2. **`resolveActionParam`** stamps `unresolvedField` (the `OBJECT.FIELD`
pair, as a string) when it cannot read a field-backed param, and says so
in dev. The last-resort `text` shape stays — a partially cached console
must not crash — but it no longer travels anonymously.
3. **`ActionParamDialog`** refuses such a param: an alert naming the
pair in place of the control, a `console.error` per dialog opening **in
every build**, and a disabled Confirm so the action cannot be launched
with a value the dialog had no contract to collect. The refusal is
deliberately not gated on `required` — a param that cannot be read is a
broken declaration either way, and a silent optional one is the same
invisible failure a submit later.

`actionDialog.unresolvedParam` is added to all ten locale bundles. The
`OBJECT.FIELD` locator renders as its own node rather than an
interpolation, so an identifier is never re-ordered by a translation.

## Evidence

Pins:
`packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx`
— 13 passing, four legs, every leg with a control beside it.

Reverse verification. Each leg mutated one file on disk, proved the
mutation by blob hash (and for the surgical one, by an occurrence count
that had to move `1 -> 0`), ran the pins, then restored with `git
checkout HEAD -- path` and proved the restore by an empty `git diff
HEAD` — never by an exit code. A `trap … EXIT INT TERM` carried the
restore. Run from the committed fix, so the red and green numbers are
about committed bytes.

| ablation | result |
|---|---|
| revert `useConsoleActionRuntime.tsx` to the branch base | **1
failed**, 12 passed — only "the picker renders and QUERIES the
referenced object, from the supply ConsoleShell really uses" |
| revert `ActionParamDialog.tsx` to the branch base | **2 failed**, 11
passed — both refusal legs, `Unable to find an element by:
[data-testid="param-unresolved-contract_type"]` |
| delete only the `unresolvedField` emit from the resolver | **4
failed**, 9 passed — `expected undefined to be
'clm_contract.contract_type'`, plus both refusal legs |
| restored | 13 passed, `git diff HEAD` empty |

Local checks, all run from the repository root after the merge of
`origin/main` at `6cae9a0b3`, against `6d4f4da96`:

- `pnpm exec vitest run packages/app-shell/ packages/core/src/actions/
packages/i18n/` — `Test Files 837 passed (837)`, `Tests 8925 passed | 1
skipped (8926)`.
- `pnpm --filter @object-ui/core --filter @object-ui/app-shell --filter
@object-ui/i18n type-check` — exit 0, after `pnpm --filter
'@object-ui/app-shell^...' build`.
- `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check:new-line-citations`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:test-path-roots`,
`check:vi-mock-specifiers`, `check:vi-mock-inherit` — all exit 0.
- `node scripts/check-changeset-presence.mjs` and `node
scripts/check-changeset-no-major.mjs` — exit 0.
- `check:eager-locale-catalogues` and `check:eager-closure` — exit 0
after `pnpm --filter '@object-ui/console...' build`. The tenth locale
key adds nothing eager: the nine non-`en` catalogues stay on demand and
`en` stays the only resident one. ⛔ No ceiling in
`scripts/check-eager-closure-budget.mjs` was touched.

Lint, as a declared narrowing rather than a repo-wide run. `pnpm exec
eslint --no-inline-config` over the five changed `.ts`/`.tsx` sources:
exit 0, `0 errors`, 76 warnings, **0 of them new** — the two
`react-hooks/refs` warnings in the report are the pre-existing
`serverActionEnvRef` pair, not this diff's. The population is what
`eslint.config.js` itself configures, the file count is read off
`--format json` (5), and the narrowing is sound because this repo does
not enable type-aware linting: no `parserOptions.project` and no
`projectService` appear in `eslint.config.js`, so nothing in this diff
can move a verdict on a file it did not touch. The full repo-wide
`eslint . --no-inline-config` is CI's run, not this branch's.

## Acceptance notes

Out of scope, observed, ⛔ not filed and ⛔ not changed here:

- `RecordDetailView` builds a **second**, near-identical
`paramCollectionHandler` that does not go through the store union. It is
not a defect today — that view receives the full `objects` list as a
prop, which is why it was never in the reported path — but the two
handlers are documented as a pair that must not drift
(`RecordDetailView.paramRuntimeParity-6431.test.tsx`), and they now
differ by this union. Successor: whoever next edits either handler; the
pin file names both.
- `RuntimeField` in `resolveActionParams.ts` still declares
`reference_to`, which no read in that file consumes any more
(objectui#6837 narrowed the read to `reference`). Dead declaration, no
behavioural effect — ingestion canonicalises both spellings.

⛔ The card's two further dialog defects — no max-height / internal
scroll, and the file widget posting a browser blob — are each their own
card and neither is touched here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq

---
_Generated by [Claude
Code](https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants