@object-ui/plugin-list 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,943 @@
1
1
  # @object-ui/plugin-list
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0a73b51: `ObjectView` and `ListView` now flatten a view's `map` block through a
8
+ whitelist instead of spreading the whole (untyped) block to the top level.
9
+
10
+ Both `case 'map'` flatteners used to build the `object-map` schema with
11
+ `...(options.map || {})` — a raw spread of an untyped bag
12
+ (`NamedListView.options?: Record<string, any>`), so any key an author wrote in
13
+ the `map` block reached the top level unfiltered. `ObjectMap`'s own
14
+ `FlatMapConfigKeys = Omit<ObjectMapConfig, 'style'>` declares `style` OUT of
15
+ this flat form (`style` is also `BaseSchema.style`, inline CSS legal on every
16
+ node), so the two disagreed about the same shape. `style` was the live
17
+ specimen: `map: { style: '<url>' }` reached the top level as a CSS-shaped
18
+ `style` key it was never supposed to carry.
19
+
20
+ Behavior narrowing, stated because it changes what reaches the flattened
21
+ schema: a `map` block key that is not one of `ObjectMapConfig`'s declared
22
+ flat keys (`latitudeField` / `longitudeField` / `locationField` / `titleField`
23
+ / `descriptionField` / `zoom` / `center`) — including `style` — no longer
24
+ reaches the top level of the flattened `object-map` schema. This closes a gap
25
+ rather than removing working behavior: the pinned strict spec view schemas
26
+ accept no `map` block at all today, so no author-facing surface could reach
27
+ this path, and `ObjectMap` already stopped reading a top-level `style` as a
28
+ map style (a dev warning names the correct spelling instead).
29
+
30
+ The whitelist is DERIVED from `ObjectMapConfigSchema` (`@object-ui/types/zod`)
31
+ rather than hand-listed, so the flatteners and the declaration cannot drift
32
+ apart again — a key added to (or removed from) the schema reaches both
33
+ flatteners without a second edit. `ObjectMap`'s own `FLAT_MAP_CONFIG_KEYS` is
34
+ derived from the same schema for the same reason.
35
+ - f1d4748: Remove the retired `striped` / `bordered` / `virtualScroll` list-view surface
36
+
37
+ objectstack#7176 retired `list.striped`, `list.bordered` and `list.virtualScroll`
38
+ from the spec after measuring every objectui reader as pass-through: each one
39
+ copied the key onward and no renderer ever applied it. objectui stops declaring,
40
+ typing and forwarding them.
41
+
42
+ Off the chain: the `@objectstack/spec` list-view bridge in `@object-ui/react`,
43
+ `ListView`'s child-view props in `@object-ui/plugin-list`, both `ObjectView`
44
+ relays (`@object-ui/plugin-view` and `@object-ui/app-shell`), the `ObjectGridSchema`
45
+ and `NamedListView` declarations in `@object-ui/types` (interface and zod),
46
+ `ObjectGrid.component.yml` in `@object-ui/components`, and the page-block
47
+ inspector's `striped` / `bordered` toggles in the metadata-admin designer.
48
+
49
+ Behaviour is unchanged: nothing read these keys, so nothing rendered differently
50
+ for them. Stored view metadata that still carries one keeps validating — the keys
51
+ are simply no longer relayed. `ListViewSchema` continues to take the spec's
52
+ list-view fields by reference, so the protocol's own retirement tombstones
53
+ arrive with the next `@objectstack/spec` bump and reject the keys at the
54
+ authoring boundary. Restoring any of the three as live surface requires an
55
+ implementation card filed first, per the ruling.
56
+ - b1119ec: Project every declared `recordIdField`, and refuse an action that names no record
57
+
58
+ objectstack#8018. An `api` action declaring `recordIdParam` identifies the record
59
+ it acts on by a row field — `recordIdField`, default `id`. The grid built
60
+ `$select` from the listView columns, `id` and the predicate refs only, so an
61
+ action keyed on any other field asked the server for everything except the key
62
+ naming its own record. The row arrived without it, and the injection was skipped
63
+ silently: the request went out anyway, minus the parameter. A backend reading a
64
+ missing selector as "match nothing" then answers success for having changed
65
+ nothing, so a record-scoped mutation reports success and does nothing.
66
+
67
+ Two independent repairs, both in this change:
68
+
69
+ - **Projection.** `listViewPredicates` (`@object-ui/core`) now also harvests
70
+ `recordIdField` from `rowActionDefs`, `bulkActionDefs` and the object's
71
+ `actions`, spelled as a synthetic `record.<name>` so the one existing harvester
72
+ handles it. Both projection builders — `ObjectGrid` and `ListView` — read that
73
+ function, so both gain the key with no call-site change. The existing guards
74
+ still apply: a name the object does not declare, or one that is not a bare
75
+ identifier, is dropped rather than put in `$select`, because an unknown key
76
+ there is not ignored by every backend.
77
+ - **Loud failure.** New `resolveRecordIdParamSeed` (`@object-ui/core`) is the one
78
+ definition of "can this row identify the record?". `useConsoleActionRuntime`'s
79
+ api handler now refuses the dispatch — `{ success: false, error }`, before the
80
+ request — when the row lacks the key, or holds `null` for it. The two refusals
81
+ are worded differently because they point at different repairs: an absent key
82
+ is a projection or read-visibility problem, a null value is a data one. Falsy
83
+ real values (`0`, `''`, `false`) are values and still dispatch.
84
+
85
+ The second half is what closes the class rather than the common case: a row can
86
+ lack the key for reasons projection cannot fix — a server-side read mask that
87
+ strips the field regardless of `$select`, a partial payload, a field the
88
+ principal cannot read.
89
+
90
+ Behaviour change worth noting: an action that previously dispatched an
91
+ under-specified request now fails visibly instead. That is the point — the old
92
+ path could not report the failure it was causing.
93
+
94
+ ### Patch Changes
95
+
96
+ - 516663d: RecordAttachmentsPanel no longer offers a Retry for an api-disabled `sys_attachment` read.
97
+
98
+ `OBJECT_API_DISABLED` (404, `enable.apiEnabled: false`) and its sibling
99
+ `OBJECT_API_METHOD_NOT_ALLOWED` (405, the operation is absent from
100
+ `enable.apiMethods`) are pure functions of the object's metadata — no user, no
101
+ session, no request body — so every retry of every persona re-fetches the
102
+ identical refusal. Before this change both landed in `RecordAttachmentsPanel`'s
103
+ `unavailable` state and offered a Retry that was guaranteed to change nothing,
104
+ the same wrong advice `ListView`'s error panel already stops giving for list
105
+ reads.
106
+
107
+ The panel gains a fifth status, `api-unavailable`: no Retry button, and honest
108
+ copy ("The attachments list is not available on this object.", new
109
+ `detail.attachmentsApiUnavailable` key in all ten locale packs) instead of
110
+ "We couldn't load the attachments for this record." The pre-existing `denied`
111
+ (authorization) and `unavailable` (network/5xx/expired-session) states and
112
+ their affordances are unchanged.
113
+
114
+ `ListView.classifyLoadError` — the classifier that already separated this case
115
+ into its own `api-disabled` kind for list views — is lifted out of
116
+ `packages/plugin-list/src/ListView.tsx`'s module scope into
117
+ `@object-ui/react` (`classifyLoadError`, `LoadErrorKind`), so both surfaces
118
+ consume one classification instead of `RecordAttachmentsPanel` re-deriving it.
119
+ `ListView`'s own behavior is unchanged — it now imports the function it
120
+ previously defined locally. The classifier delegates its api-disabled check to
121
+ `isApiAccessDeniedError` (`@object-ui/data-objectstack`), removing a second,
122
+ independently-maintained copy of the same code list.
123
+ - a1609a6: Console list filters: a `between` range is submitted only when both bounds are filled, and six operator labels stop rendering as raw i18n keys.
124
+
125
+ Two defects in the list-view filter panel (objectstack#8815), both in the Console
126
+ render layer, with no workaround available downstream.
127
+
128
+ **A half-filled range no longer refuses the whole view.** Picking a date column
129
+ and 「介于」 draws two inputs — that part landed in objectui#3958 — but typing
130
+ only one bound produced `["2024-01-01", ""]`, and both write paths read "is this
131
+ row filled in?" with one shape-blind predicate (`null` / `''` / empty array).
132
+ An array of length 2 passed it, so the empty bound went to the server, which
133
+ refuses the query outright (`400 INVALID_FILTER`): the list showed
134
+ 「该视图的查询被拒绝」 and the filters the user had already applied stopped
135
+ applying too. The saved-view fold persisted the same half-range, so the refusal
136
+ came back on every later read of that view, for every user of it.
137
+
138
+ The spec cannot intercept this — `ViewFilterRuleSchema` accepts
139
+ `["2024-01-01", ""]` because it counts the two slots rather than what is in
140
+ them, while refusing a scalar or a one-element array. Authoring validation is
141
+ therefore green on exactly the shape that fails at query time, which makes not
142
+ emitting it the producer's job. `@object-ui/components` now exports
143
+ `isFilterValueComplete(operator, value)` — arity-aware, so a `pair` row needs
144
+ both bounds — and the two consumers that had each kept a copy of the old
145
+ predicate (`plugin-list`'s `convertFilterGroupToAST`, `app-shell`'s
146
+ `foldFilterGroupToSpecRules`) read it instead. A half-filled range is now
147
+ dropped exactly as a half-typed `equals` row already was: no filter, rather than
148
+ a filter the server will reject. Bounds of `0` and `false` stay real bounds.
149
+
150
+ **Six operator labels are translated in all ten locale packs.**
151
+ `startsWith`, `endsWith`, `isNull`, `isNotNull`, `exists` and `notExists` were
152
+ missing from every pack, so i18next resolved them to the raw key and the dropdown
153
+ showed `filterBuilder.operators.isNull` beside translated entries. The
154
+ component's own defaults table could not cover it: that table serves only the
155
+ no-provider path, and the Console mounts a provider. The report named four —
156
+ a `date` column's bucket offers the four nullness operators; a `text` column
157
+ showed all six.
158
+
159
+ Because the label key is built dynamically (`t(\`filterBuilder.operators.${op}\`)`),
160
+ no existing gate could see the gap: the call-site checker classifies a template
161
+ key as `missing-prefix` and only asks whether the prefix resolves, and
162
+ cross-pack parity is satisfied when all ten packs are missing a key together.
163
+ A new parity test pins the packs against `FILTER_BUILDER_OPERATORS` in both
164
+ directions, so an operator added to the dropdown now fails loudly until every
165
+ pack labels it.
166
+ - c1ef923: Grid and related-list column headers no longer offer a sort on a `formula` column.
167
+
168
+ A `formula` value is computed on read: no driver materialises a column for it, so
169
+ a server `$orderby` naming one has nothing to order by. That sort never worked.
170
+ Until objectstack#6994 the platform did not say so — the response carried the very
171
+ values it had been asked to order by, out of order, under a `200`, with ascending
172
+ and descending byte-identical on a real SQL driver — and it now answers
173
+ `400 INVALID_SORT`. So the header was wrong before the platform's refusal and is
174
+ wrong after it, for the same reason: it offers a sort that cannot be performed.
175
+
176
+ `ObjectGrid` withheld the affordance only from reference-bearing columns
177
+ (objectui#3096). Unmaterialized types are a SECOND reason a server sort is
178
+ impossible, not a different mechanism, so it now reads both — and so do the two
179
+ sort entry points of a related list (the embedded table's headers and the
180
+ sort-button row a `data-list` card keeps), which each derived that rule
181
+ separately.
182
+
183
+ Client-side sorting is deliberately unchanged. There the rows are all in the
184
+ browser and the formula value is the one the server hydrated on read, so ordering
185
+ by what the cell shows is honest — the same split the relational carve-out makes.
186
+ A sort DECLARED in view metadata is also unchanged: it still goes out and is still
187
+ refused by name, because silently dropping an author's declaration would hide the
188
+ authoring error instead of surfacing it (the toolbar's sort picker keeps such a
189
+ field listed for exactly that reason — it is the only way to remove it).
190
+
191
+ The membership — `formula` alone — moved out of a private set in `ListView` into
192
+ `@object-ui/core` (`UNMATERIALIZED_FIELD_TYPES` / `isUnmaterializedFieldType`),
193
+ bound to `@objectstack/spec`'s own storage predicate so the renderer cannot drift
194
+ from what the drivers actually store. It is deliberately narrower than the spec's
195
+ write contract `COMPUTED_VALUE_TYPES`: a `summary` and an `autonumber` each get a
196
+ real maintained column and sort correctly, and withholding their headers would
197
+ have broken two affordances that work.
198
+ - 616a2a5: The list filter builder no longer offers `Is set` / `Is not set`, which its query dialects cannot express.
199
+
200
+ **User-visible before/after.** The operator dropdown in the list toolbar's
201
+ filter popover — and in the Studio view/tab/page filter inspectors, the dataset
202
+ inspector and the generic `filter` config field — loses two rows: **"Is set"**
203
+ and **"Is not set"**. The sharing-rule criteria builder (`FilterConditionField`)
204
+ keeps both, unchanged. `Is null` / `Is not null` and `Is empty` / `Is not empty`
205
+ are untouched everywhere and remain the way to filter on a missing value from
206
+ the list.
207
+
208
+ Nothing that worked stops working. Every save path behind those two rows was
209
+ already broken, in three different ways depending on the surface:
210
+
211
+ - **Live grid** — `ListView.mapOperator` had no row for either id, so its
212
+ `default:` arm returned the id verbatim and the query went out as
213
+ `['name', 'exists', 'x']`. `exists` is not a member of the spec's
214
+ `VALID_AST_OPERATORS`, so `isFilterAST()` rejects the shape: an unfiltered
215
+ read or a 400, never the filter the user asked for.
216
+ - **Save as view** — `foldFilterGroupToSpecRules` normalizes through the spec's
217
+ `normalizeFilterOperator`, which does not know the pair, and
218
+ `ViewFilterRuleSchema`'s enum then refuses the rule.
219
+ - **Dataset inspector** — `groupToCondition` has no row either and drops the
220
+ condition silently, so the filter simply never applied.
221
+
222
+ **Why withheld rather than mapped.** Measured on `@objectstack/spec`
223
+ 17.0.0-rc.6: neither `VIEW_FILTER_OPERATORS` nor `VALID_AST_OPERATORS` contains
224
+ an existence operator, under any spelling — both sets have zero members matching
225
+ `/exist/`. Only the MongoDB-style `FieldOperatorsSchema` criteria carries
226
+ `$exists`, and that is precisely the dialect `FilterConditionField` writes, so
227
+ the pair moves behind the existing `OPT_IN_OPERATORS` gate and that widget opts
228
+ in. Collapsing them onto `isNotNull` / `isNull` was rejected: the builder
229
+ already draws those as their own rows, the round trip is lossy (a saved
230
+ `exists` reads back as `isNotNull`), and the spec's own note records `$exists` =
231
+ has-value as still unsettled across drivers — `driver-memory`'s live mingo path
232
+ and `driver-mongodb` read key-presence.
233
+
234
+ **The class is now closed by an assertion, not by discipline.** objectui's three
235
+ existing operator-parity guards all sweep spec vocabulary → objectui; none asked
236
+ whether an id the dropdown draws is an id the consumer can persist, which is the
237
+ direction that broke. `plugin-list`'s new
238
+ `list-offered-operator-expressible-parity.test.ts` forces the set the list
239
+ toolbar offers to **equal** the set its two dialects can express, in both
240
+ directions — so an unexpressible operator cannot be offered, and an operator
241
+ that becomes expressible upstream cannot stay needlessly withheld.
242
+ - dfc6975: Related-list "+ New" now honours `userActions.create` predicates, and the grid
243
+ toolbar's inline-edit affordance is gated on `update` permission (objectui#4646,
244
+ objectui#4647).
245
+
246
+ Two declared-but-unenforced gaps on the same toolbar surface.
247
+
248
+ **#4646 — `createPredicates` had a producer and no consumer.**
249
+ `@objectstack/spec@17.0.0` widened `userActions.create` to
250
+ `z.union([z.boolean(), RowCrudActionOverrideSchema])`, so `resolveCrudAffordances`
251
+ emits `createPredicates` — and nothing in objectui read them, against roughly
252
+ fifteen consumption sites apiece for `editPredicates` / `deletePredicates`. The
253
+ symptom: a parent record entering a frozen state correctly greyed its children's
254
+ row Edit/Delete while the related list's "+ New" stayed fully live, so the user
255
+ filled in the whole child form to earn a server 409. The related-list toolbar now
256
+ evaluates `visibleWhen` / `disabledWhen` **once against the host parent record**,
257
+ per the spec docblock's binding for this key, on top of the existing
258
+ `o.create ∧ can(child, 'create')` check. `visibleWhen` hides "+ New" and fails
259
+ CLOSED; `disabledWhen` greys it and fails SOFT — the same evaluator, fail
260
+ directions and hidden-vs-disabled split the record header already uses for
261
+ edit/delete (objectui#4419 / PR #4515). A bare-boolean `userActions.create` is
262
+ untouched: with no predicates there is nothing to evaluate.
263
+
264
+ **#4647 — the inline-edit toggle was the one ungated affordance on its toolbar.**
265
+ It rendered on "grid view ∧ the host wired `onInlineEditChange` ∧ not the compact
266
+ toolbar", and every host wires that callback unconditionally. New and Import are
267
+ hidden for an account without the grant and the bulk-delete entry on the same
268
+ toolbar ANDs `can(obj, 'delete')`, but a read-only principal could flip inline
269
+ edit, modify cells and press "Save all" to earn a server 403. It is now gated on
270
+ the object's resolved edit affordance ∧ `can(object, 'update')`, mirroring that
271
+ bulk-delete gate. The gate is applied at all three sites that carry this
272
+ affordance — the wide toolbar's toggle, the compact toolbar's settings-popover
273
+ entry (which previously had no gate at all, not even the callback), and the
274
+ `editable` mode handed to the grid, so a stored view carrying `inlineEdit: true`
275
+ can no longer drop a read-only principal into editable cells with no toggle to
276
+ press.
277
+
278
+ `ListViewSchema.userActions.editInline` is also consumed now: an explicit `false`
279
+ withholds the affordance wholesale, which authors previously could not do.
280
+
281
+ **Behaviour change for read-only users, stated plainly.** Where the UI used to
282
+ offer inline editing and let the server refuse it, it now declines to offer the
283
+ entry point at all. No data access changes — the server gate was and remains the
284
+ enforcement boundary; this only stops the UI walking users into round-trips
285
+ guaranteed to fail. Accounts *with* the grant see no change, and hosts with no
286
+ `PermissionProvider` mounted (standalone embeds, the Studio designer) keep
287
+ today's behaviour, since `can()` answers `true` there by design.
288
+
289
+ One deliberate non-change: the absent case of `userActions.editInline` defers to
290
+ the host's existing `inlineEdit` channel rather than enforcing the spec's
291
+ `.default(false)`. Enforcing that default would remove the toggle from every
292
+ stored console list view in one release, since nothing folds a legacy key into
293
+ `editInline` and no existing view declares it. This follows the rule the
294
+ surrounding toolbar-flag block already states for itself — defaults chosen to
295
+ match what the flags have always done. `InterfaceListPage`, the key's other
296
+ consumer, reads the absent case as OFF, because the ADR-0047 interface page has
297
+ no such host channel to defer to.
298
+ - 138ab04: Fix a list filter that silently applied nothing when the first thing you picked was **Is null** or **Is not null**.
299
+
300
+ Adding a filter seeds the row with no value, and changing the operator keeps it that way — which is correct for an operator that takes no value, since the panel draws no input for one. The live grid read that row as unfinished and dropped it: the filter appeared in the panel, the query went out without it, and every record came back with no error to explain it. Only `Is empty` / `Is not empty` were exempt; the builder renders six operators value-less.
301
+
302
+ Which operators are complete without a value now has a single owner — `VALUELESS_FILTER_BUILDER_OPERATORS`, exported from `@object-ui/components` beside the code that decides it. The live grid and the saved-view fold both read it instead of keeping their own lists, so the two halves of one interaction can no longer disagree about whether a row is finished.
303
+ - Updated dependencies [88085e3]
304
+ - Updated dependencies [69251bf]
305
+ - Updated dependencies [57e668f]
306
+ - Updated dependencies [516663d]
307
+ - Updated dependencies [41ac1b7]
308
+ - Updated dependencies [1eaf0a1]
309
+ - Updated dependencies [a09bc33]
310
+ - Updated dependencies [460c4d0]
311
+ - Updated dependencies [0ae27f7]
312
+ - Updated dependencies [2533ec5]
313
+ - Updated dependencies [78c0f9a]
314
+ - Updated dependencies [bbe8b86]
315
+ - Updated dependencies [8477be5]
316
+ - Updated dependencies [279fb13]
317
+ - Updated dependencies [2e82ab2]
318
+ - Updated dependencies [ad07b65]
319
+ - Updated dependencies [41f498b]
320
+ - Updated dependencies [ef0d150]
321
+ - Updated dependencies [f34226e]
322
+ - Updated dependencies [564b605]
323
+ - Updated dependencies [e1d4251]
324
+ - Updated dependencies [40d3a33]
325
+ - Updated dependencies [8b9dc62]
326
+ - Updated dependencies [1184192]
327
+ - Updated dependencies [a2a9747]
328
+ - Updated dependencies [65e88e6]
329
+ - Updated dependencies [a1609a6]
330
+ - Updated dependencies [53f23bc]
331
+ - Updated dependencies [c4533dc]
332
+ - Updated dependencies [be60815]
333
+ - Updated dependencies [37f6844]
334
+ - Updated dependencies [93de4f6]
335
+ - Updated dependencies [2b50261]
336
+ - Updated dependencies [384f30d]
337
+ - Updated dependencies [ac600e5]
338
+ - Updated dependencies [97fba31]
339
+ - Updated dependencies [232f61a]
340
+ - Updated dependencies [d374caf]
341
+ - Updated dependencies [5673576]
342
+ - Updated dependencies [c1ef923]
343
+ - Updated dependencies [911ceaa]
344
+ - Updated dependencies [98eab36]
345
+ - Updated dependencies [af5e292]
346
+ - Updated dependencies [3fbbea1]
347
+ - Updated dependencies [0bffb18]
348
+ - Updated dependencies [800f455]
349
+ - Updated dependencies [5458414]
350
+ - Updated dependencies [3241559]
351
+ - Updated dependencies [7f96b10]
352
+ - Updated dependencies [167ec42]
353
+ - Updated dependencies [616a2a5]
354
+ - Updated dependencies [6c68b13]
355
+ - Updated dependencies [0046d8f]
356
+ - Updated dependencies [f1d4748]
357
+ - Updated dependencies [bea374e]
358
+ - Updated dependencies [b1119ec]
359
+ - Updated dependencies [5607092]
360
+ - Updated dependencies [9f23d2b]
361
+ - Updated dependencies [578e025]
362
+ - Updated dependencies [af025ee]
363
+ - Updated dependencies [d109a4d]
364
+ - Updated dependencies [598c89a]
365
+ - Updated dependencies [4a0bd17]
366
+ - Updated dependencies [b8b9af4]
367
+ - Updated dependencies [d8b9259]
368
+ - Updated dependencies [31676be]
369
+ - Updated dependencies [8c0d52e]
370
+ - Updated dependencies [aff10e2]
371
+ - Updated dependencies [70a774b]
372
+ - Updated dependencies [9ce096f]
373
+ - Updated dependencies [e05db88]
374
+ - Updated dependencies [7458a41]
375
+ - Updated dependencies [ad13d63]
376
+ - Updated dependencies [5ffcc14]
377
+ - Updated dependencies [d971e51]
378
+ - Updated dependencies [97abb24]
379
+ - Updated dependencies [deb157a]
380
+ - Updated dependencies [9c60144]
381
+ - Updated dependencies [e7747f1]
382
+ - Updated dependencies [d2ce342]
383
+ - Updated dependencies [9695da7]
384
+ - Updated dependencies [ac2f332]
385
+ - Updated dependencies [a777058]
386
+ - Updated dependencies [75444e3]
387
+ - Updated dependencies [58b8346]
388
+ - Updated dependencies [2d0bd16]
389
+ - Updated dependencies [a9e17b4]
390
+ - Updated dependencies [b8ce7dc]
391
+ - Updated dependencies [dad51e5]
392
+ - Updated dependencies [1c9c342]
393
+ - Updated dependencies [787c738]
394
+ - Updated dependencies [8396656]
395
+ - Updated dependencies [dbbd38a]
396
+ - Updated dependencies [61556dc]
397
+ - Updated dependencies [8871c14]
398
+ - Updated dependencies [93fe362]
399
+ - Updated dependencies [dfc6975]
400
+ - Updated dependencies [3cf4de0]
401
+ - Updated dependencies [c9dc811]
402
+ - Updated dependencies [144ef9b]
403
+ - Updated dependencies [138ab04]
404
+ - Updated dependencies [a0b9e91]
405
+ - Updated dependencies [99bd015]
406
+ - Updated dependencies [21e4585]
407
+ - @object-ui/types@17.6.0
408
+ - @object-ui/fields@17.6.0
409
+ - @object-ui/i18n@17.6.0
410
+ - @object-ui/react@17.6.0
411
+ - @object-ui/components@17.6.0
412
+ - @object-ui/core@17.6.0
413
+ - @object-ui/permissions@17.6.0
414
+ - @object-ui/mobile@17.6.0
415
+
416
+ ## 17.5.0
417
+
418
+ ### Minor Changes
419
+
420
+ - 7084f7d: `DashboardRenderer` and `ListView` serve the props they declare — the index signature stops erasing them
421
+
422
+ Both components declared a full props interface and neither was enforced. A `[key: string]: any` on `DashboardRendererProps` and `ListViewProps` puts `string` into `keyof Props`, so `'ref' extends keyof Props` is always true and React's `PropsWithoutRef` takes its `Omit` branch — and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared property was dropped from the resolved type, on both sides: the render function received `{ [x: string]: any }` (so even `schema` was `any` inside the component), and every JSX call site was unchecked. Measured on the pre-fix source, `keyof ComponentProps<typeof DashboardRenderer>` was `string | number` and `ComponentProps<typeof DashboardRenderer>['onWidgetClick']` was `any`, while the interface went on declaring `(widgetId: string | null) => void`. `ListView` measured identically for `onRowClick`. This is objectui#4422 / PR #4438's trap in the two packages that issue left unswept.
423
+
424
+ Graded **minor, not major**: the interfaces have always DECLARED these props; the index signature erased them from the resolved type. Restoring what the interface documents is a FIX to the published contract, not a contract break — no documented capability is removed, and `any`-typed accidental passthrough was never the documented surface. Nothing in either package's README or docs endorses relying on it.
425
+
426
+ The props each component genuinely reads but never declared are now declared by name, at the type each one lands on: `dataSource` on both, plus `onAddRecord` / `onBulkAction` / `onPageSizeChange` / `onEdit` / `onDelete` / `onBulkDelete` on `ListView`. `DashboardRenderer`'s DOM pass-through keys are derived from `toDomProps`' whitelist constant itself, so the declaration and the runtime filter cannot drift — the "declare it and forward it by name" direction `@object-ui/core`'s `dom-props` doctrine asks for, rather than reopening the spread.
427
+
428
+ Type-only: the emitted JS for both packages is byte-identical before and after (verified by sha256 on `dist/index.js` and `dist/index.umd.cjs`), and both packages' runtime suites are untouched and green.
429
+
430
+ Three latent defects the erasure had been hiding are fixed with it, each surfaced by the repo-wide type-check: `DashboardWithConfig` typed its widget-select handler `(widgetId: string)` while `DashboardRenderer` calls `onWidgetClick(null)` to deselect; `InterfaceListPage` built a list schema whose `viewType` was a bare `string`; and `StudioDesignSurface` forwarded a `refreshKey` prop that no component in the chain declares or reads, so it was silently dropped. Per-package structural guards now pin the shape in both packages, covering the public `forwardRef` that takes its props whole — the spelling objectui#4438's `schema`-destructuring scan could not see.
431
+
432
+ - fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
433
+
434
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
435
+ density vocabulary, and since objectui#4352 they answered differently for the
436
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
437
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
438
+ `comfortable`. One metadata-driven system, two answers for one input
439
+ (objectui#4440).
440
+
441
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
442
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
443
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
444
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
445
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
446
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
447
+ still fails the build here.
448
+
449
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
450
+ `major` — its major tracks `@objectstack`). Two things change:
451
+
452
+ - **Published type.** `rowHeightToDensityMode` is exported from
453
+ `@object-ui/core`, and its return widens from `DensityMode` to
454
+ `DensityMode | undefined`. A host assigning the result straight into a
455
+ `DensityMode` now has to say what an off-spec row height should mean to it.
456
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
457
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
458
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
459
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
460
+ A sweep of this repo, the `objectstack` example apps and one downstream app
461
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
462
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
463
+ `Record< DensityMode, RowHeight >`).
464
+
465
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
466
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
467
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
468
+ is an own-property check now.
469
+
470
+ ### Patch Changes
471
+
472
+ - ae10a01: Console chrome reaches the bundle — the list switcher, the aggregate footer, the dialog a11y fallbacks and the whole Settings namespace screen stop being English on non-English consoles
473
+
474
+ Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups, so they stayed English on every non-English console with nothing an app could author to change them. They are not object, field, view or action labels — no key in `TranslationData` reaches them — while the console's own bundle already ships zh-CN, ja-JP, es-ES, de, fr, pt, ru, ko and ar and translates hundreds of neighbouring strings. Omissions from an otherwise complete bundle, not a missing capability.
475
+
476
+ **Two of the six needed no new keys at all, which is the more interesting half.** The list-view mode switcher named its nine visualizations from a private `VIEW_LABELS` table while `console.objectView.viewType*` — the same nine words — had been resolved through the bundle by the create-view picker for months; the switcher now reads those keys, so the picker's 「画廊」 and the switcher's 「画廊」 cannot drift apart in nine languages. The create/edit dialog's close button is the remainder of a fix that already landed: objectstack#5505 routed the `sr-only` close label through `common.close` for the two Shadcn-synced primitives, but `MobileDialogContent` is a hand-written wrapper outside that regeneration zone with its own close button, and it is exactly what `ModalForm` renders — so the dialog the report measured was the one place still announcing "Close" in English.
477
+
478
+ The aggregate footer is the one the original report singled out: the **number** was already locale-formatted and the **prefix** was a hardcoded `Avg: ` / `Sum: `. All eleven aggregation kinds now take their prefix from `grid.summary.*`, and the label/value join is its own key rather than a `': '` baked into the renderer — the separator is translatable content, so zh sets a fullwidth colon and fr the French space-before-colon. The numbers are untouched. The form dialog's `sr-only` description fallback joins the packs too; it is clipped, not visible, so the only way an app could displace it was to author a `description` and thereby put a visible subtitle on every dialog.
479
+
480
+ **The Settings namespace screen converts as one unit.** `SettingsView` routed zero framing copy through i18n — save/failure toasts, the env-lock and crypto refusals, the load-error card, the empty-route state, the navigation buttons, the unsaved-changes save bar — while its immediate sibling `SettingsHub`, in the same directory, resolved everything through `t('console.settingsHub.*')`. A zh-CN admin read correctly translated field labels sitting inside an English save bar, because `useSettingsLabel` translates a namespace's authored content but reaches none of the chrome around it. All of it now resolves through a `console.settingsView.*` namespace placed beside the hub's, including the crypto-refusal strings that objectui#4579 deliberately left in English rather than leave one translated string among a dozen literals.
481
+
482
+ The save-bar counter was an English plural rule executing in every locale (`change` plus an `s` when the count exceeds one). It is now a real i18next plural family — base key plus `_one` and `_other` in all ten packs — not the `(s)` spelling translated nine ways. The base key is the load-bearing part: i18next asks `Intl.PluralRules` for the one suffix a language needs and, finding no such slot, falls back to English, so without it Russian would read English at counts 2-20 and Arabic at 2-99. Russian and Arabic take the "noun: {count}" form their packs already use for this exact reason, and the counter is verified rendering in-language at 1, 2 and 5.
483
+
484
+ The Beta badge reuses the hub's existing key rather than minting a twin, and the refusal messages interpolate their subject through the bundle instead of concatenating a translated word onto an English prefix.
485
+
486
+ - bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
487
+
488
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
489
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
490
+ in all ten locales on screens where objectui#3878 had converged everything else on
491
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
492
+ was appended to right-to-left text). Both now read `table.search`, which is already
493
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
494
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
495
+ packs. No new keys.
496
+
497
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
498
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
499
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
500
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
501
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
502
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
503
+
504
+ - bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
505
+
506
+ rc.6 widened `I18nLabel` from `string` to `string | Record<string, string>`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
507
+
508
+ | package | what an author can now write and see |
509
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
510
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
511
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
512
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
513
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
514
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
515
+
516
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
517
+
518
+ Two consequences worth knowing:
519
+
520
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
521
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
522
+
523
+ - 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
524
+
525
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
526
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
527
+ pack value always wins, so the inline string is dead code — and 43 of those dead
528
+ strings said something different from the sentence users actually read.
529
+
530
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
531
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
532
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
533
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
534
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
535
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
536
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
537
+ which keeps reporting it alone.
538
+
539
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
540
+ values are what users read today, and changing one would oblige the same change in
541
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
542
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
543
+ is how they survived three i18n gates that are each blind to this class by
544
+ construction.
545
+
546
+ The visible effect is confined to hosts that render these components with **no**
547
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
548
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
549
+ one; it now matches what a provider-backed app has always shown. Inside the
550
+ console — provider mounted — nothing users see changes. The clearest converging
551
+ examples: the workspaces screen was written as "Organizations" at nine call sites
552
+ while every user has been reading "Workspaces"; the forgot-password success line
553
+ was written as "If an account exists, a reset link has been sent." while the pack
554
+ asserts "We've sent a password reset link to {{email}}."
555
+
556
+ - f8595a0: A list emptied by the view's own filter says "no records match", instead of inviting you to create your first record
557
+
558
+ `ListView`'s empty state distinguishes "filtered to empty" from "truly empty (first run)", but the view's own declared `filter` did not count toward that decision — only the search term, the user-filter conditions and the toolbar's live filter group did. A view that returns nothing _because it is filtered_ therefore rendered the first-run copy over an object full of records.
559
+
560
+ That is a small string, and it cost real triage time. In objectui#4155 a stored overlay filter had emptied a list, and the screen said "no data yet / create your first record" — so the report read as data loss or a permission problem, and the investigation went to the data and permission layers rather than to the view layer where the defect was. The same misread is available without any bug at all: a perfectly healthy view declaring `status not_in [archived, deleted]` over an object whose rows are all archived told the user the object was empty.
561
+
562
+ The base `filter` now counts as an active query, in both at-rest shapes (an array of conditions, and the Mongo-style object form). No new copy — this only routes to the `list.noMatches` / `list.noMatchesMessage` strings that already exist in all ten locales, so there are no new keys to translate. An author-supplied `emptyState.title` / `emptyState.message` still wins over both branches, unchanged.
563
+
564
+ - 33c32bf: List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
565
+
566
+ `filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for _filtering_ silently became the _sort_ whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
567
+
568
+ Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
569
+
570
+ One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
571
+
572
+ The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
573
+
574
+ - 7ffd616: fix(plugin-list): ListView hands the child grid the query behind the window it passes down
575
+
576
+ ListView owns the fetch on the external-pagination path — it holds the filter,
577
+ the search term and the sort, and it is the side that calls `dataSource.find`.
578
+ The grid it hands the window to has a cross-page "select all N matching"
579
+ escalation that RE-ISSUES that query to collect the whole match set, and with
580
+ nothing handed down it replayed its own never-written params ref and so asked the
581
+ server for the entire object, feeding unmatched records to bulk delete.
582
+
583
+ The params object is now hoisted out of the `find` call — one object, one query,
584
+ no reconstruction that could drift from what was actually asked — recorded past
585
+ the stale-request guard so it is always the query that produced the rows on
586
+ screen, and forwarded as `findParams` in the same handoff block as `rowCount`,
587
+ `page` and `onPageChange`. No public API of `ListView` changes.
588
+
589
+ - 37cd8e4: `list-view` now reads its `dataSource` binding through the shared `ElementDataSourceGate` instead of a private copy of the precedence table
590
+
591
+ objectstack#5576 landed the per-element `dataSource` binding on `list-view` by
592
+ writing the precedence table — binding keys override the component's, a `view` is
593
+ only a baseline, `filter` AND-combines rather than replaces, an authored-but-empty
594
+ `columns` counts as unauthored, the row cap lands on `pagination.pageSize`, and
595
+ `viewType` is taken only when the component declared none — inline in
596
+ `ListViewBlock`. objectstack#6953 then needed the same table for the other eight
597
+ object-bound blocks and lifted it into `@object-ui/react`
598
+ (`useElementDataSourceSchema` / `ElementDataSourceGate`), deliberately not
599
+ touching `ListViewBlock`: refactoring already-merged code inside a wiring PR
600
+ would have been an out-of-scope regression surface.
601
+
602
+ That left one table with two implementations — `list-view` on the private copy,
603
+ every other block on the shared one. Nothing was wrong for a user today; the risk
604
+ is the next person to change the rules changing one side, which is how the spec's
605
+ "_additional_ filter criteria" becomes two dialects and a per-element filter
606
+ quietly starts replacing a saved view's instead of narrowing it.
607
+
608
+ `ListViewBlock` now contributes only what is genuinely its own — the names of the
609
+ keys `ListView` reads:
610
+
611
+ ```ts
612
+ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = {
613
+ columns: true,
614
+ filter: true,
615
+ sort: true,
616
+ limit: "pagination.pageSize",
617
+ viewType: true,
618
+ };
619
+ ```
620
+
621
+ and the ~45-line `useMemo` mapping block is deleted, along with the block's
622
+ hand-rolled error and loading panels (the shared
623
+ `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel` render with the
624
+ `list-view` testId prefix, so `list-view-datasource-error` and
625
+ `list-view-resolving-view` are unchanged down to the byte, and the error heading
626
+ is passed through as `errorTitle`).
627
+
628
+ **No behaviour changes in either direction**, and that is the acceptance
629
+ criterion rather than a hoped-for outcome: objectstack#5576's entire suite passes
630
+ untouched, with no assertion edited — had any single case needed adapting, the
631
+ two implementations would have been proven to disagree, which is a defect to
632
+ re-grade rather than a refactor detail to absorb. New pins cover the mapping
633
+ table key by key at the block/gate seam, plus the shared loading panel, which
634
+ neither implementation had ever asserted.
635
+
636
+ - e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
637
+
638
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
639
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
640
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
641
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
642
+
643
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
644
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
645
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
646
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
647
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
648
+
649
+ - a84385b: `NavigationConfig.mode` is optional — the type now says what the hook does
650
+
651
+ `@object-ui/react` published a `NavigationConfig` that required `mode`, in front of a `useNavigationOverlay` that has always defaulted it. The declaration took the spec's authored config, `Omit`ted `mode`, and re-added it as `NonNullable< … >`; 140 lines below, the hook read `navigation?.mode ?? 'page'`. The type was strictly stricter than the implementation it fronted, and `'page'` is meaningful behaviour rather than a placeholder.
652
+
653
+ The spec never asked for that. `NavigationConfigSchema` declares `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the authoring side as `| undefined` — so `navigation: { view: 'summary_view' }` is legal authored metadata that lets the mode default. `@object-ui/types` already re-exported the spec's own `NavigationConfig` unchanged, which meant one monorepo shipped two published types of the same name that disagreed about whether `mode` could be omitted.
654
+
655
+ The alias is now the spec's authored config verbatim, with no divergence of its own:
656
+
657
+ ```ts
658
+ export type NavigationConfig = SpecAuthoredInput<
659
+ typeof NavigationConfigSchema
660
+ >;
661
+ ```
662
+
663
+ The cost of the old spelling was paid by callers. `ListView` carried `schema.navigation as NavigationConfig | undefined` for no reason except to get a valid spec-shaped value past the declaration; that assertion is deleted here, not replaced. A type in front of an implementation must not be stricter than the implementation — when it is, every caller pays in casts, and a cast is exactly the renderer-side workaround that belongs back at the producer.
664
+
665
+ **Nothing changes at runtime.** `navigation?.mode ?? 'page'` is untouched, and the default is now pinned as observable behaviour (`useNavigationOverlay.modeDefault.test.tsx`) rather than only as a comment — the explicit modes, the `preventNavigation` and `none` short-circuits, the `onRowClick` priority, and the Cmd/Ctrl/middle-click and `new_window` branches are all pinned alongside it.
666
+
667
+ **Why minor rather than patch**, from the measured `.d.ts`. Optional-izing a property is looser for writers and narrower for readers, so the grade turns on which role the published surface actually plays. In this package `NavigationConfig` occurs only in input positions — `useNavigationOverlay`'s `navigation?:` option and `resolveOverlayWidth`'s parameter — and never in a return type; the package consumes these values and never hands one back. For consumers the change is therefore purely permissive: every call that compiled before still compiles, and spec-shaped configs that previously needed an assertion now compile without one. That gained input shape is a real capability rather than an internal repair, which is more than a patch describes. The reader-side narrowing is real but secondary: code that imports the bare type, annotates its own value with it and reads `.mode` now sees `NavigationMode | undefined`. The in-repo census found exactly one such importer — `ListView` — and it imported the type only to write the assertion this change removes.
668
+
669
+ - dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
670
+
671
+ `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
672
+
673
+ Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
674
+
675
+ - 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
676
+
677
+ The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: _add a formula field holding it_. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
678
+
679
+ The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
680
+
681
+ All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
682
+
683
+ - 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
684
+
685
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
686
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
687
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
688
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
689
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
690
+ whole life: a merely unpopulated page invites nobody to click through.
691
+
692
+ The masking had two halves, in two packages, and neither package could see the other:
693
+
694
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
695
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
696
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
697
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
698
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
699
+ would have pinned the object to "empty" for the rest of the session.
700
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
701
+ half was never swallowed, so it already reached this panel, but classified as `network`:
702
+ _"check your connection and try again"_ for a condition no retry can change. It now says
703
+ the object is not exposed through the API, that this is a setting on the object rather than
704
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
705
+ identical refusal.
706
+
707
+ Both denials are pure functions of the object's metadata — no user, no permission, no
708
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
709
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
710
+ missing collection, a missing record and a disabled object are all 404.
711
+
712
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
713
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
714
+ the view, and once end-to-end over a real adapter and a real `ListView`.
715
+
716
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
717
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
718
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
719
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
720
+
721
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
722
+ `en` pack and mirrored in the list defaults map.
723
+
724
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
725
+
726
+ Two independent reasons, either of which is sufficient under this repo's precedent
727
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
728
+
729
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
730
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
731
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
732
+ public surface. Additive surface growth is minor.
733
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
734
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
735
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
736
+ relied on those two codes arriving as a successful empty result now receives a rejected
737
+ promise carrying `code` + `httpStatus`, and must handle it.
738
+
739
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
740
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
741
+ is scoped to the two denial codes named above and to nothing else.
742
+
743
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
744
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
745
+ described in the body, which is what this note is.
746
+
747
+ - 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
748
+
749
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
750
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
751
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
752
+ they were being accepted against "export to PDF" requirements, which they have never been.
753
+
754
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
755
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
756
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
757
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
758
+ report and dashboard.
759
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
760
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
761
+ translated in all ten locale packs).
762
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
763
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
764
+ `dashboardActions.printDialogOpening`).
765
+
766
+ No control was removed and no headless detection was added. A real print/PDF primitive
767
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
768
+
769
+ - ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
770
+
771
+ Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
772
+
773
+ `ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
774
+
775
+ `ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
776
+
777
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
778
+
779
+ ## 17.4.0
780
+
781
+ ### Minor Changes
782
+
783
+ - bd863fe: fix(timeline): the timeline binds to the date axis the view actually declares (#3129)
784
+
785
+ A view whose date axis is bound under `calendar` was **offered** the Timeline
786
+ visualization and then bucketed every record into "No date" — while the calendar
787
+ rendered the very same field correctly. Two read-sites disagreed about what
788
+ counts as a timeline binding:
789
+
790
+ - `ListView`'s capability gate accepted `options.calendar.startDateField` as a
791
+ timeline-resolvable axis; the render branch never read calendar config at all,
792
+ so it fell through to its `created_at` last resort.
793
+ - `app-shell`'s object page emitted `startDateField: 'due_date'` into
794
+ `options.timeline` for **every** object view, declared or not. Downstream that
795
+ is indistinguishable from a real binding, and because it is always present it
796
+ shadowed the fallback entirely.
797
+
798
+ `ListView` now resolves the axis once — `resolveTimelineDateBinding`, consumed by
799
+ the capability gate and the render branch alike, reading spec key before legacy
800
+ alias and `timeline` before `calendar` in both nestings — and the object page
801
+ forwards only what the view declared. A declared `timeline.startDateField` still
802
+ wins wherever both appear, and a view that declares no date axis anywhere keeps
803
+ the historical `created_at` fallback.
804
+
805
+ Observable rendering change (records move out of "No date" into real date
806
+ buckets), hence `minor`.
807
+
808
+ ### Patch Changes
809
+
810
+ - e06810e: `PageComponentSchema.dataSource` is now consumed instead of discarded — a
811
+ `list-view` page component can reference a **saved view by name** for the first
812
+ time, and writing the binding no longer breaks the component
813
+ (objectstack#5576).
814
+
815
+ The spec declares a per-element data binding on every page component —
816
+ `dataSource: { object, view?, filter?, sort?, limit? }` — and objectui read none
817
+ of it. `ViewDataProvider.resolveElementDataSource` forwarded
818
+ `filter`/`sort`/`limit` and dropped `view` entirely, and had no caller outside its
819
+ own test; nothing mapped `object` onto the `objectName` a list actually reads. So
820
+ "reference a saved view by name" was published, validated and inert, and every
821
+ page that wanted a saved view's columns/filter/sort had to inline a second copy of
822
+ them — the drift the binding exists to remove.
823
+
824
+ Writing the binding also **broke** the block, for a reason unrelated to `view`:
825
+ `SchemaRenderer` spread the schema's `dataSource` metadata onto the component as a
826
+ React prop, and that is the prop name the host uses to inject the data-source
827
+ ADAPTER. The plain `{ object, view }` object shadowed the adapter, so the first
828
+ `dataSource.find(…)` threw `dataSource.find is not a function` and `list-view`
829
+ rendered "Couldn't load records" — a spec-compliant component failing next to
830
+ identical ones that omitted the binding.
831
+
832
+ - `@object-ui/react` — `SchemaRenderer` no longer spreads `schema.dataSource` as a
833
+ prop (it is metadata, like `visibleWhen`); renderers read it off `schema`. An
834
+ explicit React `dataSource` prop is unaffected. New
835
+ `useElementDataSource(schema, dataSource?)` hook resolves a binding, fetching
836
+ the named saved view from the object definition's `listViews` and the metadata
837
+ overlay's `listViews()`.
838
+ - `@object-ui/core` — new `isElementDataSourceConfig` / `collectSavedViews` /
839
+ `resolveSavedView` / `composeElementDataSource`, and `resolveElementDataSource`
840
+ now honours `view` through an optional `DataFetcher.fetchViews`, reporting an
841
+ unresolvable view as an error instead of silently returning every record.
842
+ `resolveViewId` moved here from `@object-ui/app-shell` (re-exported there) so
843
+ one matcher serves both the object page and a page component.
844
+ - `@object-ui/plugin-list` — `list-view` maps the binding onto the props
845
+ `ListView` reads. `dataSource.*` keys are authoritative, view-supplied values
846
+ are a baseline the component's own keys override, and `filter` AND-combines at
847
+ every level (the spec calls the binding's filter "additional criteria"), so a
848
+ binding can narrow a saved view but never widen it. A `view` name that does not
849
+ resolve renders a configuration error naming the object's actual views and
850
+ issues no query — it never falls back to the object's default view, because that
851
+ turns a typo into a silently wider answer.
852
+
853
+ - aeb8424: List row Edit/Delete, bulk delete and related-list CRUD now run the caller's own permission, not just the object's API exposure (objectui#4096)
854
+
855
+ The row kebab's built-in Edit/Delete rendered for every account, including ones
856
+ the server answers `403 PERMISSION_DENIED` on. Clicking Edit opened a fully
857
+ prefilled dialog that could only fail on save; Delete — a destructive entry —
858
+ sat one click away from users who could never perform it.
859
+
860
+ The gate intersected the object's resolved CRUD affordance with the server's
861
+ effective API operation set (`/me/permissions` `apiOperations`, objectui#3720),
862
+ and nothing else. `apiOperations` is the object's **API exposure surface** —
863
+ "which verbs does this object publish" — and the spec's own describe text says
864
+ so. It is principal-independent: the report measured two accounts with opposite
865
+ `allowEdit`, 30 shared objects, and **30/30 identical** `apiOperations`. A gate
866
+ made only of object-scoped layers therefore fails OPEN for every unprivileged
867
+ caller, which is why the same screen carried three different answers to "may
868
+ this user write this object": the toolbar's New was correctly hidden
869
+ (`affordances.create && can(obj, 'create')`), the record header's Edit/Delete
870
+ were correctly hidden (per-record write probe), and the row kebab was not.
871
+
872
+ Four surfaces now AND the principal's own verdict — `can(obj, 'update' |
873
+ 'delete')`, i.e. `/me/permissions` `allowEdit` / `allowDelete`, the toolbar's
874
+ source — on top of the layers they already had:
875
+
876
+ - the grid row kebab's built-in Edit/Delete (`resolveRowCrudAffordances` gained
877
+ `permissionUpdate` / `permissionDelete`, filled at the `ObjectGrid` call site);
878
+ - the grid's bulk-delete bar, which rides the same object-level delete verdict,
879
+ so the row gate and the more destructive bulk entry move together;
880
+ - the non-grid (kanban / calendar / gallery) bulk bar `ListView` renders itself;
881
+ - the related-list Create/Edit/Delete on a child object
882
+ (`RelatedRecordActionsBridge`), which had the same object-only gate.
883
+
884
+ **This is a tightening of the intersection, not a swap.** Every existing layer
885
+ stays: the ADR-0103 lifecycle bucket, `userActions.edit` / `delete`, and
886
+ `apiOperations`. A permission grant cannot re-open what any of them closed, and
887
+ none of them survives a permission denial.
888
+
889
+ Fail-open is preserved where it is the deliberate contract: `usePermissions()`
890
+ with no `PermissionProvider` answers `can: () => true`, so standalone embeds and
891
+ hosts that ship no permission source keep their Edit/Delete exactly as before.
892
+ Under `MePermissionsProvider` the semantics are the toolbar's, unchanged and now
893
+ shared: an authenticated principal whose object is absent from
894
+ `/me/permissions.objects` resolves fail-closed (objectui#2926 ④), an anonymous
895
+ session keeps the permissive default, and children never render while the
896
+ permission set is loading. Per-key absence is still permissive — an object entry
897
+ without `allowEdit` reads as allowed.
898
+
899
+ Server-side enforcement was already hard (403, DB unchanged), so this closes a
900
+ UI-affordance gap rather than an authorization hole.
901
+
902
+ - cb5e32d: `UserFilters` preset tab buttons no longer submit an enclosing form; all six buttons declare `type="button"`
903
+
904
+ An HTML `<button>` defaults to `type="submit"` inside a `<form>`, so a preset
905
+ filter tab (`filter-tab-*`, tabs mode) submitted the enclosing form on every
906
+ click. The three buttons objectstack#6952 named now declare `type="button"`
907
+ explicitly — the dropdown chip trigger (`filter-badge-*`), the overflow trigger
908
+ (`user-filters-more`) and the preset tab — joining the session-tab buttons that
909
+ objectstack#5236 already declared it on.
910
+
911
+ Only one of the three was actually at risk, and the difference is measured
912
+ rather than assumed. The chip and the overflow trigger are
913
+ `PopoverTrigger asChild` children, and Radix's `PopoverTrigger` renders
914
+ `Primitive.button type="button"`; its Slot merges that onto a child declaring no
915
+ `type` of its own, so both already rendered as `button`. Reverting the change
916
+ confirms it: those two keep reading `button`, the plain preset tab button reads
917
+ `null`. For the two triggers this therefore moves a contract out of an upstream
918
+ implementation detail and into local source — the same reasoning objectui#3344
919
+ wrote onto the Combobox trigger — while the preset tab is a real fix.
920
+
921
+ Dormant rather than live: the only mount point today is `ListView`'s toolbar,
922
+ which is not inside a form, so no shipped screen submitted anything. The new
923
+ tests pin every rendered `UserFilters` button, in both modes, so a future button
924
+ cannot land at the submit default and an upstream Radix change surfaces in this
925
+ package's tests instead of in a user's form.
926
+
927
+ The in-file comment claiming "a Radix trigger keeps the HTML default of `submit`"
928
+ is corrected in passing — it is the inaccuracy that propagated into
929
+ objectstack#6952's premise.
930
+
931
+ - cf5be4e: `userFilters` tabs: the `allowAddTab` button now adds a tab instead of doing nothing (objectstack#5236)
932
+
933
+ The affordance `allowAddTab` renders had hover styling and `title="Add filter tab"` but no `onClick`, and `TabFilters` took no add-tab callback at all — a control that looked fully clickable and did nothing, which disguises "not implemented" as "a bug where clicking does nothing". That mattered more once objectstack#5073 promoted `allowAddTab` into the spec's `UserFiltersSchema`: the key became discoverable through JSON Schema, the Studio SchemaForm and the reference docs, so an author writing `allowAddTab: true` gets a declaration the runtime did not honour.
934
+
935
+ Clicking it now opens a small naming popover (the same Popover primitive the filter chips and the "More" overflow already use). Confirming a name adds a tab to the same bar as the presets, carrying a snapshot of the conditions applied at that moment, and selects it. Session tabs also carry a remove affordance; authored presets deliberately do not, since those are metadata. Removing the active session tab re-selects the author's default with the same precedence the initial mount uses, so the bar is never left with no active tab while the removed tab's conditions stay applied.
936
+
937
+ The new tab is **session-scoped, held in component state** — no `sys_metadata` write, no API call, no web storage, per ADR-0047 ("an end user's filter choices are session-scoped and never become metadata"). `sessionStorage` was available and deliberately not used: `UserFilters` receives no object or view identity, so any storage key it could invent would be shared by every list in the browser tab, surfacing one list's ad-hoc tabs on another's bar. Persistence beyond the mount, if ever wanted, belongs to the host that already owns the session channel for filter selections (`onSelectionsChange` mirrored into `uf_*` URL params) and can key it by view. The synthetic tab id is reported through `onSelectionsChange` like any other tab switch, so a host mirroring it into the URL hands it back on the next mount, where the existing id check finds no such tab and falls back to the author's default.
938
+
939
+ No public API change: `UserFiltersProps` is untouched, and `allowAddTab: false` / an omitted `allowAddTab` still render no affordance at all.
940
+
3
941
  ## 17.3.0
4
942
 
5
943
  ### Patch Changes
@@ -19,6 +957,7 @@
19
957
  stayed invisible.
20
958
 
21
959
  ## What changed
960
+
22
961
  - Both reads now share one `resolveCoverUrl`, so the "does anything have a
23
962
  cover?" predicate and the per-card render can no longer disagree — that
24
963
  disagreement is what collapsed the area for records that did have a cover.
@@ -1383,6 +2322,7 @@ colorField, scale }` arrived with only `titleField` and an axis pinned to the
1383
2322
  platform primitive for "no records / no data" states. Two new props
1384
2323
  keep it flexible enough to absorb the hand-rolled variants that lived
1385
2324
  in `plugin-list`, `plugin-kanban`, and `plugin-dashboard`:
2325
+
1386
2326
  - `showIcon?: boolean` — drops the icon container entirely. Used by the
1387
2327
  kanban board-level empty banner, which is a status banner rather than
1388
2328
  a true empty-state.
@@ -1391,6 +2331,7 @@ colorField, scale }` arrived with only `titleField` and an axis pinned to the
1391
2331
  empty state, which uses a large standalone glyph).
1392
2332
 
1393
2333
  Adopters:
2334
+
1394
2335
  - `plugin-list` (`ListView` grid empty-state) — preserves the existing
1395
2336
  large icon, title, message, add-record button and `data-testid`s,
1396
2337
  but delegates the structural markup to `DataEmptyState`.
@@ -1467,6 +2408,7 @@ colorField, scale }` arrived with only `titleField` and an axis pinned to the
1467
2408
  chrome on phones: title + 1 primary action, plus content. We were
