@object-ui/core 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +2018 -0
  2. package/README.md +38 -34
  3. package/dist/actions/ActionEngine.d.ts +43 -26
  4. package/dist/actions/ActionEngine.js +67 -54
  5. package/dist/actions/ActionRunner.d.ts +281 -31
  6. package/dist/actions/ActionRunner.js +458 -36
  7. package/dist/actions/actionKeys.d.ts +163 -27
  8. package/dist/actions/actionKeys.js +238 -27
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  17. package/dist/data-scope/ViewDataProvider.js +83 -5
  18. package/dist/data-scope/element-data-source.d.ts +113 -0
  19. package/dist/data-scope/element-data-source.js +216 -0
  20. package/dist/data-scope/index.d.ts +2 -1
  21. package/dist/data-scope/index.js +1 -0
  22. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  23. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  24. package/dist/evaluator/SafeExpressionParser.js +0 -4
  25. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  26. package/dist/evaluator/declaredPredicate.js +152 -0
  27. package/dist/evaluator/fieldRules.d.ts +35 -0
  28. package/dist/evaluator/fieldRules.js +79 -20
  29. package/dist/evaluator/index.d.ts +2 -0
  30. package/dist/evaluator/index.js +2 -0
  31. package/dist/evaluator/listConditional.d.ts +54 -4
  32. package/dist/evaluator/listConditional.js +125 -19
  33. package/dist/evaluator/optionRules.d.ts +55 -0
  34. package/dist/evaluator/optionRules.js +60 -0
  35. package/dist/evaluator/predicateInput.d.ts +51 -3
  36. package/dist/evaluator/predicateInput.js +62 -5
  37. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  38. package/dist/evaluator/rowPredicateCanon.js +208 -0
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +31 -1
  41. package/dist/protocols/DndProtocol.d.ts +42 -1
  42. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  43. package/dist/protocols/SharingProtocol.d.ts +11 -1
  44. package/dist/registry/Registry.d.ts +62 -16
  45. package/dist/theme/ThemeEngine.d.ts +0 -8
  46. package/dist/theme/ThemeEngine.js +22 -103
  47. package/dist/theme/index.d.ts +1 -1
  48. package/dist/theme/index.js +7 -1
  49. package/dist/types/index.d.ts +125 -12
  50. package/dist/utils/chart-presentation.d.ts +205 -0
  51. package/dist/utils/chart-presentation.js +280 -0
  52. package/dist/utils/chart-series.d.ts +390 -23
  53. package/dist/utils/chart-series.js +776 -32
  54. package/dist/utils/column-identity.d.ts +43 -0
  55. package/dist/utils/column-identity.js +47 -1
  56. package/dist/utils/compare-to.d.ts +40 -13
  57. package/dist/utils/compare-to.js +4 -32
  58. package/dist/utils/dashboard-filters.d.ts +54 -8
  59. package/dist/utils/dashboard-filters.js +261 -12
  60. package/dist/utils/dataset-format.d.ts +73 -14
  61. package/dist/utils/dataset-format.js +142 -9
  62. package/dist/utils/dataset-pivot.d.ts +28 -7
  63. package/dist/utils/dataset-pivot.js +27 -6
  64. package/dist/utils/debug.js +7 -0
  65. package/dist/utils/dom-props.d.ts +137 -0
  66. package/dist/utils/dom-props.js +153 -0
  67. package/dist/utils/expand-fields.d.ts +97 -7
  68. package/dist/utils/expand-fields.js +97 -7
  69. package/dist/utils/filter-tokens.js +0 -1
  70. package/dist/utils/freeze-schema.d.ts +38 -1
  71. package/dist/utils/freeze-schema.js +10 -0
  72. package/dist/utils/humanize-label.d.ts +60 -0
  73. package/dist/utils/humanize-label.js +62 -0
  74. package/dist/utils/managedBy.d.ts +27 -2
  75. package/dist/utils/normalize-list-view.d.ts +36 -6
  76. package/dist/utils/normalize-list-view.js +93 -9
  77. package/dist/utils/number-display.d.ts +155 -0
  78. package/dist/utils/number-display.js +71 -0
  79. package/dist/utils/predicate-fields.d.ts +29 -4
  80. package/dist/utils/predicate-fields.js +44 -4
  81. package/dist/utils/resolve-view-id.d.ts +31 -0
  82. package/dist/utils/resolve-view-id.js +45 -0
  83. package/dist/utils/retired-field-types.d.ts +90 -0
  84. package/dist/utils/retired-field-types.js +124 -0
  85. package/dist/utils/sort-query.d.ts +57 -0
  86. package/dist/utils/sort-query.js +35 -0
  87. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  88. package/dist/utils/unmaterialized-fields.js +57 -0
  89. package/dist/validation/index.d.ts +1 -0
  90. package/dist/validation/index.js +1 -0
  91. package/dist/validation/schema-validator.js +105 -0
  92. package/dist/validation/server-owned-value.d.ts +62 -0
  93. package/dist/validation/server-owned-value.js +99 -0
  94. package/dist/validation/validators/object-validation-engine.js +0 -2
  95. package/package.json +5 -5
  96. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  97. package/dist/__benchmarks__/core.bench.js +0 -53
  98. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  99. package/dist/utils/merge-views-into-objects.js +0 -39
