@object-ui/plugin-detail 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +1115 -0
  2. package/README.md +61 -17
  3. package/dist/DetailSection.d.ts.map +1 -1
  4. package/dist/DetailView.d.ts.map +1 -1
  5. package/dist/HeaderHighlight.d.ts.map +1 -1
  6. package/dist/HistoryTimeline.d.ts.map +1 -1
  7. package/dist/InlineFieldInput.d.ts +78 -5
  8. package/dist/InlineFieldInput.d.ts.map +1 -1
  9. package/dist/RelatedList.d.ts +42 -1
  10. package/dist/RelatedList.d.ts.map +1 -1
  11. package/dist/autoLayout.d.ts.map +1 -1
  12. package/dist/fieldEnrichment.d.ts +61 -0
  13. package/dist/fieldEnrichment.d.ts.map +1 -1
  14. package/dist/index.d.ts +88 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2676 -2503
  17. package/dist/index.umd.cjs +3 -3
  18. package/dist/renderers/record-alert.d.ts +61 -11
  19. package/dist/renderers/record-alert.d.ts.map +1 -1
  20. package/dist/renderers/record-details.d.ts.map +1 -1
  21. package/dist/renderers/record-path.d.ts.map +1 -1
  22. package/dist/renderers/record-quick-actions.d.ts +7 -0
  23. package/dist/renderers/record-quick-actions.d.ts.map +1 -1
  24. package/dist/renderers/record-reference-rail.d.ts +2 -17
  25. package/dist/renderers/record-reference-rail.d.ts.map +1 -1
  26. package/dist/renderers/record-related-list.d.ts +29 -1
  27. package/dist/renderers/record-related-list.d.ts.map +1 -1
  28. package/dist/renderers/recordActivityFeed.d.ts +95 -8
  29. package/dist/renderers/recordActivityFeed.d.ts.map +1 -1
  30. package/dist/synth/buildDefaultPageSchema.d.ts +21 -9
  31. package/dist/synth/buildDefaultPageSchema.d.ts.map +1 -1
  32. package/dist/useDetailTranslation.d.ts +7 -0
  33. package/dist/useDetailTranslation.d.ts.map +1 -1
  34. package/dist/useRecordEditable.d.ts.map +1 -1
  35. package/package.json +20 -18
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1087 @@
1
1
  # @object-ui/plugin-detail
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 279fb13: `ComponentInput.type` can declare a UNION, so a block stops warning about legal
8
+ writes its own description recommends
9
+
10
+ A registration's `type` was one coarse control kind, while a good number of spec
11
+ keys accept more than one shape. A declaration therefore had to pick an arm, and
12
+ the repo's own manifest gate then reported `type-mismatch` on the other arm's
13
+ legal values. Four of the five measured cases were the loud shape: the input's
14
+ `description` teaches the author to write an inline translation map
15
+ (`{ en, "zh-CN", … }`) while the same input's `type: 'string'` made
16
+ `sdui-parser`'s `checkType` warn about exactly that map — one platform authority
17
+ contradicting itself on the write it had just recommended. Because these land at
18
+ warning severity the page still compiled and rendered; the cost is that noise on
19
+ correct authoring trains authors, AI authors included, to dismiss the
20
+ `unknown-prop` and `type-mismatch` reports that are real.
21
+
22
+ `type` now accepts an ARRAY of coarse kinds as well as a single one (maintainer
23
+ ruling on objectui#3832, direction (a)), and a value passes the coarse check when
24
+ ANY declared arm accepts it. Both declaration sites in `@object-ui/types` move
25
+ together with the registry's own copy in `@object-ui/core`, and
26
+ `ComponentInputSchema` enforces the same widening — a non-empty array of
27
+ DISTINCT kinds, so an empty arm list or a repeated arm is refused at authoring
28
+ time rather than normalized behind the author's back.
29
+
30
+ Five declarations now spell their real contract, and the `type-mismatch` warning
31
+ on each of these legal writes is gone:
32
+
33
+ - `page:header.title`, `page:header.subtitle`, `page:card.title` —
34
+ string **or** inline translation map (the spec's union, measured against
35
+ `ComponentPropsMap` at the pinned rc.6; the renderers resolve both through
36
+ `pickLocalized`);
37
+ - `record:alert.title`, `record:alert.body` — the same two shapes, justified
38
+ against the RENDERER since the pinned spec carries no `record:alert` props
39
+ schema;
40
+ - `element:text_input.defaultValue` — `string | number`, the spec's union,
41
+ which had been narrowed to `'string'` with the number arm named only in prose.
42
+
43
+ **Backward compatible, and measured as such.** The single-kind form stays valid
44
+ and is still the canonical spelling for a one-arm key: it validates identically
45
+ (the diagnostics for one arm, `invalid-enum` and its `error` severity included,
46
+ are byte-identical), and `manifestFromConfigs` collapses a one-element array back
47
+ to the bare string, so every entry already in a published `sdui.manifest.json`
48
+ serializes unchanged and arrays appear only where a union was really declared.
49
+ The JSX authoring surface follows in the same step — `generateDts` emits a
50
+ TypeScript union for a union input, so the `.d.ts` an author type-checks against
51
+ accepts exactly what the gate accepts.
52
+
53
+ A union widens what is legal; it does not switch the check off. A value matching
54
+ NO declared arm is still reported, a multi-arm mismatch reports at its strictest
55
+ arm's severity (`error` when an `enum` arm is present, so an enum's closed list
56
+ does not become dismissible by having a second arm added next to it), and arms
57
+ are meant to match the contract rather than relax the gate:
58
+ `element:text_input.defaultValue` deliberately gains no `object` arm because the
59
+ spec rejects a map there, and `element:record_picker.emptyText` keeps its single
60
+ `'string'` arm because that renderer drops the map form (objectui#4163) — an arm
61
+ the renderer never honours would advertise a shape that cannot reach the screen.
62
+ - 8b9dc62: `element:text.content` and `element:button.label` declare the inline translation
63
+ map they already accept
64
+
65
+ Two more instances of the contradiction objectui#3832 fixed the mechanism for,
66
+ measured after that ruling had fixed its scope at five specimens and filed
67
+ separately as objectui#4970. Both inputs' own `description` tells the author to
68
+ write an inline translation map (`{ en, "zh-CN", … }`), both renderers resolve one
69
+ through `pickLocalized`, and both spec props schemas accept one — while the
70
+ declaration said `type: 'string'`, so the manifest gate reported
71
+ `type-mismatch` on the exact shape the block had recommended. Both blocks are in
72
+ `PUBLIC_BLOCKS`, so this reached authors through `sdui.manifest.json` and
73
+ `sdui-intrinsics.d.ts` as well as the save gate.
74
+
75
+ Each declaration is now `type: ['string', 'object']`, the union form
76
+ objectui#3832 introduced, and the arms are the ones the contract accepts —
77
+ re-measured on the `@objectstack/spec` 17.0.0 GA pin rather than carried over
78
+ from the issue, which was written at the 17.0.0-rc.6 pin:
79
+ `ComponentPropsMap['element:text'].content` and
80
+ `ComponentPropsMap['element:button'].label` are both
81
+ `string | Record< string, string >`, and both refuse a number, a boolean and an
82
+ array. Those three refusals are the controls in the acceptance test, which is
83
+ what keeps a widening distinguishable from a silenced check.
84
+
85
+ Nothing else about the two blocks moves. A plain-string `content` / `label`
86
+ validates exactly as before, values matching neither arm are still reported, and
87
+ no other manifest entry changes shape — the public manifest now carries seven
88
+ array-valued input types, the five from objectui#3832 plus these two, with the
89
+ remaining 57 public blocks serializing byte for byte as they did.
90
+
91
+ `record:alert`'s renderer-local prop type is corrected in the same pass
92
+ (`plugin-detail`): its `title` / `body` were still typed `string` while the same
93
+ file resolves both through `pickLocalized` and the block's published `inputs`
94
+ have declared `['string', 'object']` since objectui#3832, so the two slots were
95
+ narrower than both the renderer and the block's own published surface. The type
96
+ is not exported, so no consumer was misled and no published surface changes. The
97
+ CTA's `action.label` one level down is left alone on purpose (objectui#4998):
98
+ `action` is published as a bare `object` whose member shape lives in prose, so
99
+ there are no declared arms for it to be aligned against yet.
100
+ - 98eab36: Publish the five `@objectstack/spec` 17.0.0 keys the renderers already honoured, so
101
+ authors can discover them
102
+
103
+ `page:header.maxVisible`, `page:header.mobileMaxVisible`, `page:tabs.alwaysShowStrip`,
104
+ `record:details.inlineEdit` and `record:details.showHeader` are declared by the spec and
105
+ read by the renderers today, and none of them was in its block's published `inputs`. That
106
+ is the direction nothing reports: `gen-manifest.ts` left all five out of
107
+ `sdui.manifest.json` and `sdui-intrinsics.d.ts`, so they were in no designer panel and no
108
+ generated type; `sdui-parser`'s prop walk reported `unknown-prop` on an author who wrote
109
+ one anyway; and the renderer honoured it regardless. Measured on the console's own
110
+ manifest before this change, all five drew
111
+
112
+ ```
113
+ unknown-prop: page:header has no prop "maxVisible"
114
+ unknown-prop: page:header has no prop "mobileMaxVisible"
115
+ unknown-prop: page:tabs has no prop "alwaysShowStrip"
116
+ unknown-prop: record:details has no prop "inlineEdit"
117
+ unknown-prop: record:details has no prop "showHeader"
118
+ ```
119
+
120
+ and now draw nothing. Same defect as `record:details.hideFields` in objectui#3808 and
121
+ `readonly` in objectui#3407; it could not land until the GA pin moved (objectui#4636),
122
+ because the pre-GA pin declared none of the five and publishing them would have failed the
123
+ repo-wide parity gate's forward direction.
124
+
125
+ Each entry carries a description, because for these keys the discoverability IS the fix.
126
+ Two are worth reading before use:
127
+
128
+ - `maxVisible` / `mobileMaxVisible` are positive integers — the contract rejects `0` and
129
+ fractional values — and they do not govern every action: an action declaring
130
+ `record_more` without `record_header`, and any action with `component: 'action:menu'`,
131
+ is routed to the overflow menu regardless of the budget.
132
+ - `inlineEdit` is an opt-OUT only. The value is combined with the object's own resolved
133
+ editability (ADR-0103) and with the server's effective API operation set, so `false`
134
+ always wins while `true` cannot open editing the platform refuses.
135
+
136
+ **`page:tabs` also gains a read.** `alwaysShowStrip` was honoured only as
137
+ `schema.properties.alwaysShowStrip`, while `inputs` publishes TOP-LEVEL keys — the shape
138
+ the manifest whitelists, the generated types declare and the JSX-page compiler validates.
139
+ Measured on a one-tab schema: the wrapped form showed the strip, the flat form did not, so
140
+ publishing the key alone would have advertised a write the renderer throws away. The
141
+ canonical top-level arm is read first now, with the `properties` arm kept for paths that
142
+ reach the renderer without `SchemaRenderer`'s hoist — the same dual read `maxVisible` has
143
+ always had. This can only ever ADD a strip to a one-tab page; multi-tab pages are
144
+ unaffected, and `false` and non-boolean values both read as "not set".
145
+
146
+ The five GA-pending entries that held this card's place in
147
+ `registry-inputs-spec-parity.test.ts` are deleted, which is what the gate's own
148
+ `carries no stale unpublished-key exemption` check demands once the keys are published.
149
+
150
+ ### Patch Changes
151
+
152
+ - 7c297e3: Retire `permission_change`, `export`, and `restore` from the audit-log action filter (`AuditLogPage`'s `ACTION_OPTIONS`) and badge maps (`AuditLogPage` and `HistoryTimeline`'s `ACTION_VARIANT`). These three values never had a writer anywhere on the platform, so the filter always returned zero rows for them and the badges never rendered — a visible product defect (audit surface should be narrow-but-honest, not broad-but-lying). `import`, `login`, and `config_change` are kept: `import` has a real writer (`plugin-auth`'s `admin-import-users.ts`) and is still declared by the server enum and filtered by the `config_changes` list view; `login`/`config_change` gained real writers in objectstack#8144/#8145.
153
+ - c1ef923: Grid and related-list column headers no longer offer a sort on a `formula` column.
154
+
155
+ A `formula` value is computed on read: no driver materialises a column for it, so
156
+ a server `$orderby` naming one has nothing to order by. That sort never worked.
157
+ Until objectstack#6994 the platform did not say so — the response carried the very
158
+ values it had been asked to order by, out of order, under a `200`, with ascending
159
+ and descending byte-identical on a real SQL driver — and it now answers
160
+ `400 INVALID_SORT`. So the header was wrong before the platform's refusal and is
161
+ wrong after it, for the same reason: it offers a sort that cannot be performed.
162
+
163
+ `ObjectGrid` withheld the affordance only from reference-bearing columns
164
+ (objectui#3096). Unmaterialized types are a SECOND reason a server sort is
165
+ impossible, not a different mechanism, so it now reads both — and so do the two
166
+ sort entry points of a related list (the embedded table's headers and the
167
+ sort-button row a `data-list` card keeps), which each derived that rule
168
+ separately.
169
+
170
+ Client-side sorting is deliberately unchanged. There the rows are all in the
171
+ browser and the formula value is the one the server hydrated on read, so ordering
172
+ by what the cell shows is honest — the same split the relational carve-out makes.
173
+ A sort DECLARED in view metadata is also unchanged: it still goes out and is still
174
+ refused by name, because silently dropping an author's declaration would hide the
175
+ authoring error instead of surfacing it (the toolbar's sort picker keeps such a
176
+ field listed for exactly that reason — it is the only way to remove it).
177
+
178
+ The membership — `formula` alone — moved out of a private set in `ListView` into
179
+ `@object-ui/core` (`UNMATERIALIZED_FIELD_TYPES` / `isUnmaterializedFieldType`),
180
+ bound to `@objectstack/spec`'s own storage predicate so the renderer cannot drift
181
+ from what the drivers actually store. It is deliberately narrower than the spec's
182
+ write contract `COMPUTED_VALUE_TYPES`: a `summary` and an `autonumber` each get a
183
+ real maintained column and sort correctly, and withholding their headers would
184
+ have broken two affordances that work.
185
+ - 5607092: objectui#4029 — the repo root now lints `no-console` (`error`, allowing
186
+ `warn`/`error`) so a stray module- or function-scope `console.log`/`info`/
187
+ `debug` fails CI instead of shipping silently (as `console.log('Registering
188
+ object-map...')` did in #7139, caught only by hand). Landing the rule meant
189
+ individually judging every real hit outside the tooling exemptions
190
+ (`scripts/**`, `**/examples/**`, test files, `packages/cli/src/**`,
191
+ `packages/create-plugin/src/**`) — this changeset covers the published
192
+ packages whose call sites changed:
193
+
194
+ - `@object-ui/app-shell`: `ObjectDataPage`'s dropped-URL-filter message is a
195
+ real diagnostic (data silently discarded), so it moves from `console.debug`
196
+ to `console.warn` to match the house convention.
197
+ - `@object-ui/plugin-detail`: `DetailView`'s Web Share API failure now reports
198
+ via `console.error` (it is a real failure, not debug noise); a redundant
199
+ "Link copied to clipboard" success log is removed.
200
+ - `@object-ui/fields`: `MasterDetailField`'s `handleView` stub no longer logs
201
+ the item it does nothing with.
202
+ - `@object-ui/runner`: `App`'s loader-selection debug prints, `LayoutRenderer`'s
203
+ unused click-handler stub log, and `MockDataSource`'s per-call narration
204
+ (`find`/`create`/`getObjectSchema`) are removed — none diagnosed a problem,
205
+ they only echoed the happy path.
206
+ - `object-ui` (VS Code extension): the "extension is now active!" activation
207
+ log is removed.
208
+
209
+ No behavior changes beyond console output. `@object-ui/core` and
210
+ `@object-ui/data-objectstack` also touch `no-console`-adjacent lines
211
+ (`debugLog`/`debugTime`/`debugTimeEnd`, `createQuietHttpLogger`) but only to
212
+ add `eslint-disable-next-line` documentation — those ARE the repo's
213
+ deliberate debug/logger infrastructure, not leaked residue, so their own
214
+ changeset carries empty frontmatter.
215
+ - 598c89a: The retired `owner` field-type spelling stops being blessed by the published contract, and inline edit refuses it the way the record form already does.
216
+
217
+ objectui#4814 retired `owner` as a field type (ruling A′): it was a synonym for
218
+ `user` with zero behavioral delta — both resolved to the same person-picker
219
+ widget — and it was never a member of `@objectstack/spec`'s closed `FieldType`,
220
+ so no object schema could ever declare it. `@object-ui/fields` now answers the
221
+ spelling with a visible tombstone refusal plus a console prescription. That PR
222
+ shrank the three public DOC unions; their CODE twins were left behind, so this
223
+ package spent the interval telling an author "legal" for a word the renderer
224
+ refuses.
225
+
226
+ **`@object-ui/types` — the three published twins shrink (objectui#4914 items 1-3).**
227
+ `ReportFieldSchema.type` (`zod/reports.zod.ts`) is a RUNTIME validator, so the
228
+ contradiction was executable, not merely advisory: a report document authored
229
+ with `type: 'owner'` validated green and then rendered a refusal. It now fails
230
+ validation, with the issue on the `type` path. Its TS twin `ReportField['type']`
231
+ and `UserFieldMetadata['type']` drop the member in the same batch, so published
232
+ `.d.ts` autocomplete stops offering it. This is an accept-set SHRINK on a
233
+ published validator and a narrowing of two published unions — patch-level
234
+ because the spelling it removes has had no working renderer since #4814, but
235
+ callers still passing `type: 'owner'` will now see a type error and a failed
236
+ parse. The record-owner idiom survives verbatim as
237
+ `{ type: 'user', name: 'owner' }`: the field NAME carries the ownership meaning,
238
+ the type carries the widget.
239
+
240
+ **`@object-ui/plugin-detail` — inline edit joins the tombstone (objectui#4914 item 5).**
241
+ `InlineFieldInput` routes by a STORED field's actual type, so a record whose
242
+ field is still typed `owner` was getting a working person picker inline while
243
+ the record form showed the refusal — two edit surfaces disagreeing about one
244
+ field, which is worse than either uniform outcome. A retired spelling now
245
+ renders the same `RetiredFieldTombstone` the form does, reported once per
246
+ spelling rather than once per row. The table is read live from
247
+ `@object-ui/fields`, so a future retirement is covered the day it lands.
248
+
249
+ Measured while implementing, and the reason the refusal is the load-bearing
250
+ half: simply deleting `owner` from the inline routing table would have changed
251
+ nothing an author could see. `hasFieldEditWidget('owner')` is still true — the
252
+ fields package maps `owner: UserField` in `EDIT_WIDGETS` — so the type would
253
+ have reached the same picker down the delegation road instead of the routing
254
+ road. That residual face is outside this change's scope and is filed separately.
255
+ - 7dd93c0: `record:quick_actions` reads the toolbar's accessible name under the spelling the ARIA contract accepts, and stops advertising an action fallback it never had.
256
+
257
+ Two producer-side defects in the same component (objectui#4663), found while
258
+ objectstack#8744 measured this renderer's read points.
259
+
260
+ **The `aria` read point was dead in both directions.** The toolbar read
261
+ `schema.aria?.label` and nothing else — the ONE spelling `@objectstack/spec`'s
262
+ `AriaPropsSchema` refuses. On that closed shape `label` is an ALIAS ENTRY: a
263
+ rename prescription pointing at `ariaLabel`, there to produce a better rejection
264
+ message, never accepted (measured: `safeParse({ label })` returns
265
+ `unrecognized_keys` naming `label`, while `safeParse({ ariaLabel })` passes). So
266
+ a spec-valid `aria: { ariaLabel: 'Account actions' }` reached the renderer and
267
+ was read by nothing — the built-in "Quick actions" default won every time — and
268
+ the spelling that did work was one no author can write without the contract
269
+ rejecting the document. `SchemaRenderer`'s generic ARIA channel was no escape
270
+ hatch either: it reads the FLAT `schema.ariaLabel` and injects `aria-label` as a
271
+ component prop, which this renderer drops along with every other non-designer
272
+ prop.
273
+
274
+ The read is now `(aria.ariaLabel ?? aria.label) || 'Quick actions'`. The legacy
275
+ leg is back-compat only, for documents stored before the contract closed;
276
+ canonical wins when both are present. Both halves follow how the repo already
277
+ handles this key: `normalizeListViewSchema`'s aria fold copies the legacy key
278
+ across only when the canonical one is `undefined` (so a declared `ariaLabel: ''`
279
+ shadows a stale `label`), and `ListView`'s own read point treats an empty string
280
+ as no accessible name at all — which here resolves to the built-in default,
281
+ since `role="toolbar"` needs a name.
282
+
283
+ **The `actionNames` description promised a fallback that exists on no path.** It
284
+ read "(else every action declared for the object at this location)". Measured:
285
+ with no `actionNames` and no host-supplied `actions`, `namesToResolve` is empty,
286
+ `needsLookup` is false, the object metadata is never queried, and the bar renders
287
+ its dashed placeholder. The registry `inputs` are published — they are serialized
288
+ into `sdui.manifest.json` and the JSX authoring types, and Studio teaches authors
289
+ from them — so the promise reached tooling; objectstack#8744's dispatch prompt
290
+ quoted it verbatim as a declared input. Per the triage ruling the sentence is
291
+ what changes: implementing the fallback would be a behaviour expansion and needs
292
+ its own card. No runtime behaviour changes with it, and a regression test now
293
+ drives a LOADED metadata provider to prove the object's declared actions really
294
+ are not pulled in (with a control proving the same wiring delivers them the
295
+ moment a name asks).
296
+ - 229b17e: 修复相关列表(`RelatedList`)以 spec 规范拼写 `field` 声明的对象列只出表头、单元格全空的问题(objectui#5022)。
297
+
298
+ `RelatedList` 的所有身份读点都经由 `columnIdentity` 规范优先解析,因此
299
+ `{ field: 'status', label: 'Status' }` 能通过 FLS 过滤、外键过滤、空列裁剪与排序判定;而它喂给的
300
+ `data-table` 只按 `col.accessorKey || col.name` 归一化访问键,从不读 `field`,于是每个单元格取的是
301
+ `row[undefined]`,渲染成空值占位符 —— 与 objectui#3951 同形,只差一种拼写。
302
+
303
+ 现在 `normalizeColumn` 在把对象列交给表格前,把解析出的身份写回 `accessorKey`:
304
+ 词表解析留在 `RelatedList` 一侧,不并入表格适配器(`column-identity.ts` 有意画下的
305
+ `TABLE_ADAPTER_COLUMN_KEY` 边界)。作者已显式声明的 `accessorKey` 不被覆盖,原有拼写一并保留,
306
+ 无法解析出身份的条目原样返回。legacy `name` 拼写、字符串列与显式 `accessorKey` 列行为不变。
307
+
308
+ 同一处身份现在同时供给单元格取数与表头排序 —— 此前 `field` 列的表头排序派发的是
309
+ `undefined` 字段(被 `RelatedList` 丢弃),该列在取数与排序两个方向上同时失效。
310
+ - dfc6975: Related-list "+ New" now honours `userActions.create` predicates, and the grid
311
+ toolbar's inline-edit affordance is gated on `update` permission (objectui#4646,
312
+ objectui#4647).
313
+
314
+ Two declared-but-unenforced gaps on the same toolbar surface.
315
+
316
+ **#4646 — `createPredicates` had a producer and no consumer.**
317
+ `@objectstack/spec@17.0.0` widened `userActions.create` to
318
+ `z.union([z.boolean(), RowCrudActionOverrideSchema])`, so `resolveCrudAffordances`
319
+ emits `createPredicates` — and nothing in objectui read them, against roughly
320
+ fifteen consumption sites apiece for `editPredicates` / `deletePredicates`. The
321
+ symptom: a parent record entering a frozen state correctly greyed its children's
322
+ row Edit/Delete while the related list's "+ New" stayed fully live, so the user
323
+ filled in the whole child form to earn a server 409. The related-list toolbar now
324
+ evaluates `visibleWhen` / `disabledWhen` **once against the host parent record**,
325
+ per the spec docblock's binding for this key, on top of the existing
326
+ `o.create ∧ can(child, 'create')` check. `visibleWhen` hides "+ New" and fails
327
+ CLOSED; `disabledWhen` greys it and fails SOFT — the same evaluator, fail
328
+ directions and hidden-vs-disabled split the record header already uses for
329
+ edit/delete (objectui#4419 / PR #4515). A bare-boolean `userActions.create` is
330
+ untouched: with no predicates there is nothing to evaluate.
331
+
332
+ **#4647 — the inline-edit toggle was the one ungated affordance on its toolbar.**
333
+ It rendered on "grid view ∧ the host wired `onInlineEditChange` ∧ not the compact
334
+ toolbar", and every host wires that callback unconditionally. New and Import are
335
+ hidden for an account without the grant and the bulk-delete entry on the same
336
+ toolbar ANDs `can(obj, 'delete')`, but a read-only principal could flip inline
337
+ edit, modify cells and press "Save all" to earn a server 403. It is now gated on
338
+ the object's resolved edit affordance ∧ `can(object, 'update')`, mirroring that
339
+ bulk-delete gate. The gate is applied at all three sites that carry this
340
+ affordance — the wide toolbar's toggle, the compact toolbar's settings-popover
341
+ entry (which previously had no gate at all, not even the callback), and the
342
+ `editable` mode handed to the grid, so a stored view carrying `inlineEdit: true`
343
+ can no longer drop a read-only principal into editable cells with no toggle to
344
+ press.
345
+
346
+ `ListViewSchema.userActions.editInline` is also consumed now: an explicit `false`
347
+ withholds the affordance wholesale, which authors previously could not do.
348
+
349
+ **Behaviour change for read-only users, stated plainly.** Where the UI used to
350
+ offer inline editing and let the server refuse it, it now declines to offer the
351
+ entry point at all. No data access changes — the server gate was and remains the
352
+ enforcement boundary; this only stops the UI walking users into round-trips
353
+ guaranteed to fail. Accounts *with* the grant see no change, and hosts with no
354
+ `PermissionProvider` mounted (standalone embeds, the Studio designer) keep
355
+ today's behaviour, since `can()` answers `true` there by design.
356
+
357
+ One deliberate non-change: the absent case of `userActions.editInline` defers to
358
+ the host's existing `inlineEdit` channel rather than enforcing the spec's
359
+ `.default(false)`. Enforcing that default would remove the toggle from every
360
+ stored console list view in one release, since nothing folds a legacy key into
361
+ `editInline` and no existing view declares it. This follows the rule the
362
+ surrounding toolbar-flag block already states for itself — defaults chosen to
363
+ match what the flags have always done. `InterfaceListPage`, the key's other
364
+ consumer, reads the absent case as OFF, because the ADR-0047 interface page has
365
+ no such host channel to defer to.
366
+ - Updated dependencies [88085e3]
367
+ - Updated dependencies [69251bf]
368
+ - Updated dependencies [57e668f]
369
+ - Updated dependencies [516663d]
370
+ - Updated dependencies [41ac1b7]
371
+ - Updated dependencies [1eaf0a1]
372
+ - Updated dependencies [a09bc33]
373
+ - Updated dependencies [460c4d0]
374
+ - Updated dependencies [0ae27f7]
375
+ - Updated dependencies [2533ec5]
376
+ - Updated dependencies [78c0f9a]
377
+ - Updated dependencies [bbe8b86]
378
+ - Updated dependencies [8477be5]
379
+ - Updated dependencies [279fb13]
380
+ - Updated dependencies [2e82ab2]
381
+ - Updated dependencies [ad07b65]
382
+ - Updated dependencies [41f498b]
383
+ - Updated dependencies [ef0d150]
384
+ - Updated dependencies [f34226e]
385
+ - Updated dependencies [564b605]
386
+ - Updated dependencies [e1d4251]
387
+ - Updated dependencies [40d3a33]
388
+ - Updated dependencies [8b9dc62]
389
+ - Updated dependencies [1184192]
390
+ - Updated dependencies [a2a9747]
391
+ - Updated dependencies [65e88e6]
392
+ - Updated dependencies [a1609a6]
393
+ - Updated dependencies [53f23bc]
394
+ - Updated dependencies [c4533dc]
395
+ - Updated dependencies [be60815]
396
+ - Updated dependencies [37f6844]
397
+ - Updated dependencies [93de4f6]
398
+ - Updated dependencies [2b50261]
399
+ - Updated dependencies [384f30d]
400
+ - Updated dependencies [ac600e5]
401
+ - Updated dependencies [97fba31]
402
+ - Updated dependencies [232f61a]
403
+ - Updated dependencies [d374caf]
404
+ - Updated dependencies [5673576]
405
+ - Updated dependencies [c1ef923]
406
+ - Updated dependencies [911ceaa]
407
+ - Updated dependencies [98eab36]
408
+ - Updated dependencies [af5e292]
409
+ - Updated dependencies [3fbbea1]
410
+ - Updated dependencies [0bffb18]
411
+ - Updated dependencies [800f455]
412
+ - Updated dependencies [5458414]
413
+ - Updated dependencies [3241559]
414
+ - Updated dependencies [7f96b10]
415
+ - Updated dependencies [167ec42]
416
+ - Updated dependencies [616a2a5]
417
+ - Updated dependencies [6c68b13]
418
+ - Updated dependencies [0046d8f]
419
+ - Updated dependencies [f1d4748]
420
+ - Updated dependencies [bea374e]
421
+ - Updated dependencies [b1119ec]
422
+ - Updated dependencies [5607092]
423
+ - Updated dependencies [9f23d2b]
424
+ - Updated dependencies [578e025]
425
+ - Updated dependencies [af025ee]
426
+ - Updated dependencies [d109a4d]
427
+ - Updated dependencies [598c89a]
428
+ - Updated dependencies [4a0bd17]
429
+ - Updated dependencies [b8b9af4]
430
+ - Updated dependencies [d8b9259]
431
+ - Updated dependencies [31676be]
432
+ - Updated dependencies [8c0d52e]
433
+ - Updated dependencies [aff10e2]
434
+ - Updated dependencies [70a774b]
435
+ - Updated dependencies [9ce096f]
436
+ - Updated dependencies [e05db88]
437
+ - Updated dependencies [7458a41]
438
+ - Updated dependencies [ad13d63]
439
+ - Updated dependencies [5ffcc14]
440
+ - Updated dependencies [d971e51]
441
+ - Updated dependencies [97abb24]
442
+ - Updated dependencies [deb157a]
443
+ - Updated dependencies [9c60144]
444
+ - Updated dependencies [e7747f1]
445
+ - Updated dependencies [d2ce342]
446
+ - Updated dependencies [9695da7]
447
+ - Updated dependencies [ac2f332]
448
+ - Updated dependencies [a777058]
449
+ - Updated dependencies [75444e3]
450
+ - Updated dependencies [58b8346]
451
+ - Updated dependencies [2d0bd16]
452
+ - Updated dependencies [a9e17b4]
453
+ - Updated dependencies [b8ce7dc]
454
+ - Updated dependencies [dad51e5]
455
+ - Updated dependencies [1c9c342]
456
+ - Updated dependencies [787c738]
457
+ - Updated dependencies [8396656]
458
+ - Updated dependencies [dbbd38a]
459
+ - Updated dependencies [61556dc]
460
+ - Updated dependencies [8871c14]
461
+ - Updated dependencies [93fe362]
462
+ - Updated dependencies [dfc6975]
463
+ - Updated dependencies [3cf4de0]
464
+ - Updated dependencies [c9dc811]
465
+ - Updated dependencies [144ef9b]
466
+ - Updated dependencies [138ab04]
467
+ - Updated dependencies [a0b9e91]
468
+ - Updated dependencies [99bd015]
469
+ - Updated dependencies [21e4585]
470
+ - @object-ui/types@17.6.0
471
+ - @object-ui/fields@17.6.0
472
+ - @object-ui/i18n@17.6.0
473
+ - @object-ui/react@17.6.0
474
+ - @object-ui/components@17.6.0
475
+ - @object-ui/core@17.6.0
476
+ - @object-ui/permissions@17.6.0
477
+
478
+ ## 17.5.0
479
+
480
+ ### Patch Changes
481
+
482
+ - ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
483
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
484
+
485
+ A TranslationBundle entry for an action carries three keys under one
486
+ `_actions.<name>` node — `label`, `confirmText`, `successMessage` — and
487
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
488
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
489
+ and the related-list row menu resolved the button `label` only and dispatched
490
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
491
+ two fates: the button rendered the translation, the confirm dialog rendered the
492
+ authored English.
493
+
494
+ All action-rendering surfaces now go through one resolver,
495
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
496
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
497
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
498
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
499
+ introduce a `confirmText` or `successMessage` the metadata never declared.
500
+
501
+ - 6d01319: Inline edit no longer offers a record picker for a spec-spelled `autonumber` field that carries a `reference_to`
502
+
503
+ `TEXTUAL_REF_FALLBACK_TYPES` — the detail page's one definition of "machine-computed" — spelled the auto-number type `auto_number` only. `@objectstack/spec`, the designer and the metadata importer all spell it `autonumber`, and the set is matched by RAW spelling, so it carried half the type.
504
+
505
+ The reader that had no gate in front of it is `InlineFieldInput`'s reference fallback, `!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`, on exported public API. A field typed `autonumber` keeps a `reference_to` for relational metadata — which is the entire reason this set exists — so it took the lookup branch and rendered the RECORD PICKER: a searchable list of records offered as replacements for a machine-generated identity. The `auto_number` spelling of the identical field rendered the textual fallback, as intended. Both spellings are now members, matching how `plugin-form` carries both in each of its non-input sets.
506
+
507
+ The editability half of the same report (objectui#4219) was already closed from another direction by #4228, whose shared exclusion resolves aliases before matching — a field typed `autonumber` offers no inline affordance in either host. The two gates are a union, so this fix also removes the union's dependence on which spelling the metadata happens to use: previously `autonumber` was held by the exclusion gate alone and `auto_number` by both, and losing either gate would have re-opened a different half of the defect depending on how the field was authored.
508
+
509
+ Pins land with it: the reference fallback for `autonumber` (red before this change — the picker really did render), `auto_number` and a real `lookup` as controls in both directions, and set membership asserted directly so the union statement is checked rather than described.
510
+
511
+ - 63fe8fd: `record:related_list` and the detail synthesizer now declare two shapes they already accepted at runtime.
512
+
513
+ `RecordRelatedListRenderer`'s `schema` prop made `objectName` required, which rejected the exact authoring shape the per-element `dataSource` binding exists to support (`{ relationshipField, dataSource: { object, view } }`) — the gate maps the binding onto `objectName` before the body reads it, so the key is supplied, not missing. It is optional on the wrapper's input now, and required everywhere else.
514
+
515
+ `ObjectDefLike.fieldGroups` is derived from the spec's authorable field group instead of restating it. The hand-written list had drifted: it omitted `icon` and `description`, both of which the synthesizer passes through to detail section descriptors, so an object definition declaring the group icon the code honours did not type-check against it.
516
+
517
+ - 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
518
+
519
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
520
+
521
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
522
+
523
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
524
+
525
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
526
+
527
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
528
+
529
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
530
+
531
+ No behavior, no keys added or removed, no placeholder changed.
532
+
533
+ - 6314e87: Inline-editing an `address` on the record detail page now edits it as real sub-fields, instead of collapsing it to one text box reading `[Object]` and saving a string over the structured value.
534
+
535
+ `InlineFieldInput`'s type switch routed the scalar and relational families to their dedicated widgets; every structured-object type matched nothing and fell through to the terminal raw text input at the end of the component. That fallback stringifies an object value through `coerceToSafeValue`, whose general-object case extracts `name || label || externalId || id || _id` and otherwise returns the literal `[Object]`. A stored address carries none of those keys, so the edit box read `[Object]`.
536
+
537
+ The display half was cosmetic; the write half was not. The fallback is a plain input wired to `onChange(v)`, so whatever the user typed was emitted as a **string** that replaced the whole `{ street, city, state, postalCode, country }` object on save — and `[Object]` was what the user saw as the current value they were correcting, which makes typing over it the natural gesture. An ordinary double-click inline edit therefore destroyed the sub-field structure. This is the input path only: objectui#4037 fixed the display registry, and read mode (including the inline-edit read state before editing starts) already rendered a formatted address.
538
+
539
+ `location` and `geolocation` are fixed with it. Both store objects too (`{ latitude, longitude }`), both reached the same terminal input, and both produced the identical `[Object]`-then-overwrite pair — one defect in three spellings, not three defects.
540
+
541
+ No new editor was written and no consumer-side tolerance was added. All three route to the widgets the create/edit dialog already uses (`AddressField` / `LocationField` / `GeolocationField`, the form's own structured-value editors), so the two entry points cannot diverge on the value shape they write back, and `coerceToSafeValue` is left untouched — the routing is what stops an address from ever reaching it. `autoFocus` follows the numeric branches' convention and lands on each widget's first sub-input (street / the coordinate box / latitude).
542
+
543
+ String-valued types are unchanged: `text`, `textarea`, `email`, `phone`, `url`, `color`, `code`, `time`, `qrcode` and the rest keep the terminal text input, where stringification is the identity and nothing is lost.
544
+
545
+ - 5e2e9fa: A `password` or `secret` field on the record detail page is no longer inline-editable: it renders no pencil / double-click affordance and produces no editor, on both the details body and the highlights strip.
546
+
547
+ Both types are **masked on read** — `getCellRenderer` returns a fixed bullet run for either — so the value the row could hand an editor was never the credential. `InlineFieldInput` had no branch for either type, so both reached the terminal raw text input at the end of the component, and the row's payload value was seeded into a `type="text"` box: rendered in clear, selectable and copyable, in a control the user reads as holding their credential. Committing the row then wrote that placeholder back verbatim over the field. For `secret` the overwritten value is an opaque reference into an encrypted store (ADR-0100), so the write destroyed the pointer, not just the display. Nothing in the flow said so; the failure surfaced later, wherever that credential was used.
548
+
549
+ The decision already existed one package over. `INLINE_EXCLUDED_FIELD_TYPES` in `@object-ui/fields` excludes both types with exactly this reasoning, and the grid honours it through `isInlineExcludedFieldType()`. The detail hosts gated on readonly / computed / system only and never consulted it, so the detail page reproduced the precise failure the set exists to prevent. Both hosts now consult that same alias-aware contract (`isInlineExcludedDetailFieldType`, a narrow-only union of the authored and the object type, matching the computed gate under objectui#3355) rather than growing a second hand-maintained list — so the rule cannot drift between the grid and the detail page again.
550
+
551
+ Consulting the shared set closes the container family on the detail page with it: `object`, `composite`, `record`, `grid`, `repeater` and `vector` rode the same plain-text fallback with an object-shaped value, and are now excluded too. The spec spelling `autonumber` is likewise excluded, where the detail computed gate only knew the `auto_number` spelling. The heavy-editor family (`markdown`, `html`, `richtext`) loses its one-line text box on the detail page — those are authored in the record form, which has the real editors.
552
+
553
+ The binary/attachment family is deliberately exempt and keeps its detail editor. It is in the shared set for a grid-cell reason — a cell cannot host an upload dropzone — while `InlineFieldInput` routes `image` / `avatar` / `signature` / `file` (and the `video` / `audio` spellings) to the same upload widgets the record form uses. That exemption is pinned against the routing it claims, so it cannot outlive it.
554
+
555
+ Re-authoring a credential is unchanged and still belongs in the record form, which has the widget for it (`PasswordField`).
556
+
557
+ - 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
558
+
559
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
560
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
561
+ pack value always wins, so the inline string is dead code — and 43 of those dead
562
+ strings said something different from the sentence users actually read.
563
+
564
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
565
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
566
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
567
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
568
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
569
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
570
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
571
+ which keeps reporting it alone.
572
+
573
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
574
+ values are what users read today, and changing one would oblige the same change in
575
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
576
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
577
+ is how they survived three i18n gates that are each blind to this class by
578
+ construction.
579
+
580
+ The visible effect is confined to hosts that render these components with **no**
581
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
582
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
583
+ one; it now matches what a provider-backed app has always shown. Inside the
584
+ console — provider mounted — nothing users see changes. The clearest converging
585
+ examples: the workspaces screen was written as "Organizations" at nine call sites
586
+ while every user has been reading "Workspaces"; the forgot-password success line
587
+ was written as "If an account exists, a reset link has been sent." while the pack
588
+ asserts "We've sent a password reset link to {{email}}."
589
+
590
+ - e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
591
+
592
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
593
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
594
+ and written back as whatever the user typed — a bare string.
595
+
596
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
597
+ `checkboxes`, an options-less multi picklist) were offered for editing as
598
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
599
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
600
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
601
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
602
+ value its option list never offered.
603
+
604
+ Types the switch already routes keep their editors. Everything else that the
605
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
606
+ control the form renders, `json` → the code editor included — and only genuinely
607
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
608
+ input. A drift guard asserts every field type is exactly one of routed /
609
+ excluded / delegated / benign, so a new type can no longer inherit the
610
+ value-destroying default in silence.
611
+
612
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
613
+ value when the field declares no `options` at all. An empty offered set had two
614
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
615
+ authored (nothing to decide) — and the second deleted the value on mount, which
616
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
617
+ also forwards `autoFocus` to the widget it renders.
618
+
619
+ - e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
620
+
621
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
622
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
623
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
624
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
625
+
626
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
627
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
628
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
629
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
630
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
631
+
632
+ - 456aac8: `@object-ui/plugin-detail` now declares `react-router-dom` as a peer dependency (`^6.0.0 || ^7.0.0`), the range its three siblings already use.
633
+
634
+ It has been importing the router all along — `PermissionFacetLink.tsx` and `record-reference-rail.tsx` both take `Link` and `useParams` from it — while its manifest named it in no field at all. That resolved locally for a reason that does not travel: the workspace root declares `react-router-dom` in its own `devDependencies`, so a `node_modules/react-router-dom` symlink exists at the root of this repository and Node's upward directory walk reaches it from every package directory. A consumer's install has no such root, and this package's rollup config externalises every bare specifier, so the published `dist/index.js` carried an import of a package the manifest never asked for.
635
+
636
+ Consumers already installing `@object-ui/app-shell`, `@object-ui/layout` or `@object-ui/plugin-designer` were unaffected — all three declare the same peer — so this closes the case of a consumer that pulls `plugin-detail` on its own.
637
+
638
+ A new repository gate, `pnpm check:phantom-deps`, now asserts that every bare specifier a released package imports under `src/` is declared by that package rather than merely resolvable from it, so the next one of these fails on the pull request that introduces it (objectui#4394).
639
+
640
+ - 7d04b0e: `record:details` stops publishing a `layout` key the spec removed and the renderer never honoured
641
+
642
+ `record:details` declared `layout: enum ['auto','custom']` with `defaultValue: 'auto'` and the description "auto uses the object highlightFields; custom uses explicit sections". None of that was ever implemented. The renderer's only `schema.layout` read tested `'inline'` | `'compact'` — two values the schema never permitted — so both legal values fell through the same ternary and the key selected nothing. `auto` and `custom` have behaved identically for as long as both have existed.
643
+
644
+ Two directions were wrong with zero diagnostics: `layout: 'auto'` plus explicit `sections` still rendered the sections, and `layout: 'custom'` with no sections silently fell back to the flat body rather than reporting the missing groups. Because the input carried a `defaultValue`, this was not stale documentation — it was the manifest, the generated `sdui-intrinsics.d.ts` and the designer panel actively offering the key. An AI author writing `layout: 'custom'` believed it took effect.
645
+
646
+ `@objectstack/spec` 17.0.0 removed the property (objectstack#6946, ADR-0087 D2); `17.0.0-rc.6` is pinned here, so the key is already rejected on parse with a named migration message pointing at `os migrate meta --from 16`. This release completes the objectui half of that retirement: the input declaration is gone, and so is the dead `inline`/`compact` branch — the synthesized layout is now the constant it always resolved to.
647
+
648
+ Nothing that worked stops working. The body-source contract is unchanged and is now the only one declared: **`sections` renders the explicit groups; omitting it falls back to the flat body derived from the object's fields.** That is pinned in both directions, plus the empty-array boundary between them, in `recordDetailsBodySource.test.tsx`.
649
+
650
+ One gate got sharper on the way through. The parity test's "declares no top-level input the spec does not accept" check read raw `.shape` keys — but an ADR-0087 D2 tombstone stays _in_ the shape as a `z.never()`, so a retired key still answers "is this declared?" with yes. That is precisely why this input survived the rc.6 pin bump with every derived gate green. The check now filters tombstoned members out, so it catches the next D2 retirement instead of waving it through.
651
+
652
+ - c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
653
+
654
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
655
+
656
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
657
+
658
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
659
+
660
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
661
+
662
+ - 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
663
+
664
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record<string, any>`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps<typeof SchemaRenderer>` was `string` and `ComponentProps<typeof SchemaRenderer>['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — `<SchemaRenderer />` with no schema at all, `<SchemaRenderer schema={12345} />`, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record<string, any>` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
665
+
666
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
667
+
668
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent<Omit<{ schema: SchemaNode } & Record<string, any>, "ref"> & RefAttributes<any>>` becomes `ForwardRefExoticComponent<SchemaRendererProps & Record<string, any> & RefAttributes<any>>`.
669
+
670
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
671
+
672
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
673
+
674
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record<string, any>`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
675
+
676
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record<string, …>` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
677
+
678
+ - 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
679
+
680
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
681
+
682
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
683
+
684
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
685
+
686
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
687
+
688
+ - dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
689
+
690
+ `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
691
+
692
+ Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
693
+
694
+ - 35997ce: fix(plugin-detail): synthesize page components in the spec's `properties` carrier so Studio page-create can persist
695
+
696
+ Creating a page in Studio never completed. The create path seeds a record
697
+ page's `regions` from `buildDefaultPageSchema(objectDef)` and PUTs the result,
698
+ and every node that synthesizer emitted carried its widget props at the TOP
699
+ level of the component — `{ type: 'page:header', recordChrome: true }`,
700
+ `{ type: 'page:tabs', items: [...] }`, and the same for `record:highlights`,
701
+ `record:path`, `record:details`, `record:related_list`, `record:history` and
702
+ `record:reference_rail`. ADR-0089 D3a closed `PageComponentSchema` with
703
+ `.strict()`, so those keys are not stripped, they are a parse error
704
+ (`Unrecognized key(s) on this view/page schema: 'recordChrome', 'actions'`).
705
+ The server refused the body and no page row was ever stored.
706
+
707
+ The props now go where the spec declares them — the node's `properties` bag,
708
+ which is where `ComponentPropsMap` defines `page:header.recordChrome` and
709
+ `page:tabs.items` in the first place. Nothing is dropped and nothing changes on
710
+ screen: a header still defaults to record chrome ON, an author's
711
+ `recordChrome: false` is still carried (and now actually persists), the tabs
712
+ keep their items, and `SchemaRenderer` hoists `properties` back onto the node
713
+ before dispatch, so every renderer receives exactly the props it did before.
714
+
715
+ One code path does the wrapping for every node the synthesizer builds, so there
716
+ is a single answer to "what may go in a page write". Slot overrides are
717
+ untouched — a node handed in by a caller is still placed verbatim.
718
+
719
+ - b388950: fix(plugin-detail): the record detail header honors `userActions` predicates
720
+
721
+ `userActions.delete` reached the record detail header in its **boolean** form
722
+ but not in its **predicate** form. `userActions: { delete: false }` removed
723
+ Delete from the row kebab, the selection bar and the detail header, because the
724
+ host lowers the boolean into `schema.showDelete`. `userActions: { delete: {
725
+ visibleWhen: … } }` reached only the row kebab: the header ANDed
726
+ `schema.showDelete ∧ objectAllowsDelete ∧ canDeleteRecord` and never evaluated
727
+ the predicate, so an author upgrading from "nobody may delete this object" to
728
+ "these records may not be deleted" silently lost the surface they were most
729
+ likely to have tested on.
730
+
731
+ The header now folds the per-record predicates in as a fourth conjunct,
732
+ evaluated against the open record through the same helper family the row
733
+ surfaces use — `userActionPredicates` from `@object-ui/core` for the parse (the
734
+ import `RelatedList` already makes) and `useRowPredicate` from
735
+ `@object-ui/react` for the evaluation:
736
+
737
+ - `delete.visibleWhen` / `edit.visibleWhen` false → the header affordance is
738
+ hidden (fails closed; `visibleWhen: false` counts as a declared gate).
739
+ - `delete.disabledWhen` / `edit.disabledWhen` true → the header affordance
740
+ renders disabled rather than disappearing, matching the row kebab.
741
+
742
+ The existing permission and record-writability gates are unchanged, so a
743
+ predicate that holds can never resurrect a button the user may not press. The
744
+ boolean form stays the host's channel — a bare boolean yields no predicate.
745
+
746
+ - Updated dependencies [0e67b53]
747
+ - Updated dependencies [932cbcd]
748
+ - Updated dependencies [734d186]
749
+ - Updated dependencies [3fc2971]
750
+ - Updated dependencies [f7c6430]
751
+ - Updated dependencies [ae10a01]
752
+ - Updated dependencies [92876f0]
753
+ - Updated dependencies [828549a]
754
+ - Updated dependencies [e1ade8f]
755
+ - Updated dependencies [3e19fe7]
756
+ - Updated dependencies [bb58d1d]
757
+ - Updated dependencies [5cc847c]
758
+ - Updated dependencies [fa21254]
759
+ - Updated dependencies [33c32bf]
760
+ - Updated dependencies [66fb4fa]
761
+ - Updated dependencies [6d641c9]
762
+ - Updated dependencies [45e1949]
763
+ - Updated dependencies [92250d6]
764
+ - Updated dependencies [58bebf6]
765
+ - Updated dependencies [405e808]
766
+ - Updated dependencies [c0f9a4b]
767
+ - Updated dependencies [ac853ce]
768
+ - Updated dependencies [fa51109]
769
+ - Updated dependencies [d46f9b8]
770
+ - Updated dependencies [2fea4d2]
771
+ - Updated dependencies [7f1cb33]
772
+ - Updated dependencies [2e3b0c0]
773
+ - Updated dependencies [78fa331]
774
+ - Updated dependencies [31ab1ac]
775
+ - Updated dependencies [0082db8]
776
+ - Updated dependencies [06915b0]
777
+ - Updated dependencies [ff84b05]
778
+ - @object-ui/i18n@17.5.0
779
+
780
+ ## 17.4.0
781
+
782
+ ### Minor Changes
783
+
784
+ - ecae400: Retire the `capability-multiselect` field widget name, which existed only on the docs-site registration path and which nothing ever stamped (objectui#3308, ADR-0049 enforce-or-remove)
785
+
786
+ `field:capability-multiselect` was registered by `registerFields()` and only there. That function's sole caller is the docs site, so the key never existed on the live path (`registerAllFields()`, run at module import, iterates `fieldWidgetMap` — which never listed it). A field authored with `widget: 'capability-multiselect'` therefore resolved to nothing in every real application, while the comment above the registration described it as usable from a record form: a code comment promising a capability that does not exist, which is the worst direction for a metadata renderer AI-authored apps read as authority.
787
+
788
+ Nothing stamped the hint either. ADR-0056 P1 stamps `permission-facet-link` on all six `sys_permission_set` facets — `system_permissions` included — through the single `ObjectStackAdapter.getObjectSchema` choke point, and P2 put the capability editor in Studio. The widget name was a leftover from an intermediate iteration of that rollout.
789
+
790
+ Removed, with a tombstone at each site:
791
+
792
+ - `@object-ui/fields` — the `field:capability-multiselect` registration and the comment that advertised it. **Breaking in name only**: the key was unreachable outside the docs site, so no application could have resolved it. A field still carrying the hint now degrades to its declared `type` renderer, the defined behavior for an unregistered widget.
793
+ - `@object-ui/plugin-detail` — `InlineFieldInput`'s `widget === 'capability-multiselect'` branch, the hint's last honoring surface. Leaving one consumer for a name no producer emits and no form resolves is the same declared-vs-enforced split, inverted. The sibling `permission-facet-link` branch is untouched and pinned.
794
+ - `@object-ui/components` — the dead `capability-multiselect` entry in the form renderer's `DATA_SOURCE_FIELD_TYPES` set, which could never match a resolvable widget.
795
+ - `@object-ui/plugin-form` — a comment naming `capability-multiselect` as the widget stamped onto `sys_permission_set.system_permissions`; it names `permission-facet-link` now, which is what is actually stamped.
796
+
797
+ `CapabilityMultiSelectField` itself is **unchanged and still exported**: Studio's `PermissionMatrixEditor` imports and renders it directly, which is ADR-0056 P2's design. Only the widget name is retired — the component is not a registry field widget and its doc comment now says so.
798
+
799
+ `registerFields()` is also **kept**, with its `@deprecated Use registerAllFields() instead` note corrected. The two are not interchangeable: it registers `createFieldRenderer(widget)`, which synthesizes the label, description and the local `value`/`onChange` state that lets a bare field node (`{ type: 'currency', label: 'Amount' }`) render standalone in the docs demos. Retiring it needs a decision about where that demo chrome goes; the note now records that instead of implying a drop-in replacement.
800
+
801
+ ### Patch Changes
802
+
803
+ - f0a625a: `disabled: ''` no longer greys out the remaining five action surfaces (objectui#3849)
804
+
805
+ objectui#3842 / PR #3851 fixed the "is a `disabled` gate DECLARED?" test on
806
+ `action:button` and app-shell's `DeclaredActionsBar`. Five same-shaped sites were
807
+ outside that PR's scope and stayed on `!= null`, so within one component the
808
+ `visible` gate asked `hasDeclaredVisibilityGate` while the `disabled` gate on the
809
+ next line asked `!= null` — two spellings of one question:
810
+
811
+ - `@object-ui/components` — `action:icon`, `action:group`'s inline button
812
+ (`InlineActionButton`) and dropdown item (`DropdownActionItem`), and
813
+ `action:menu`'s item (`ActionMenuItem`).
814
+ - `@object-ui/plugin-detail` — `record:quick_actions`' `QuickActionButton`.
815
+
816
+ Why the missing `!== ''` half is a defect on this key and not on `visible`:
817
+ `toPredicateInput('')` is `undefined` and `evaluateCondition(undefined)` is
818
+ `true`. On `visible` that `true` means SHOW, so an over-broad "declared" test and
819
+ a permissive empty predicate cancel out. On `disabled` it means DISABLE, so they
820
+ compound — `disabled: ''` (an empty predicate: nothing declared) rendered a
821
+ permanently greyed-out control, with nothing the author could write to un-grey
822
+ it. Unlike #3842's approvals inbox, these five are the general action face
823
+ (toolbars, dropdowns, record quick actions), so the reach is wider even though no
824
+ single high-value host owns them.
825
+
826
+ **Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes —
827
+ from disabled to clickable, which is what "no predicate" asked for. `disabled:
828
+ true` still disables, `disabled: false` and an absent `disabled` still do not, and
829
+ no expression-valued `disabled` changes verdict. On the four sites that also carry
830
+ the legacy non-spec `enabled` fallback, one consequence follows: an empty
831
+ `disabled` now falls THROUGH to that leg instead of short-circuiting on the empty
832
+ predicate, so an action spelling both (`disabled: ''` + `enabled: true`) becomes
833
+ clickable. `record:quick_actions` has no `enabled` leg, so its chain is the single
834
+ gate.
835
+
836
+ Routing those legacy `enabled` legs through the same definition is
837
+ behaviour-preserving by derivation rather than a fix: the leg is negated
838
+ (`disabled = !isEnabled`), so an empty predicate's `true` already arrived as "not
839
+ disabled" — the verdict "no gate declared" produces. #3842's four-shape derivation
840
+ table is reproduced next to the new pins, together with the statement that no
841
+ `enabled` case can go red by reverting that leg.
842
+
843
+ `hasDeclaredVisibilityGate` keeps its historic name (the objectui#3842 ruling): the
844
+ predicate is key-neutral, and one implementation behind two names is how a repo
845
+ grows dialects. The three `@object-ui/components` sites import it relatively;
846
+ `record:quick_actions` takes it from the package barrel, the cross-package route
847
+ objectui#3835 opened. Every call site says so in a comment.
848
+
849
+ - 5bfaabd: `PageComponentSchema.dataSource` now reaches every object-bound block, not just
850
+ `list-view` — and `element:record_picker` stops discarding `view`
851
+ (objectstack#6953).
852
+
853
+ objectstack#5576 wired the spec's per-element data binding
854
+ (`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left
855
+ the same declaration inert on every other page component. Two gaps remained, and
856
+ both were silent:
857
+
858
+ - **`element:record_picker` read four of the five keys and dropped `view`.** So
859
+ `dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
860
+ built a picker over EVERY account instead of the rows the saved view selects.
861
+ Nothing threw and nothing rendered an error; the option list was simply wider
862
+ than what was authored, which also means a user could select a record the page
863
+ said was out of scope.
864
+ - **`object-grid` / `object-form` / `object-kanban` / `object-calendar` /
865
+ `object-chart` / `object-metric` / `record:related_list` read none of it.**
866
+ Each gates its fetch on its own `objectName`, and nothing mapped
867
+ `dataSource.object` onto it, so a page written the way the spec documents
868
+ rendered an empty grid / a field-less form / a board with no cards / an empty
869
+ month / an empty chart / a static metric number — with no request and no
870
+ diagnostic anywhere. Spec-valid metadata rendering nothing is the
871
+ objectstack#4413 shape.
872
+
873
+ Composition follows objectstack#5576's landed semantics unchanged on every block:
874
+ a named saved view supplies the baseline, a key written on the component itself
875
+ overrides it, an explicit binding key overrides both, `filter` AND-combines
876
+ ("additional filter criteria" — a binding can narrow a view, never widen it), and
877
+ a `view` name that does not resolve renders a configuration error instead of
878
+ degrading to the object's full scope.
879
+
880
+ - `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)`
881
+ and `ElementDataSourceGate` apply a resolved binding to the schema keys a given
882
+ block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`
883
+ for the two non-final states. One precedence table for all blocks rather than
884
+ one copy per block — that copy is how "additional filter criteria" would have
885
+ become two dialects.
886
+ - A mapping names **only** keys its block genuinely reads. A composed value
887
+ written onto a key the block ignores would be accepted and dropped, which is
888
+ the defect being removed, one layer deeper — so a kanban's swimlane `columns`
889
+ never receive a view's field list, and a block with no row cap leaves `limit`
890
+ unmapped. The per-block coverage table, including two residual gaps that are
891
+ named rather than papered over, is in `content/docs/guide/data-source.md`.
892
+
893
+ No behaviour changes for a block that carries no `dataSource`: the binding-free
894
+ path returns the schema by reference, so nothing remounts and nothing refetches.
895
+
896
+ - 7b3e048: `record:highlights` publishes the `readonly` entry key, so an AI author can discover it from the manifest
897
+
898
+ `readonly` on a `fields[]` entry has been enforced for a while — the renderer copies it
899
+ through normalization and `HeaderHighlight`'s editability gate refuses inline editing on a
900
+ chip carrying it (objectstack#5077) — and `@objectstack/spec` declares it on
901
+ `RecordHighlightsField` (objectstack#5176 / PR #5607). The block's own published authoring
902
+ surface never mentioned it: the `fields` input still spelled the entry shape
903
+ `{name,label?,icon?,type?}`, and since the registry `inputs` are what
904
+ `gen-manifest.ts` serializes into `sdui.manifest.json`, an author reading the manifest was
905
+ told the key did not exist. The `fields` description now states the full entry shape and
906
+ what `readonly` does, which is the discoverability the manifest is for.
907
+
908
+ `readonly` is documented **inside** the `fields` description rather than declared as an
909
+ input of its own, because that is where the contract puts it. The spec's
910
+ `RecordHighlightsProps` has exactly three top-level keys (`fields`, `layout`, `aria`) and
911
+ carries `readonly` per ENTRY. A top-level `{ name: 'readonly', type: 'boolean' }` input
912
+ would publish a key the platform silently discards: the generated `sdui.manifest.json` and
913
+ `sdui-intrinsics.d.ts` would advertise a `readonly` prop, the manifest gate validates
914
+ top-level props only and would raise no diagnostic, `RecordHighlightsProps` is a plain
915
+ `z.object` so the unknown key is stripped on parse without error, and the renderer — which
916
+ reads `field.readonly` per entry — would never see it. An author who trusted that surface
917
+ would be left with the machine-owned column still hand-editable and no diagnostic anywhere
918
+ explaining why. `ComponentInput` is flat by design, so an array-of-objects input publishes
919
+ its member keys in prose, as `record:path.stages` and `record:alert.action` already do.
920
+
921
+ A new spec-parity test derives both directions from `@objectstack/spec` at runtime instead
922
+ of restating today's key list: every key of `RecordHighlightsField`'s object arm must be
923
+ named in the `fields` description, and the block must declare no top-level input that
924
+ `RecordHighlightsProps` does not accept. Nothing previously cross-checked the registry
925
+ `inputs` against the spec, so both drift directions were silent. No runtime behaviour
926
+ changes.
927
+
928
+ - aca561a: Four spec keys the renderers already honoured are now discoverable from the published `inputs`
929
+
930
+ `record:details.hideFields`, `record:related_list.relationshipValueField`,
931
+ `record:related_list.add` and `element:text_input.defaultValue` were declared by
932
+ `@objectstack/spec` and read by their renderers, while the registry `inputs` —
933
+ the surface `gen-manifest.ts` serializes into `sdui.manifest.json` and
934
+ `sdui-intrinsics.d.ts` — never mentioned them. Nothing anywhere reported the
935
+ mismatch, and every layer that reads a manifest said the opposite of the
936
+ runtime: the keys were in no designer panel and no generated `.d.ts`,
937
+ `sdui-parser`'s prop walk returned `unknown-prop` for an author who wrote one,
938
+ and the renderer honoured it regardless. That is objectui#3407's original
939
+ complaint (`readonly` was enforced and honoured, the description just never said
940
+ so) on four more keys.
941
+
942
+ Each description is derived from what the renderer actually does, not from
943
+ restating the spec's one-liner, because the two can differ and the published
944
+ text is what an AI author reads:
945
+
946
+ - `hideFields` documents bare field names only — the renderer tolerates
947
+ `{name}` / `{field}` entries but the spec is `z.array(z.string())` and rejects
948
+ them, so teaching that spelling would publish a dialect the contract refuses;
949
+ - `relationshipValueField` publishes the renderer's `'id'` default and says that
950
+ the resolved value drives the list filter, the Add-picker link value and the
951
+ pre-filled create form together;
952
+ - `add` publishes its member shape in prose (`ComponentInput` is flat and has no
953
+ member-shape slot) with each default taken from the renderer — including
954
+ `picker.labelField`, where the renderer defaults to `name` while the spec's
955
+ own wording says "the object title field". It also names `picker.filter` as a
956
+ KNOWN GAP rather than documenting it as a restriction: the spec declares it
957
+ and nothing reads it, so an author would otherwise believe their picker is
958
+ scoped when it offers every record (objectui#3831);
959
+ - `defaultValue` distinguishes the two behaviours an author can get — seeding a
960
+ bound page variable once while it is still empty, versus the native
961
+ uncontrolled initial value with no variable bound.
962
+
963
+ `element:text_input` is not in the public tier, so its gap was not in
964
+ `sdui.manifest.json` at all — it was in the JSX-page compiler's prop whitelist,
965
+ which `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same
966
+ `inputs`, making the undeclared `defaultValue` a live `unknown-prop` warning.
967
+
968
+ The repo-wide parity gate now runs in both directions over one covered set and
969
+ one exemption discipline, so neither direction can be forgotten again the way
970
+ the reverse half was after PR #3806. Nine spec keys stay deliberately
971
+ unpublished, each with a written reason and a tracking issue: two the renderers
972
+ do not read at all (objectui#3829), three retired upstream by ADR-0087
973
+ tombstones, `page:tabs.type` (a carrier collision, objectstack#6776), two
974
+ `targetVariable` declarative hints (objectui#3834), and
975
+ `element:record_picker.filter` (objectui#3830).
976
+
977
+ - 6d762da: The five locale keys behind #3546's eight no-fallback `t()` call sites are now defined in all ten packs, so the built-in-view toasts, the activity-timeline source link, the wizard's required-field toast and the Gantt refresh button's accessible name are translated instead of falling back to English — or, on two surfaces, to the key itself (part of #3546).
978
+
979
+ `scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site asks for and no pack defines. These five were the subset with no working inline default: `console.objectView.cannotEditMetaView`, `console.objectView.cannotDeleteMetaView`, `detail.viewSource`, `gantt.toolbar.refresh` and `wizard.missingRequired`. Adding a `defaultValue` is deliberately not the fix — that mechanism is what kept all 258 invisible for months.
980
+
981
+ **Two of the eight sites really did render the raw key**, and both go through a binding with nothing in front of i18next. `ObjectView.tsx` calls `useObjectTranslation()` directly, so five toasts read `console.objectView.cannotEditMetaView` / `cannotDeleteMetaView` on screen; the `|| 'Built-in views cannot be renamed.'` guards next to them were dead on every path, because i18next answers a miss with the key itself and a non-empty string never falls through `||`. Those four unreachable English strings are removed rather than repaired: one key served four call sites (rename / pin / set-as-default / configure), so the pack copy covers any change to a built-in view instead of naming one operation. `RecordActivityTimeline.tsx` fails the same way for a subtler reason — `useDetailTranslation` is `createSafeTranslation(..., 'detail.back')`, and because `detail.back` does resolve, the probe hands back i18next's `t` for every key and bypasses the defaults map wholesale, so `detail.viewSource` reached the user verbatim.
982
+
983
+ **The other two sites were not rendering a raw key**, contrary to the issue's description, and are fixed here as the milder "English in all ten languages" class. `wizard.missingRequired` is its own hook's probe key, so the probe failed and `createSafeTranslation` correctly served its English default. `gantt.toolbar.refresh` goes through `useGanttTranslation`, which deliberately does not use `createSafeTranslation` and falls back per key — so the refresh button's `aria-label` was "Refresh", in English, never the key. Screen-reader users heard an English word rather than an identifier; a `zh` session now hears 刷新.
984
+
985
+ Regression cover is provider-mounted on purpose: with no `I18nProvider` the defaults maps answer every one of these keys and the assertions pass while the console is broken, which is precisely the false-green the issue documents. For the two sites whose English output was already correct, `en` cannot discriminate before from after — the `zh` assertions are the ones that pin the fix.
986
+
987
+ - 4178d5a: `record:details` 的 `sections` 输入说明改为从 spec 形状派生的对象形,不再教已被退役的「Section IDs」
988
+
989
+ `inputs` 不是文档,而是发布出去的编写契约:`gen-manifest.ts` 把它序列化进
990
+ `sdui.manifest.json`(保存门 + parser 白名单)和 `sdui-intrinsics.d.ts`。而
991
+ `record:details.sections` 的说明写的是 `Section IDs to show (required when layout
992
+ is "custom")` —— 那是 17.x 以前的形状。pin 版 `@objectstack/spec@17.0.0-rc.5` 的
993
+ `RecordDetailsProps.sections` 是对象数组 `{ name?, label?, columns?, fields }`,
994
+ objectstack#5611 把 `z.array(z.string())` 那条拼法**删掉**而不是 union 进来(既无
995
+ producer 也无 consumer,一种形状而不是两套事实契约)。
996
+
997
+ 照旧说明写 `sections: ['contact_info', 'address']` 的作者,在四层之间拿不到任何
998
+ 诊断:`['a','b']` 对 manifest 门是合法 `array`(门只看顶层键名 + 粗类型),上游
999
+ `validateComponentProps` 是 advisory 级,spec 只在真的走 parse 的路径上才拒,而
1000
+ `RecordDetailsRenderer` 对每个条目读 `s.name` / `s.label` / `s.fields` —— 字符串上
1001
+ 三者全 `undefined`,该 section 一个字段都不渲染。`layout: 'custom'` 时 sections 是
1002
+ 详情页正文的唯一来源,所以结果是一张没有报错的空白详情页。
1003
+
1004
+ 新说明逐键派生自 spec 各成员的 `.describe()` 与渲染器实读:`fields` 必填、按序渲染;
1005
+ `label` 是标题(省略即无标题、无边框);`name` 是 snake_case 稳定标识与 i18n 锚点
1006
+ (标题走 `objects.{object}._sections.{name}.label`);`columns`(1-4)是本 section 的
1007
+ 字段栅格宽度,省略则由渲染器推导;并明确写出字符串条目不被接受。渲染器另外还认的
1008
+ `title` / `showBorder` / `hideEmpty` **故意不写进说明** —— spec 的 section 对象没有
1009
+ 声明它们,parse 时会被静默剥掉,发布它们等于教作者写契约丢弃的键。
1010
+
1011
+ 同时新增 `recordDetailsInputs.spec-parity.test.ts`:两个方向的断言都在运行时从 spec
1012
+ schema 派生(每个 spec 成员键都能从说明里发现;本 block 不声明 spec 不接受的顶层
1013
+ input),所以下一次 spec 变形会先让测试红,而不是又一次静默张开。仅说明文本变化,无
1014
+ 运行时行为改动。
1015
+
1016
+ - 54233b1: Record detail pages: a header ⟳ that refreshes the record, its related lists and its tab counts in place — no browser reload
1017
+
1018
+ Concurrent-editing scenario from the shop floor (MES work orders): operator A sits on a record's detail page while operator B starts or reports the same order. A had no way to see the new state except F5, which throws away the open tab, the scroll position and any in-progress inline edit along with the stale data.
1019
+
1020
+ The pipeline for this already existed — the objectui#2269 invalidation bus refetches every mounted reader in place, and `RecordContext.refresh` had been declared for it — but nothing produced that field and no UI reached for it. Three changes give it a trigger:
1021
+
1022
+ - **`RecordDetailView` produces `RecordContext.refresh`**, publishing `notifyDataChanged({ objectName: '*' })`. The wildcard is deliberate: a user reaches for refresh because of a write made by SOMEONE ELSE, which this client never saw and therefore cannot attribute to particular objects. `'*'` marks everything mounted as stale, so the main record, every related child list and the tab-count badges all refetch — no remount, so tab / scroll / draft state survive. First phase covers the standalone record route; embedded hosts (list drawer, split-pane preview) keep their existing chrome unchanged.
1023
+ - **`page:header` renders the ⟳** at the far end of the header row when — and only when — the host provides `refresh`. It is page chrome rather than a header action, so its position is the same on every record page regardless of which business actions the object declares, and it can never be collapsed into the `⋯` overflow. Styled as that `⋯` trigger's twin so the row reads as one button family. Its accessible name and tooltip come from the existing `common.refresh` key, so the icon-only button is not English-only in the other nine locales. The icon spins for a short floor after a click, because the bus is fire-and-forget and a warm backend would otherwise finish before the click looked like it landed.
1024
+ - **`RelatedList` accepts the `'*'` wildcard** on the legacy `objectui:related-changed` event, matching what `dataChangeMatches` already does for the bus's own readers. This listener compared the payload's object name to its own, so a wildcard invalidation reached everything on the page except the related lists — a concrete foreign object name is still ignored.
1025
+
1026
+ Hosts that provide no `refresh` render exactly as before.
1027
+
1028
+ - c2ecbae: 相关列表 Add 选择器兑现 `add.picker.filter`:作者限定的候选范围现在真的生效
1029
+
1030
+ `record:related_list.add.picker.filter` 被 spec 声明为「Restrict which records the picker offers」,但渲染器从未读过它 —— 挂 `RecordPickerDialog` 时不传任何 filter,对话框照样提供 `picker.object` 的全部记录,选中即建链接行或改父,`os validate` / `os build` 全绿、运行时零诊断。作者写下「只允许指派 active 的岗位」「只允许挂未过期的许可」,得到的是完整候选列表。
1031
+
1032
+ 现在它按原样传给 `RecordPickerDialog` 的 `baseFilter` —— 不是 `lookupFilters`,后者会把条件渲染成用户可编辑的筛选栏行,等于把作者的硬性限制降级成建议。
1033
+
1034
+ `baseFilter` 因此接受两种形状,按结构判别(`Array.isArray`):
1035
+
1036
+ - **`QueryParams.$filter` 记录形式**(依赖型 lookup 链)保持原有的键覆盖语义逐字节不变 —— 级联父值必须**替换**同字段上过期的 `lookupFilters` 条目,而不是与之求交。
1037
+ - **spec 的 `ViewFilterRule[]`** 经 `mergeFilterNodes`(仓内唯一的 filter 下沉口)下沉,19 个 operator 全部无损到达服务端,包括记录形式没有 `$op` 可用的 `before` / `after` / `is_empty` / `is_not_empty`。此处**不新增**第二份 operator 词汇表。
1038
+
1039
+ 槽位类型同时从 `Record<string, any>` 收紧为 `unknown`:前者会接受规则数组(数组满足 `any` 的字符串索引),旧的对象展开再把它压成 `{"0": {...}}`,于是查询去过滤名为 `0` 的列 —— 类型全绿、查询错误、无任何诊断。
1040
+
1041
+ - acc34c5: `record:related_list`: an `add` without `add.picker` no longer takes the whole related list down.
1042
+
1043
+ The Add-picker gate compared only `add` for truthiness and then read `add.picker.object` bare, so page metadata declaring `add` but omitting the (spec-required) `picker` threw during render and `SchemaRenderer` replaced the entire related list with a "Component failed to render" card whose message never mentioned `picker`. Both the Add button and the picker dialog now gate on the resolved `add.picker.object` — the list body renders as usual, only the unconfigured Add affordance is withheld, and a console hint names the missing key. Off-spec `add` still does nothing, so no lenient second dialect is introduced; producing-side validation of page metadata is tracked separately.
1044
+
1045
+ - c4768a7: `record:related_list` — the declared `filter` reaches the query, and the Add button answers to the same gate as its dialog
1046
+
1047
+ - **`filter` is consumed** (objectstack#7118). The spec declares
1048
+ `RecordRelatedListProps.filter` ("additional filter criteria") and this repo
1049
+ published it as a registry input, but nothing read it: `RelatedList` built its
1050
+ query from `{ [relationshipField]: parentId }` alone, so an authored filter was
1051
+ accepted by every gate and silently dropped — the list answered with every child
1052
+ of the parent. It is now AND-combined with the parent condition (never
1053
+ substituted for it, so an additional criterion can only narrow), lowered through
1054
+ the repo's single filter sink so the spec's `[{ field, operator, value }]`
1055
+ vocabulary and a composed `dataSource` binding both work. With nothing authored
1056
+ the query is unchanged. As a consequence a saved view named through
1057
+ `dataSource: { object, view }` no longer contributes its columns/sort/limit while
1058
+ its filter is discarded — the list can no longer be wider than the view it names.
1059
+ On the legacy raw-URL fallback path, which cannot express an operator, a declared
1060
+ filter is refused with a console explanation instead of dropped.
1061
+ - **The Add button now requires `dataSource`** (objectui#3895), matching the picker
1062
+ dialog and the add callback. In hosts that supply no `RecordContext` — Studio
1063
+ designer previews, context-free embeds — the button rendered and did nothing at
1064
+ all when clicked; the affordance is now withheld where the capability behind it
1065
+ is absent.
1066
+
1067
+ - Updated dependencies [7864f03]
1068
+ - Updated dependencies [f5f8744]
1069
+ - Updated dependencies [69becd2]
1070
+ - Updated dependencies [5e52495]
1071
+ - Updated dependencies [b750823]
1072
+ - Updated dependencies [ac2139c]
1073
+ - Updated dependencies [b14ab3a]
1074
+ - Updated dependencies [8c60819]
1075
+ - Updated dependencies [e64a52e]
1076
+ - Updated dependencies [844d17f]
1077
+ - Updated dependencies [4dcd52a]
1078
+ - Updated dependencies [42ae5c6]
1079
+ - Updated dependencies [6d762da]
1080
+ - Updated dependencies [f9faa7d]
1081
+ - Updated dependencies [33526fd]
1082
+ - Updated dependencies [32413ec]
1083
+ - @object-ui/i18n@17.4.0
1084
+
3
1085
  ## 17.3.0
4
1086
 
5
1087
  ### Minor Changes
@@ -1544,6 +2626,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1544
2626
  objects+fields were editable in Studio; this reworks both surfaces.
1545
2627
 
1546
2628
  **Setup (assign + read-only):**
2629
+
1547
2630
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1548
2631
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1549
2632
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1558,6 +2641,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1558
2641
 
1559
2642
  **Studio (design every facet):** the permission matrix editor gains structured
1560
2643
  editors for the facets that were JSON-only —
2644
+
1561
2645
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1562
2646
  (scope-grouped, labelled chips).
1563
2647
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -1992,6 +3076,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1992
3076
  unknown @-tokens. 9 unit tests.
1993
3077
 
1994
3078
  `@object-ui/app-shell` `RecordDetailView` now:
3079
+
1995
3080
  1. Serializes the resolved mention ids into `sys_comment.mentions`
1996
3081
  (previously hard-coded `'[]'`, so servers had no idea who was being
1997
3082
  pinged).
@@ -2012,6 +3097,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2012
3097
  and the renderer also falls back to a value/label heuristic (matches
2013
3098
  `closed_lost`, `lost`, `failed`, `cancelled`, `失败`, `流失`, `丢单`, etc.)
2014
3099
  so existing CRM-style picklists get the treatment without migration.
3100
+
2015
3101
  - **Lost** stages render in a visually separated group with a left
2016
3102
  border, destructive (red) tint, pill shape, and `✗` glyph — mirroring
2017
3103
  the Salesforce / HubSpot alt-terminus pattern that signals "this
@@ -2041,6 +3127,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2041
3127
  - Stage chevron (`record:path`): bump completed-stage contrast (emerald-800 text on emerald-500/15, was 700 on /10) and future-stage text from `foreground/70` to `foreground/85` for legibility.
2042
3128
  - i18n: add `notifications.emptyUnread`, `notifications.filterUnread`, `notifications.filterAll` (en + zh) so the InboxPopover Unread/All sub-filter renders in the active locale.
2043
3129
  - 5425608: CRM UX polish pass — calmer enterprise look across detail + kanban.
3130
+
2044
3131
  - **plugin-kanban**: column headers now use a 2px muted accent stripe with
2045
3132
  neutral foreground titles + a quiet grey count pill instead of full
2046
3133
  rainbow gradient + colored title + colored count. Pipeline boards
@@ -2065,12 +3152,14 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2065
3152
  - 5633edd: feat(detail,grid): tab + selection motion polish
2066
3153
 
2067
3154
  **plugin-detail**
3155
+
2068
3156
  - `DetailTabs` and the auto-tabs path in `DetailView` (5 inline
2069
3157
  `<TabsContent>` instances: details, related, activity, discussion,
2070
3158
  history) now fade in when their tab becomes active, eliminating
2071
3159
  the harsh flash when switching tabs.
2072
3160
 
2073
3161
  **plugin-grid**
3162
+
2074
3163
  - `BulkActionBar` slides in from the bottom + fades in when a
2075
3164
  selection is made, instead of popping into existence.
2076
3165
  - The "N items selected" counter re-animates on every count change
@@ -2099,6 +3188,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2099
3188
  `defaultCurrency` is set on the field/column, formatting is unchanged.
2100
3189
 
2101
3190
  Fixed call sites:
3191
+
2102
3192
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
2103
3193
  `CurrencyCellRenderer` no longer default-param `'USD'`.
2104
3194
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -2120,6 +3210,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2120
3210
  - d1ec6a2: Fold inline-edit into the page-header overflow menu (HubSpot/Lightning
2121
3211
  pattern) and remove the orphan "Edit fields" toolbar row that previously
2122
3212
  floated between the tab strip and the first detail section.
3213
+
2123
3214
  - `@object-ui/app-shell` `RecordDetailView`: injects a new `sys_inline_edit`
2124
3215
  system action that appears in the ⋯ overflow menu and dispatches a
2125
3216
  `objectui:record:inline-edit-toggle` window CustomEvent (filtered by
@@ -2136,6 +3227,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2136
3227
  in `page:accordion` / `page:tabs` items.
2137
3228
 
2138
3229
  - cf30cc2: Polish Lightning record detail page layout.
3230
+
2139
3231
  - `record:details` sections now render with Card chrome by default when a `title` is present, restoring visual grouping that was missing on pages like the opportunity detail page.
2140
3232
  - Section labels can be translated via the `{ns}.objects.{objectName}._sections.{name}.label` convention. Author each section with a stable `name` (e.g. `info`, `forecast`) and the renderer picks up the locale-specific label automatically. Falls back to the literal `label` when no translation exists.
2141
3233
  - The `page:header` action toolbar now collapses into a `⋯` overflow menu when more than two actions are present. The first business action stays inline; secondary system actions (Edit / Share / Delete) move into the menu, with destructive styling applied to Delete.
@@ -2150,6 +3242,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2150
3242
  `CONCURRENT_UPDATE`). `RecordDetailsRenderer` catches it and opens a
2151
3243
  new `<ConcurrentUpdateDialog>` showing the user's pending value next
2152
3244
  to the server's current value, with three resolution paths:
3245
+
2153
3246
  - **Reload latest** — discard the pending edit and refetch.
2154
3247
  - **Overwrite anyway** — retry against the server's freshest version
2155
3248
  (still OCC-checked, but acknowledges "I've seen the newer version").
@@ -2165,6 +3258,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2165
3258
  backend support in `@objectstack/rest@>=4.2.0`.
2166
3259
 
2167
3260
  - 49b1760: Polish the ConcurrentUpdateDialog and add i18n.
3261
+
2168
3262
  - Internationalise all dialog strings (title, body, button labels, "your edit" / "current value" headings, audit-trail line) through `useDetailTranslation`. Locale strings added to `@object-ui/i18n` for English and Chinese.
2169
3263
  - Replace the plain dialog header with an amber warning badge + `AlertTriangle` icon to communicate that this is a conflict, not a routine confirmation.
2170
3264
  - Visually differentiate the two value blocks: amber tint for the user's pending edit, sky tint for the server's current value. Both wrap long values cleanly.
@@ -2180,17 +3274,20 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2180
3274
  - bd8447d: Three platform-wide detail polish items.
2181
3275
 
2182
3276
  **Tighter page rhythm**
3277
+
2183
3278
  - Outer `PageRenderer` padding `p-4 md:p-6 lg:p-8` → `p-3 md:p-4 lg:p-6`
2184
3279
  and outer body wrap `space-y-8` → `space-y-6` so list / detail / home
2185
3280
  pages share the same edge rhythm. Cuts ~16px of edge slack on lg.
2186
3281
 
2187
3282
  **Highlights KPI treatment**
3283
+
2188
3284
  - `HeaderHighlight` now renders numeric / currency / percent / decimal
2189
3285
  values as KPI numbers (`text-xl md:text-2xl font-semibold tabular-nums`)
2190
3286
  instead of the uniform `text-sm font-semibold`, so amount / probability
2191
3287
  / count fields read as headline stats — Salesforce-style key facts.
2192
3288
 
2193
3289
  **Discussion footer upgrade**
3290
+
2194
3291
  - `RecordActivityTimeline` now uses `RichTextCommentInput` (bold / italic /
2195
3292
  list / code, `@`-mention autocomplete, preview toggle, Send) instead of
2196
3293
  a bare `<textarea>`.
@@ -2205,6 +3302,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2205
3302
  accordion in flush mode dropped all per-item borders so the collapsed
2206
3303
  "Quotes / Products / Open Tasks" triggers stacked with zero visual
2207
3304
  separation.
3305
+
2208
3306
  - `@object-ui/plugin-detail` `DetailSection`: override the `CardTitle`
2209
3307
  className to `text-base font-semibold tracking-tight`, slim down
2210
3308
  `CardHeader` padding (`py-3 px-4 sm:py-4 sm:px-6`) and `CardContent`
@@ -2219,6 +3317,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2219
3317
  CRM-style related-list lists).
2220
3318
 
2221
3319
  - d51a577: feat(platform): Discussion attachments + @mention directory + Reference Rail aside
3320
+
2222
3321
  - **Discussion attachments** — `RichTextCommentInput` now accepts an `extraSlot`
2223
3322
  and a `canSubmitEmpty` flag so hosts can mount the existing
2224
3323
  `CommentAttachment` composer beneath the editor without forking the toolbar.
@@ -2242,6 +3341,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2242
3341
  custom `Page`. Catalog-style objects (Product, Task) ship with the rail
2243
3342
  off by default; hub objects (Account, Opportunity, Contact, Case) keep it
2244
3343
  on.
3344
+
2245
3345
  - `RecordDetailView` now reads `(objectDef as any)?.detail?.hideReferenceRail`
2246
3346
  and `…?.hideRelatedTab` and threads them to `buildDefaultPageSchema`.
2247
3347
  - The Reference Rail renderer also accepts entries authored as either a
@@ -2292,6 +3392,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2292
3392
  not only contacts of that account).
2293
3393
 
2294
3394
  Two coupled fixes:
3395
+
2295
3396
  1. `RelatedList` now requires `parentId` + `referenceField` to auto-
2296
3397
  fetch. When both are present it calls `dataSource.find(api,
2297
3398
  { $filter: { [referenceField]: parentId } })`. When either is
@@ -2337,6 +3438,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2337
3438
  default detail view. They were missing cross-cutting affordances and
2338
3439
  shipped with English-only tab labels and heavy bordered section cards
2339
3440
  even when the host locale was Chinese. Track 1 closes the visible gap:
3441
+
2340
3442
  - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
2341
3443
  the same chrome as the default branch — lifecycle managed-by badge
2342
3444
  and presence avatars in the top-right, `MetadataPanel` debug panel,
@@ -2439,6 +3541,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2439
3541
  layout — only the auto-inference default changed.
2440
3542
 
2441
3543
  - b14fe09: Phase P.0 + P.5: tighten record-detail header chrome.
3544
+
2442
3545
  - `RecordTitleChip` collapses the title row to a single baseline-aligned line — H1, eyebrow object label, copy-id, favorite star — instead of the previous two-row title + subtitle layout.
2443
3546
  - `record:details` extends the highlight-field dedup set to also exclude the title field resolved from `objectSchema.primaryField` (or the standard `name`/`full_name`/`title`/`subject`/`display_name`/`label` fallbacks). Removes the duplicate row that previously echoed the H1 (e.g. "客户名称: Acme Corporation") inside the field grid.
2444
3547
 
@@ -2481,6 +3584,7 @@ populated` disclosure that expands on click. The expanded view shows
2481
3584
  result was visually noisy ("有的下划线,有的有边框,有的没边框").
2482
3585
 
2483
3586
  This change commits to a single design language:
3587
+
2484
3588
  - **Highlights** (`HeaderHighlight`): drop the `Card`/`CardContent`
2485
3589
  wrapper. Render as a borderless `<section>` of stat cells with a
2486
3590
  subtle `border-b` separator. The tab strip below now carries the
@@ -2501,6 +3605,7 @@ populated` disclosure that expands on click. The expanded view shows
2501
3605
  by their tabular content. No internal package APIs changed.
2502
3606
 
2503
3607
  - 74962b0: feat(detail): record:discussion schema component + flush accordion variant
3608
+
2504
3609
  - New `record:discussion` schema type lets authors place the record
2505
3610
  chatter feed anywhere in a custom Page schema. Wired through a
2506
3611
  shared `DiscussionContext` provider on the `assignedPage` branch
@@ -2514,6 +3619,7 @@ populated` disclosure that expands on click. The expanded view shows
2514
3619
  or `和` (e.g. `Notes & Attachments` → `备注与附件`).
2515
3620
 
2516
3621
  - 8b850b5: feat(detail): record:path chevron stepper + record:highlights surface refresh (Phase E)
3622
+
2517
3623
  - `record:path` now renders Salesforce Lightning-style chevron segments
2518
3624
  (clip-path arrows + overlap) with a primary glow on the current step
2519
3625
  and a check mark on completed steps. On mobile (`<sm`) it falls back
@@ -2538,6 +3644,7 @@ populated` disclosure that expands on click. The expanded view shows
2538
3644
  accordion, discussion slot) that custom Lightning pages already enjoy.
2539
3645
 
2540
3646
  Changes:
3647
+
2541
3648
  - `buildDefaultPageSchema` now emits `page:tabs.items` (correct shape
2542
3649
  for the renderer) rather than `tabs`.
2543
3650
  - `PageHeaderRenderer.resolvedTitle` honors `objectSchema.primaryField`
@@ -2561,6 +3668,7 @@ populated` disclosure that expands on click. The expanded view shows
2561
3668
  customize the header or one tab.
2562
3669
 
2563
3670
  **Slot menu (v1):**
3671
+
2564
3672
  - `header` — replaces `page:header`
2565
3673
  - `actions` — replaces the `record:quick_actions` action bar
2566
3674
  - `highlights` — replaces the chips + chevron path strip
@@ -2588,6 +3696,7 @@ populated` disclosure that expands on click. The expanded view shows
2588
3696
  ```
2589
3697
 
2590
3698
  **API changes:**
3699
+
2591
3700
  - `PageSchema` (in `@object-ui/types`): adds `kind?: 'full' | 'slotted'`
2592
3701
  (default `'full'`) and `slots?: PageSlotMap`.
2593
3702
  - `usePageAssignment` (in `@object-ui/react`): result now exposes a
@@ -2597,6 +3706,7 @@ populated` disclosure that expands on click. The expanded view shows
2597
3706
  `options.slots` map that overrides individual regions at synthesis time.
2598
3707
 
2599
3708
  - 34b66bf: feat(detail): synthesize Related / Activity / History tabs + record:quick_actions header (Track 3 Phase G slice 4)
3709
+
2600
3710
  - `buildDefaultPageSchema` now accepts `headerActions`, `related`,
2601
3711
  `showActivity`, and `history` options. When provided, the synthesizer
2602
3712
  emits a `record:quick_actions` node after `page:header` and appends
@@ -2621,6 +3731,7 @@ populated` disclosure that expands on click. The expanded view shows
2621
3731
 
2622
3732
  Two regression fixes surfaced by the Phase J browser canary across CRM
2623
3733
  record detail pages:
3734
+
2624
3735
  1. **`record:path` now localizes stage labels.** The renderer threads
2625
3736
  `useSafeFieldLabel().translateOptions` against the record-context's
2626
3737
  `objectName` + the schema's `statusField`, so picklist labels match the
@@ -2697,6 +3808,7 @@ populated` disclosure that expands on click. The expanded view shows
2697
3808
  - d714e85: Lookup display-name resolution now falls back through a Salesforce-style chain
2698
3809
  when an `$expand`'d reference object lacks a top-level `name`/`label`/
2699
3810
  `display_name`/`title` field:
3811
+
2700
3812
  1. Standard display fields (existing behaviour)
2701
3813
  2. `salutation first_name last_name` composite — handles person records that
2702
3814
  only carry first/last name parts
@@ -2732,6 +3844,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2732
3844
  ### Patch Changes
2733
3845
 
2734
3846
  - 0d8eb98: feat(detail): Salesforce-style record header + section field grid
3847
+
2735
3848
  - `page:header` now renders an icon chip (resolves Lucide names via
2736
3849
  `LazyIcon`) plus subtitle, so detail pages can show
2737
3850
  "Name / Company" without an extra component.
@@ -2780,6 +3893,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2780
3893
  detail view (`TypeError: titleFormat.replace is not a function`) and printed
2781
3894
  `Failed to evaluate expression: ${[object Object]}` for every action visibility
2782
3895
  predicate.
3896
+
2783
3897
  - `@object-ui/core`: `ExpressionEvaluator.evaluate` / `evaluateCondition` now
2784
3898
  unwrap Expression envelopes transparently.
2785
3899
  - `@object-ui/react`: new `toPredicateInput()` helper to safely normalize
@@ -2831,6 +3945,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2831
3945
  Library builds (vite lib mode) now externalize every non-relative import instead of bundling third-party CJS dependencies into the published dist. This avoids inlined `require("react")` / `require("react-dom")` calls that cause `Calling \`require\` for "react" in an environment that doesn't expose the \`require\` function` runtime errors when consumer apps re-bundle the published dist.
2832
3946
 
2833
3947
  Specifically fixes:
3948
+
2834
3949
  - `@object-ui/plugin-dashboard` no longer inlines `react-grid-layout` (and its transitive `react-draggable` / `react-resizable` CJS bundles). `react-grid-layout` is now declared as a peer dependency so consumers install a single ESM-friendly copy.
2835
3950
  - `@object-ui/components`, `@object-ui/plugin-calendar`, `@object-ui/plugin-charts`, `@object-ui/plugin-designer` no longer inline `react-i18next` / `i18next` / `use-sync-external-store` CJS shims.
2836
3951
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.