1468
2409
  shipping ~5 rows of toolbars + chips + tabs above the data. This commit
1469
2410
  hides the desktop-only chrome at the `<sm` breakpoint:
2411
+
1470
2412
  - **ListView**: TabBar (view switcher), UserFilters chip row, quick-filters
1471
2413
  chip row, Sort button, list-scoped Search popover, and the
1472
2414
  (newly-added) mobile-only ViewSettingsPopover gear are all hidden on
@@ -1488,6 +2430,7 @@ colorField, scale }` arrived with only `titleField` and an axis pinned to the
1488
2430
 
1489
2431
  Refactor mobile object-view layout to match the Airtable Interface
1490
2432
  pattern:
2433
+
1491
2434
  - **AppHeader**: the mobile topbar's static page label is now a
1492
2435
  view-switcher dropdown (`<viewName> ▾`). Tapping opens a list of
1493
2436
  available views with icons + active-state checkmark. Falls back to
@@ -1618,6 +2561,7 @@ colorField, scale }` arrived with only `titleField` and an axis pinned to the
1618
2561
  Library builds (vite lib mode) now externalize every non-relative import instead of bundling third-party CJS dependencies into the published dist. This avoids inlined `require("react")` / `require("react-dom")` calls that cause `Calling \`require\` for "react" in an environment that doesn't expose the \`require\` function` runtime errors when consumer apps re-bundle the published dist.
1619
2562
 
1620
2563
  Specifically fixes:
2564
+
1621
2565
  - `@object-ui/plugin-dashboard` no longer inlines `react-grid-layout` (and its transitive `react-draggable` / `react-resizable` CJS bundles). `react-grid-layout` is now declared as a peer dependency so consumers install a single ESM-friendly copy.
1622
2566
  - `@object-ui/components`, `@object-ui/plugin-calendar`, `@object-ui/plugin-charts`, `@object-ui/plugin-designer` no longer inline `react-i18next` / `i18next` / `use-sync-external-store` CJS shims.
1623
2567
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.