package/CHANGELOG.md CHANGED
@@ -1,5 +1,2013 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 279fb13: `ComponentInput.type` can declare a UNION, so a block stops warning about legal
8
+ writes its own description recommends
9
+
10
+ A registration's `type` was one coarse control kind, while a good number of spec
11
+ keys accept more than one shape. A declaration therefore had to pick an arm, and
12
+ the repo's own manifest gate then reported `type-mismatch` on the other arm's
13
+ legal values. Four of the five measured cases were the loud shape: the input's
14
+ `description` teaches the author to write an inline translation map
15
+ (`{ en, "zh-CN", … }`) while the same input's `type: 'string'` made
16
+ `sdui-parser`'s `checkType` warn about exactly that map — one platform authority
17
+ contradicting itself on the write it had just recommended. Because these land at
18
+ warning severity the page still compiled and rendered; the cost is that noise on
19
+ correct authoring trains authors, AI authors included, to dismiss the
20
+ `unknown-prop` and `type-mismatch` reports that are real.
21
+
22
+ `type` now accepts an ARRAY of coarse kinds as well as a single one (maintainer
23
+ ruling on objectui#3832, direction (a)), and a value passes the coarse check when
24
+ ANY declared arm accepts it. Both declaration sites in `@object-ui/types` move
25
+ together with the registry's own copy in `@object-ui/core`, and
26
+ `ComponentInputSchema` enforces the same widening — a non-empty array of
27
+ DISTINCT kinds, so an empty arm list or a repeated arm is refused at authoring
28
+ time rather than normalized behind the author's back.
29
+
30
+ Five declarations now spell their real contract, and the `type-mismatch` warning
31
+ on each of these legal writes is gone:
32
+
33
+ - `page:header.title`, `page:header.subtitle`, `page:card.title` —
34
+ string **or** inline translation map (the spec's union, measured against
35
+ `ComponentPropsMap` at the pinned rc.6; the renderers resolve both through
36
+ `pickLocalized`);
37
+ - `record:alert.title`, `record:alert.body` — the same two shapes, justified
38
+ against the RENDERER since the pinned spec carries no `record:alert` props
39
+ schema;
40
+ - `element:text_input.defaultValue` — `string | number`, the spec's union,
41
+ which had been narrowed to `'string'` with the number arm named only in prose.
42
+
43
+ **Backward compatible, and measured as such.** The single-kind form stays valid
44
+ and is still the canonical spelling for a one-arm key: it validates identically
45
+ (the diagnostics for one arm, `invalid-enum` and its `error` severity included,
46
+ are byte-identical), and `manifestFromConfigs` collapses a one-element array back
47
+ to the bare string, so every entry already in a published `sdui.manifest.json`
48
+ serializes unchanged and arrays appear only where a union was really declared.
49
+ The JSX authoring surface follows in the same step — `generateDts` emits a
50
+ TypeScript union for a union input, so the `.d.ts` an author type-checks against
51
+ accepts exactly what the gate accepts.
52
+
53
+ A union widens what is legal; it does not switch the check off. A value matching
54
+ NO declared arm is still reported, a multi-arm mismatch reports at its strictest
55
+ arm's severity (`error` when an `enum` arm is present, so an enum's closed list
56
+ does not become dismissible by having a second arm added next to it), and arms
57
+ are meant to match the contract rather than relax the gate:
58
+ `element:text_input.defaultValue` deliberately gains no `object` arm because the
59
+ spec rejects a map there, and `element:record_picker.emptyText` keeps its single
60
+ `'string'` arm because that renderer drops the map form (objectui#4163) — an arm
61
+ the renderer never honours would advertise a shape that cannot reach the screen.
62
+ - e1d4251: Action param `visible`: one dialect answer, and a fault that is fail-open and LOUD
63
+
64
+ `ActionParamDialog`'s `filterVisibleParams` was the last predicate face never
65
+ converted to the canonical entry. It evaluated each param's `visible` on a bare
66
+ `ExpressionEvaluator` inside `try { … } catch { return true }`, and that produced
67
+ two defects at once (objectui#4640, measured on `main`):
68
+
69
+ - **Silence.** Three of the four fault shapes emitted nothing at all — an
70
+ unparseable source, an unbound identifier and a faulting legacy predicate all
71
+ resolved without a word, so a broken `visible` was indistinguishable from an
72
+ absent one. The standing 2026-08-06 ruling on objectui#4051 /
73
+ objectstack#5149 names silence as the one option that is not available.
74
+ - **The fail DIRECTION was decided by the predicate's dialect, not by the
75
+ surface.** A bare string ran the legacy JS evaluator (lenient → falsy → param
76
+ silently DROPPED); a `{ dialect, source }` envelope ran CEL (fault → param
77
+ silently KEPT). One `visible` key, two opposite outcomes, chosen by whether
78
+ the authored text happened to contain `${…}` / `===` — the objectui#3314
79
+ shape. Both halves hurt: a dropped param means the dialog never collects a
80
+ value the server requires and the action fails at submit with nothing pointing
81
+ at the predicate; a kept one offers a field the backend rejects.
82
+
83
+ `filterVisibleParams` now routes through `evalRowPredicate`. A param whose
84
+ `visible` cannot be evaluated is **shown**, and reported once, with the action
85
+ and the param named and the predicate quoted. Fail-open is the ruled direction
86
+ for this surface: an extra offered field is rejected by the server with a
87
+ message, while a silently hidden required param is undiagnosable. (Row surfaces
88
+ keep failing closed — there the harm runs the other way.) Boolean and blank
89
+ predicates are answered before the evaluator, so `visible: false` hides the
90
+ param and an empty predicate is not reported as broken.
91
+
92
+ **Behaviour change worth knowing before you upgrade.** On the canonical CEL
93
+ engine an ABSENT key is a runtime fault, not a falsy read. A param gated on
94
+ `features.phoneNumber == true` in a deployment whose scope carries no
95
+ `phoneNumber` key at all now takes the fail-open branch: the param is SHOWN,
96
+ with a warning naming it, where it used to be hidden. The conservative outcome
97
+ is still available, in the spelling that is portable to the server's own engine:
98
+
99
+ ```
100
+ has(features.phoneNumber) && features.phoneNumber == true
101
+ ```
102
+
103
+ Deployments that DECLARE the flag (`features: { phoneNumber: false }`) are
104
+ unaffected — that is a genuine verdict on both engines, and it did not move.
105
+
106
+ `@object-ui/core` gains the two evaluator changes this needed:
107
+
108
+ - `evalRowPredicate` accepts **`rowless`** — "this surface has no row of its
109
+ own", so nothing is bound over the host scope and a `record` / `data` the
110
+ scope carries survives instead of being shadowed by an empty row. Row surfaces
111
+ are untouched: without the option the row is still the subject (objectui#3796).
112
+ - A faulting **`{ dialect, source }` envelope now reports its own source**.
113
+ It used to print the literal `"(expression)"`, and because the warn-once key
114
+ is (label, predicate), the first faulting envelope under a label silenced
115
+ every other one. The envelope is what `@objectstack/spec` normalizes every
116
+ authored predicate into, so this was the likeliest shape in served metadata
117
+ and the least diagnosable one.
118
+ - 167ec42: `ComponentMeta.labelling` grows a third value: `'control' | 'group' | 'display'`
119
+ (objectui#4857, ruled jointly with objectui#4871 as the single repo-wide vocabulary for
120
+ "how does a host learn what a widget will render"). `'display'` declares a widget whose
121
+ whole surface is a pure display in EVERY state — no focusable control, nothing a
122
+ `<label for>` could ever reach.
123
+
124
+ The form renderer answers the declaration with the objectui#4788 host container (field
125
+ id + `aria-labelledby` + `aria-describedby` + `role="group"`) in the editable state too;
126
+ the `readonly === true` arm keeps its exact #4788 semantics for undeclared widgets. The
127
+ display-only four (`formula` / `summary` / `auto_number` / `vector`) declare `'display'`
128
+ — on the real object-form path they arrive `disabled`, never `readonly` (a deliberate
129
+ distinction this change does not touch), so their visible labels pointed `for` at an id
130
+ no element carried and their help text had zero consumers in every editable form.
131
+
132
+ `grid` was re-measured before being classified: its only bare-config focusable is the
133
+ auxiliary "Add line" button (routing `for` there would have label clicks insert rows),
134
+ and every realistic config is a table of per-cell inputs — a composite. It declares
135
+ `labelling: 'group'` and its root container now consumes the host id, name and
136
+ description, exactly like `address` / `checkboxes`.
137
+
138
+ Companion registry gate: `FIELD_WIDGET_LABELLING` (exported) is a `Record` keyed by the
139
+ field-widget map's own literal key union, so registering a widget without deciding its
140
+ labelling is a compile error rather than a silent fall-through to the dangling-`for`
141
+ path, and the declaration test asserts the registered meta agrees with it key by key.
142
+ - b1119ec: Project every declared `recordIdField`, and refuse an action that names no record
143
+
144
+ objectstack#8018. An `api` action declaring `recordIdParam` identifies the record
145
+ it acts on by a row field — `recordIdField`, default `id`. The grid built
146
+ `$select` from the listView columns, `id` and the predicate refs only, so an
147
+ action keyed on any other field asked the server for everything except the key
148
+ naming its own record. The row arrived without it, and the injection was skipped
149
+ silently: the request went out anyway, minus the parameter. A backend reading a
150
+ missing selector as "match nothing" then answers success for having changed
151
+ nothing, so a record-scoped mutation reports success and does nothing.
152
+
153
+ Two independent repairs, both in this change:
154
+
155
+ - **Projection.** `listViewPredicates` (`@object-ui/core`) now also harvests
156
+ `recordIdField` from `rowActionDefs`, `bulkActionDefs` and the object's
157
+ `actions`, spelled as a synthetic `record.<name>` so the one existing harvester
158
+ handles it. Both projection builders — `ObjectGrid` and `ListView` — read that
159
+ function, so both gain the key with no call-site change. The existing guards
160
+ still apply: a name the object does not declare, or one that is not a bare
161
+ identifier, is dropped rather than put in `$select`, because an unknown key
162
+ there is not ignored by every backend.
163
+ - **Loud failure.** New `resolveRecordIdParamSeed` (`@object-ui/core`) is the one
164
+ definition of "can this row identify the record?". `useConsoleActionRuntime`'s
165
+ api handler now refuses the dispatch — `{ success: false, error }`, before the
166
+ request — when the row lacks the key, or holds `null` for it. The two refusals
167
+ are worded differently because they point at different repairs: an absent key
168
+ is a projection or read-visibility problem, a null value is a data one. Falsy
169
+ real values (`0`, `''`, `false`) are values and still dispatch.
170
+
171
+ The second half is what closes the class rather than the common case: a row can
172
+ lack the key for reasons projection cannot fix — a server-side read mask that
173
+ strips the field regardless of `$select`, a partial payload, a field the
174
+ principal cannot read.
175
+
176
+ Behaviour change worth noting: an action that previously dispatched an
177
+ under-specified request now fails visibly instead. That is the point — the old
178
+ path could not report the failure it was causing.
179
+ - af025ee: Draw a null second-dimension group instead of carrying its measure invisibly
180
+
181
+ objectui#4673. `buildChartSeries`' pivot branch kept the pre-objectui#4466
182
+ answer on the SECOND dimension: it bucketed groups by `String(row[groupKey] ??
183
+ '')` behind a `gId !== ''` gate, so a group whose second dimension is `null`,
184
+ `undefined` or `''` never joined the series list — while the line below the
185
+ gate still wrote its measure into the emitted row under the `''` key. The
186
+ number was in the data and bound to no mark, which is #4466's harm verbatim one
187
+ dimension over: the chart understated its own data without saying so.
188
+
189
+ Measured on the card's repro — `GROUP BY status, priority` over a Backlog with
190
+ 5 hours at High priority and 40 hours at no priority — the transform emitted
191
+ `{status: 'Backlog', High: 5, '': 40}` with a single `High` series, and the
192
+ renderer drew exactly ONE bar. The 40 hours were present in the row, scaled for
193
+ on the y-axis, and painted on nothing.
194
+
195
+ Two such groups were worse than unbound: `null` and `''` both key `''`, so the
196
+ later group silently overwrote the earlier one's measure and one of the two
197
+ numbers did not survive the transform at all.
198
+
199
+ **A known-empty group now draws; an unprojected key still refuses.** That split
200
+ is the doctrine the first dimension already used (objectui#4466 versus
201
+ `hasNoCategoryKey`, framework#4033), and it now answers the same way on both
202
+ dimensions. Concretely, a row that does not carry the group key at all gets no
203
+ bucket and contributes no column, where it previously wrote its measure under
204
+ `''`.
205
+
206
+ `null` and `''` are two different groups with two series, following
207
+ objectui#4508's ruling on the first dimension.
208
+
209
+ **The series key is collision-safe, not merely unlikely to collide.** Unlike an
210
+ axis bucket's private map key, a series key is a column of the emitted row and
211
+ the `dataKey` a renderer binds to, so it has to be unique within that row. A
212
+ group keys its column by its own display label — leaving an ordinary pivot's
213
+ rows, series, legend, tooltip and drill title exactly as they were — unless
214
+ that label cannot name it: shared with another group (a stored value spelling
215
+ the null bucket's label), reserved by the row itself (the x-axis column, the
216
+ identity carrier), or equal to some group's identity. Those key by identity
217
+ instead, which no other group has. Because no surviving label is any group's
218
+ identity, the two key spaces cannot meet.
219
+
220
+ Drill-through follows the same assignment: a clicked series key resolves back
221
+ to the group IDENTITY it names, and rows are matched on that. The previous
222
+ `String(r[gDim] ?? '')` comparison was the display-string matching
223
+ objectui#4508 removed on the x-axis — it spelled a null group and an
224
+ empty-string group alike, so the empty-string group's segment resolved to the
225
+ null group's records rather than its own.
226
+
227
+ No renderer change was needed: the null group's series carries the same
228
+ `nullCategoryLabel` the renderers already pass for the first dimension.
229
+ - 9ce096f: Give a chart bucket an identity distinct from its display label
230
+
231
+ objectui#4508. `buildChartSeries` used the bucket's DISPLAY string as the
232
+ bucket's own key, so two pairs of genuinely different groups were conflated —
233
+ and the segment click that drills a bar back to its records inherited both
234
+ conflations. The maintainer ruling (2026-08-14) approved the sentinel-identity
235
+ direction, aligning the chart branch with the distinct-bucket-id form the pivot
236
+ TABLE (`buildPivot`) already uses over the same dataset rows.
237
+
238
+ Two collisions, one cause:
239
+
240
+ - **A null group and an empty-string group drew ONE bar.** The pivot branch
241
+ keyed buckets by `String(xRaw ?? '')`, which spells `null` and `''`
242
+ identically. The bar took its label from whichever row created the bucket, and
243
+ the other group's segment then resolved to no row at all — a visible bar whose
244
+ click did nothing.
245
+ - **A record whose stored value spells the bucket label stole the null bucket's
246
+ drill.** A row storing the literal text `(None)` (or any localized
247
+ `chart.nullCategory` — `(未指定)` and the other nine packs) kept its own
248
+ bucket, so two bars carried the same axis text and BOTH resolved to the first.
249
+ That one is a wrong drill, not a dead one: clicking the null bucket's bar
250
+ opened the drawer on another group's records.
251
+
252
+ What changed:
253
+
254
+ - **`chartBucketId`** (`@object-ui/core`) is the bucket identity — the SAME
255
+ encoder `buildPivot` keys its buckets with (`pivotBucketId` over
256
+ `pivotDimensionValue`), so the two surfaces stop answering one question two
257
+ ways. The pivot branch now buckets by it, which is what makes null and `''`
258
+ two groups again.
259
+ - **`CHART_BUCKET_ID_KEY`** carries that identity on an emitted row, written
260
+ exactly where two DISTINCT buckets paint the same axis text — the complete set
261
+ of cases where the display string cannot name what was clicked. An ordinary
262
+ chart's rows are returned untouched (by identity), so no renderer-internal key
263
+ reaches an authoring surface.
264
+ - **`findChartSeriesRow`** takes that identity back as `options.bucketId` and
265
+ treats it as authoritative. The renderers forward it: the drill event gains
266
+ `categoryId` (`ChartSegmentClickEvent`, now declared once in
267
+ `@object-ui/core` instead of inline in three packages), `AdvancedChartImpl`
268
+ reads it off the clicked row on the cartesian, pie and funnel paths, and
269
+ `DatasetWidget.handleChartDrill` hands it to the lookup.
270
+
271
+ Behaviour change worth noting: an empty-string category no longer resolves to a
272
+ null-valued row. That tolerance was justified as the drill layer's own spelling
273
+ of "no group value", but no producer of this lookup's `category` writes it,
274
+ while `''` IS the axis text a genuine empty-string group paints — so the
275
+ tolerance was giving that group's bar a different group's records. A host that
276
+ forwards no `categoryId` keeps its existing drill unchanged.
277
+ - e05db88: A clicked cartesian mark names its own series, and the drill title reads its label
278
+
279
+ objectui#4672, objectui#4682.
280
+
281
+ **The dead pivoted drill.** objectui#4680 fixed what a cartesian click could
282
+ read out of recharts 3's `MouseHandlerDataParam`, and measured the wall it could
283
+ not get past: a chart-level click is an AXIS interaction, and recharts
284
+ dispatches those with `activeDataKey` hard-coded `undefined`, because the shared
285
+ cursor spans every series at that tick. A pivoted dataset chart — 2 dimensions,
286
+ 1 measure, the shape ADR-0021 introduced — needs the series to resolve its drill
287
+ row, so every segment of every such dashboard chart stayed a dead click. The
288
+ series was left unresolved rather than guessed, and the card carried the rest.
289
+
290
+ The answer is the mark itself. This renderer draws the `Bar` / `Line` / `Area`,
291
+ so an item-level `onClick` closes over the very `dataKey` it was rendered with —
292
+ the series is statically known, not inferred from tooltip state.
293
+
294
+ Both handlers fire for one gesture (measured: item first, chart second, sharing
295
+ one `nativeEvent` object), so the item handler does not emit. It RECORDS its
296
+ series, stamped with that gesture, and the chart-level handler composes the one
297
+ event. That is the double-fire answer and the additive property together:
298
+
299
+ - **one click, one drill event**, because there is one emit site — not a second
300
+ event suppressed after the fact;
301
+ - **a click that lands on no mark is untouched**: it records nothing and falls
302
+ through to the objectui#4680 axis answer exactly as shipped — category, bucket
303
+ identity, and the series only where one series is plotted. Empty plot area
304
+ stays category-only, and "drill the whole category" was rejected as a
305
+ different product question. Nothing that resolved before stops resolving; a
306
+ line's `dot={false}` stroke simply GAINS the exact series where it is hit;
307
+ - pairing on the shared DOM event rather than on a flag means a record left by
308
+ one gesture can never be adopted by a later click.
309
+
310
+ The clicked key is forwarded exactly as rendered, `''` included: the
311
+ empty-string second-dimension group draws its own bar since objectui#4673, and
312
+ `''` is falsy, so a truthiness test on the way out would send no series at all
313
+ and leave that bar's drill standing on the reader's coercion instead of on what
314
+ was clicked.
315
+
316
+ **The opaque drill title.** `ChartSegmentClickEvent` gains `seriesLabel`, and
317
+ `DatasetWidget`'s drill drawer titles itself from `seriesLabel ?? series`.
318
+ `ev.series` stays the LOOKUP key — `findChartSeriesRow` resolves it through the
319
+ same assignment `buildChartSeries` made — and only the title reads the label.
320
+
321
+ The two strings are equal for every ordinary group, which is why reading the key
322
+ as a title went unnoticed. They part company when a group's label cannot name
323
+ it: the null bucket beside a record whose stored value literally spells
324
+ `(None)`, which is objectui#4508's collision on the series axis, reachable since
325
+ objectui#4673. Both groups then key by `chartBucketId`, and the drawer opened on
326
+ the right records under the title `Backlog / [null]`. An internal id where a
327
+ label belongs reads as broken DATA rather than as a broken title.
328
+
329
+ Neither string can do the other's job, which is why this is a second field
330
+ rather than a change to the first: the label is not resolvable (it is exactly
331
+ what the colliding groups share) and the key is not showable. `seriesLabel` is
332
+ optional and absent wherever a renderer resolved no label, so every other
333
+ chart's title is byte-identical.
334
+ - d2ce342: Retire the structured `confirm` object on actions (objectui#4314, maintainer ruling
335
+ 2026-08-17, ADR-0049 enforce-or-remove). `confirmText` is now the one confirm
336
+ spelling — the only one the translation bundle can address
337
+ (`{ns}.objects.{obj}._actions.{name}.confirmText`), matching `@objectstack/spec`'s
338
+ action surface.
339
+
340
+ Breaking semantics (flagged `minor` per this repo's version-alignment policy):
341
+
342
+ - `@object-ui/types`: `ActionSchema.confirm` is a `?: never` tombstone — authoring
343
+ it is now a tsc error, and the Zod twin rejects any authored value at parse time
344
+ (it previously accepted the object). The backwards `@deprecated` note that
345
+ steered authors from `confirmText` INTO the structured arm is gone.
346
+ - `@object-ui/core`: `ActionRunner` no longer reads `confirm.message` (which used
347
+ to outrank `confirmText`, untranslated). `ActionDef.confirm` carries the same
348
+ `never` tombstone. The `ConfirmationHandler` signature is unchanged, but the
349
+ runner now invokes it without the `options` argument.
350
+ - `@object-ui/plugin-grid`: `resolveBulkActions` no longer falls back to
351
+ `confirm.message` when promoting an object action — spec metadata can never
352
+ deliver that key.
353
+
354
+ Nothing in the repo, the example apps, or the schema catalog authored the
355
+ structured form (verified on the issue); a dialog authored that way silently lost
356
+ localization. Reopen condition recorded on objectui#4314: real demand returns the
357
+ arm WITH bundle keys designed in.
358
+
359
+ ### Patch Changes
360
+
361
+ - 2533ec5: The bulk-action dialog's "this widget needs a DataSource" rule now derives from the shared reference-field family instead of a fourth private copy.
362
+
363
+ `packages/plugin-grid/src/components/bulkParamToField.ts` held its own
364
+ `DATA_SOURCE_WIDGET_TYPES` — the fourth hand-maintained answer to one question
365
+ ("which widget has to query records, so it must be handed a DataSource and a
366
+ `reference_to`"), and the only one whose member set matched none of the other
367
+ three: `lookup` / `master_detail` / `user`, against `@object-ui/core`'s
368
+ `EXPANDABLE_FIELD_TYPES` (which also holds `tree`) and the object form's rule
369
+ (which adds three widget-hint pickers). Nothing anywhere could detect the drift;
370
+ the same shape objectui#4770 and objectui#4790 each closed on another surface.
371
+
372
+ It now reads core's set through one predicate, so all three consumers of the rule
373
+ — the label prefetch / option source (`isLookupishParam`), the `dataSource` prop
374
+ the dialog threads into the widget (`fieldNeedsDataSource`), and the
375
+ `reference_to` / `display_field` branch of `bulkParamToField` — cannot drift apart
376
+ from each other or from the form again.
377
+
378
+ No behaviour change on any reachable path, which is why this is a patch. The one
379
+ member the two tables differed on, `tree`, can never be a widget key on this
380
+ surface: it is absent from the fields widget map and `mapFieldTypeToFormType`
381
+ sends it to `field:lookup`, so a `tree` param arrives at the rule as `lookup`
382
+ (pinned). The divergence in the other direction is deliberately preserved: the
383
+ form additionally wires `object-ref` / `filter-condition` / `recipient-picker`,
384
+ widget hints no object schema can declare and no bulk param produces — absorbing
385
+ them would change which widgets receive a DataSource here, which is a behaviour
386
+ change and not a convergence.
387
+
388
+ The pin is an identity pin, not a membership one: it spies on the `has` of the
389
+ Set object core exports, so a member-identical private copy fails it. A
390
+ value-equality assertion would have passed against exactly the defect this
391
+ change removes.
392
+ - bbe8b86: The allow-list of option widgets that are fed the live record is now one exported constant, `CASCADE_OPTION_WIDGET_TYPES`, instead of three private copies.
393
+
394
+ `select` / `multiselect` / `radio` / `checkboxes` are the widgets whose OFFERED
395
+ option set is re-resolved against a record (per-option `visibleWhen`, plus the
396
+ `dependsOn` gate), so they are the widgets a surface must thread its live record
397
+ to. Three surfaces feed that one evaluator — the object form, the single-record
398
+ action dialog and the bulk action dialog — and until now each carried its own
399
+ private `new Set([...])` of the same four keys, with a comment in each asking the
400
+ next person to change all three together. Nothing could have reported them
401
+ drifting: every copy passed its own behavioural tests, and a divergence would
402
+ have shown up only as one surface silently disagreeing with another about what
403
+ "the record" is.
404
+
405
+ The set now lives in `@object-ui/core`, next to `resolveCascadingOptions` — the
406
+ evaluator that reads that record — because core is the one package all three
407
+ surfaces already depend on, and it is re-exported from `@object-ui/fields` next
408
+ to `resolveFormWidgetType`, whose output is the vocabulary the keys are written
409
+ in. Both are the same object, pinned by test; each consumer keeps its own
410
+ normalization (`normalizeFieldType` in the form, `resolveFormWidgetType` in the
411
+ dialogs), which agree on these four members.
412
+
413
+ No behaviour changes: the members are identical on all three surfaces, and the
414
+ existing pins for each surface still assert the same records reaching the same
415
+ widgets. The rationale that was repeated in the three copies — including the
416
+ note that the widget-hint picker family (`filter-condition`, `recipient-picker`,
417
+ the lookup family) reads a different sibling key off the same channel and is
418
+ deliberately NOT in this set — is now stated once, in the constant's own
419
+ documentation. Whether the action and bulk dialogs should ever feed those
420
+ pickers stays an open question (objectui#4771), unchanged by this convergence.
421
+ - 8477be5: `cloneAsOverride()` now returns `DeepMutable<T>`, so a Tenant/User override draft type-checks as the mutable value it has always been.
422
+
423
+ `cloneAsOverride<T>(view: T): T` handed the input type straight back. Cloning a
424
+ `SystemView<S>` — which is `DeepReadonly<S>` plus the marker symbol — therefore
425
+ returned something still typed deep-readonly, even though the implementation has
426
+ always produced a plain mutable object (`structuredClone`, or a JSON round-trip
427
+ fallback) and deliberately drops the marker. The declaration was simply wrong
428
+ about its own value, and the documented override flow was the thing that broke:
429
+
430
+ ```ts
431
+ const draft = cloneAsOverride(userListView)
432
+ draft.columns.push({ name: 'name' }) // TS2339 before this change
433
+ ```
434
+
435
+ That is `packages/core/README.md`'s override example, and it failed to compile
436
+ against the built types — measured by the doc-snippet compile gate, which reads
437
+ `dist/*.d.ts` rather than source. The neighbouring line one block up,
438
+ `userListView.columns.push(...) // ❌ TypeError (strict mode)`, is the opposite
439
+ demonstration and correctly still fails; only the draft line changes colour.
440
+
441
+ The fix adds `DeepMutable<T>`, the inverse of the `DeepReadonly<T>` that
442
+ `SystemView` is built from, and returns it. Per the maintainer's 2026-08-19
443
+ ruling (option A on objectui#5257), the alternatives were rejected by name:
444
+ teaching a cast in the README is the lenient-consumer pattern the contract rules
445
+ out, and declaring the block a documentation fragment hides a real signature
446
+ defect behind the fragment marker.
447
+
448
+ Why this is a patch and not a break: the return type relaxes TOWARD what the
449
+ runtime already does, never away from it. A caller gains permission to mutate;
450
+ nobody loses one. `DeepMutable<S>` stays assignable everywhere `DeepReadonly<S>`
451
+ or `SystemView<S>` was expected, so a caller who fed a draft back into a
452
+ deep-readonly position still compiles — both directions are pinned as type-level
453
+ cases in `freeze-schema.types.test.ts`. A repo-wide sweep found no call site at
454
+ all outside the README, so nothing in this workspace needed changing.
455
+
456
+ Two limits of `DeepMutable`, stated rather than discovered later. It is
457
+ symmetric with `DeepReadonly` arm for arm, which means it inherits the same
458
+ tuple behaviour: a tuple widens to an array, exactly as `DeepReadonly` widens it
459
+ in the other direction. And it does not remove the `SYSTEM_VIEW_MARKER` key —
460
+ the clone never carries the symbol at runtime, but the key is declared optional,
461
+ so keeping it states "may be absent", which is true. Excluding it would require
462
+ a non-homomorphic mapped type that drops the `?` modifier from every other
463
+ property and turns optional keys required — a strictly worse type traded for
464
+ removing a key that already reads as optional.
465
+ - ad07b65: Four packages stop publishing tooling material in their `dist/`
466
+
467
+ Each of these packages spelled its build exclusions as `*.test.*`, while this repo's tooling convention is a directory one — `__tests__` / `__mocks__` / `__benchmarks__`, exactly as `TOOLING_FILE` in `scripts/check-phantom-dependencies.mjs` spells it. Any tooling file whose *name* is not `*.test.*` therefore stayed in the emit program and shipped in the tarball. This is the same shape and the same cause as objectui#4006, which fixed `@object-ui/fields` and `@object-ui/plugin-editor` by the filename criterion and so did not reach these four.
468
+
469
+ Measured by building each package from a cleared `dist/` on both sides of the change. Nine files disappear, none appears, and every surviving file is untouched — the totals move by exactly the count removed:
470
+
471
+ | package | `dist/` files | removed |
472
+ | --- | --- | --- |
473
+ | `@object-ui/core` | 176 to 174 | `dist/__benchmarks__/core.bench.js`, `core.bench.d.ts` |
474
+ | `@object-ui/plugin-designer` | 70 to 66 | `dist/__tests__/__mocks__/plugin-form.d.ts`, `plugin-grid.d.ts`, and both `.d.ts.map` |
475
+ | `@object-ui/plugin-grid` | 62 to 60 | `dist/__tests__/explainDouble.d.ts` and its `.d.ts.map` |
476
+ | `@object-ui/plugin-view` | 13 to 12 | `dist/__tests__/explainDouble.d.ts` |
477
+
478
+ Only `@object-ui/core`'s had runtime weight. The other eight are declarations nothing resolves, but `core.bench.js` is a real emitted module whose first import is `import { bench, describe } from 'vitest'` — a runtime import of a package a consumer never installs, since `vitest` is a devDependency of `@object-ui/core` and devDependencies are not installed transitively. Nothing resolves it today either (it is not in the `exports` map), so no consumer breaks in either direction; this is the tarball shedding files nothing reached.
479
+
480
+ No type coverage leaves with the emit. The three plugins' helper and mock files are already program inputs of the `tsconfig.test.json` that each package's `type-check` chains, reached through the imports in the suites beside them — `tsc --listFiles` names all four files on both sides of the change. `core.bench.ts` had no such edge, since nothing imports a benchmark, so it is now named explicitly in `packages/core/tsconfig.test.json`. That move was deliberate rather than forced: `scripts/check-type-check-coverage.mjs` enumerates `*.test.ts(x)` only, so a benchmark that no program reads is invisible to it, and dropping the coverage silently would have been the "coverage that was right by accident" objectui#4006 recorded. Verified by appending a provably-false annotation to the benchmark, which turns `tsc -p packages/core/tsconfig.test.json` red at exit 2.
481
+ - 41f498b: Fix `packages/core/README.md`'s Component Registry example, which taught
482
+ `new ComponentRegistry()` against an exported singleton **instance**, not a
483
+ class — the built `packages/core/dist/registry/Registry.d.ts` declares
484
+ `export declare const ComponentRegistry: Registry<any>`, so the snippet did
485
+ not compile (`TS2351: This expression is not constructable`, measured by the
486
+ objectui#5138 doc-snippet type gate). A reader who copied it got a compile
487
+ error; if `new ComponentRegistry()` had compiled it would have produced a
488
+ second, empty registry nothing renders from, the more expensive half of the
489
+ mistake.
490
+
491
+ The snippet now calls `ComponentRegistry.register(...)` /
492
+ `ComponentRegistry.get(...)` directly on the singleton, with one line stating
493
+ it is the process-level shared instance `SchemaRenderer` resolves every
494
+ `type` against — the same wording `packages/components/README.md` was given
495
+ in objectui#5160, kept consistent across both READMEs. Readers who want their
496
+ own isolated registry still have `Registry` itself, separately exported as a
497
+ real class.
498
+
499
+ `scripts/check-doc-snippet-types.mjs`'s `UNGATED_DOCS` entry for
500
+ `packages/core/README.md` is updated to match: `TS2351x1` is dropped from its
501
+ reason text now that the diagnostic is gone. The entry is not deleted — the
502
+ document's remaining `TS2339x2` pair (a different, pre-existing defect) is
503
+ out of scope for this change; it's tracked as objectui#5257.
504
+ - ac600e5: A `user` field in a form now receives `dataSource` / `dependentValues` / `dependsOnLabels`, like every other reference field.
505
+
506
+ The form renderer decided which registered widget gets those three props from a
507
+ module-private `DATA_SOURCE_FIELD_TYPES` set, while `@object-ui/core` kept
508
+ `EXPANDABLE_FIELD_TYPES` for the same underlying fact — a field whose stored
509
+ value is a foreign key into another object. The core side's TSDoc claimed to
510
+ mirror the form's set, and it did for 15 days: the form's copy then gained
511
+ `capability-multiselect` (objectui#2403) and the three widget-hint pickers
512
+ `object-ref` / `filter-condition` / `recipient-picker` (objectui#2421) on the
513
+ same day, after which the two sets were not in a subset relation in either
514
+ direction — `user` only in core, the picker names only in the form — with
515
+ nothing able to report it.
516
+
517
+ The form now derives its rule instead of restating it: the reference half is
518
+ core's set, the form-specific half is the three picker names, which are widget
519
+ hints and can never be a declarable field `type`. Adding a member to
520
+ `EXPANDABLE_FIELD_TYPES` therefore also grants it the form's data-source wiring;
521
+ that coupling is intended and is now written down on both sides.
522
+
523
+ The user-visible half is `user`. It previously received none of the three props.
524
+ `dataSource` and `dependentValues` each have a `SchemaRendererContext` fallback
525
+ inside the widget, so the person picker limped along wherever a provider
526
+ happened to supply one; `dependsOnLabels` has no fallback, so a
527
+ dependency-gated user picker interpolated the raw API name into its
528
+ "select ... first" hint in every locale — the leak objectstack#5407 closed for
529
+ lookups and left open here. The widget contract's own `dataSource` doc has
530
+ always named `user` among the types the form renderer injects for.
531
+
532
+ No change to what is expanded, projected or rendered anywhere else: the core
533
+ set's members are untouched.
534
+ - c1ef923: Grid and related-list column headers no longer offer a sort on a `formula` column.
535
+
536
+ A `formula` value is computed on read: no driver materialises a column for it, so
537
+ a server `$orderby` naming one has nothing to order by. That sort never worked.
538
+ Until objectstack#6994 the platform did not say so — the response carried the very
539
+ values it had been asked to order by, out of order, under a `200`, with ascending
540
+ and descending byte-identical on a real SQL driver — and it now answers
541
+ `400 INVALID_SORT`. So the header was wrong before the platform's refusal and is
542
+ wrong after it, for the same reason: it offers a sort that cannot be performed.
543
+
544
+ `ObjectGrid` withheld the affordance only from reference-bearing columns
545
+ (objectui#3096). Unmaterialized types are a SECOND reason a server sort is
546
+ impossible, not a different mechanism, so it now reads both — and so do the two
547
+ sort entry points of a related list (the embedded table's headers and the
548
+ sort-button row a `data-list` card keeps), which each derived that rule
549
+ separately.
550
+
551
+ Client-side sorting is deliberately unchanged. There the rows are all in the
552
+ browser and the formula value is the one the server hydrated on read, so ordering
553
+ by what the cell shows is honest — the same split the relational carve-out makes.
554
+ A sort DECLARED in view metadata is also unchanged: it still goes out and is still
555
+ refused by name, because silently dropping an author's declaration would hide the
556
+ authoring error instead of surfacing it (the toolbar's sort picker keeps such a
557
+ field listed for exactly that reason — it is the only way to remove it).
558
+
559
+ The membership — `formula` alone — moved out of a private set in `ListView` into
560
+ `@object-ui/core` (`UNMATERIALIZED_FIELD_TYPES` / `isUnmaterializedFieldType`),
561
+ bound to `@objectstack/spec`'s own storage predicate so the renderer cannot drift
562
+ from what the drivers actually store. It is deliberately narrower than the spec's
563
+ write contract `COMPUTED_VALUE_TYPES`: a `summary` and an `autonumber` each get a
564
+ real maintained column and sort correctly, and withholding their headers would
565
+ have broken two affordances that work.
566
+ - af5e292: Emit explicit file extensions on relative import specifiers, so the published
567
+ entries can be imported by Node's own ESM resolver.
568
+
569
+ `@object-ui/react`'s built entry re-exported through extensionless relative
570
+ specifiers (`export * from './SchemaRenderer'`). Node does not extension-search
571
+ relative specifiers, so `import('@object-ui/react')` under plain Node — an SSR
572
+ host, or any consumer without a bundler — failed with `ERR_MODULE_NOT_FOUND`.
573
+ Bundled consumers were never affected and are unchanged by this.
574
+
575
+ `@object-ui/types`, `@object-ui/core` and `@object-ui/i18n` carried the same
576
+ emission; `@object-ui/react`'s entry stayed unloadable until they were fixed
577
+ too, because evaluation crosses into them. No exported API changed.
578
+ - 9f23d2b: The object list's Import button now honours `userActions.import`'s CEL predicates.
579
+
580
+ `@objectstack/spec@17.0.0` widened BOTH toolbar-scope keys, not just `create`:
581
+ `userActions.create` and `userActions.import` are typed identically
582
+ (`z.union([z.boolean(), RowCrudActionOverrideSchema])`) and
583
+ `resolveCrudAffordances` emits a predicate envelope for each, with the docblock
584
+ binding them in one breath ("`importPredicates` — same binding as
585
+ `createPredicates`"). objectui#4646 gave the `create` half a consumer and left
586
+ the `import` half declared-and-inert: `importPredicates` had zero readers in
587
+ objectui. An author could write `userActions.import.visibleWhen`, have the spec
588
+ accept it and the resolver parse it, and watch the object-list toolbar offer the
589
+ CSV import wizard unconditionally.
590
+
591
+ The toolbar now evaluates them, mirroring the related list's create half:
592
+ `visibleWhen` fails CLOSED (an unevaluable predicate hides the entry and warns
593
+ once), `disabledWhen` fails SOFT (an unevaluable one leaves the button enabled),
594
+ and the declared-ness rules are the family's — `?? true` for `visibleWhen` so
595
+ `visibleWhen: false` hides rather than reading as "ungated", `!= null` for
596
+ `disabledWhen` so an empty predicate reads as "no condition". The layer sits on
597
+ TOP of the object-level verdict: a predicate can narrow what the `managedBy`
598
+ bucket, the server's effective API operations and the principal's grant already
599
+ allow, never re-open what they closed.
600
+
601
+ Per the spec's binding, a toolbar predicate evaluates once per toolbar against
602
+ the record of the scope the toolbar sits in — and a standalone object list has
603
+ no record in scope. Predicates over the host scope (`os.user.*`, `features.*`)
604
+ are the meaningful shape there; one reading `record.*` has nothing to bind and
605
+ hides the entry, which is the fail-closed rule the spec spells out for exactly
606
+ this surface.
607
+
608
+ `UserActionsOverride.import` widens from `boolean` to the same union as
609
+ `create`, deliberately in this same change: objectui#4646 kept it narrow on
610
+ purpose because widening a type ahead of its consumer re-declares the
611
+ inert-metadata defect one key over. Type and consumer travel together.
612
+ - 31676be: `buildChartSeries`' pivot branch preserves key ABSENCE, so a never-projected first dimension still reaches the framework#4033 placeholder.
613
+
614
+ `hasNoCategoryKey` (plugin-charts' `AdvancedChartImpl`) exists to catch one
615
+ shape: a dimension a dataset query GROUPED BY but never PROJECTED, so no row
616
+ carries the category key. Rather than draw an axis with no marks, the renderer
617
+ names the missing key. Its whole signal is `key in row`, asked of the rows it is
618
+ handed — which for a dataset-bound chart are `buildChartSeries`' output.
619
+
620
+ The pivot branch wrote `[xKey]` onto every bucket it created, so `key in row`
621
+ was unconditionally true downstream and the guard could not fire for a
622
+ 2-dimension/1-measure chart no matter what the query returned. Every row
623
+ collapsed into one unnamed bucket drawing a blank tick — the exact silent shape
624
+ the placeholder was introduced to eliminate. The defence was dead precisely
625
+ where the defect it guards against lands. The single-dimension branch was never
626
+ affected: it passes rows through, so key-absent rows stay key-absent.
627
+
628
+ An emitted bucket now carries the axis key exactly when some row of it did.
629
+ That is `hasNoCategoryKey`'s own `rows.some(key in row)` lifted through the
630
+ pivot's aggregation, so the guard reads the same fact before and after.
631
+
632
+ The route rests on a measurement of how a dataset query actually reports an
633
+ unprojected dimension in a 2-dimension grouping: it OMITS the key. The ObjectQL
634
+ strategy writes a dimension key only when the engine returned that column, the
635
+ native-SQL strategy returns driver rows carrying only the selected columns, JSON
636
+ cannot transport `undefined`, and `ObjectStackAdapter.queryDataset` passes rows
637
+ through by reference. An explicit `null` means the opposite — the column WAS
638
+ projected and its value is null — and that case is untouched: it still renders
639
+ under the `(None)` bucket label (objectui#4466 / objectui#4497). Where one
640
+ bucket collects both (an absent value and a stored `null` share the `[null]`
641
+ identity), the bucket keeps its label and draws, because refusing there would
642
+ tell an author their query never projected a dimension that it did.
643
+
644
+ No change to any chart whose first dimension projected: ordinary, null-valued
645
+ and empty-string categories all emit byte-identical rows, key order included.
646
+ - 5ffcc14: fix(plugin-report): forward the chart chrome and series presentation `ReportChartSchema` declares (objectui#4877)
647
+
648
+ A report's embedded chart forwarded exactly six keys to the registered chart
649
+ component — `chartType`, `data`, `height`, `isAnimationActive`, `series`,
650
+ `xAxisKey`. Everything else `ReportChartSchema` declares as authorable never
651
+ left the report renderer, so it was inert metadata: the author writes it, the
652
+ schema accepts it, nothing reads it.
653
+
654
+ `showLegend` was the sharpest case because dropping it does not merely ignore
655
+ the author, it INVERTS them: `AdvancedChartImpl` computes
656
+ `legendVisible = showLegend !== false`, so an absent value means the legend is
657
+ on and an explicit `showLegend: false` still drew one.
658
+
659
+ Now lowered, under objectui#4229's ruled data/presentation split:
660
+
661
+ - chrome — `showLegend`, `showDataLabels`, `colors` (both the positional-palette
662
+ array and the per-category record), `subtitle`, `description`, `annotations`,
663
+ `interaction`, `height`;
664
+ - per-series presentation — `color`, `stack`, `type`, `yAxis`, `dashArray`,
665
+ `opacity`, `variant`, matched by `series[].name` so series MEMBERSHIP stays
666
+ with the dataset.
667
+
668
+ `title` is deliberately not forwarded: the report renderer paints it as its own
669
+ heading above the plot, and forwarding it would draw a second one inside the
670
+ chart's frame. `aria` is not lowered either — nothing on this path reads it
671
+ (`AdvancedChartImpl` has no `aria` prop, and this renderer hands the component a
672
+ schema directly rather than through `SchemaRenderer`'s flat ARIA injection), so
673
+ forwarding it would move declared-but-unread one layer down.
674
+
675
+ The two helpers (`chartConfigPresentation`, `mergeAuthoredPresentation`) moved
676
+ from `plugin-dashboard`'s `DatasetWidget` to `@object-ui/core` beside
677
+ `buildChartSeries`, the derivation they merge onto, so both surfaces lower one
678
+ vocabulary once instead of keeping a second copy (the duplication objectui#4389
679
+ filed as a defect). `@object-ui/core` additionally exports `mergeAuthoredSeries`
680
+ — the series merge alone — for a surface whose axes are bare dimension/measure
681
+ NAME strings rather than spec `ChartAxis` objects, which is what a report chart
682
+ declares. `DatasetWidget` re-exports both names, so its public surface and its
683
+ rendering are unchanged.
684
+ - d971e51: A create form no longer deadlocks on a `requiredWhen` field that also declares a runtime `defaultValue`.
685
+
686
+ `#4069` ruled that in **create** mode a field whose `defaultValue` is a runtime
687
+ instruction the server resolves per insert (`NOW()` / `current_user`, or a CEL
688
+ Expression envelope) is producer-owned: the control is deliberately left empty
689
+ and the key is omitted from the payload, because `ObjectQL.applyFieldDefaults`
690
+ resolves the declaration only for a field that arrives absent or null. That was
691
+ implemented on the STATIC `required` flag.
692
+
693
+ The conditional spelling was not covered. `requiredWhen` is resolved one layer
694
+ downstream, in the form renderer, against the live record — so a predicate
695
+ resolving TRUE on a create form put the requirement straight back: the control
696
+ was still empty by design, the submit was refused, and the user had nothing
697
+ sensible to type.
698
+
699
+ Both spellings now behave identically on a producer-owned field. A
700
+ `requiredWhen` predicate is a claim about the value at rest in a given state,
701
+ and `NOW()` / `current_user` resolve at insert regardless of state, so the
702
+ producer's guarantee covers the conditional claim by the same argument that
703
+ covers the unconditional one. An author who really means "the user must supply
704
+ this in this state" has a natural spelling for it: do not declare the default.
705
+
706
+ The suppression lands in the single evaluator both layers read,
707
+ `resolveFieldRuleState` — the same verdict that draws the required marker and
708
+ the one the submit-time check consults — so a field can never lose its asterisk
709
+ while still refusing the write. The classifier that answers "is this value the
710
+ producer's to supply" moved down to `@object-ui/core`
711
+ (`isRuntimeDefault` / `isServerOwnedValue`, re-exported from
712
+ `@object-ui/plugin-form`) so the renderer, the wizard's cross-step gate and the
713
+ create-form field builders all read one implementation rather than three.
714
+
715
+ **Edit mode is unchanged.** Defaults do not re-apply to an existing record, so
716
+ on a persisted row the token was already resolved at insert and blanking the
717
+ column is a real removal: `requiredWhen` enforces there exactly as authored.
718
+ Fields with no declared default, and fields whose default is a static literal
719
+ (which IS seeded into the control), are also unaffected in both modes.
720
+ - dfc6975: Related-list "+ New" now honours `userActions.create` predicates, and the grid
721
+ toolbar's inline-edit affordance is gated on `update` permission (objectui#4646,
722
+ objectui#4647).
723
+
724
+ Two declared-but-unenforced gaps on the same toolbar surface.
725
+
726
+ **#4646 — `createPredicates` had a producer and no consumer.**
727
+ `@objectstack/spec@17.0.0` widened `userActions.create` to
728
+ `z.union([z.boolean(), RowCrudActionOverrideSchema])`, so `resolveCrudAffordances`
729
+ emits `createPredicates` — and nothing in objectui read them, against roughly
730
+ fifteen consumption sites apiece for `editPredicates` / `deletePredicates`. The
731
+ symptom: a parent record entering a frozen state correctly greyed its children's
732
+ row Edit/Delete while the related list's "+ New" stayed fully live, so the user
733
+ filled in the whole child form to earn a server 409. The related-list toolbar now
734
+ evaluates `visibleWhen` / `disabledWhen` **once against the host parent record**,
735
+ per the spec docblock's binding for this key, on top of the existing
736
+ `o.create ∧ can(child, 'create')` check. `visibleWhen` hides "+ New" and fails
737
+ CLOSED; `disabledWhen` greys it and fails SOFT — the same evaluator, fail
738
+ directions and hidden-vs-disabled split the record header already uses for
739
+ edit/delete (objectui#4419 / PR #4515). A bare-boolean `userActions.create` is
740
+ untouched: with no predicates there is nothing to evaluate.
741
+
742
+ **#4647 — the inline-edit toggle was the one ungated affordance on its toolbar.**
743
+ It rendered on "grid view ∧ the host wired `onInlineEditChange` ∧ not the compact
744
+ toolbar", and every host wires that callback unconditionally. New and Import are
745
+ hidden for an account without the grant and the bulk-delete entry on the same
746
+ toolbar ANDs `can(obj, 'delete')`, but a read-only principal could flip inline
747
+ edit, modify cells and press "Save all" to earn a server 403. It is now gated on
748
+ the object's resolved edit affordance ∧ `can(object, 'update')`, mirroring that
749
+ bulk-delete gate. The gate is applied at all three sites that carry this
750
+ affordance — the wide toolbar's toggle, the compact toolbar's settings-popover
751
+ entry (which previously had no gate at all, not even the callback), and the
752
+ `editable` mode handed to the grid, so a stored view carrying `inlineEdit: true`
753
+ can no longer drop a read-only principal into editable cells with no toggle to
754
+ press.
755
+
756
+ `ListViewSchema.userActions.editInline` is also consumed now: an explicit `false`
757
+ withholds the affordance wholesale, which authors previously could not do.
758
+
759
+ **Behaviour change for read-only users, stated plainly.** Where the UI used to
760
+ offer inline editing and let the server refuse it, it now declines to offer the
761
+ entry point at all. No data access changes — the server gate was and remains the
762
+ enforcement boundary; this only stops the UI walking users into round-trips
763
+ guaranteed to fail. Accounts *with* the grant see no change, and hosts with no
764
+ `PermissionProvider` mounted (standalone embeds, the Studio designer) keep
765
+ today's behaviour, since `can()` answers `true` there by design.
766
+
767
+ One deliberate non-change: the absent case of `userActions.editInline` defers to
768
+ the host's existing `inlineEdit` channel rather than enforcing the spec's
769
+ `.default(false)`. Enforcing that default would remove the toggle from every
770
+ stored console list view in one release, since nothing folds a legacy key into
771
+ `editInline` and no existing view declares it. This follows the rule the
772
+ surrounding toolbar-flag block already states for itself — defaults chosen to
773
+ match what the flags have always done. `InterfaceListPage`, the key's other
774
+ consumer, reads the absent case as OFF, because the ADR-0047 interface page has
775
+ no such host channel to defer to.
776
+ - Updated dependencies [88085e3]
777
+ - Updated dependencies [279fb13]
778
+ - Updated dependencies [1184192]
779
+ - Updated dependencies [a2a9747]
780
+ - Updated dependencies [af5e292]
781
+ - Updated dependencies [7f96b10]
782
+ - Updated dependencies [f1d4748]
783
+ - Updated dependencies [578e025]
784
+ - Updated dependencies [598c89a]
785
+ - Updated dependencies [b8b9af4]
786
+ - Updated dependencies [97abb24]
787
+ - Updated dependencies [deb157a]
788
+ - Updated dependencies [d2ce342]
789
+ - Updated dependencies [9695da7]
790
+ - Updated dependencies [58b8346]
791
+ - Updated dependencies [3cf4de0]
792
+ - Updated dependencies [c9dc811]
793
+ - Updated dependencies [a0b9e91]
794
+ - Updated dependencies [99bd015]
795
+ - @object-ui/types@17.6.0
796
+
797
+ ## 17.5.0
798
+
799
+ ### Minor Changes
800
+
801
+ - ee66e2e: Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.
802
+
803
+ `ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
804
+ key (`execute`) both type-checked and the runner then silently bound no handler
805
+ — the objectstack#2169 "Mark Done does nothing" shape. Step 1
806
+ (objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
807
+ the 18 spec-owned keys to real fields. This is **step 3**, executing the
808
+ maintainer's 2026-08-06 ruling now that its upstream half shipped in
809
+ `@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).
810
+
811
+ - **`visible` and `disabled` now have ONE shape, derived from the spec** —
812
+ `boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
813
+ 采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
814
+ the envelope is the full form. `visible` loses its hand-written `| boolean`
815
+ (the spec adopted that arm, so restating it locally would be a second
816
+ contract), and `disabled` gains the envelope arm it never had — it was
817
+ `string | boolean`, which is why the envelope the spec emits could only be
818
+ read through a cast.
819
+ - **The index signature is gone.** `tsc` now rejects an unknown or retired key
820
+ at any site that authors an action literal in code.
821
+ - **Five keys the deletion surfaced, promoted to real fields.** `to`,
822
+ `external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
823
+ legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
824
+ declared only as data; and `description`, which every action renderer forwards
825
+ (`check:action-forward-parity` requires it) and the param-collection dialog
826
+ reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
827
+ deletion produced across the whole workspace.
828
+ - **`ActionContext` keeps its index signature**, deliberately. It is a runtime
829
+ data bag whose keys are genuinely open; `ActionDef` is a declared metadata
830
+ contract. That asymmetry is the point, and it is now pinned in both
831
+ directions.
832
+
833
+ **Breaking edge, deliberate — same class as step 2's, one step further.** An
834
+ `ActionDef` literal carrying a key this interface does not declare is now a
835
+ compile error where it previously compiled and did nothing at runtime. That
836
+ includes the retired `execute` (rename it to `target`; `os migrate meta --from
837
+ 16` rewrites it) and plain typos. Values that were only ever absorbed silently
838
+ are the ones that stop compiling, so the failure moves to where it can be fixed
839
+ rather than appearing as a button that does nothing.
840
+
841
+ **What did NOT retire with the index signature**, contrary to step 1's
842
+ expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
843
+ `execute` rename prescription both stay. `tsc` only ever sees actions authored
844
+ as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
845
+ (objectstack#3903) — which is the population `execute: 'markDone'` actually
846
+ lives in. The two mechanisms cover disjoint populations; retiring the runtime
847
+ half would have re-opened the gap it was written for.
848
+
849
+ - 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
850
+
851
+ `buildChartSeries`' single-dimension branch passed rows through verbatim, so a row whose category VALUE is `null` reached recharts with a null category and drew no mark. The visible outcome was not an empty chart but a quietly wrong one: rows `[{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}]` drew exactly ONE bar — the dominant group, 51 of 53 events, dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there. With every group null it drew axes, gridlines and an axis title with zero marks and no empty state, which is the shipped first-boot state of the built-in System Overview board's "Events by User" (every seeded `sys_audit_log` row is written with `user_id = NULL`).
852
+
853
+ The mapping lives in the shared series layer, so dashboard widgets and standalone `ObjectChart` get one answer rather than a per-chart patch in the recharts wrapper. It resolves the two-answers disagreement the card names as well: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.
854
+
855
+ `@object-ui/core` gains `NULL_CATEGORY_LABEL` and `ChartSeriesOptions`; `buildChartSeries` and `findChartSeriesRow` each take an optional trailing `options`. Both additive — every existing call site compiles and behaves identically, and a result with no null category is still returned by array identity. The two helpers are a pair on purpose: the caller matches a clicked segment against rows that still carry the raw `null`, so `findChartSeriesRow` reads the bucket label back to that row and the newly-visible bar keeps its drill-through instead of resolving to `-1`.
856
+
857
+ The label goes through the i18n channel (`chart.nullCategory`, en `(None)` / zh `(未指定)`, all ten packs), passed down by the renderer: `@object-ui/core` is React-free and cannot read the locale bundle, so it takes the resolved string the same way `dimensionOptionTranslator` takes a resolver. Its English constant is the floor for a provider-less host, not the mechanism.
858
+
859
+ `hasNoCategoryKey` (framework#4033) is untouched and now documented against this: a row that does not carry the category key AT ALL is a different defect — a dimension grouped by but never projected — and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's signal alive. Key absent → the placeholder; key present with a null value → the bucket.
860
+
861
+ - dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
862
+
863
+ Both registrations destructured `schema` and `className` and forwarded everything else. `SchemaRenderer` hands a registered component the authored node's own keys, the contents of its `props` container, the ARIA it resolved and the host's trailing props — so all of it became attributes on the chat root `div`, because React passes unknown lowercase attributes through in silence and stringifies object values. Measured through the real SDUI path with a data-source adapter attached: **14 non-DOM attributes on each widget**, including `datasource="[object Object]"` (the injected adapter, which only appears on a deployment that really loads data) and a camelCase `arialabel` sitting next to the resolved `aria-label`, so the element carried each ARIA value twice under two spellings — one of them meaningless to assistive technology.
864
+
865
+ Both are now consume-or-whitelist: configuration is read off `schema` as before, the evaluated `disabled` verdict is consumed by name, and only `toDomProps`' output reaches the element. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the pin asserts the delivered set exactly, not just the absent one. `chatbot-floating` is untouched: its content mounts through a portal and its root never spread.
866
+
867
+ `@object-ui/core` gains the shared executor this migration needs (`utils/dom-props.ts`): `toDomProps` for the SDUI widget contract, plus `pickDomProps` — the mechanism — for a package whose own contract declares a different key set. That is the objectui#4409 dependency direction: plugin packages declare `@object-ui/core` and must not grow a dependency on `@object-ui/fields` to reach a whitelist.
868
+
869
+ `@object-ui/fields` keeps its own key list and its compile-time bindings, and now executes them through core's mechanism. Its behaviour is unchanged and its exported `DomProps<P>` is the same structural type. The two lists differ for measured reasons and no longer can drift silently: `name` and `disabled` are legal only on form controls, which is what every field widget renders and what `FieldWidgetComponentProps` declares, while `role` is resolved by `SchemaRenderer` for every SDUI node and is not part of the field contract. A new assertion binds every shared key in both directions, with `role` named as the single deliberate exception.
870
+
871
+ - f279deb: fix(core): bare-string filter options — docs/examples stop teaching it, the runtime lift warns (objectui#4356)
872
+
873
+ `globalFilters[].options` had two de-facto contracts. `@objectstack/spec`'s `GlobalFilterSchema` accepts only `{ value, label }` pairs, while `normalizeFilterOptions` also lifted a bare-string shorthand (`options: ['EMEA', 'APAC']`) — so a dashboard authored that way rendered correctly in objectui and was refused the moment it reached the platform's validation. That is the "one strict contract beats N dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance hiding the producer's bug instead of surfacing it.
874
+
875
+ Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: **the spec stays strict; the runtime lift retires behind a deprecation window sized by a stored-dashboard survey.** This is Phases 0 and 1 of that window, shipped together. Phase 2 (removing the lift) is scheduled on objectstack#7917 and is deliberately not here.
876
+
877
+ **Phase 1 — the lift now says so out loud.** `normalizeFilterOptions` still lifts a bare string, unchanged and mechanically lossless (`'EMEA'` becomes `{ value: 'EMEA', label: 'EMEA' }`), because stored dashboards carry the shorthand and dropping it silently would turn a rendering filter into an empty one. It now also logs a deprecation warning naming the offending filter, quoting the offending values, and printing the canonical replacement. The warning fires **once per offending filter per session** — `resolveDashboardFilterDefs` runs on every dashboard render, and a warning that floods the console is a warning that gets muted — and it is dev-mode only, matching the `warnOnDeprecatedObjectParams` convention in `actions/actionKeys.ts`. It does not fire for canonical object options, and a mixed array names only its bare members, since partial migrations happen. A silent lift can never be retired, because nothing would ever show that the last shorthand document is gone (ADR-0078).
878
+
879
+ **Phase 0 — objectui stopped teaching the form.** The stored-dashboard survey on objectstack#7917 found the shorthand's source: objectui's own docs and its schema-catalog corpus — which the catalog's `package.json` declares an AI RAG/few-shot retrieval source — still authored it, so the stored population was still growing. All seven non-test occurrences are corrected to the pair form: `content/docs/guide/dashboard-filters.md` (a code block **and** a prose passage that presented the shorthand as an equal alternative), `content/docs/plugins/plugin-dashboard.mdx`, `packages/plugin-dashboard/README.md`, and the three `examples/schema-catalog` `filtered-dashboard*.json` entries. Warning authors while the docs still taught the form would have been a contradiction users report as a bug.
880
+
881
+ **Guardrail.** The schema catalog previously asserted only that its entries were structurally well-formed and rendered without throwing — which is exactly how a spec-invalid example got in. Every `globalFilters[]` entry in every `plugin-dashboard` catalog example is now parsed with the real `@objectstack/spec` `GlobalFilterSchema`, with a non-vacuity control so a broken sweep cannot read as green.
882
+
883
+ New export: `resetDashboardFilterWarnings()`, the warn-once memo reset, matching `resetActionKeyWarnings`. Graded `minor` for that additive export — measured, the emitted `.d.ts` gains exactly one declaration and narrows nothing.
884
+
885
+ - eb7f586: Dashboard dataset measures follow the display locale (objectui#4566).
886
+
887
+ `formatMeasure` and `formatDimensionValue` in `@object-ui/core` formatted every
888
+ value with a bare `undefined` locale tag at all three of their `Intl` sites.
889
+ `undefined` is not "the user's locale", it is the MACHINE's — neither of the
890
+ repo's two locale channels. A German session read a dashboard KPI as `1,234.5`
891
+ next to a grid cell rendering the same number as `1.234,5`, and inverted
892
+ separators read as a different number, not as an unstyled one.
893
+
894
+ Both functions take the display locale as a new OPTIONAL LAST parameter, and
895
+ `DatasetWidget` threads `useDisplayLocale()` into every site it formats through:
896
+ the KPI, the grouped table's measure and dimension cells, and the cross-tab's
897
+ header labels and cells.
898
+
899
+ **English output does not move**, and that is the discriminator against the
900
+ sibling fix. These sites already went through `Intl` with default grouping, so
901
+ the only thing that changes is WHOSE locale is used:
902
+
903
+ | | before | after |
904
+ | ----------------- | ----------- | --------------------- |
905
+ | en, 1234.5 `0.0` | `1,234.5` | `1,234.5` (unchanged) |
906
+ | de, 1234.5 `0.0` | `1,234.5` | `1.234,5` |
907
+ | de, 1234.5 EUR | `€1,234.50` | `1.234,50 €` |
908
+ | de, 0.6083 `0.0%` | `60.8%` | `60,8%` |
909
+
910
+ Contrast objectui#4553, where `formatPercent` had never grouped at all and
911
+ moving en `1235%` → `1,235%` WAS the fix.
912
+
913
+ Omitting the new argument reproduces the previous output byte for byte, so
914
+ callers that do not thread a locale yet are unaffected.
915
+
916
+ Two behaviours are deliberately preserved rather than "improved" alongside the
917
+ locale fix, both measured:
918
+
919
+ - **Integers stay verbatim.** The integer branch renders no separator and no
920
+ decimal mark, so a locale has nothing to change there — and routing it through
921
+ `Intl` WOULD change it (a locale with its own numbering system re-digits it,
922
+ and `1e21` expands to 22 digits).
923
+ - **The percent sign stays a literal suffix.** `Intl`'s `style: 'percent'`
924
+ re-scales by 100, and that round trip loses precision at the top of the range
925
+ (en `100,000,000,000,000,000,000,000%` becomes
926
+ `99,999,999,999,999,990,000,000%`). The consequence — a German list cell
927
+ writing `1.234,5 %` with a no-break space where a dashboard measure writes
928
+ `1.234,5%` — is filed separately rather than smuggled in behind a locale fix.
929
+
930
+ `@object-ui/core` is `minor` because two of its ENTRY exports gained an optional
931
+ parameter (measured in the built `.d.ts`). `@object-ui/plugin-dashboard` is
932
+ `patch`: its published declarations are unchanged — `buildPivot`'s new optional
933
+ parameter is internal, as that function is not on the package's `exports`
934
+ surface.
935
+
936
+ - e901131: `DatasetResultField` is now `@objectstack/spec`'s `AnalyticsResult.fields[]` element itself, not a hand-written restatement of it
937
+
938
+ `packages/core/src/utils/dataset-format.ts` declared its own six-key interface for the analytics result column, under a doc comment describing the server's contract. The key set happened to match the spec today, so nothing was broken — but it was the last surviving member of the derive-don't-restate family (#3613 / #3753 on the parameter side, #3752 on the adapter return side), and it was the member with no compile-time tripwire: three surfaces (`plugin-dashboard`'s `DatasetWidget`, `plugin-report`'s `DatasetReportRenderer`, app-shell's `DatasetPreview`) consume this name AS the real column type, so the next spec column key would simply never appear here and no build would complain. It is now `AnalyticsResult['fields'][number]`, so it cannot lag the contract again.
939
+
940
+ **Consumer-visible type tightening (the reason this is a minor, not a patch).** The restatement had relaxed `type` to optional; the contract requires it. Anything that assigned a column literal without `type` — or a bare `{ name, label?, format? }` — to `DatasetResultField` will now fail to compile, and the fix is to supply the `type` the server always sends. Nothing in this repo needed changing: every value of this type originates in `ObjectStackAdapter.queryDataset`, which already declares the spec element, and no consumer reads `.type` at all, so the widening had bought no caller anything while advertising a `string | undefined` the wire never produces. Marked `minor` per the repo's bump policy, which reserves `major` for following `@objectstack/spec` across a major.
941
+
942
+ The exported name is unchanged and the `PercentScale` re-export from this module is untouched, so existing import paths keep working. `packages/core/tsconfig.typetests.json` (chained off the package's `type-check`) compiles the new parity test, so the pins are checked by CI rather than merely written down — including a negative pin that goes red if the hand-written interface is ever restored, and the `ChartResultField` superset relationship the module's comment claims.
943
+
944
+ - d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
945
+ measured as declared-but-never-read at the branch point, and each is removed rather than
946
+ left as an authoring surface whose values nothing acts on.
947
+
948
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
949
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
950
+ API-break count):
951
+
952
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
953
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
954
+ view container's default `list` and keyed views by the authored bare key instead of the
955
+ composer's `<object>.<key>` identity. The live implementation — `MetadataProvider`'s, in
956
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
957
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
958
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
959
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
960
+ field was _required_, so five fixtures across three packages had been declaring an empty
961
+ array for a value nothing would ever look at. Role-attached grants are now a compile
962
+ error rather than silently ignored data. (#4288)
963
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
964
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
965
+ them — and only the provider's memo dependency list still named them. Record-level
966
+ loading and error state stays where it is actually expressed: each renderer's own data
967
+ source. (#3773)
968
+
969
+ No behaviour change, no request-count change:
970
+
971
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:<object>')`
972
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
973
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
974
+ were permanent no-ops. The invalidations of the keys that do have readers
975
+ (`view:<object>:<viewId>` for `getView`, `view-overrides:<object>` for
976
+ `listViewOverrides`) are untouched and now pinned. (#3778)
977
+
978
+ - 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
979
+
980
+ ## The retirement
981
+
982
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
983
+
984
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
985
+
986
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
987
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
988
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
989
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
990
+
991
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
992
+
993
+ ## The dependency move
994
+
995
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
996
+
997
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
998
+
999
+ ## Breaking, in FROM → TO form
1000
+
1001
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
1002
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
1003
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
1004
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
1005
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
1006
+
1007
+ - 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
1008
+
1009
+ `formatDisplayNumber`, `shouldGroupDisplayNumber` and `DisplayNumberFormatOptions` move from `@object-ui/i18n` into `@object-ui/core`. `@object-ui/i18n` re-exports all three under the same names, so every existing import path keeps working unchanged and both spellings resolve to the same function object; nothing published was removed.
1010
+
1011
+ The move is what fixes the bug. `@object-ui/core`'s `formatMeasure` needed exactly this policy and could not import it — `core` is the React-free engine and is a runtime dependency of React-free consumers (the `object-ui` VS Code extension, `@object-ui/data-objectstack`), while `i18n` depends on `i18next`/`react-i18next` and peer-depends on React. So `formatMeasure` carried a parallel `Intl` implementation, recorded at both ends as deliberate duplication, and the two drifted in the one place a hand-built string and `Intl` disagree. A German session read `1.234,5 %` from a list cell and `1.234,5%` from a dashboard measure showing the same number. The function is pure, so the boundary was never a property of the code — only of where the code sat; moving it down removes the obstacle instead of working around it. `core` imports nothing from `i18n`, so the new edge adds no cycle.
1012
+
1013
+ **Behaviour change — a measure's percent sign now follows the locale.** `formatMeasure` appended a literal `%` in every locale; it now renders the locale's own percent convention, the same one the list-cell `formatPercent` has used since the fix to its own machine-locale defect. Measured to change output in de, fr, es, ru, sv, cs, fi (a no-break space appears before the sign), tr (the sign moves to the FRONT: `%1.234,5`) and ar (its own percent sign plus U+061C). English, Japanese and Chinese are byte-identical — their convention is a bare trailing sign — which is why this was invisible in an English session.
1014
+
1015
+ **No numeral moves, in any locale, at any magnitude.** The obvious route to the locale's convention is `Intl`'s `style: 'percent'`, but that style expects a fraction, so a value already in percentage points would have to be divided by 100 for `Intl` to multiply it straight back — and that round trip is lossy. Measured, it moves 27,581 of 1,200,013 ordinary-magnitude en-US forms at rounding ties (`0.175` at two decimals becomes `0.17%` instead of `0.18%`), plus `MAX_SAFE_INTEGER` and everything from 1e23 up, where `100,000,000,000,000,000,000,000%` becomes `99,999,999,999,999,990,000,000%`. The percentage points are formatted directly instead, through a new `style: 'percentPoints'` on `DisplayNumberFormatOptions`; that route was measured to produce a byte-identical percent affix to `style: 'percent'` across all 171 locale tags tested while moving none of those 1,200,013 forms. Callers holding a fraction keep using `style: 'percent'`, whose behaviour is unchanged — naming the two cases apart is what stops the next caller from reaching for the lossy one.
1016
+
1017
+ `@object-ui/i18n`'s entry declaration is byte-identical, but the declaration it points at now lives in `@object-ui/core` and the package gains that dependency, so it takes the same minor bump rather than a patch.
1018
+
1019
+ - c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
1020
+
1021
+ Two packages published a type called `SchemaNode` and they were not the same type. `@object-ui/core` hand-declared `interface SchemaNode { type: string; … [key: string]: any }`; `@object-ui/types` exported `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`, whose own doc comment names `'Plain string'` a valid node. Both were exported under one name from packages the same consumers import together, so which declaration a call site got depended on which package it happened to import from — #4548's canary measured 19 of 35 errors as exactly that collision. Core's declaration is now a re-export of types', so there is one declaration left to disagree with. Core's entry surface is unchanged: `dist/index.d.ts` is byte-identical across the change.
1022
+
1023
+ Reconciling it exposed a real defect rather than a mechanical narrowing, which is why the first attempt was withdrawn instead of forced. The spec bridges write `spec.label` — the spec's `I18nLabel`, an INLINE locale map like `{ en: 'Owner', 'zh-CN': '负责人' }` — into `node.label`, and `BaseSchema.label` declared `string`. Under core's old index signature that assignment was invisibly `any`; under one honest `SchemaNode` it is a type error. `BaseSchema.label` and `.description` therefore now accept `string | I18nLabel`, and the two bridge assignments compile with their expressions untouched.
1024
+
1025
+ Resolution happens at READ time, in the renderer, against the display locale — not at the bridge. Resolving at the bridge was measured unimplementable: it is a plain class method that cannot call a hook, `BridgeContext` declares no locale, and `updateContext()` has zero callers, so a bridge-resolved label would freeze one audience's language into the node tree with no re-translation channel. React's own invalidation re-translates for free at the read site.
1026
+
1027
+ The widening turned every blind `schema.label`-as-string read into a named compiler error, and that inventory is the audit: it named four sites repo-wide, all one class — the label reaching a React child position, where a map does not render as `[object Object]` but THROWS `Objects are not valid as a React child`, failing the whole subtree. Three are `@object-ui/components` renderers (`filter-builder`, `sidebar-group`, `dropdown-menu`), which now resolve with the spec's own `resolveI18nLabel` against `useDisplayLocale()`. The fourth is `plugin-dashboard`'s `DashboardGridLayout` heading, which resolves with `pickLocalized` against the active UI language — matching the widget-title resolution already in that same component rather than putting two resolvers and two disagreeing locale channels in one render; the two resolvers are limb-for-limb twins with a parity test pinning them.
1028
+
1029
+ One interface now carries both label vocabularies two properties apart — `label`/`description` are the spec's INLINE map, `ariaLabel` is the KEYED bundle reference — and each accepts the other's shape vacuously. That confusability is objectui#4167's known hazard, inherent to the spec's `I18nLabel` design; both shapes are named with cross-referenced doc comments stating which resolver owns which slot, and a pin asserts the two unions do not collapse into each other.
1030
+
1031
+ Finally, the spec bridges declare their return type as `BaseSchema` instead of the union. Both bridges end in a single `return node` on an object literal, so the union described nothing real while forcing a narrowing at every read — 272 mechanical errors across five suites in the first round. That change is a type annotation only; the emitted JavaScript is byte-identical.
1032
+
1033
+ - 2459a3e: Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
1034
+ `ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
1035
+ `ActionMapping` interface are removed under enforce-or-remove: all four were public surface
1036
+ of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
1037
+ mapping, so `dispatch()` had no reachable caller either, and every call site was in the
1038
+ engine's own test file.
1039
+
1040
+ Breaking for anyone who typed against or called the removed declarations, marked `minor`
1041
+ per this repository's version-alignment convention (the major tracks `@objectstack`, never
1042
+ an API-break count). Actions are still entered by name (`executeAction`), by location
1043
+ (`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
1044
+ only the event-keyed entry point is gone, and no runtime behaviour changes because no
1045
+ runtime path reached it.
1046
+
1047
+ The three ways the retired condition gate had drifted from the `visible` contract that
1048
+ `getActionsForLocation` implements die with the path rather than being fixed on it: it
1049
+ entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
1050
+ `string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
1051
+ `@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
1052
+ failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
1053
+ API nobody calls would only have widened behaviour nobody uses.
1054
+
1055
+ - fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
1056
+
1057
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
1058
+ density vocabulary, and since objectui#4352 they answered differently for the
1059
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
1060
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
1061
+ `comfortable`. One metadata-driven system, two answers for one input
1062
+ (objectui#4440).
1063
+
1064
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
1065
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
1066
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
1067
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
1068
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
1069
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
1070
+ still fails the build here.
1071
+
1072
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
1073
+ `major` — its major tracks `@objectstack`). Two things change:
1074
+
1075
+ - **Published type.** `rowHeightToDensityMode` is exported from
1076
+ `@object-ui/core`, and its return widens from `DensityMode` to
1077
+ `DensityMode | undefined`. A host assigning the result straight into a
1078
+ `DensityMode` now has to say what an off-spec row height should mean to it.
1079
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
1080
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
1081
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
1082
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
1083
+ A sweep of this repo, the `objectstack` example apps and one downstream app
1084
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
1085
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
1086
+ `Record< DensityMode, RowHeight >`).
1087
+
1088
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
1089
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
1090
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
1091
+ is an own-property check now.
1092
+
1093
+ - bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
1094
+ (objectui#4167, objectstack#4115).
1095
+
1096
+ The rc.6 bump published nine names this repo already declared locally, on top of
1097
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
1098
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
1099
+ triaged on its own rather than blanket-renamed, because the right answer differs
1100
+ per symbol: five bind to the spec, three are renamed because the spec's
1101
+ same-named export means something else, five arrive by derivation, and one is a
1102
+ declared dialect with a written reason.
1103
+
1104
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
1105
+ `@object-ui/types`** — three exported names changed, because the spec exports the
1106
+ same name for a _different_ thing:
1107
+
1108
+ | package | was | now | what the spec's same-named export actually is |
1109
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
1110
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
1111
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
1112
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
1113
+
1114
+ `resolveI18nLabel` is the one where the collision had already started costing
1115
+ something. rc.6 widened `I18nLabel` from `string` to
1116
+ `string | Record< string, string >`, so the same authored value now reaches
1117
+ either resolver — and each answers wrongly, silently, for the other's input: the
1118
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
1119
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
1120
+ tags. The rc.6 bump PR met this and aliased the spec's import as
1121
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
1122
+ them. That is a review convention, which is what objectstack#4115 exists to
1123
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
1124
+ name says which vocabulary it resolves at every call site.
1125
+
1126
+ **Eleven keep their names and are now imported or derived from the spec** instead
1127
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
1128
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
1129
+ `GanttConfig`, plus the three renamed above at their new names.
1130
+
1131
+ **Four of the copies were losing information, not just duplicating it.**
1132
+
1133
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
1134
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
1135
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
1136
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
1137
+ `quickFilters`, `autoZoomToFilter` — are all read by
1138
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
1139
+ intersection that existed only because this type did not carry them. It now
1140
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
1141
+ genuinely local extension; the schema is `$loose` upstream, so that key is
1142
+ legal metadata rather than a second dialect.
1143
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
1144
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
1145
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
1146
+ `formatted` were missing, under a comment already claiming to be "the part
1147
+ names of `AddressSchema`". The widget still renders five inputs; binding the
1148
+ type stops it from asserting the platform cannot store the other two, and makes
1149
+ the `{ ...address }` write-through say so.
1150
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
1151
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
1152
+ own doc comment names this module as one of the three. It is now the spec's
1153
+ array by reference, and the local date-macro bounds table is pinned complete
1154
+ against it with `satisfies`, so a preset the schema gains without bounds here
1155
+ is a compile error rather than a filter that validates clean and then selects
1156
+ nothing.
1157
+
1158
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
1159
+ it is bound directly, with a both-directions type pin that it stays the same type
1160
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
1161
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
1162
+ schemas and are now re-exports — "still exact" is the argument for binding them,
1163
+ since a copy with nothing to protect can only drift.
1164
+
1165
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
1166
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
1167
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
1168
+ moved a derivation the guard could see into an object literal it deliberately
1169
+ does not descend into. The dialect is unchanged and its three divergences are
1170
+ pinned; which side moves on the refinement itself is objectui#4165.
1171
+
1172
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
1173
+ `@object-ui/layout`: its public type surface now references the spec.
1174
+
1175
+ ### Patch Changes
1176
+
1177
+ - ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
1178
+
1179
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
1180
+
1181
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
1182
+
1183
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
1184
+
1185
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
1186
+
1187
+ - 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
1188
+
1189
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
1190
+
1191
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions.<object>.<field>.<value>` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
1192
+
1193
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
1194
+
1195
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
1196
+
1197
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
1198
+
1199
+ - aca27fa: The multi-dimension pivot branch buckets a null first-dimension value instead of dropping its bar (objectui#4497)
1200
+
1201
+ `buildChartSeries`' pivot branch (2+ dimensions, single measure) bucketed rows by `String(xRaw ?? '')` but wrote the RAW value into the emitted row, so a null first-dimension value produced `{status: null, Low: 3}` and reached recharts with a null category — which draws no mark. Measured at the DOM: a two-group pivot drew ONE bar, and an all-null pivot drew axes and gridlines with zero bar rectangles and no empty state. That is the same mechanism objectui#4466 fixed one branch below, on the branch that card deliberately left pinned as-is until the pivot's own bucketing had been measured.
1202
+
1203
+ The pivot now maps a null/undefined first-dimension VALUE to the same bucket label the single-dimension branch uses — `ChartSeriesOptions.nullCategoryLabel`, defaulting to `NULL_CATEGORY_LABEL`. One doctrine, one predicate, two call sites; no new export, and every existing call site compiles and behaves identically.
1204
+
1205
+ The bucket KEY is untouched, which is what keeps this a display fix: `String(xRaw ?? '')` still decides which rows share a bar, so every existing grouping is byte-identical and only the label the bucket carries changes. Rows that lack the category key entirely are still not bucketed — that shape is a dimension grouped by but never projected (framework#4033), a different defect with a different answer.
1206
+
1207
+ Drill-through needed no change, which was measured rather than assumed: the pivot's emitted rows are AGGREGATED, so they are not index-aligned with `drillRawRows` and the one production caller (`DatasetWidget.handleChartDrill`) already drills by SEARCHING the raw rows through `findChartSeriesRow`. Those raw rows still carry their null, and objectui#4466's label-matching covers the multi-dimension arm as well as the single-dimension one, so the newly-visible bar resolves to the right record. Pinned at both levels so a regression in either half surfaces as the dead click it would be.
1208
+
1209
+ - 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
1210
+
1211
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
1212
+
1213
+ The label lookup read options as `baseObject.fields[<path>]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
1214
+
1215
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
1216
+
1217
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
1218
+
1219
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
1220
+
1221
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
1222
+
1223
+ - abb0f81: A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
1224
+
1225
+ `@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
1226
+
1227
+ Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
1228
+
1229
+ No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
1230
+
1231
+ The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
1232
+
1233
+ - 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
1234
+
1235
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
1236
+ type on the same dashboard rendered the translation, and dashboard filter chips
1237
+ rendered `[object Object]` or the raw stored value. Both come from the same
1238
+ cause: authored labels reaching a render site that could not read the
1239
+ vocabulary `@objectstack/spec` actually admits.
1240
+
1241
+ - **KPI cards rejoin the widget translation channel.** The self-contained
1242
+ `metric` branch built its own label from the raw `widget.title`, so the
1243
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
1244
+ resolved was computed and thrown away. It now reads that channel like every
1245
+ other widget header.
1246
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
1247
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
1248
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
1249
+ handed the inline per-locale map the spec admits today they returned nothing —
1250
+ a KPI card with a map title rendered the literal string `metric`. All three
1251
+ now use `pickLocalized`, the resolver already used for this vocabulary
1252
+ elsewhere in the package.
1253
+ - **Dashboard filter labels and static option labels resolve per locale.**
1254
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
1255
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
1256
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
1257
+ tests the RESOLVED string — an object is always truthy, so it never reached
1258
+ the fallback before.
1259
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
1260
+ map label to the raw stored value in every locale, English included, so
1261
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
1262
+ The pair shape is still normalized; the label vocabulary is preserved for the
1263
+ render side to resolve.
1264
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
1265
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
1266
+ narrow (`label?: string`, which is what made these read sites invisible to
1267
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
1268
+ rejects at publish).
1269
+
1270
+ Plain-string labels are unaffected and render byte-identically.
1271
+
1272
+ - 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
1273
+
1274
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
1275
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
1276
+ string, a plain space, a control character — assumed the data would not contain
1277
+ it, and each assumption failed on ordinary data. This closes the last place the
1278
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
1279
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
1280
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
1281
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
1282
+ cell showed a different row's measure, the overwritten row was unreachable, and
1283
+ drill-through followed the same wrong index into the wrong records, all without
1284
+ an error. The trigger requires that character to appear as a dimension value, so
1285
+ this is the assumption being removed rather than a defect users hit today.
1286
+
1287
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
1288
+ bare `null` that no string can spell. The normalization lives in
1289
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
1290
+ its string form) rather than at each call site, because a placeholder spelled by
1291
+ a caller is a placeholder that can collide again — which is exactly how this one
1292
+ survived the previous fix. `pivotBucketId` accepts `Array<string | null>`
1293
+ accordingly; that is a widening, so existing callers passing `string[]` are
1294
+ unaffected.
1295
+
1296
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
1297
+ and the subtotal map keyed by it are built from the same expression, so changing
1298
+ one alone would split the headers while the subtotal map still merged, landing
1299
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
1300
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
1301
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
1302
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
1303
+
1304
+ The dashboard's column bucket id also stops being a bare string and becomes a
1305
+ one-element tuple through the same shared encoder. It was the one id in the
1306
+ family still built by hand, on the reasoning that a single value needs no
1307
+ boundary — true of the boundary, false of everything else the encoder does, and
1308
+ it is why the across axis kept carrying this collision after the row ids were
1309
+ fixed.
1310
+
1311
+ No display change: these placeholders only ever entered ids, never labels. An
1312
+ unset dimension still renders through `formatDimensionValue` exactly as before,
1313
+ and data containing neither an absent value nor that character buckets
1314
+ identically — the ids are opaque lookup keys, never parsed back into a value,
1315
+ never shown, never persisted.
1316
+
1317
+ - d6aa172: Retire `params.newTab` on a url action — `openIn: 'new-tab'` is the sanctioned spelling
1318
+
1319
+ `ActionRunner`'s navigator read a legacy `params.newTab` escape hatch below `openIn` and above the external-URL heuristic. That read is removed, executing the objectstack#6828 maintainer ruling of 2026-08-10, whose contract half shipped in objectstack PR #7375: the url-side readings of an object-form `params` are retired, not renamed.
1320
+
1321
+ Nothing that ever validated can regress. `params` is declared as `z.array(ActionParamSchema)`, so an object-form `params` has always failed the props parse — the fallback could only fire on a stack the spec refuses. The removal also closes a collision hazard: a params dialog declaring a field named `newTab` had the user's own collected input silently steering navigation.
1322
+
1323
+ `openIn: 'self' | 'new-tab'`, the legacy `navigate.newTab` modifier on the `navigation` shape, and the external/relative default are all unchanged.
1324
+
1325
+ - 9461dd3: Form actions no longer carry a record id across an object boundary (#4292).
1326
+
1327
+ `ActionRunner.executeForm` forwarded `/forms/:name?recordId=<id>` unconditionally,
1328
+ and that URL says nothing about which object the id belongs to — so the form route
1329
+ resolved it against the FormView's own target object. When an action fired from a
1330
+ record of a DIFFERENT object and ids collide across objects (per-table integer
1331
+ keys), the form silently prefilled and, since the route learned to honour the param,
1332
+ `PATCH`ed a same-id record of the wrong object.
1333
+
1334
+ - **Producer**: the id is forwarded only when the firing context record's object
1335
+ (`context.objectName`) matches the target view's object; on a mismatch no id is
1336
+ forwarded, preserving create semantics. When it IS forwarded, the object travels
1337
+ with it as `?recordObject=`.
1338
+ - **Consumer**: `/forms/:name` refuses — no record read, no write — when
1339
+ `recordObject` disagrees with the FormView's object. A URL without the param
1340
+ behaves exactly as before, so existing deep links are unaffected.
1341
+
1342
+ - Updated dependencies [f650253]
1343
+ - Updated dependencies [3d9769a]
1344
+ - Updated dependencies [92876f0]
1345
+ - Updated dependencies [d9d3463]
1346
+ - Updated dependencies [2a40f69]
1347
+ - Updated dependencies [bec3e14]
1348
+ - Updated dependencies [1f9b905]
1349
+ - Updated dependencies [abb0f81]
1350
+ - Updated dependencies [38ab505]
1351
+ - Updated dependencies [7e4f0e5]
1352
+ - Updated dependencies [c1d939f]
1353
+ - Updated dependencies [bb68488]
1354
+ - Updated dependencies [ab04728]
1355
+ - @object-ui/types@17.5.0
1356
+
1357
+ ## 17.4.0
1358
+
1359
+ ### Minor Changes
1360
+
1361
+ - c3b01a7: Give composite and grouped field widgets a real accessible name: the form renderer now associates its label by IDREF for widgets that declare `labelling: 'group'`, instead of emitting a `<label for>` that nothing labelable answers (objectui#3961).
1362
+
1363
+ Six widgets rendered a visible group label that named **nothing** in the accessibility tree. Measured in a real form, one field per row, reading each label's `for` against the DOM:
1364
+
1365
+ ```
1366
+ address for=…-form-item -> MISSING byLabelText=0 role+name=0
1367
+ geolocation for=…-form-item -> MISSING byLabelText=0 role+name=0
1368
+ checkboxes for=…-form-item -> div byLabelText=0 role+name=0
1369
+ radio for=…-form-item -> div[radiogroup] byLabelText=0 role+name=0
1370
+ rating for=…-form-item -> div byLabelText=0 role+name=0
1371
+ file for=…-form-item -> div[role=button] byLabelText=0 role+name=0
1372
+ ```
1373
+
1374
+ Two shapes, one outcome. `address` / `geolocation` spread the host's id onto their first sub-input and then replaced it with that input's own unique id (objectui#3343, correct in itself), so the `for` named an id no element carried — clicking "Shipping Address" did nothing and the group label was absent from the accessibility tree entirely. `checkboxes` / `radio` / `rating` / `file` kept the id, but on a `div`: a `<label for>` on a non-labelable element is inert HTML — `HTMLLabelElement.control` is `null`, so it activates nothing and contributes no name. A screen reader heard "Street Address", "City", "Alpha", "Beta" — never which group they belonged to.
1375
+
1376
+ The fix is the WAI-ARIA group pattern, driven by a DECLARATION rather than by the host guessing at widget DOM:
1377
+
1378
+ - `@object-ui/core` — `ComponentMeta` gains `labelling?: 'control' | 'group'`. Additive and optional; absent means `'control'`, which is every existing component's behaviour.
1379
+ - `@object-ui/components` — the form renderer reads it. For a `'group'` field the `<FormLabel>` publishes an `id` and drops its `for`, and the widget receives `aria-labelledby`. The single-control path is unchanged down to the attribute: no id on the label, no `aria-labelledby` key on the widget, so no field acquires a second naming channel. `ui/form.tsx` is untouched (Shadcn no-touch) — both halves travel as ordinary props, since `<FormLabel>` spreads props after its own `htmlFor`.
1380
+ - `@object-ui/fields` — the six audited widgets declare `labelling: 'group'`. `address` / `geolocation` move the host id (and only the id) from their first sub-input to the group container; `checkboxes` / `rating` answer a host-supplied `aria-labelledby` with `role="group"`; `radio` keeps Radix's more specific `radiogroup`; `file` takes the name on its dropzone with no invented group layer, because it has exactly one control that merely happens not to be labelable.
1381
+
1382
+ No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the same channel `aria-required` (objectui#3290) travels.
1383
+
1384
+ Deliberately unchanged: sub-labels keep naming their own inputs (`aria-labelledby` overrides `<label for>`, so putting the group name on the first sub-input would have replaced "Street Address" with the field name — the concatenated-name outcome this issue rejected), `aria-describedby` stays on the first focusable sub-input where focus can reach it (objectui#3318), the sub-input ids of objectui#3343 do not move, and standalone rendering — the inline grid editor, a bare SDUI node, where nobody hands down an id and there is no host label to point at — emits no role and no IDREF at all.
1385
+
1386
+ A widget that does not declare itself keeps the old `for`, which the label-association tests report as an association resolving to a non-labelable element. Silence was the failure mode being fixed; the default path stays loud.
1387
+
1388
+ - 48132f7: Track the `@objectstack` family at `17.0.0-rc.5` (objectui#3560).
1389
+
1390
+ The pin moves from `^17.0.0-rc.2` to `^17.0.0-rc.5` across all 37 declarations in
1391
+ 30 `package.json` files, and the sibling `@objectstack/*` packages (`client` /
1392
+ `formula` / `lint`) move with it — they pin `@objectstack/spec` **exactly**, so
1393
+ leaving them behind would keep a second copy of the spec in the tree and have
1394
+ `@objectstack/lint` validating against schemas that still accept the keys rc.3–rc.5
1395
+ retire. `pnpm-lock.yaml` now resolves one copy of each of the six family packages
1396
+ (`spec` / `client` / `core` / `formula` / `lint` / `sdui-parser`), all at rc.5.
1397
+
1398
+ Bumping the pin and repairing the fallout cannot be split: the pin alone reddens
1399
+ CI, and the code alone targets a shape that is not in effect yet.
1400
+
1401
+ ## A live bug this upgrade fixes
1402
+
1403
+ **`ObjectStackDataSource.delete()` never emitted its mutation event, and resolved
1404
+ `undefined` instead of a boolean.** `@objectstack/client`'s `DeleteDataResult`
1405
+ declared a key called `deleted` — a key no schema has ever declared and no server
1406
+ path has ever returned on `DELETE /data/:object/:id`. So `result.deleted`
1407
+ compiled and read `undefined` at runtime: the guard never fired, a successful
1408
+ delete notified no subscriber, and every consumer's cache stayed stale.
1409
+ objectstack#5638 corrected the interface to the schema's `success`; following the
1410
+ rename is what restores both behaviours. Nothing in this repo had to change shape
1411
+ for it — the code was already asking the right question of the wrong key.
1412
+
1413
+ ## Breaking, in FROM → TO form
1414
+
1415
+ - **The five `@objectstack/spec/ui` interaction-config modules are gone** —
1416
+ touch / dnd / keyboard / animation / offline, 32 defs and 64 exports
1417
+ (objectstack#4988, PR objectstack#5321). None of them had an authoring door: no
1418
+ metadata document could ever carry one of these blocks, so a stack that parsed
1419
+ before the retirement parses byte-for-byte the same after it. `@object-ui/types`
1420
+ drops the 32 `export type` re-exports. The vocabulary each one's only real
1421
+ consumer needs is now declared by that consumer, which is the remedy the spec's
1422
+ own retirement ledger prescribes ("declare that union locally — it is your
1423
+ client's policy, not the platform's"):
1424
+
1425
+ - `@object-ui/react`'s `useOffline` owns `OfflineStrategy`, `ConflictResolution`,
1426
+ `PersistStorageType`, `EvictionPolicyType`, `OfflineConfig`,
1427
+ `OfflineCacheConfig`, `OfflineSyncConfig`;
1428
+ - `@object-ui/core`'s `DndProtocol` / `KeyboardProtocol` own `DndConfig`,
1429
+ `DragItem`, `DropZone`, `DragConstraint`, `DragHandle`, `DropEffect`,
1430
+ `KeyboardNavigationConfig`, `KeyboardShortcut`, `FocusManagement`,
1431
+ `FocusTrapConfig`;
1432
+ - `@object-ui/types`' `mobile` module owns `SpecGestureConfig`,
1433
+ `SwipeGestureConfig`, `PinchGestureConfig`, `LongPressGestureConfig`,
1434
+ `TouchTargetConfig`, `TouchInteraction` (plus a new `SPEC_GESTURE_TYPES`
1435
+ runtime tuple), so `@object-ui/mobile`'s import paths are unchanged.
1436
+
1437
+ Every shape is moved verbatim — same keys, same members, same optionality — so
1438
+ no hook or bridge changes behaviour. Consumers importing these names from
1439
+ `@object-ui/types` must import them from the owning package instead. Note the
1440
+ spec's _surviving_ `ConnectorConflictResolution` (`/integration`, connector sync)
1441
+ and `ConflictResolutionStrategy` (`/api`, route merge policy) are **different
1442
+ concepts** — do not re-point at them.
1443
+
1444
+ - **`@object-ui/types` no longer re-exports `NotificationAction` or `EmbedConfig`**
1445
+ (objectstack#5015, PR objectstack#5300). Both were published `ui` vocabulary with
1446
+ no authoring door; no notification action was ever parsed from metadata and no
1447
+ iframe route ever read an embed config. The presentation enums
1448
+ (`NotificationType` / `NotificationSeverity` / `NotificationPosition`) and
1449
+ `SharingConfig` **survive** and are untouched — public form sharing still gates
1450
+ the anonymous endpoints on `allowAnonymous` + `publicLink`.
1451
+ `@object-ui/core`'s `SharingProtocol` keeps `resolveEmbedConfig` /
1452
+ `generateEmbedCode` against a locally declared `EmbedConfig`, so its surface is
1453
+ unchanged.
1454
+ - **`ThemeEngine` stops emitting nine retired CSS variable groups**
1455
+ (objectstack#5021 option 2, PR objectstack#5289). `theme.animation`,
1456
+ `theme.zIndex` and five typography groups (`fontSize` / `fontWeight` /
1457
+ `lineHeight` / `letterSpacing`, plus `fontFamily.heading` / `fontFamily.mono`)
1458
+ are tombstones the schema now rejects by name, so `--duration-*`, `--timing-*`,
1459
+ `--z-*`, `--font-size-*`, `--font-weight-*`, `--line-height-*`,
1460
+ `--letter-spacing-*`, `--font-heading` and `--font-mono` had become structurally
1461
+ dead code — no author could produce the input that reached them.
1462
+ `generateAnimationVars` and `generateZIndexVars` are removed from
1463
+ `@object-ui/core`, and `@object-ui/types` drops `Animation` / `ZIndex` /
1464
+ `AnimationSchema` / `ZIndexSchema`. **`theme.customVars` is the declared — and
1465
+ since #5021 the only — door**: each entry is emitted verbatim as
1466
+ `--<key>: <value>`, so a `--z-modal` or a `--duration-fast` goes there now.
1467
+ LIVE emission is untouched byte for byte: `colors`, `borderRadius`, `shadows`,
1468
+ `typography.fontFamily.base` (→ `--font-sans`) and `customVars`.
1469
+ - **`@object-ui/types`' `HttpMethodSchema` now binds the spec's
1470
+ `HttpMethodSubsetSchema`, and `HttpMethod` binds `HttpMethodSubset`**
1471
+ (objectstack#5832, PR objectstack#5976 — objectui#3499). The spec renamed its
1472
+ 5-value UI subset because `schemaNameFromExportKey` strips the `Schema` suffix,
1473
+ so the 5-value and 7-value enums both published as `shared/HttpMethod` and the
1474
+ later write won — the emitted JSON Schema and reference page described only one
1475
+ of them. **The runtime domain is unchanged and this repo's exported names are
1476
+ unchanged**; this follows the rename without touching cross-package semantics.
1477
+ Deliberately NOT re-pointed at the spec's bare `HttpMethod`: that is the 7-value
1478
+ enum, and widening to it would let `method: 'HEAD'` compile and then throw in
1479
+ `HttpRequestSchema.parse()`.
1480
+ - **`dashboard.widgets[].actionUrl` / `actionType` / `actionIcon` / `aria` are
1481
+ refused, not stripped** (objectstack#5010, ADR-0049 enforce-or-remove). A
1482
+ dashboard widget has no action button and never had one — every action the
1483
+ dashboard dispatches comes from `header.actions[]` — and no renderer ever applied
1484
+ the widget `aria`, so it promised accessibility compliance it did not deliver.
1485
+ A stale dashboard now gets a named error telling it where the affordance moved,
1486
+ instead of silently losing it. Run `os migrate meta --from 16` to rewrite.
1487
+
1488
+ ### Patch Changes
1489
+
1490
+ - 6719877: `condition: false` now actually prevents the action from executing (objectui#3872)
1491
+
1492
+ `ActionRunner.execute` — the engine's public execution entry, shared by every
1493
+ action surface — gated conditional execution on `if (action.condition)`, i.e. on
1494
+ the raw value's TRUTHINESS. Truthiness cannot answer the question the gate needs
1495
+ answered ("did the author declare a condition?"), and on this key it answered in
1496
+ the over-permissive direction: `condition: false` fell on `if (false)`, so the
1497
+ whole block was skipped, `evaluateCondition` was never consulted, and the action
1498
+ executed. Measured with a call-counting handler:
1499
+
1500
+ - `condition: false` — handler ran: **true**, result `{ success: true }`
1501
+ - `condition: { dialect: 'cel', source: 'false' }` — handler ran: false, result `{ success: false, error: 'Action condition not met' }`
1502
+
1503
+ Two spellings of the same statement, opposite outcomes. `false` is the most
1504
+ explicit "never execute this" metadata can carry — and what a template that
1505
+ switches an action off emits — so the direction matters: the action really ran,
1506
+ possibly writing. This is the over-permission half of the objectui#3492 family
1507
+ (the `disabled` gate one line below is objectui#3848, whose defect pointed the
1508
+ other way).
1509
+
1510
+ The gate now asks whether a `condition` gate is DECLARED before evaluating.
1511
+ "Nothing to evaluate" is read from core's single predicate normalizer
1512
+ (`toPredicateInput`, which maps `''`, `null`, an empty-`source` envelope and
1513
+ non-predicate values to `undefined`), plus the whitespace-only string, which the
1514
+ normalizer wraps rather than collapses — objectui#3850's ruling on the scope of
1515
+ "empty predicate", the same one the `disabled` gate already applies. Once the
1516
+ door asks the right question the verdict needs no boolean branch of its own:
1517
+ `evaluateCondition` returns a boolean argument verbatim.
1518
+
1519
+ **Behaviour change surface, deliberately one-directional and one row wide.**
1520
+ Exactly one shape changes verdict — a declared boolean `false`, from executing to
1521
+ refused (`{ success: false, error: 'Action condition not met' }`, the message the
1522
+ key already used). Everything else is byte-identical: `condition: true`, an
1523
+ absent `condition`, and truthy expressions/envelopes still execute; falsy
1524
+ expressions, falsy CEL envelopes and falsy `${…}` templates are still refused,
1525
+ as before; the three empty predicates (`''`, whitespace-only, an empty-`source`
1526
+ envelope) still execute, now because nothing was declared rather than because
1527
+ `if ('')` happened to be falsy; and non-predicate junk (`0`, `{}`) still
1528
+ executes — a value that is not a predicate must not decide an action's fate,
1529
+ which is the fail-open posture this module already committed to for `disabled`.
1530
+ So this change can only start refusing execution, never start allowing it — the
1531
+ mirror image of objectui#3848's fix.
1532
+
1533
+ `ActionDef.condition` is widened to `string | boolean` to match what the gate now
1534
+ honours (and the `disabled` key beside it). This is not a lenient consumer
1535
+ alias: the boolean was always accepted at runtime through the interface's index
1536
+ signature, it was simply ignored.
1537
+
1538
+ The value handed to the evaluator is deliberately left RAW rather than normalized
1539
+ first, for the same reason as objectui#3848 with the sign flipped:
1540
+ `toPredicateInput` wraps unconditionally, so an already-templated `'${x}'`
1541
+ becomes `'${${x}}'`, fails to parse, returns verbatim and coerces to a constant
1542
+ `true` — on `disabled` that blocks everything, on `condition` it would EXECUTE
1543
+ everything. That normalizer defect is objectui#3871; a tripwire next to the new
1544
+ pins goes red the day it is fixed.
1545
+
1546
+ - 56ff091: An empty `disabled` predicate no longer refuses to run the action (objectui#3848)
1547
+
1548
+ `ActionRunner.execute` — the engine's public execution entry, shared by every
1549
+ action surface — gated on `action.disabled != null && action.disabled !== false`
1550
+ and handed the value straight to `evaluateCondition`. That function documents one
1551
+ default for "there is no condition here": return `true`, meaning
1552
+ _visible/enabled_. On `disabled`, `true` means BLOCKED. So every empty predicate
1553
+ was read as "disabled": the handler was never invoked and the caller got
1554
+ `{ success: false, error: 'Action is disabled' }` — a state the metadata never
1555
+ declared. Measured with a call-counting handler:
1556
+
1557
+ - `disabled: ''` — handler ran: false
1558
+ - `disabled: ' '` (whitespace only) — handler ran: false
1559
+ - `disabled: { dialect: 'cel', source: '' }` (the empty envelope `objectstack build` can emit) — handler ran: false
1560
+
1561
+ After objectui#3842 / objectui#3849 fixed the renderer halves, this was live
1562
+ user-visible behaviour: the button became clickable and clicking it returned
1563
+ `Action is disabled` — the renderer and the execution entry disagreeing about one
1564
+ predicate value, the shape objectui#3314 already paid for once.
1565
+
1566
+ The gate now asks whether a `disabled` gate is DECLARED — whether there is a
1567
+ condition to reach a verdict on — before evaluating. "Nothing to evaluate" is
1568
+ read from core's single predicate normalizer (`toPredicateInput`, which maps
1569
+ `''`, `null`, an empty-`source` envelope and non-predicate values to
1570
+ `undefined`), plus the whitespace-only string, which the normalizer wraps rather
1571
+ than collapses and which `evaluateCondition` itself calls "no condition"
1572
+ (`evalRowPredicate` applies the same blank-source rule).
1573
+
1574
+ **Behaviour change surface, deliberately one-directional.** Only values with
1575
+ nothing to evaluate change, and only from blocked to allowed: `''`,
1576
+ whitespace-only, an empty-`source` envelope, and non-predicate junk (`0`, `{}`,
1577
+ which previously coerced to "disabled"). `disabled: true`, a truthy expression
1578
+ and a truthy CEL envelope still block; `disabled: false` and an absent `disabled`
1579
+ still run; no expression- or envelope-valued predicate changes verdict. The
1580
+ existing `catch { isDisabled = false }` fail-open posture is untouched, and this
1581
+ change can only stop blocking things, never start.
1582
+
1583
+ The value handed to the evaluator is deliberately left RAW rather than normalized
1584
+ first. `evaluateCondition(toPredicateInput(x))` is not interchangeable with
1585
+ `evaluateCondition(x)` for a string that is already a `${…}` template:
1586
+ `toPredicateInput` assumes a bare expression and wraps unconditionally, so
1587
+ `'${x}'` becomes `'${${x}}'`, fails to parse, returns verbatim, and coerces to a
1588
+ constant `true` — a template-spelled predicate evaluated that way is ALWAYS
1589
+ "disabled", whatever it says. That normalizer defect is filed as objectui#3871
1590
+ (it is live at the action renderers and `ActionEngine`, while `SchemaRenderer` and
1591
+ `page:header` evaluate the raw value and pin the correct verdict); a tripwire next
1592
+ to the new pins goes red the day it is fixed. Two rows therefore still differ
1593
+ between the execution and renderer paths, each recorded with its owning issue:
1594
+ the empty envelope (objectui#3850 owns the renderer half's scope ruling) and the
1595
+ `${…}` spelling (objectui#3871).
1596
+
1597
+ - 4bc6c23: Converge dashboard widget `compareTo` on the executor's `{ kind, dimension? }` contract, and make the dataset path actually render a comparison
1598
+
1599
+ `CompareToConfig` was a three-branch union (`'previousPeriod' | 'previousYear' | { offset }`). `@objectstack/spec` collapsed it to the shape the analytics executor already implements — `DatasetCompareTo`, a plain strict object `{ kind: 'previousPeriod' | 'previousYear'; dimension?: string }` (objectstack#5011) — so this renderer now reads that one shape:
1600
+
1601
+ - `shiftFilterByCompareTo` / `compareToTrendLabelKey` dispatch on `compareTo.kind`. The `{ offset }` duration shift is gone: `{ offset: '1y' }` is `kind: 'previousYear'`, while `'7d'` / `'1M'` have no faithful target and are restated by the author on the widget's own `filter` plus `kind: 'previousPeriod'`. No trend label key is retired — the offset arm resolved to `vsPreviousPeriod`, which survives as the `previousPeriod` fallback.
1602
+ - `DatasetWidget` no longer discards part of `compareTo`. It used to forward only the object form because the two string forms had no meaning downstream; with one shape there is nothing to discard, and a stale string is now invalid metadata rejected where it is authored rather than silently reinterpreted here.
1603
+ - **The comparison now actually runs on the dataset path.** A widget states its window in its own `filter` (a date macro, or the dashboard date-range filter merged in), but the executor shifts a `timeDimensions` entry carrying a `dateRange` — so a dataset widget asking for a comparison got "compareTo needs a dated window to shift" and rendered none. When (and only when) a comparison is requested, the resolved filter's bounded date windows are lowered into `selection.timeDimensions[].dateRange` and moved out of `runtimeFilter` (a copy left behind would intersect the shifted window with the current one and empty every comparison column). Which dimension gets shifted stays the executor's decision: every window found is lowered under the name the author wrote, and zero or two candidates surfaces the executor's own error, listing them.
1604
+ - The `<measure>__compare` columns that come back are now shown: a delta + window label on KPI widgets, a comparison column on tables, and a `variant: 'comparison'` overlay series on charts — the same treatment and the same `dashboard.trend.*` labels the inline object-provider widgets already use.
1605
+
1606
+ - e06810e: `PageComponentSchema.dataSource` is now consumed instead of discarded — a
1607
+ `list-view` page component can reference a **saved view by name** for the first
1608
+ time, and writing the binding no longer breaks the component
1609
+ (objectstack#5576).
1610
+
1611
+ The spec declares a per-element data binding on every page component —
1612
+ `dataSource: { object, view?, filter?, sort?, limit? }` — and objectui read none
1613
+ of it. `ViewDataProvider.resolveElementDataSource` forwarded
1614
+ `filter`/`sort`/`limit` and dropped `view` entirely, and had no caller outside its
1615
+ own test; nothing mapped `object` onto the `objectName` a list actually reads. So
1616
+ "reference a saved view by name" was published, validated and inert, and every
1617
+ page that wanted a saved view's columns/filter/sort had to inline a second copy of
1618
+ them — the drift the binding exists to remove.
1619
+
1620
+ Writing the binding also **broke** the block, for a reason unrelated to `view`:
1621
+ `SchemaRenderer` spread the schema's `dataSource` metadata onto the component as a
1622
+ React prop, and that is the prop name the host uses to inject the data-source
1623
+ ADAPTER. The plain `{ object, view }` object shadowed the adapter, so the first
1624
+ `dataSource.find(…)` threw `dataSource.find is not a function` and `list-view`
1625
+ rendered "Couldn't load records" — a spec-compliant component failing next to
1626
+ identical ones that omitted the binding.
1627
+
1628
+ - `@object-ui/react` — `SchemaRenderer` no longer spreads `schema.dataSource` as a
1629
+ prop (it is metadata, like `visibleWhen`); renderers read it off `schema`. An
1630
+ explicit React `dataSource` prop is unaffected. New
1631
+ `useElementDataSource(schema, dataSource?)` hook resolves a binding, fetching
1632
+ the named saved view from the object definition's `listViews` and the metadata
1633
+ overlay's `listViews()`.
1634
+ - `@object-ui/core` — new `isElementDataSourceConfig` / `collectSavedViews` /
1635
+ `resolveSavedView` / `composeElementDataSource`, and `resolveElementDataSource`
1636
+ now honours `view` through an optional `DataFetcher.fetchViews`, reporting an
1637
+ unresolvable view as an error instead of silently returning every record.
1638
+ `resolveViewId` moved here from `@object-ui/app-shell` (re-exported there) so
1639
+ one matcher serves both the object page and a page component.
1640
+ - `@object-ui/plugin-list` — `list-view` maps the binding onto the props
1641
+ `ListView` reads. `dataSource.*` keys are authoritative, view-supplied values
1642
+ are a baseline the component's own keys override, and `filter` AND-combines at
1643
+ every level (the spec calls the binding's filter "additional criteria"), so a
1644
+ binding can narrow a saved view but never widen it. A `view` name that does not
1645
+ resolve renders a configuration error naming the object's actual views and
1646
+ issues no query — it never falls back to the object's default view, because that
1647
+ turns a typo into a silently wider answer.
1648
+
1649
+ - ab3ad4f: An empty predicate is no longer a declared gate anywhere (objectui#3850, objectui#3862)
1650
+
1651
+ "Is a gate DECLARED on this key — is there a condition to reach a verdict on?" was
1652
+ answered three times in this repo, with three different scopes, and the widest
1653
+ answers sat on `disabled`, where the mistake is not benign:
1654
+
1655
+ - `hasDeclaredVisibilityGate` (the action face) asked `!= null && !== ''`, so every
1656
+ OBJECT counted — including `{ dialect: 'cel', source: '' }`. That envelope is not
1657
+ a hand-written curiosity: `@objectstack/spec`'s `ExpressionInputSchema` normalizes
1658
+ every authored predicate into one, so "the author left the predicate empty"
1659
+ compiles to exactly it. The verdict path normalized the same value back to
1660
+ `undefined`, and `evaluateCondition(undefined)` answers `true` — "no condition, so
1661
+ visible/enabled". On `visible` that `true` means SHOW, so the two mistakes
1662
+ cancelled; on `disabled` it means GREY, so they compounded: a button disabled
1663
+ forever that no author asked to disable (objectui#3850, the residue objectui#3842
1664
+ left behind).
1665
+ - `SchemaRenderer` asked `disabled !== undefined` inline, one notch wider again, so
1666
+ `disabled: null` greyed out too — on the GENERIC rendering path, since that block
1667
+ runs for every node type, and not as an internal flag either: `_disabled` is
1668
+ forwarded to the component as a real `disabled` prop (objectui#3862).
1669
+ - `ActionRunner`'s execution gates asked "does this normalize to something
1670
+ evaluable?" — the scope that turned out to be right (objectui#3848 / objectui#3872).
1671
+
1672
+ There is now ONE definition, `hasDeclaredPredicate`, exported from
1673
+ `@object-ui/core` (`evaluator/declaredPredicate.ts`, beside the `toPredicateInput`
1674
+ normalizer it is derived from): a gate is declared when normalization still leaves a
1675
+ condition to evaluate. `''`, a whitespace-only string, an empty-`source` envelope
1676
+ and any non-predicate value (`0`, `{}`) are NOT declared; `false` IS (a verdict is
1677
+ not a missing gate — objectui#3812). `hasDeclaredVisibilityGate` keeps its name as a
1678
+ re-export of it, so the five member-action renderer call sites, `DeclaredActionsBar`
1679
+ and `record-quick-actions` are unchanged and inherit the scope;
1680
+ `SchemaRenderer`'s `disabled` / `disabledOn` chain and `ActionRunner`'s two gates
1681
+ read the same function. No consumer got a local "and also check for empty" test —
1682
+ that fourth dialect is what objectui#3842 / objectui#3849 spent two PRs merging away.
1683
+
1684
+ Measured behaviour change, `action:button` and the generic path, before → after:
1685
+
1686
+ | value | `visible` | `disabled` | `enabled` | `SchemaRenderer` `disabled` prop |
1687
+ | --------------------------------------------------------- | -------------- | ---------- | --------- | -------------------------------- |
1688
+ | `''` | shown → shown | on → on | on → on | forwarded → absent |
1689
+ | `null` | shown → shown | on → on | on → on | forwarded → absent |
1690
+ | `{ dialect: 'cel', source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
1691
+ | `{ source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
1692
+ | `' '` (whitespace) | HIDDEN → shown | on → on | GREY → on | forwarded → absent |
1693
+ | `0` / `{}` (not predicates) | shown → shown | GREY → on | on → on | forwarded → absent |
1694
+ | `true` / `false` / bare CEL / `${…}` / non-empty envelope | unchanged | unchanged | unchanged | unchanged |
1695
+
1696
+ Every row moves toward "there is no gate here", never away from it, and no value
1697
+ that HAS a verdict changes it — the verdict is still read from the raw value, only
1698
+ the gate in front of it narrowed. Two rows are behaviour changes rather than the
1699
+ equivalence the ruling expected, and are pinned as such: the whitespace string moves
1700
+ on `visible` / `enabled` (it used to normalize to `'${ }'`, which evaluates falsy,
1701
+ so a predicate that says nothing HID the action from everyone), and non-predicate
1702
+ junk stops greying controls out (fail-open, the posture `ActionRunner` already
1703
+ committed to).
1704
+
1705
+ One blank spelling is knowingly still outside the scope: an envelope whose `source`
1706
+ is blank but not EMPTY (`{ dialect: 'cel', source: ' ' }`) — the normalizer folds a
1707
+ `source` of `''` and does not trim, so the string spelling of a blank predicate is
1708
+ trimmed and the envelope spelling is not, and `disabled` still greys out for that one
1709
+ value. The ruling enumerated three empty spellings; this is a fourth, measured and
1710
+ filed as objectui#3960 rather than widened in here.
1711
+
1712
+ One chain is deliberately untouched: `SchemaRenderer`'s `visible` / `visibleWhen` /
1713
+ `visibleOn` / `visibility` / `hidden` / `hiddenOn` legs keep `!== undefined`, because
1714
+ narrowing them would change ALIAS PRECEDENCE, not just emptiness. The `hidden` legs
1715
+ are not negated and therefore carry this same defect with the polarity that makes the
1716
+ node vanish — measured, out of this ruling's scope, filed as objectui#3955.
1717
+
1718
+ - c2fd122: fix(actions): forward `bodyShape` end-to-end so a declared body wrap is honoured
1719
+
1720
+ Sibling of the `bodyExtra` fix, same failure shape one key over. `bodyShape` is
1721
+ the spec's body-WRAPPING declaration for a `type: 'api'` action — `'flat'` (the
1722
+ default) or `{ wrap: key }` to nest the collected params under `key`, the shape
1723
+ better-auth's `organization/update` needs. The console `apiHandler` read it
1724
+ unconditionally while **no** action renderer forwarded it, so an author who
1725
+ declared `bodyShape: { wrap: 'data' }` on an `action:button` / `:group` / `:icon`
1726
+ / `:menu` action got a FLAT body on the wire: the endpoint received the params at
1727
+ the top level, and the declaration read as honoured because it parsed and
1728
+ published.
1729
+
1730
+ The four declared-action renderers now forward the key, and `ActionSchema`
1731
+ declares it (typed by derivation from the spec, so the union cannot drift).
1732
+ `ActionRunner.executeAPI` — the fallback path taken when no host registered an
1733
+ `api` handler — now reads it too, closing a second asymmetry in which the same
1734
+ action changed body shape depending on which host executed it. The wrap covers
1735
+ the collected params only; `bodyExtra` and other top-level keys stay flat, which
1736
+ is the spec's own wording for the key and what both console read-sites already
1737
+ did.
1738
+
1739
+ `element:button` deliberately does **not** forward it: its whitelist mirrors
1740
+ spec's `InlineActionSchema` pick list field for field, and that pick list does
1741
+ not include `bodyShape` — it is not inline vocabulary.
1742
+
1743
+ - 1d723e3: fix(core): stop re-wrapping an already-`${…}` predicate, so action-face `visible` / `disabled` finally honour it (objectui#3871)
1744
+
1745
+ `toPredicateInput` — the one normalizer every action surface and the action
1746
+ engine share — wrapped **every** string as `${string}`, assuming a bare
1747
+ expression. But `${…}` is a spelling this repo documents for a predicate
1748
+ (AGENTS.md §4) and one the normalizer's own output type lists as valid, so an
1749
+ already-normalized value was wrapped a second time: `'${x}'` became `'${${x}}'`,
1750
+ which cannot match the evaluator's single-template fast path and does not parse.
1751
+ The author's expression then decided nothing, and which constant came back
1752
+ depended on the caller's error policy:
1753
+
1754
+ - **fail-soft** legs (every `disabled` / `enabled` leg; `visible` on
1755
+ `action:icon`, `action:group` and the related-list toolbar) got the unparsed
1756
+ string back, so `Boolean(…)` was a constant `true`: `disabled: '${…}'` greyed
1757
+ the action out permanently, `visible: '${…}'` showed one the author had gated
1758
+ away, and `enabled: '${…}'` never disabled anything.
1759
+ - **fail-closed** legs (`throwOnError: true` — `visible` on `action:button`,
1760
+ `action:bar`, `action:menu`, `DeclaredActionsBar`, and
1761
+ `ActionEngine.getActionsForLocation`) got a **throw**, which each site turns
1762
+ into "hidden": an action gated with a template predicate was invisible even
1763
+ while its gate held, and the fail-closed warning blamed the author's
1764
+ expression.
1765
+
1766
+ A string that already carries `${` is now returned untouched (the same guard
1767
+ covers the envelope branch, where an unwrapped `source` reaches the identical
1768
+ wrap), which makes the normalizer idempotent. Every affected surface goes from a
1769
+ constant verdict to the predicate's real one — converging the action face with
1770
+ `SchemaRenderer` and `page:header`, which read the raw value and have always
1771
+ been right about this spelling (objectui#3314's shape). Bare expressions and
1772
+ `{ dialect: 'cel' }` envelopes are untouched.
1773
+
1774
+ `ActionRunner`'s two execution gates already read the raw value and are
1775
+ unchanged; the objectui#3871 tripwires they carried have been replaced by pins
1776
+ of the now-converged behaviour.
1777
+
1778
+ Whether `${…}` should be _authorable_ on an action predicate at all is a
1779
+ separate, spec-side question (`@objectstack/spec`'s `PredicateInput` models a
1780
+ bare string and a dialect envelope): if it is to be rejected, that belongs in
1781
+ publish-time validation, not in a consumer that silently invents a verdict.
1782
+
1783
+ - 0109f54: Blank predicates and non-predicate values are no longer gates, at the last three entries that still judged them (objectui#3955, objectui#3957, objectui#3960)
1784
+
1785
+ objectui#3850 sank "is a predicate gate DECLARED here?" into one definition,
1786
+ `@object-ui/core`'s `hasDeclaredPredicate`. Three places were left out of that
1787
+ ruling's placement clause, each with the same shape of defect: the evaluator's
1788
+ single default for "there is nothing here to evaluate" is `true`, meaning
1789
+ _visible/enabled_, and wherever a too-wide "declared" test hands it an empty
1790
+ predicate on an INVERTED key, that `true` turns a control off for a value the
1791
+ metadata never used to say anything.
1792
+
1793
+ **`SchemaRenderer`'s `hidden` / `hiddenOn` legs (objectui#3955)** asked
1794
+ `!== undefined` and did NOT negate the verdict, so an empty predicate meant HIDE
1795
+ and the node disappeared — on the generic rendering path, since that block runs
1796
+ for every schema type. Harder to diagnose than the `disabled` twin objectui#3862
1797
+ fixed: a greyed-out control is still on screen, while a node that never rendered
1798
+ is indistinguishable from metadata that meant to hide it. Both legs now read the
1799
+ shared definition.
1800
+
1801
+ **The "blank" criterion now covers the envelope spelling (objectui#3960).** The
1802
+ definition trimmed a whitespace-only STRING and not an envelope's whitespace-only
1803
+ `source`, because `toPredicateInput` folds a `source` of `''` and does not trim.
1804
+ So `{ dialect: 'cel', source: ' ' }` was a declared gate whose verdict came from
1805
+ core's own CEL entry calling that exact value "no predicate" (`if (!source.trim())
1806
+ return true`) — `disabled` greyed out forever and `ActionRunner.execute` answered
1807
+ `{ success: false, error: 'Action is disabled' }` with the handler never invoked.
1808
+ Blankness is now decided once for both spellings, at the definition. The
1809
+ NORMALIZER's contract is deliberately unchanged: "what shape does the evaluator
1810
+ accept" is not the same question as "is there a condition", and moving the trim
1811
+ there would have flipped verdicts for every
1812
+ `useCondition(toPredicateInput(…))` call site, including container-level `visible`
1813
+ reads that never asked this question at all.
1814
+
1815
+ **`ActionEngine.getActionsForLocation`'s `visible` filter (objectui#3957)** was the
1816
+ last consumer answering the question with a range of its own — three empty
1817
+ spellings folded by hand, everything else coerced with `Boolean(raw)`. It now reads
1818
+ the shared definition and the coercion branch is gone, so one value no longer gets
1819
+ two answers depending on whether an action was surfaced by the engine or rendered
1820
+ standalone (the invariant objectui#3314 established). Its fail-CLOSED posture on a
1821
+ predicate that THROWS is untouched (`throwOnError: true` + `warnHiddenPredicate`):
1822
+ "the predicate faulted" and "there is no predicate" are different facts.
1823
+
1824
+ Behaviour changes, before → after. Observation-class: each needs an author to write
1825
+ an empty/blank predicate or a non-predicate value, and there is no known user path
1826
+ today.
1827
+
1828
+ | value | `ActionEngine` `visible` | `SchemaRenderer` `hidden` | `disabled` (action face + generic path) | `ActionRunner.execute` `disabled` |
1829
+ | ----------------------------------------------------------- | ------------------------ | ------------------------- | --------------------------------------- | --------------------------------- |
1830
+ | `''` / `null` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1831
+ | `' '` (blank text) | HIDDEN → shown | HIDDEN → rendered | unchanged | unchanged |
1832
+ | `0` / `NaN` | HIDDEN → shown | HIDDEN → rendered | unchanged | unchanged |
1833
+ | `{}` / `[]` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1834
+ | `{ dialect: 'cel', source: '' }` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1835
+ | `{ dialect: 'cel', source: ' ' }` | shown → shown | HIDDEN → rendered | GREY → on | refused → runs |
1836
+ | `{ source: ' ' }` (no dialect) | HIDDEN → shown | HIDDEN → rendered | GREY → on | refused → runs |
1837
+ | `true` / `false` / bare CEL / `${…}` / a non-blank envelope | unchanged | unchanged | unchanged | unchanged |
1838
+
1839
+ Every row moves toward "there is no gate here", never away from it, and no value
1840
+ that HAS a verdict changes it — a declared `false` is still a verdict, not a
1841
+ missing gate (objectui#3812), and blankness is `trim()`, not "short": `{ dialect:
1842
+ 'cel', source: ' x ' }` is a predicate. One alias precedence changes with the
1843
+ `hidden` legs and is pinned rather than claimed as an equivalence: an undeclared
1844
+ `hidden` no longer short-circuits the chain, so a declared `hiddenOn` is finally
1845
+ consulted.
1846
+
1847
+ `SchemaRenderer`'s four `visible*` legs keep `!== undefined` deliberately, as
1848
+ objectui#3850 ruled: their `true` is negated, so an empty predicate already lands
1849
+ on "shown", and narrowing them would change alias precedence rather than fix
1850
+ anything.
1851
+
1852
+ - 7e5bb5d: fix(actions): forward `bodyExtra` end-to-end through the action chain
1853
+
1854
+ An action's static request body (`bodyExtra`) was dropped one hop before the
1855
+ `ActionRunner`: every action renderer forwards an explicit whitelist of keys, and
1856
+ none of them listed `bodyExtra`. Since `@objectstack/spec` 17 made it the only way
1857
+ a `type: 'api'` action can carry a payload (`params` keeps its single meaning as
1858
+ the parameter definition array), and the ADR-0087
1859
+ `inline-action-api-params-to-body-extra` conversion rewrites older object-form
1860
+ `params` pages onto it at load, a previously-working published page validated,
1861
+ published and then POSTed an empty body.
1862
+
1863
+ `element:button`, `action:button`, `action:group`, `action:icon` and `action:menu`
1864
+ now forward the key; `ActionRunner.executeAPI` merges it into the request body
1865
+ **last** (so a constant always overrides a same-named user param, matching the
1866
+ console `apiHandler`); `ActionSchema` declares it; and a non-array `params` on a
1867
+ `type: 'api'` action keeps working for one version window with a dev-mode
1868
+ deprecation warning naming `bodyExtra`.
1869
+
1870
+ - fbc23e0: Action params that inherit a field's options now keep the keys that field declared
1871
+
1872
+ A field-backed action param (`{ field: 'tier' }`) had its inherited option list
1873
+ rebuilt entry by entry as `{ label, value }`, which silently dropped every other
1874
+ key the field's options declared — most consequentially the per-option
1875
+ `visibleWhen` predicate (ADR-0058). A select field whose options narrow by
1876
+ predicate in an object form therefore offered the FULL list in an action dialog,
1877
+ including the entries the predicate exists to hide, with no diagnostic on either
1878
+ side; `color` / `icon` / `disabled` were lost the same way. Options authored
1879
+ inline on the param were never affected — they always passed through verbatim,
1880
+ which is the asymmetry this restores.
1881
+
1882
+ The resolver now preserves each inherited entry and only does its two real jobs:
1883
+ expanding bare strings into label/value pairs and translating the label through
1884
+ `fieldOptionLabel`. The option widgets already filter on `visibleWhen`, so a
1885
+ role-gated option (`'admin' in current_user.positions`) inherited by a dialog
1886
+ param now narrows the offered set and clears a seeded value the predicate hides.
1887
+
1888
+ `ActionParamDef.options` (`@object-ui/core`) and the resolver's `RawActionParam`
1889
+ are widened to match: `ActionParamOption` names the two keys the param layer
1890
+ reads and carries the rest of a field's option vocabulary through.
1891
+
1892
+ - 6bb454a: `evalRowPredicate`: the fail-closed report now names the engine's failure reason, and the ROW always wins over host scope (objectui#3792, objectui#3796)
1893
+
1894
+ Two defects in one function, `packages/core/src/evaluator/listConditional.ts`,
1895
+ shared by every surface that gates on a row predicate: the row kebab, the bulk
1896
+ selection bar, kanban conditional formatting, and — since objectui#3521 — the
1897
+ record page header.
1898
+
1899
+ **The safest path said the least (objectui#3792).** `evalRowPredicate` has two
1900
+ diagnostic routes, and their information content was inverted. The single-eval
1901
+ fast route lets the canonical helper warn, so it prints the engine's own reason
1902
+ (`Reason: [runtime] No such key: owner_id`). The `warnOnError: true` route — the
1903
+ fail-CLOSED one, taken by every caller that makes a button disappear — runs
1904
+ `evalFieldPredicate` twice with `{ warn: false }` to tell a fault from a genuine
1905
+ `false`, and that silence discarded the reason along with the duplicate warning.
1906
+ So the more decisively a surface hid a control, the less it said about why: the
1907
+ console line named the predicate but not the defect, and `No such key: owner_id`
1908
+ or `no such overload: string == null` is usually the whole answer.
1909
+
1910
+ `FieldPredicateDiagnostic` gains an optional `onFault(reason)` passback:
1911
+ `evalFieldPredicate` hands out the engine's verbatim text (kind tag, message and
1912
+ source excerpt — the exact string it prints after `Reason:`) even when its own
1913
+ warning is suppressed. It fires per fault, deliberately independent of the
1914
+ once-per-predicate warning dedupe, so a caller doing its own warn-once
1915
+ bookkeeping keeps control of it; the verdict is unaffected, and callers that pass
1916
+ nothing are unchanged. `evalRowPredicate` threads that reason into its labelled
1917
+ warning, and the legacy-dialect route does the same with the message its engine
1918
+ throws (`Reason: [legacy] …`), so both dialect paths report a fault the same way.
1919
+ Warn-once semantics are unchanged — the reason is deliberately not part of the
1920
+ dedupe key.
1921
+
1922
+ **Wording: not a list function, and not for a long time.** The same message
1923
+ called every caller a "**list** conditional predicate". A hidden button on a
1924
+ record page header reporting "a list conditional predicate" sends its author to
1925
+ the list view to look for a control that was never there. Both messages this
1926
+ module emits (evaluation failure and the legacy-dialect deprecation) now read
1927
+ "conditional predicate".
1928
+
1929
+ **The row is the subject — on both dialect paths (objectui#3796).** The scope
1930
+ merge pinned `data` after the host-scope spread but not `record`, leaving
1931
+ `record` to each engine's own binding — and the two engines disagreed. The legacy
1932
+ evaluator re-pinned `record` to the row (row won); the CEL engine takes its
1933
+ `extra` bag over its `record` binding, so a host scope carrying a `record` key
1934
+ won there instead. One function, one predicate text, two subjects — selected by
1935
+ whether the string happens to contain `===` or `${…}`, the dialect-routing
1936
+ markers, which no author picks deliberately. `record` is now pinned after the
1937
+ spread exactly as `data` is, which fixes both paths at the merge site rather than
1938
+ relying on either engine's precedence.
1939
+
1940
+ No host injects a `record` key today — `ExpressionProvider` binds
1941
+ `current_user` / `user` / `ctx` / `os` / `app` / `data` / `features` — so nothing
1942
+ observable changes for existing apps; this closes the edge before a host adds one
1943
+ (`ctx.record` already exists, which is exactly how it would arrive). A row FIELD
1944
+ named `record` is likewise no longer able to become the row root; it stays
1945
+ addressable as `data.record`.
1946
+
1947
+ Shipped as a patch: no new exported symbol, one optional field added to an
1948
+ already-exported options interface, and no existing call signature changed.
1949
+
1950
+ - 523be48: `object-timeline` and `record:line_items` now apply the filter / sort / row cap they are given, so a named `dataSource.view` narrows them instead of contributing nothing
1951
+
1952
+ These were the two residual gaps in objectstack#7121's per-block coverage table
1953
+ (objectstack#7137). Both blocks are object-bound lists, both accepted the spec's
1954
+ per-element `dataSource` binding, and neither had a read site for `filter` or
1955
+ `sort` anywhere in its fetch:
1956
+
1957
+ - `object-timeline`'s entire query was
1958
+ `find(objectName, { options: { $top: 100 } })`.
1959
+ - `record:line_items`' was the parent FK plus a fixed `$top: 500`.
1960
+
1961
+ So `dataSource: { object, view: 'hot' }` resolved the view — a typo still reported
1962
+ a configuration error, it never degraded into an unfiltered query — and then
1963
+ dropped everything the view said. The rendered rows could be **wider than the view
1964
+ they named**, silently, which is exactly the class of mistake AI-authored metadata
1965
+ hides best: the page looks like it works. objectstack#7121 deliberately left the
1966
+ keys unmapped and recorded the gap rather than writing composed values onto schema
1967
+ keys nobody read; this closes it at the fetch instead.
1968
+
1969
+ What each block now reads:
1970
+
1971
+ - **`object-timeline`** — `$filter: schema.filter`,
1972
+ `$orderby: convertSortToQueryParams(schema.sort)`, and
1973
+ `$top: schema.limit ?? 100`, matching the form `object-gantt` / `object-map` /
1974
+ `object-calendar` already use. Its registry mapping gains
1975
+ `filter` / `sort` / `limit`; `columns` stays unmapped, because a timeline
1976
+ projects the fields its `timeline` config names.
1977
+ - **`record:line_items`** — the composed filter is **AND-combined** with the parent
1978
+ relationship condition through `mergeFilterNodes`, never substituted for it, the
1979
+ same way `record:related_list` composes its own since objectstack#7118: a
1980
+ line-items panel is always scoped to the record it sits on, so an _additional_
1981
+ criterion can only narrow this parent's children and can never surface another
1982
+ parent's rows. `sort` becomes the load order and `limit` the row cap (default
1983
+ 500). `columns` stays unmapped — here they are `GridColumn[]` driving an editable
1984
+ grid, not a field-name projection, so a view's column list would be the wrong
1985
+ _shape_ rather than merely a wider answer.
1986
+
1987
+ **Behaviour change worth knowing about:** the timeline's default window is now a
1988
+ real cap. `{ options: { $top: 100 } }` nested the limit under a key that is not a
1989
+ `QueryParams` field and that no adapter in this repo reads (`convertQueryParams`
1990
+ maps `params.$top`), so the intended window never reached the wire and a timeline
1991
+ over a large object fetched whatever the server chose to return. It is now sent as
1992
+ `$top`, and authorable via `limit` or a view's `pagination.pageSize`.
1993
+
1994
+ `@object-ui/core` gains `convertSortToQueryParams`, the sort→`$orderby` lowering
1995
+ the three sibling blocks each inline privately. It is shared rather than copied
1996
+ twice more, and is slightly more faithful to the declared contract than those
1997
+ copies: a sort entry that omits `order` means ascending instead of being dropped
1998
+ (the string spelling `"amount"` already meant ascending in the same copies), and
1999
+ nothing orderable yields `undefined` rather than a truthy empty `{}`. Migrating
2000
+ the three existing copies onto it is objectstack#7148 and is not done here.
2001
+
2002
+ - Updated dependencies [d229dfa]
2003
+ - Updated dependencies [c2fd122]
2004
+ - Updated dependencies [48132f7]
2005
+ - Updated dependencies [7e5bb5d]
2006
+ - Updated dependencies [e6fdbdc]
2007
+ - Updated dependencies [7e2b7e9]
2008
+ - Updated dependencies [c1e1e6b]
2009
+ - @object-ui/types@17.4.0
2010
+
3
2011
  ## 17.3.0
4
2012
 
5
2013
  ### Minor Changes
@@ -2641,6 +4649,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2641
4649
  project `health`) painted its categories from the generic `--chart-1..5`
2642
4650
  palette — the same gap the chart view (`object-chart`) had before #1932. It now
2643
4651
  resolves the dimension field's option colors (using the dataset's base `object`
4652
+
2644
4653
  - dimension→field map the query already returns) and threads them to the
2645
4654
  renderer as a per-category `categoryColors` map, so health green/red/yellow
2646
4655
  paints semantically.
@@ -2883,6 +4892,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2883
4892
  - 991b62d: Add `compareTo` field to dashboard widgets for period-over-period
2884
4893
  comparison. Supports `'previousPeriod'`, `'previousYear'`, and
2885
4894
  `{ offset: '7d' | '4w' | '1M' | '1y' }`.
4895
+
2886
4896
  - **Metric / gauge widgets** now compute a delta percentage when `compareTo`
2887
4897
  is set and surface it as a derived `trend` (auto-labelled via
2888
4898
  `dashboard.trend.vsLast*` i18n keys sniffed from the filter macros).
@@ -2985,6 +4995,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2985
4995
 
2986
4996
  Extract the bulk-vs-per-row decision into a reusable
2987
4997
  `executeBulkBatch(input, ops)` helper in `@object-ui/core`:
4998
+
2988
4999
  - Single decision tree shared by both update and delete fast paths.
2989
5000
  - Bulk success → no per-row pass.
2990
5001
  - Bulk partial-count → aggregate batch error.
@@ -3029,6 +5040,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3029
5040
  multi-user editing scenarios.
3030
5041
 
3031
5042
  **`@object-ui/data-objectstack`**
5043
+
3032
5044
  - Exports `ConcurrentUpdateError` (carries `currentVersion` and
3033
5045
  `currentRecord`) and `isConcurrentUpdateError()` type guard.
3034
5046
  - `update()` / `delete()` accept `opts.ifMatch` and forward it via the
@@ -3041,6 +5053,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3041
5053
  from conflicts without parsing the wire format.
3042
5054
 
3043
5055
  **`@object-ui/core`**
5056
+
3044
5057
  - `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
3045
5058
  the `If-Match` HTTP header.
3046
5059
 
@@ -3176,6 +5189,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3176
5189
  detail view (`TypeError: titleFormat.replace is not a function`) and printed
3177
5190
  `Failed to evaluate expression: ${[object Object]}` for every action visibility
3178
5191
  predicate.
5192
+
3179
5193
  - `@object-ui/core`: `ExpressionEvaluator.evaluate` / `evaluateCondition` now
3180
5194
  unwrap Expression envelopes transparently.
3181
5195
  - `@object-ui/react`: new `toPredicateInput()` helper to safely normalize
@@ -3190,6 +5204,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3190
5204
  the new `{ source }` envelope.
3191
5205
 
3192
5206
  All changes are backward-compatible — legacy bare strings continue to work.
5207
+
3193
5208
  - @object-ui/types@4.0.7
3194
5209
 
3195
5210
  ## 4.0.6
@@ -3368,6 +5383,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3368
5383
  - New plugin-object and ObjectQL SDK updates
3369
5384
 
3370
5385
  **Added:**
5386
+
3371
5387
  - New Plugin: @object-ui/plugin-object - ObjectQL plugin for automatic table and form generation
3372
5388
  - ObjectTable: Auto-generates tables from ObjectQL object schemas
3373
5389
  - ObjectForm: Auto-generates forms from ObjectQL object schemas with create/edit/view modes
@@ -3376,6 +5392,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3376
5392
  - ObjectQL Integration: Enhanced ObjectQLDataSource with getObjectSchema() method using MetadataApiClient
3377
5393
 
3378
5394
  **Changed:**
5395
+
3379
5396
  - Updated @objectql/sdk from ^1.8.3 to ^1.9.1
3380
5397
  - Updated @objectql/types from ^1.8.3 to ^1.9.1
3381
5398
 
@@ -3389,6 +5406,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3389
5406
  - Patch release: Add automated changeset workflow and CI/CD improvements
3390
5407
 
3391
5408
  This release includes infrastructure improvements:
5409
+
3392
5410
  - Added changeset-based version management
3393
5411
  - Enhanced CI/CD workflows with GitHub Actions
3394
5412
  - Improved documentation for contributing and releasing