@object-ui/plugin-detail 17.2.0 → 17.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,815 @@
1
1
  # @object-ui/plugin-detail
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
8
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
9
+
10
+ A TranslationBundle entry for an action carries three keys under one
11
+ `_actions.<name>` node — `label`, `confirmText`, `successMessage` — and
12
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
13
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
14
+ and the related-list row menu resolved the button `label` only and dispatched
15
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
16
+ two fates: the button rendered the translation, the confirm dialog rendered the
17
+ authored English.
18
+
19
+ All action-rendering surfaces now go through one resolver,
20
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
21
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
22
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
23
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
24
+ introduce a `confirmText` or `successMessage` the metadata never declared.
25
+
26
+ - 6d01319: Inline edit no longer offers a record picker for a spec-spelled `autonumber` field that carries a `reference_to`
27
+
28
+ `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.
29
+
30
+ 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.
31
+
32
+ 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.
33
+
34
+ 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.
35
+
36
+ - 63fe8fd: `record:related_list` and the detail synthesizer now declare two shapes they already accepted at runtime.
37
+
38
+ `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.
39
+
40
+ `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.
41
+
42
+ - 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
43
+
44
+ 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.
45
+
46
+ **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.
47
+
48
+ 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.
49
+
50
+ 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.
51
+
52
+ **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.
53
+
54
+ **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.
55
+
56
+ No behavior, no keys added or removed, no placeholder changed.
57
+
58
+ - 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.
59
+
60
+ `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]`.
61
+
62
+ 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.
63
+
64
+ `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.
65
+
66
+ 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).
67
+
68
+ 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.
69
+
70
+ - 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.
71
+
72
+ 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.
73
+
74
+ 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.
75
+
76
+ 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.
77
+
78
+ 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.
79
+
80
+ Re-authoring a credential is unchanged and still belongs in the record form, which has the widget for it (`PasswordField`).
81
+
82
+ - 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
83
+
84
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
85
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
86
+ pack value always wins, so the inline string is dead code — and 43 of those dead
87
+ strings said something different from the sentence users actually read.
88
+
89
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
90
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
91
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
92
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
93
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
94
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
95
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
96
+ which keeps reporting it alone.
97
+
98
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
99
+ values are what users read today, and changing one would oblige the same change in
100
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
101
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
102
+ is how they survived three i18n gates that are each blind to this class by
103
+ construction.
104
+
105
+ The visible effect is confined to hosts that render these components with **no**
106
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
107
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
108
+ one; it now matches what a provider-backed app has always shown. Inside the
109
+ console — provider mounted — nothing users see changes. The clearest converging
110
+ examples: the workspaces screen was written as "Organizations" at nine call sites
111
+ while every user has been reading "Workspaces"; the forgot-password success line
112
+ was written as "If an account exists, a reset link has been sent." while the pack
113
+ asserts "We've sent a password reset link to {{email}}."
114
+
115
+ - e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
116
+
117
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
118
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
119
+ and written back as whatever the user typed — a bare string.
120
+
121
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
122
+ `checkboxes`, an options-less multi picklist) were offered for editing as
123
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
124
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
125
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
126
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
127
+ value its option list never offered.
128
+
129
+ Types the switch already routes keep their editors. Everything else that the
130
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
131
+ control the form renders, `json` → the code editor included — and only genuinely
132
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
133
+ input. A drift guard asserts every field type is exactly one of routed /
134
+ excluded / delegated / benign, so a new type can no longer inherit the
135
+ value-destroying default in silence.
136
+
137
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
138
+ value when the field declares no `options` at all. An empty offered set had two
139
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
140
+ authored (nothing to decide) — and the second deleted the value on mount, which
141
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
142
+ also forwards `autoFocus` to the widget it renders.
143
+
144
+ - e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
145
+
146
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
147
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
148
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
149
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
150
+
151
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
152
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
153
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
154
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
155
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
156
+
157
+ - 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.
158
+
159
+ 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.
160
+
161
+ 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.
162
+
163
+ 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).
164
+
165
+ - 7d04b0e: `record:details` stops publishing a `layout` key the spec removed and the renderer never honoured
166
+
167
+ `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.
168
+
169
+ 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.
170
+
171
+ `@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.
172
+
173
+ 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`.
174
+
175
+ 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.
176
+
177
+ - c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
178
+
179
+ `@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.
180
+
181
+ 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.
182
+
183
+ 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.
184
+
185
+ `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.
186
+
187
+ - 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
188
+
189
+ `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.
190
+
191
+ 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.
192
+
193
+ **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>>`.
194
+
195
+ `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.
196
+
197
+ **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.
198
+
199
+ 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.
200
+
201
+ 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.
202
+
203
+ - 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
204
+
205
+ 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.
206
+
207
+ 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.
208
+
209
+ 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.
210
+
211
+ 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.
212
+
213
+ - dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
214
+
215
+ `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.
216
+
217
+ 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.
218
+
219
+ - 35997ce: fix(plugin-detail): synthesize page components in the spec's `properties` carrier so Studio page-create can persist
220
+
221
+ Creating a page in Studio never completed. The create path seeds a record
222
+ page's `regions` from `buildDefaultPageSchema(objectDef)` and PUTs the result,
223
+ and every node that synthesizer emitted carried its widget props at the TOP
224
+ level of the component — `{ type: 'page:header', recordChrome: true }`,
225
+ `{ type: 'page:tabs', items: [...] }`, and the same for `record:highlights`,
226
+ `record:path`, `record:details`, `record:related_list`, `record:history` and
227
+ `record:reference_rail`. ADR-0089 D3a closed `PageComponentSchema` with
228
+ `.strict()`, so those keys are not stripped, they are a parse error
229
+ (`Unrecognized key(s) on this view/page schema: 'recordChrome', 'actions'`).
230
+ The server refused the body and no page row was ever stored.
231
+
232
+ The props now go where the spec declares them — the node's `properties` bag,
233
+ which is where `ComponentPropsMap` defines `page:header.recordChrome` and
234
+ `page:tabs.items` in the first place. Nothing is dropped and nothing changes on
235
+ screen: a header still defaults to record chrome ON, an author's
236
+ `recordChrome: false` is still carried (and now actually persists), the tabs
237
+ keep their items, and `SchemaRenderer` hoists `properties` back onto the node
238
+ before dispatch, so every renderer receives exactly the props it did before.
239
+
240
+ One code path does the wrapping for every node the synthesizer builds, so there
241
+ is a single answer to "what may go in a page write". Slot overrides are
242
+ untouched — a node handed in by a caller is still placed verbatim.
243
+
244
+ - b388950: fix(plugin-detail): the record detail header honors `userActions` predicates
245
+
246
+ `userActions.delete` reached the record detail header in its **boolean** form
247
+ but not in its **predicate** form. `userActions: { delete: false }` removed
248
+ Delete from the row kebab, the selection bar and the detail header, because the
249
+ host lowers the boolean into `schema.showDelete`. `userActions: { delete: {
250
+ visibleWhen: … } }` reached only the row kebab: the header ANDed
251
+ `schema.showDelete ∧ objectAllowsDelete ∧ canDeleteRecord` and never evaluated
252
+ the predicate, so an author upgrading from "nobody may delete this object" to
253
+ "these records may not be deleted" silently lost the surface they were most
254
+ likely to have tested on.
255
+
256
+ The header now folds the per-record predicates in as a fourth conjunct,
257
+ evaluated against the open record through the same helper family the row
258
+ surfaces use — `userActionPredicates` from `@object-ui/core` for the parse (the
259
+ import `RelatedList` already makes) and `useRowPredicate` from
260
+ `@object-ui/react` for the evaluation:
261
+
262
+ - `delete.visibleWhen` / `edit.visibleWhen` false → the header affordance is
263
+ hidden (fails closed; `visibleWhen: false` counts as a declared gate).
264
+ - `delete.disabledWhen` / `edit.disabledWhen` true → the header affordance
265
+ renders disabled rather than disappearing, matching the row kebab.
266
+
267
+ The existing permission and record-writability gates are unchanged, so a
268
+ predicate that holds can never resurrect a button the user may not press. The
269
+ boolean form stays the host's channel — a bare boolean yields no predicate.
270
+
271
+ - Updated dependencies [0e67b53]
272
+ - Updated dependencies [932cbcd]
273
+ - Updated dependencies [734d186]
274
+ - Updated dependencies [3fc2971]
275
+ - Updated dependencies [f7c6430]
276
+ - Updated dependencies [ae10a01]
277
+ - Updated dependencies [92876f0]
278
+ - Updated dependencies [828549a]
279
+ - Updated dependencies [e1ade8f]
280
+ - Updated dependencies [3e19fe7]
281
+ - Updated dependencies [bb58d1d]
282
+ - Updated dependencies [5cc847c]
283
+ - Updated dependencies [fa21254]
284
+ - Updated dependencies [33c32bf]
285
+ - Updated dependencies [66fb4fa]
286
+ - Updated dependencies [6d641c9]
287
+ - Updated dependencies [45e1949]
288
+ - Updated dependencies [92250d6]
289
+ - Updated dependencies [58bebf6]
290
+ - Updated dependencies [405e808]
291
+ - Updated dependencies [c0f9a4b]
292
+ - Updated dependencies [ac853ce]
293
+ - Updated dependencies [fa51109]
294
+ - Updated dependencies [d46f9b8]
295
+ - Updated dependencies [2fea4d2]
296
+ - Updated dependencies [7f1cb33]
297
+ - Updated dependencies [2e3b0c0]
298
+ - Updated dependencies [78fa331]
299
+ - Updated dependencies [31ab1ac]
300
+ - Updated dependencies [0082db8]
301
+ - Updated dependencies [06915b0]
302
+ - Updated dependencies [ff84b05]
303
+ - @object-ui/i18n@17.5.0
304
+
305
+ ## 17.4.0
306
+
307
+ ### Minor Changes
308
+
309
+ - 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)
310
+
311
+ `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.
312
+
313
+ 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.
314
+
315
+ Removed, with a tombstone at each site:
316
+
317
+ - `@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.
318
+ - `@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.
319
+ - `@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.
320
+ - `@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.
321
+
322
+ `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.
323
+
324
+ `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.
325
+
326
+ ### Patch Changes
327
+
328
+ - f0a625a: `disabled: ''` no longer greys out the remaining five action surfaces (objectui#3849)
329
+
330
+ objectui#3842 / PR #3851 fixed the "is a `disabled` gate DECLARED?" test on
331
+ `action:button` and app-shell's `DeclaredActionsBar`. Five same-shaped sites were
332
+ outside that PR's scope and stayed on `!= null`, so within one component the
333
+ `visible` gate asked `hasDeclaredVisibilityGate` while the `disabled` gate on the
334
+ next line asked `!= null` — two spellings of one question:
335
+
336
+ - `@object-ui/components` — `action:icon`, `action:group`'s inline button
337
+ (`InlineActionButton`) and dropdown item (`DropdownActionItem`), and
338
+ `action:menu`'s item (`ActionMenuItem`).
339
+ - `@object-ui/plugin-detail` — `record:quick_actions`' `QuickActionButton`.
340
+
341
+ Why the missing `!== ''` half is a defect on this key and not on `visible`:
342
+ `toPredicateInput('')` is `undefined` and `evaluateCondition(undefined)` is
343
+ `true`. On `visible` that `true` means SHOW, so an over-broad "declared" test and
344
+ a permissive empty predicate cancel out. On `disabled` it means DISABLE, so they
345
+ compound — `disabled: ''` (an empty predicate: nothing declared) rendered a
346
+ permanently greyed-out control, with nothing the author could write to un-grey
347
+ it. Unlike #3842's approvals inbox, these five are the general action face
348
+ (toolbars, dropdowns, record quick actions), so the reach is wider even though no
349
+ single high-value host owns them.
350
+
351
+ **Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes —
352
+ from disabled to clickable, which is what "no predicate" asked for. `disabled:
353
+ true` still disables, `disabled: false` and an absent `disabled` still do not, and
354
+ no expression-valued `disabled` changes verdict. On the four sites that also carry
355
+ the legacy non-spec `enabled` fallback, one consequence follows: an empty
356
+ `disabled` now falls THROUGH to that leg instead of short-circuiting on the empty
357
+ predicate, so an action spelling both (`disabled: ''` + `enabled: true`) becomes
358
+ clickable. `record:quick_actions` has no `enabled` leg, so its chain is the single
359
+ gate.
360
+
361
+ Routing those legacy `enabled` legs through the same definition is
362
+ behaviour-preserving by derivation rather than a fix: the leg is negated
363
+ (`disabled = !isEnabled`), so an empty predicate's `true` already arrived as "not
364
+ disabled" — the verdict "no gate declared" produces. #3842's four-shape derivation
365
+ table is reproduced next to the new pins, together with the statement that no
366
+ `enabled` case can go red by reverting that leg.
367
+
368
+ `hasDeclaredVisibilityGate` keeps its historic name (the objectui#3842 ruling): the
369
+ predicate is key-neutral, and one implementation behind two names is how a repo
370
+ grows dialects. The three `@object-ui/components` sites import it relatively;
371
+ `record:quick_actions` takes it from the package barrel, the cross-package route
372
+ objectui#3835 opened. Every call site says so in a comment.
373
+
374
+ - 5bfaabd: `PageComponentSchema.dataSource` now reaches every object-bound block, not just
375
+ `list-view` — and `element:record_picker` stops discarding `view`
376
+ (objectstack#6953).
377
+
378
+ objectstack#5576 wired the spec's per-element data binding
379
+ (`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left
380
+ the same declaration inert on every other page component. Two gaps remained, and
381
+ both were silent:
382
+
383
+ - **`element:record_picker` read four of the five keys and dropped `view`.** So
384
+ `dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
385
+ built a picker over EVERY account instead of the rows the saved view selects.
386
+ Nothing threw and nothing rendered an error; the option list was simply wider
387
+ than what was authored, which also means a user could select a record the page
388
+ said was out of scope.
389
+ - **`object-grid` / `object-form` / `object-kanban` / `object-calendar` /
390
+ `object-chart` / `object-metric` / `record:related_list` read none of it.**
391
+ Each gates its fetch on its own `objectName`, and nothing mapped
392
+ `dataSource.object` onto it, so a page written the way the spec documents
393
+ rendered an empty grid / a field-less form / a board with no cards / an empty
394
+ month / an empty chart / a static metric number — with no request and no
395
+ diagnostic anywhere. Spec-valid metadata rendering nothing is the
396
+ objectstack#4413 shape.
397
+
398
+ Composition follows objectstack#5576's landed semantics unchanged on every block:
399
+ a named saved view supplies the baseline, a key written on the component itself
400
+ overrides it, an explicit binding key overrides both, `filter` AND-combines
401
+ ("additional filter criteria" — a binding can narrow a view, never widen it), and
402
+ a `view` name that does not resolve renders a configuration error instead of
403
+ degrading to the object's full scope.
404
+
405
+ - `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)`
406
+ and `ElementDataSourceGate` apply a resolved binding to the schema keys a given
407
+ block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`
408
+ for the two non-final states. One precedence table for all blocks rather than
409
+ one copy per block — that copy is how "additional filter criteria" would have
410
+ become two dialects.
411
+ - A mapping names **only** keys its block genuinely reads. A composed value
412
+ written onto a key the block ignores would be accepted and dropped, which is
413
+ the defect being removed, one layer deeper — so a kanban's swimlane `columns`
414
+ never receive a view's field list, and a block with no row cap leaves `limit`
415
+ unmapped. The per-block coverage table, including two residual gaps that are
416
+ named rather than papered over, is in `content/docs/guide/data-source.md`.
417
+
418
+ No behaviour changes for a block that carries no `dataSource`: the binding-free
419
+ path returns the schema by reference, so nothing remounts and nothing refetches.
420
+
421
+ - 7b3e048: `record:highlights` publishes the `readonly` entry key, so an AI author can discover it from the manifest
422
+
423
+ `readonly` on a `fields[]` entry has been enforced for a while — the renderer copies it
424
+ through normalization and `HeaderHighlight`'s editability gate refuses inline editing on a
425
+ chip carrying it (objectstack#5077) — and `@objectstack/spec` declares it on
426
+ `RecordHighlightsField` (objectstack#5176 / PR #5607). The block's own published authoring
427
+ surface never mentioned it: the `fields` input still spelled the entry shape
428
+ `{name,label?,icon?,type?}`, and since the registry `inputs` are what
429
+ `gen-manifest.ts` serializes into `sdui.manifest.json`, an author reading the manifest was
430
+ told the key did not exist. The `fields` description now states the full entry shape and
431
+ what `readonly` does, which is the discoverability the manifest is for.
432
+
433
+ `readonly` is documented **inside** the `fields` description rather than declared as an
434
+ input of its own, because that is where the contract puts it. The spec's
435
+ `RecordHighlightsProps` has exactly three top-level keys (`fields`, `layout`, `aria`) and
436
+ carries `readonly` per ENTRY. A top-level `{ name: 'readonly', type: 'boolean' }` input
437
+ would publish a key the platform silently discards: the generated `sdui.manifest.json` and
438
+ `sdui-intrinsics.d.ts` would advertise a `readonly` prop, the manifest gate validates
439
+ top-level props only and would raise no diagnostic, `RecordHighlightsProps` is a plain
440
+ `z.object` so the unknown key is stripped on parse without error, and the renderer — which
441
+ reads `field.readonly` per entry — would never see it. An author who trusted that surface
442
+ would be left with the machine-owned column still hand-editable and no diagnostic anywhere
443
+ explaining why. `ComponentInput` is flat by design, so an array-of-objects input publishes
444
+ its member keys in prose, as `record:path.stages` and `record:alert.action` already do.
445
+
446
+ A new spec-parity test derives both directions from `@objectstack/spec` at runtime instead
447
+ of restating today's key list: every key of `RecordHighlightsField`'s object arm must be
448
+ named in the `fields` description, and the block must declare no top-level input that
449
+ `RecordHighlightsProps` does not accept. Nothing previously cross-checked the registry
450
+ `inputs` against the spec, so both drift directions were silent. No runtime behaviour
451
+ changes.
452
+
453
+ - aca561a: Four spec keys the renderers already honoured are now discoverable from the published `inputs`
454
+
455
+ `record:details.hideFields`, `record:related_list.relationshipValueField`,
456
+ `record:related_list.add` and `element:text_input.defaultValue` were declared by
457
+ `@objectstack/spec` and read by their renderers, while the registry `inputs` —
458
+ the surface `gen-manifest.ts` serializes into `sdui.manifest.json` and
459
+ `sdui-intrinsics.d.ts` — never mentioned them. Nothing anywhere reported the
460
+ mismatch, and every layer that reads a manifest said the opposite of the
461
+ runtime: the keys were in no designer panel and no generated `.d.ts`,
462
+ `sdui-parser`'s prop walk returned `unknown-prop` for an author who wrote one,
463
+ and the renderer honoured it regardless. That is objectui#3407's original
464
+ complaint (`readonly` was enforced and honoured, the description just never said
465
+ so) on four more keys.
466
+
467
+ Each description is derived from what the renderer actually does, not from
468
+ restating the spec's one-liner, because the two can differ and the published
469
+ text is what an AI author reads:
470
+
471
+ - `hideFields` documents bare field names only — the renderer tolerates
472
+ `{name}` / `{field}` entries but the spec is `z.array(z.string())` and rejects
473
+ them, so teaching that spelling would publish a dialect the contract refuses;
474
+ - `relationshipValueField` publishes the renderer's `'id'` default and says that
475
+ the resolved value drives the list filter, the Add-picker link value and the
476
+ pre-filled create form together;
477
+ - `add` publishes its member shape in prose (`ComponentInput` is flat and has no
478
+ member-shape slot) with each default taken from the renderer — including
479
+ `picker.labelField`, where the renderer defaults to `name` while the spec's
480
+ own wording says "the object title field". It also names `picker.filter` as a
481
+ KNOWN GAP rather than documenting it as a restriction: the spec declares it
482
+ and nothing reads it, so an author would otherwise believe their picker is
483
+ scoped when it offers every record (objectui#3831);
484
+ - `defaultValue` distinguishes the two behaviours an author can get — seeding a
485
+ bound page variable once while it is still empty, versus the native
486
+ uncontrolled initial value with no variable bound.
487
+
488
+ `element:text_input` is not in the public tier, so its gap was not in
489
+ `sdui.manifest.json` at all — it was in the JSX-page compiler's prop whitelist,
490
+ which `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same
491
+ `inputs`, making the undeclared `defaultValue` a live `unknown-prop` warning.
492
+
493
+ The repo-wide parity gate now runs in both directions over one covered set and
494
+ one exemption discipline, so neither direction can be forgotten again the way
495
+ the reverse half was after PR #3806. Nine spec keys stay deliberately
496
+ unpublished, each with a written reason and a tracking issue: two the renderers
497
+ do not read at all (objectui#3829), three retired upstream by ADR-0087
498
+ tombstones, `page:tabs.type` (a carrier collision, objectstack#6776), two
499
+ `targetVariable` declarative hints (objectui#3834), and
500
+ `element:record_picker.filter` (objectui#3830).
501
+
502
+ - 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).
503
+
504
+ `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.
505
+
506
+ **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.
507
+
508
+ **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 刷新.
509
+
510
+ 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.
511
+
512
+ - 4178d5a: `record:details` 的 `sections` 输入说明改为从 spec 形状派生的对象形,不再教已被退役的「Section IDs」
513
+
514
+ `inputs` 不是文档,而是发布出去的编写契约:`gen-manifest.ts` 把它序列化进
515
+ `sdui.manifest.json`(保存门 + parser 白名单)和 `sdui-intrinsics.d.ts`。而
516
+ `record:details.sections` 的说明写的是 `Section IDs to show (required when layout
517
+ is "custom")` —— 那是 17.x 以前的形状。pin 版 `@objectstack/spec@17.0.0-rc.5` 的
518
+ `RecordDetailsProps.sections` 是对象数组 `{ name?, label?, columns?, fields }`,
519
+ objectstack#5611 把 `z.array(z.string())` 那条拼法**删掉**而不是 union 进来(既无
520
+ producer 也无 consumer,一种形状而不是两套事实契约)。
521
+
522
+ 照旧说明写 `sections: ['contact_info', 'address']` 的作者,在四层之间拿不到任何
523
+ 诊断:`['a','b']` 对 manifest 门是合法 `array`(门只看顶层键名 + 粗类型),上游
524
+ `validateComponentProps` 是 advisory 级,spec 只在真的走 parse 的路径上才拒,而
525
+ `RecordDetailsRenderer` 对每个条目读 `s.name` / `s.label` / `s.fields` —— 字符串上
526
+ 三者全 `undefined`,该 section 一个字段都不渲染。`layout: 'custom'` 时 sections 是
527
+ 详情页正文的唯一来源,所以结果是一张没有报错的空白详情页。
528
+
529
+ 新说明逐键派生自 spec 各成员的 `.describe()` 与渲染器实读:`fields` 必填、按序渲染;
530
+ `label` 是标题(省略即无标题、无边框);`name` 是 snake_case 稳定标识与 i18n 锚点
531
+ (标题走 `objects.{object}._sections.{name}.label`);`columns`(1-4)是本 section 的
532
+ 字段栅格宽度,省略则由渲染器推导;并明确写出字符串条目不被接受。渲染器另外还认的
533
+ `title` / `showBorder` / `hideEmpty` **故意不写进说明** —— spec 的 section 对象没有
534
+ 声明它们,parse 时会被静默剥掉,发布它们等于教作者写契约丢弃的键。
535
+
536
+ 同时新增 `recordDetailsInputs.spec-parity.test.ts`:两个方向的断言都在运行时从 spec
537
+ schema 派生(每个 spec 成员键都能从说明里发现;本 block 不声明 spec 不接受的顶层
538
+ input),所以下一次 spec 变形会先让测试红,而不是又一次静默张开。仅说明文本变化,无
539
+ 运行时行为改动。
540
+
541
+ - 54233b1: Record detail pages: a header ⟳ that refreshes the record, its related lists and its tab counts in place — no browser reload
542
+
543
+ 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.
544
+
545
+ 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:
546
+
547
+ - **`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.
548
+ - **`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.
549
+ - **`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.
550
+
551
+ Hosts that provide no `refresh` render exactly as before.
552
+
553
+ - c2ecbae: 相关列表 Add 选择器兑现 `add.picker.filter`:作者限定的候选范围现在真的生效
554
+
555
+ `record:related_list.add.picker.filter` 被 spec 声明为「Restrict which records the picker offers」,但渲染器从未读过它 —— 挂 `RecordPickerDialog` 时不传任何 filter,对话框照样提供 `picker.object` 的全部记录,选中即建链接行或改父,`os validate` / `os build` 全绿、运行时零诊断。作者写下「只允许指派 active 的岗位」「只允许挂未过期的许可」,得到的是完整候选列表。
556
+
557
+ 现在它按原样传给 `RecordPickerDialog` 的 `baseFilter` —— 不是 `lookupFilters`,后者会把条件渲染成用户可编辑的筛选栏行,等于把作者的硬性限制降级成建议。
558
+
559
+ `baseFilter` 因此接受两种形状,按结构判别(`Array.isArray`):
560
+
561
+ - **`QueryParams.$filter` 记录形式**(依赖型 lookup 链)保持原有的键覆盖语义逐字节不变 —— 级联父值必须**替换**同字段上过期的 `lookupFilters` 条目,而不是与之求交。
562
+ - **spec 的 `ViewFilterRule[]`** 经 `mergeFilterNodes`(仓内唯一的 filter 下沉口)下沉,19 个 operator 全部无损到达服务端,包括记录形式没有 `$op` 可用的 `before` / `after` / `is_empty` / `is_not_empty`。此处**不新增**第二份 operator 词汇表。
563
+
564
+ 槽位类型同时从 `Record<string, any>` 收紧为 `unknown`:前者会接受规则数组(数组满足 `any` 的字符串索引),旧的对象展开再把它压成 `{"0": {...}}`,于是查询去过滤名为 `0` 的列 —— 类型全绿、查询错误、无任何诊断。
565
+
566
+ - acc34c5: `record:related_list`: an `add` without `add.picker` no longer takes the whole related list down.
567
+
568
+ 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.
569
+
570
+ - c4768a7: `record:related_list` — the declared `filter` reaches the query, and the Add button answers to the same gate as its dialog
571
+
572
+ - **`filter` is consumed** (objectstack#7118). The spec declares
573
+ `RecordRelatedListProps.filter` ("additional filter criteria") and this repo
574
+ published it as a registry input, but nothing read it: `RelatedList` built its
575
+ query from `{ [relationshipField]: parentId }` alone, so an authored filter was
576
+ accepted by every gate and silently dropped — the list answered with every child
577
+ of the parent. It is now AND-combined with the parent condition (never
578
+ substituted for it, so an additional criterion can only narrow), lowered through
579
+ the repo's single filter sink so the spec's `[{ field, operator, value }]`
580
+ vocabulary and a composed `dataSource` binding both work. With nothing authored
581
+ the query is unchanged. As a consequence a saved view named through
582
+ `dataSource: { object, view }` no longer contributes its columns/sort/limit while
583
+ its filter is discarded — the list can no longer be wider than the view it names.
584
+ On the legacy raw-URL fallback path, which cannot express an operator, a declared
585
+ filter is refused with a console explanation instead of dropped.
586
+ - **The Add button now requires `dataSource`** (objectui#3895), matching the picker
587
+ dialog and the add callback. In hosts that supply no `RecordContext` — Studio
588
+ designer previews, context-free embeds — the button rendered and did nothing at
589
+ all when clicked; the affordance is now withheld where the capability behind it
590
+ is absent.
591
+
592
+ - Updated dependencies [7864f03]
593
+ - Updated dependencies [f5f8744]
594
+ - Updated dependencies [69becd2]
595
+ - Updated dependencies [5e52495]
596
+ - Updated dependencies [b750823]
597
+ - Updated dependencies [ac2139c]
598
+ - Updated dependencies [b14ab3a]
599
+ - Updated dependencies [8c60819]
600
+ - Updated dependencies [e64a52e]
601
+ - Updated dependencies [844d17f]
602
+ - Updated dependencies [4dcd52a]
603
+ - Updated dependencies [42ae5c6]
604
+ - Updated dependencies [6d762da]
605
+ - Updated dependencies [f9faa7d]
606
+ - Updated dependencies [33526fd]
607
+ - Updated dependencies [32413ec]
608
+ - @object-ui/i18n@17.4.0
609
+
610
+ ## 17.3.0
611
+
612
+ ### Minor Changes
613
+
614
+ - 5af2852: The record detail page now shows a read-gated approval panel (#3461). A record in approval used to expose NOTHING about the running approval to anyone but the current pending approver — `useRecordApprovals` was consumed solely to inject the header Approve/Reject buttons, while the pending-approver list, decision progress, and the `sys_approval_action` timeline existed only in the Approval Center's drawer, a `setup`-app surface that business roles can't navigate to (and whose backing object is tenant-wide, so granting read there is over-broad). The submitter couldn't tell whom to nudge; the record's own audit history was no help either, since the engine mirrors business fields as `runAs:'system'` and decisions never enter record history. The new surface is an **Approvals tab** on the record page — a peer of Details/Related (same promotion Attachments got in objectstack#4358), emitted by `buildDefaultTabs` only when the record actually has requests, with a request-count badge and the label localizing through the tab strip's KNOWN_LABEL_DICT (审批). The tab wraps the new `record:approvals` node (`RecordApprovalsPanel`), visible to EVERY viewer who can read the record: current flow/step with the enriched flow-steps strip, server-computed decision progress (quorum tally, per-group 会签 ticks), the waiting-on chips with server-resolved names and group labels (never raw ids), one chronological action timeline merged across all of the record's requests (a multi-level flow opens one request per node), decision comments and attachments, and an inline remind button for the submitter (`viewer.is_submitter`, with an id-match fallback for older backends) that POSTs the existing `/approvals/requests/:id/remind`. The host threads its live `useRecordApprovals` read through the node so the tab and the header decision buttons never disagree; on authored pages the `record:approvals` renderer self-fetches, and an authored page that omits the node gets a bottom-of-page fallback append so the approval story is never lost to a custom layout. Copy reuses the Approval Center's `approvalsInbox.*` keys so the two surfaces can't drift; `useRecordApprovals` now exposes the full `requests` array plus `listApprovalActions` / `remindApprovalRequest`, and its `ApprovalRequestLite` carries the display enrichment (`process_label`, `step_label`, `flow_steps`, `viewer`, `round`) the single-read endpoint already sent.
615
+
616
+ ### Patch Changes
617
+
618
+ - 532cf8b: Deliver the required state to the control in the five renderers outside the object form that still painted it as an asterisk only (objectui#3299 — the same defect #3290/#3298 fixed in `form.tsx`).
619
+
620
+ Each site converges on the reference shape (`EmbeddableForm.tsx`): the control carries `aria-required={required || undefined}` and the asterisk is `aria-hidden="true"`, so assistive tech announces required once, as a state — instead of hearing a bare "asterisk" folded into the accessible name, or nothing at all.
621
+
622
+ - `@object-ui/app-shell` — `ActionParamDialog` (both the boolean row and the default branch, delivered through the real field widgets' `toDomProps` whitelist) and `CreateViewDialog` (display label, machine name, and every type-specific required-field selector).
623
+ - `@object-ui/components` — the custom `ActionParamDialog` (all five typed branches, including the Radix select trigger) and `FieldContainer`, whose existing Slot injection (`id` / `aria-describedby` / `aria-invalid`) now also injects `aria-required`, covering every consumer in one place.
624
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab inputs.
625
+
626
+ Deliberately NOT the native `required` attribute (#3290 ruling): each of these hosts runs its own validation, and native `required` would arm the browser's constraint-validation bubble beside it. The SDUI controls that already use native `required` (`renderers/form/{input,textarea,select,checkbox}.tsx`, `basic/text-input.tsx`) are unchanged — they don't have a second validator, so their channel is already correct.
627
+
628
+ - b71fc92: Localize the last untranslated console-chrome accessible names (objectstack#5430)
629
+
630
+ Four icon-only controls still carried hardcoded English accessible names, so
631
+ under a non-English session they were the only English left in the record
632
+ chrome — and because the controls have no visible label, that literal _is_ the
633
+ control to a screen reader and to the hover tooltip.
634
+
635
+ - `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
636
+ overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
637
+ - `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
638
+ - `ReactionPicker`'s per-reaction chip, which built its name by concatenation
639
+ with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
640
+ now `detail.reactionCount` / `detail.reactionCountOne`
641
+ - `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
642
+ (the key the rest of the console already uses) and `common.closePanel`
643
+
644
+ The pluralized label follows this repo's **two-key** convention
645
+ (`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
646
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
647
+ form, so those packs would legitimately omit the `_one` half and
648
+ `all-locales-key-parity` would read that as a lost key.
649
+
650
+ All five new keys are added to all ten locale packs.
651
+
652
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
653
+
654
+ - A dependency-gated lookup now names its controlling field by its **label**
655
+ instead of its raw API name. The sentence was localized but the interpolated
656
+ name was not, so every locale — English included — read `Select crm_account
657
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
658
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
659
+ for the fixed-option widgets); a name the host does not cover still falls
660
+ back to itself.
661
+ - The page-header overflow trigger's `More actions` accessible name now reads
662
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
663
+ so the two cannot diverge per locale.
664
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
665
+ bundle key (`detail.addReaction`, added to all ten packs).
666
+ - The "check the highlighted fields" toast joins field names with a per-locale
667
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
668
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
669
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
670
+ - The Spanish `validation.required` / `validation.unique` templates gained
671
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
672
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
673
+ ungrammatical.
674
+
675
+ - bbbde12: Behavior change — **an authored display `type` can NARROW inline editability, but never WIDEN it** (objectui#3355).
676
+
677
+ Both detail-surface editability gates (`HeaderHighlight`, the `record:highlights` strip; `DetailSection`, the details body) used to resolve ONE effective type with display precedence — `viewFieldType || objectFieldType`. An authored non-computed `type` therefore ERASED the object's `formula` / `summary` / `rollup` / `auto_number` declaration from the gate's view, and a machine-owned column became inline-editable.
678
+
679
+ The gate now reads the two types separately and takes their UNION: a field is non-editable if the authored entry type **or** the object field's type is computed. Renderer/editor selection keeps the old precedence, so nothing about the display changes — only who may write.
680
+
681
+ What flips:
682
+
683
+ - `{ name: 'supply_share', type: 'number' }` authored over an object field declared `rollup` (or `formula` / `summary` / `auto_number`) — a display override written to fix formatting — no longer offers a pencil / double-click editor. This is the shipped configuration behind objectstack-ai/objectstack#5077: a hook-maintained rollup was overwritten by hand from the header strip and stayed corrupted until an unrelated child-row touch re-fired it (downstream yinlianghui/hotcrm-heimao#61).
684
+ - Narrowing is unchanged: an authored `type: 'formula'` still locks a plain object column.
685
+ - Fields with no authored computed type over a plain object column stay editable, and the entry-level `readonly` declaration from objectui#3356 is still honored.
686
+
687
+ The object schema is authoritative about what is machine-computed; a presentation override has no business granting write access. The rule now lives in ONE shared helper, `isComputedFieldType` in `fieldEnrichment.ts` — beside `enrichDetailField`, the module both hosts already share — with the computed-type set moved there too (still re-exported as `TEXTUAL_REF_FALLBACK_TYPES`), so the strip and the body cannot drift apart again.
688
+
689
+ - 5a24ad9: Localize `RecordDetailDrawer`'s drag-resize handle (objectstack#5733)
690
+
691
+ `packages/plugin-detail/src/RecordDetailDrawer.tsx` carried a byte-identical twin
692
+ of the literal objectstack#5506 removed from `NavigationOverlay`: a
693
+ `role="separator"` drag handle on the drawer's left edge with a hardcoded
694
+ `aria-label="Resize drawer"`. #5506's sweep fenced on `packages/components`, so
695
+ this second copy survived it.
696
+
697
+ The handle has no visible label, so that string IS the control as far as a
698
+ screen reader is concerned — a zh/ja/de session got one English announcement in
699
+ an otherwise localized drawer. It is not a dormant branch either: `resizable`
700
+ defaults to `true`, and the drawer is what plugin-kanban / plugin-calendar /
701
+ plugin-gantt open on row, card and event click.
702
+
703
+ It now reads `t('common.resizeDrawer')` — deliberately the SAME key #5506 gave
704
+ the other handle (already present in all ten locale packs) rather than a new
705
+ `detail.resizeDrawer` twin, so one control rendered from two packages cannot end
706
+ up with two translations that drift apart.
707
+
708
+ `common.resizeDrawer` is also added to `DETAIL_DEFAULT_TRANSLATIONS`, the map
709
+ `createSafeTranslation` falls back to when no `I18nProvider` is mounted. Without
710
+ that entry the name would degrade to the raw key for every provider-less host —
711
+ which is the regression the accompanying no-provider test pins.
712
+
713
+ - 23018cc: `record:highlights` now honours a `readonly: true` on an authored field entry, so a header chip for a platform-owned column no longer offers inline edit. `HeaderHighlight`'s editability gate already consulted `field.readonly`, but the renderer rebuilt each entry from a fixed `{name,label,icon,type}` list and dropped `readonly` one layer before that check, so the gate could never fire from authored metadata — a hook-maintained rollup or approval-written grade could be overwritten by hand from the detail-page header strip and stayed wrong until an unrelated write re-fired the computation. `readonly` is now a declared key on `HighlightField` and on the `RecordHighlightsComponentProps.fields[]` entry union, mirroring `DetailViewField.readonly` (objectstack#5077).
714
+ - 58a00f0: Give `InlineCreateRelated`'s card-header close button an accessible name (objectui#3411 — the neighbouring defect found while implementing #3381/PR #3410, in the same file and left outside that PR's scope fence as a different class).
715
+
716
+ The button is icon-only: its sole child was a lucide `X`, with no text, `aria-label`, `aria-labelledby` or `title`. lucide-react excludes childless, a11y-prop-less icons from the accessibility tree (it defaults them to `aria-hidden="true"`), so the button had no name source at all and its computed accessible name was the empty string — a screen reader announced a nameless "button". Unlike the placeholder case in #3381 there was no browser-side fallback to soften it: the name was empty in every implementation. WCAG 4.1.2 / 2.4.6.
717
+
718
+ The fix is `aria-label="Close"` on the button, plus an explicit `aria-hidden="true"` on the icon so the intent is local rather than inherited from the icon library's default. `aria-label` rather than #3381's visually hidden `<label>` because this control has no visible copy for a label to stay in step with — the drift that ruling guarded against cannot arise here — and it matches the shape the repo's other close buttons already use (shadcn's dialog/sheet, `DashboardEditor`).
719
+
720
+ No props, spec or visible-copy change; the component's rendering is otherwise identical.
721
+
722
+ - 53811d1: Associate the label with its control at the two form surfaces where the two were never programmatically connected (objectui#3341 — found while implementing #3299/PR #3340, and deliberately left out of that PR's scope fence as a different class of defect).
723
+
724
+ `aria-required` reaching the control (#3299) only fixes the required _state_; at these two sites the control's accessible _name_ was still wrong, because the label pointed at nothing:
725
+
726
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab fields rendered a `<label>` with no `htmlFor` beside an `<Input>` with no `id`, and the two were siblings rather than wrapper/child. The field label was unreachable for assistive tech, and clicking the label did not focus the input. The ids are namespaced with `React.useId`, because `field.name` alone is unique only within one instance and a detail page mounts one of these per related list.
727
+ - `@object-ui/components` — the custom `ActionParamDialog`'s `select` branch rendered `<Label htmlFor={param.name}>` but never put the matching `id` on its Radix `SelectTrigger`, so the reference dangled. The textarea / number / date / text branches already set `id={param.name}`; select was the only one that did not.
728
+
729
+ `SelectTrigger` renders a `<button role="combobox">`, and `button` is a labelable element, so the plain `htmlFor`/`id` pair is the correct association there — no `aria-labelledby` required. No spec change and no widget-props contract change.
730
+
731
+ - b17ce4c: Give `InlineCreateRelated`'s "Link Existing" search box a real accessible name (objectui#3381 — the neighbouring defect found while implementing #3341/PR #3380, and left out of that PR's scope fence as a different class).
732
+
733
+ The box carried a `placeholder` and nothing else: no `<label>`, no `aria-label`, no `aria-labelledby`. Its accessible name therefore fell through to the placeholder, which is the last resort in HTML-AAM and fails in two ways — the name a browser derives from it is the one thing that disappears the moment the user types, and the fallback is not implemented uniformly (`dom-accessibility-api` has no placeholder step at all, so under test the control computed to the empty string while a browser would have said "Search Contact…").
734
+
735
+ The fix is a visually hidden `<label htmlFor>` pointing at a `React.useId`-namespaced input id — the same shape #3341 left on the create tab, rather than an `aria-label`, so the accessible name stays a real label element instead of a detached string that can drift from the visible copy. The label text and the placeholder are now derived from one expression (the placeholder only adds the ellipsis), and the id uses a hyphenated `link-search` segment so it cannot collide with a create-tab field literally named `search`. The decorative magnifier is explicitly `aria-hidden` — lucide already defaults childless icons to that, but spelling it out keeps the intent local and independent of the icon library's defaults.
736
+
737
+ No props, spec or rendered-copy change: the placeholder string is byte-identical to before.
738
+
739
+ - c7fba27: `field:permission-facet-link` now registers through `withFieldCarrier` — the
740
+ repo's only raw `field:` registration bypassed the single-metadata-carrier seam
741
+ (objectui#3233), so under the SDUI path (`SchemaRenderer` passes `schema`,
742
+ never `field`) the widget read `field === undefined` and silently rendered an
743
+ anonymous facet summary (`field?.name` empty, no facet branch selected). The
744
+ form and inline-edit hosts were unaffected — they pass `field` directly, which
745
+ the carrier forwards unchanged. Fixes objectui#3307.
746
+ - 12bf669: The record discussion panel now says "loading" while it is loading, instead of
747
+ "No comments yet" (objectui#3209).
748
+
749
+ FROM: opening any record page showed the discussion/chatter panel asserting
750
+ `No comments yet` for the whole first leg of the page, then contradicting
751
+ itself when the comments appeared. TO: the panel shows the loading row until
752
+ the feed has actually answered, and only then commits to "this record has no
753
+ comments".
754
+
755
+ objectui#3205 gave `RecordActivityTimeline` the render branch that prefers a
756
+ loading row over the empty copy, and `RecordChatterPanel` already forwarded
757
+ `loading` to it in both positions — but on the chatter chain **nothing
758
+ produced the signal**, so that branch could never fire. `record:activity`
759
+ computes its own flag and was visibly fixed by #3205; chatter was not. The
760
+ four wiring points are one chain and are all closed here, because any one of
761
+ them left open still ships the empty copy to some user:
762
+
763
+ - `RecordDetailView` — the host that OWNS the feed fetch — now derives a
764
+ `feedLoading` flag from its two reads (`sys_comment` + `sys_activity`);
765
+ - `<DiscussionContextProvider loading={feedLoading}>` publishes it (the field
766
+ was already declared on `DiscussionContextValue`, and already read by
767
+ `record:activity`);
768
+ - the auto-appended `<RecordChatterPanel loading={feedLoading}>` — the panel
769
+ authored pages get when they place no discussion slot — receives it
770
+ directly;
771
+ - the `record:chatter` / `record:discussion` renderer forwards
772
+ `loading={discussion?.loading}`, so a hand-placed block is on the same
773
+ chain as the synthesized one.
774
+
775
+ The two reads run in parallel, so the flag closes over **both**: it clears on
776
+ `Promise.allSettled`, and a REJECTED read counts as an answer. A deployment
777
+ without the audit plugin 404s `sys_activity` and an object with
778
+ `enable.feeds: false` 403s `sys_comment`; neither may pin the panel in a
779
+ permanent spinner, which would be a worse bug than the one being fixed. The
780
+ flag is keyed by `object:recordId` rather than being a plain boolean, so the
781
+ first render of a record already reads as loading (no one-frame flash of the
782
+ empty state) and navigating between records cannot show the previous record's
783
+ settled answer.
784
+
785
+ No tolerance was added at the consumer. The timeline still does not guess that
786
+ "no items yet and just mounted" means loading — that guess is wrong the moment
787
+ a record genuinely has no comments, and the signal belongs to whoever owns the
788
+ fetch. Same shape as objectui#3165 / #3205: divergence converges at the
789
+ producer.
790
+
791
+ - Updated dependencies [b71fc92]
792
+ - Updated dependencies [65516ba]
793
+ - Updated dependencies [94c5b7c]
794
+ - Updated dependencies [ca0fa8f]
795
+ - Updated dependencies [3889ffb]
796
+ - Updated dependencies [7e2406a]
797
+ - Updated dependencies [4eeb932]
798
+ - Updated dependencies [5c856ec]
799
+ - Updated dependencies [68b6a28]
800
+ - Updated dependencies [0554e88]
801
+ - Updated dependencies [28b2e65]
802
+ - Updated dependencies [825bbe3]
803
+ - Updated dependencies [6195841]
804
+ - Updated dependencies [5dd0127]
805
+ - Updated dependencies [a415684]
806
+ - Updated dependencies [5af2852]
807
+ - Updated dependencies [a6ec93d]
808
+ - Updated dependencies [c7ed4c3]
809
+ - Updated dependencies [2409e1d]
810
+ - Updated dependencies [789fe3e]
811
+ - @object-ui/i18n@17.3.0
812
+
3
813
  ## 17.2.0
4
814
 
5
815
  ### Minor Changes
@@ -1341,6 +2151,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1341
2151
  objects+fields were editable in Studio; this reworks both surfaces.
1342
2152
 
1343
2153
  **Setup (assign + read-only):**
2154
+
1344
2155
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1345
2156
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1346
2157
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1355,6 +2166,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1355
2166
 
1356
2167
  **Studio (design every facet):** the permission matrix editor gains structured
1357
2168
  editors for the facets that were JSON-only —
2169
+
1358
2170
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1359
2171
  (scope-grouped, labelled chips).
1360
2172
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -1789,6 +2601,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1789
2601
  unknown @-tokens. 9 unit tests.
1790
2602
 
1791
2603
  `@object-ui/app-shell` `RecordDetailView` now:
2604
+
1792
2605
  1. Serializes the resolved mention ids into `sys_comment.mentions`
1793
2606
  (previously hard-coded `'[]'`, so servers had no idea who was being
1794
2607
  pinged).
@@ -1809,6 +2622,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1809
2622
  and the renderer also falls back to a value/label heuristic (matches
1810
2623
  `closed_lost`, `lost`, `failed`, `cancelled`, `失败`, `流失`, `丢单`, etc.)
1811
2624
  so existing CRM-style picklists get the treatment without migration.
2625
+
1812
2626
  - **Lost** stages render in a visually separated group with a left
1813
2627
  border, destructive (red) tint, pill shape, and `✗` glyph — mirroring
1814
2628
  the Salesforce / HubSpot alt-terminus pattern that signals "this
@@ -1838,6 +2652,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1838
2652
  - 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.
1839
2653
  - i18n: add `notifications.emptyUnread`, `notifications.filterUnread`, `notifications.filterAll` (en + zh) so the InboxPopover Unread/All sub-filter renders in the active locale.
1840
2654
  - 5425608: CRM UX polish pass — calmer enterprise look across detail + kanban.
2655
+
1841
2656
  - **plugin-kanban**: column headers now use a 2px muted accent stripe with
1842
2657
  neutral foreground titles + a quiet grey count pill instead of full
1843
2658
  rainbow gradient + colored title + colored count. Pipeline boards
@@ -1862,12 +2677,14 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1862
2677
  - 5633edd: feat(detail,grid): tab + selection motion polish
1863
2678
 
1864
2679
  **plugin-detail**
2680
+
1865
2681
  - `DetailTabs` and the auto-tabs path in `DetailView` (5 inline
1866
2682
  `<TabsContent>` instances: details, related, activity, discussion,
1867
2683
  history) now fade in when their tab becomes active, eliminating
1868
2684
  the harsh flash when switching tabs.
1869
2685
 
1870
2686
  **plugin-grid**
2687
+
1871
2688
  - `BulkActionBar` slides in from the bottom + fades in when a
1872
2689
  selection is made, instead of popping into existence.
1873
2690
  - The "N items selected" counter re-animates on every count change
@@ -1896,6 +2713,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1896
2713
  `defaultCurrency` is set on the field/column, formatting is unchanged.
1897
2714
 
1898
2715
  Fixed call sites:
2716
+
1899
2717
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
1900
2718
  `CurrencyCellRenderer` no longer default-param `'USD'`.
1901
2719
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -1917,6 +2735,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1917
2735
  - d1ec6a2: Fold inline-edit into the page-header overflow menu (HubSpot/Lightning
1918
2736
  pattern) and remove the orphan "Edit fields" toolbar row that previously
1919
2737
  floated between the tab strip and the first detail section.
2738
+
1920
2739
  - `@object-ui/app-shell` `RecordDetailView`: injects a new `sys_inline_edit`
1921
2740
  system action that appears in the ⋯ overflow menu and dispatches a
1922
2741
  `objectui:record:inline-edit-toggle` window CustomEvent (filtered by
@@ -1933,6 +2752,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1933
2752
  in `page:accordion` / `page:tabs` items.
1934
2753
 
1935
2754
  - cf30cc2: Polish Lightning record detail page layout.
2755
+
1936
2756
  - `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.
1937
2757
  - 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.
1938
2758
  - 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.
@@ -1947,6 +2767,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1947
2767
  `CONCURRENT_UPDATE`). `RecordDetailsRenderer` catches it and opens a
1948
2768
  new `<ConcurrentUpdateDialog>` showing the user's pending value next
1949
2769
  to the server's current value, with three resolution paths:
2770
+
1950
2771
  - **Reload latest** — discard the pending edit and refetch.
1951
2772
  - **Overwrite anyway** — retry against the server's freshest version
1952
2773
  (still OCC-checked, but acknowledges "I've seen the newer version").
@@ -1962,6 +2783,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1962
2783
  backend support in `@objectstack/rest@>=4.2.0`.
1963
2784
 
1964
2785
  - 49b1760: Polish the ConcurrentUpdateDialog and add i18n.
2786
+
1965
2787
  - 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.
1966
2788
  - Replace the plain dialog header with an amber warning badge + `AlertTriangle` icon to communicate that this is a conflict, not a routine confirmation.
1967
2789
  - 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.
@@ -1977,17 +2799,20 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1977
2799
  - bd8447d: Three platform-wide detail polish items.
1978
2800
 
1979
2801
  **Tighter page rhythm**
2802
+
1980
2803
  - Outer `PageRenderer` padding `p-4 md:p-6 lg:p-8` → `p-3 md:p-4 lg:p-6`
1981
2804
  and outer body wrap `space-y-8` → `space-y-6` so list / detail / home
1982
2805
  pages share the same edge rhythm. Cuts ~16px of edge slack on lg.
1983
2806
 
1984
2807
  **Highlights KPI treatment**
2808
+
1985
2809
  - `HeaderHighlight` now renders numeric / currency / percent / decimal
1986
2810
  values as KPI numbers (`text-xl md:text-2xl font-semibold tabular-nums`)
1987
2811
  instead of the uniform `text-sm font-semibold`, so amount / probability
1988
2812
  / count fields read as headline stats — Salesforce-style key facts.
1989
2813
 
1990
2814
  **Discussion footer upgrade**
2815
+
1991
2816
  - `RecordActivityTimeline` now uses `RichTextCommentInput` (bold / italic /
1992
2817
  list / code, `@`-mention autocomplete, preview toggle, Send) instead of
1993
2818
  a bare `<textarea>`.
@@ -2002,6 +2827,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2002
2827
  accordion in flush mode dropped all per-item borders so the collapsed
2003
2828
  "Quotes / Products / Open Tasks" triggers stacked with zero visual
2004
2829
  separation.
2830
+
2005
2831
  - `@object-ui/plugin-detail` `DetailSection`: override the `CardTitle`
2006
2832
  className to `text-base font-semibold tracking-tight`, slim down
2007
2833
  `CardHeader` padding (`py-3 px-4 sm:py-4 sm:px-6`) and `CardContent`
@@ -2016,6 +2842,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2016
2842
  CRM-style related-list lists).
2017
2843
 
2018
2844
  - d51a577: feat(platform): Discussion attachments + @mention directory + Reference Rail aside
2845
+
2019
2846
  - **Discussion attachments** — `RichTextCommentInput` now accepts an `extraSlot`
2020
2847
  and a `canSubmitEmpty` flag so hosts can mount the existing
2021
2848
  `CommentAttachment` composer beneath the editor without forking the toolbar.
@@ -2039,6 +2866,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2039
2866
  custom `Page`. Catalog-style objects (Product, Task) ship with the rail
2040
2867
  off by default; hub objects (Account, Opportunity, Contact, Case) keep it
2041
2868
  on.
2869
+
2042
2870
  - `RecordDetailView` now reads `(objectDef as any)?.detail?.hideReferenceRail`
2043
2871
  and `…?.hideRelatedTab` and threads them to `buildDefaultPageSchema`.
2044
2872
  - The Reference Rail renderer also accepts entries authored as either a
@@ -2089,6 +2917,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2089
2917
  not only contacts of that account).
2090
2918
 
2091
2919
  Two coupled fixes:
2920
+
2092
2921
  1. `RelatedList` now requires `parentId` + `referenceField` to auto-
2093
2922
  fetch. When both are present it calls `dataSource.find(api,
2094
2923
  { $filter: { [referenceField]: parentId } })`. When either is
@@ -2134,6 +2963,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2134
2963
  default detail view. They were missing cross-cutting affordances and
2135
2964
  shipped with English-only tab labels and heavy bordered section cards
2136
2965
  even when the host locale was Chinese. Track 1 closes the visible gap:
2966
+
2137
2967
  - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
2138
2968
  the same chrome as the default branch — lifecycle managed-by badge
2139
2969
  and presence avatars in the top-right, `MetadataPanel` debug panel,
@@ -2236,6 +3066,7 @@ hover:no-underline` (Shadcn's hover-underline default looks busy on
2236
3066
  layout — only the auto-inference default changed.
2237
3067
 
2238
3068
  - b14fe09: Phase P.0 + P.5: tighten record-detail header chrome.
3069
+
2239
3070
  - `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.
2240
3071
  - `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.
2241
3072
 
@@ -2278,6 +3109,7 @@ populated` disclosure that expands on click. The expanded view shows
2278
3109
  result was visually noisy ("有的下划线,有的有边框,有的没边框").
2279
3110
 
2280
3111
  This change commits to a single design language:
3112
+
2281
3113
  - **Highlights** (`HeaderHighlight`): drop the `Card`/`CardContent`
2282
3114
  wrapper. Render as a borderless `<section>` of stat cells with a
2283
3115
  subtle `border-b` separator. The tab strip below now carries the
@@ -2298,6 +3130,7 @@ populated` disclosure that expands on click. The expanded view shows
2298
3130
  by their tabular content. No internal package APIs changed.
2299
3131
 
2300
3132
  - 74962b0: feat(detail): record:discussion schema component + flush accordion variant
3133
+
2301
3134
  - New `record:discussion` schema type lets authors place the record
2302
3135
  chatter feed anywhere in a custom Page schema. Wired through a
2303
3136
  shared `DiscussionContext` provider on the `assignedPage` branch
@@ -2311,6 +3144,7 @@ populated` disclosure that expands on click. The expanded view shows
2311
3144
  or `和` (e.g. `Notes & Attachments` → `备注与附件`).
2312
3145
 
2313
3146
  - 8b850b5: feat(detail): record:path chevron stepper + record:highlights surface refresh (Phase E)
3147
+
2314
3148
  - `record:path` now renders Salesforce Lightning-style chevron segments
2315
3149
  (clip-path arrows + overlap) with a primary glow on the current step
2316
3150
  and a check mark on completed steps. On mobile (`<sm`) it falls back
@@ -2335,6 +3169,7 @@ populated` disclosure that expands on click. The expanded view shows
2335
3169
  accordion, discussion slot) that custom Lightning pages already enjoy.
2336
3170
 
2337
3171
  Changes:
3172
+
2338
3173
  - `buildDefaultPageSchema` now emits `page:tabs.items` (correct shape
2339
3174
  for the renderer) rather than `tabs`.
2340
3175
  - `PageHeaderRenderer.resolvedTitle` honors `objectSchema.primaryField`
@@ -2358,6 +3193,7 @@ populated` disclosure that expands on click. The expanded view shows
2358
3193
  customize the header or one tab.
2359
3194
 
2360
3195
  **Slot menu (v1):**
3196
+
2361
3197
  - `header` — replaces `page:header`
2362
3198
  - `actions` — replaces the `record:quick_actions` action bar
2363
3199
  - `highlights` — replaces the chips + chevron path strip
@@ -2385,6 +3221,7 @@ populated` disclosure that expands on click. The expanded view shows
2385
3221
  ```
2386
3222
 
2387
3223
  **API changes:**
3224
+
2388
3225
  - `PageSchema` (in `@object-ui/types`): adds `kind?: 'full' | 'slotted'`
2389
3226
  (default `'full'`) and `slots?: PageSlotMap`.
2390
3227
  - `usePageAssignment` (in `@object-ui/react`): result now exposes a
@@ -2394,6 +3231,7 @@ populated` disclosure that expands on click. The expanded view shows
2394
3231
  `options.slots` map that overrides individual regions at synthesis time.
2395
3232
 
2396
3233
  - 34b66bf: feat(detail): synthesize Related / Activity / History tabs + record:quick_actions header (Track 3 Phase G slice 4)
3234
+
2397
3235
  - `buildDefaultPageSchema` now accepts `headerActions`, `related`,
2398
3236
  `showActivity`, and `history` options. When provided, the synthesizer
2399
3237
  emits a `record:quick_actions` node after `page:header` and appends
@@ -2418,6 +3256,7 @@ populated` disclosure that expands on click. The expanded view shows
2418
3256
 
2419
3257
  Two regression fixes surfaced by the Phase J browser canary across CRM
2420
3258
  record detail pages:
3259
+
2421
3260
  1. **`record:path` now localizes stage labels.** The renderer threads
2422
3261
  `useSafeFieldLabel().translateOptions` against the record-context's
2423
3262
  `objectName` + the schema's `statusField`, so picklist labels match the
@@ -2494,6 +3333,7 @@ populated` disclosure that expands on click. The expanded view shows
2494
3333
  - d714e85: Lookup display-name resolution now falls back through a Salesforce-style chain
2495
3334
  when an `$expand`'d reference object lacks a top-level `name`/`label`/
2496
3335
  `display_name`/`title` field:
3336
+
2497
3337
  1. Standard display fields (existing behaviour)
2498
3338
  2. `salutation first_name last_name` composite — handles person records that
2499
3339
  only carry first/last name parts
@@ -2529,6 +3369,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2529
3369
  ### Patch Changes
2530
3370
 
2531
3371
  - 0d8eb98: feat(detail): Salesforce-style record header + section field grid
3372
+
2532
3373
  - `page:header` now renders an icon chip (resolves Lucide names via
2533
3374
  `LazyIcon`) plus subtitle, so detail pages can show
2534
3375
  "Name / Company" without an extra component.
@@ -2577,6 +3418,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2577
3418
  detail view (`TypeError: titleFormat.replace is not a function`) and printed
2578
3419
  `Failed to evaluate expression: ${[object Object]}` for every action visibility
2579
3420
  predicate.
3421
+
2580
3422
  - `@object-ui/core`: `ExpressionEvaluator.evaluate` / `evaluateCondition` now
2581
3423
  unwrap Expression envelopes transparently.
2582
3424
  - `@object-ui/react`: new `toPredicateInput()` helper to safely normalize
@@ -2628,6 +3470,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2628
3470
  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.
2629
3471
 
2630
3472
  Specifically fixes:
3473
+
2631
3474
  - `@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.
2632
3475
  - `@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.
2633
3476
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.